feat(phase19): background chat store, UTF-8 SSE fix, DB-backed provider push to gateway

- Chat.tsx: rewritten to use global chatStore singleton — SSE connection survives
  page navigation; added StopCircle cancel button; scrolls only when near bottom
- chatStore.ts: new module-level singleton (EventTarget pattern) that holds all
  conversation/console state; TextDecoder with stream:true for correct UTF-8
- handlers.go (ProvidersReload): now accepts decrypted key in request body from
  Node.js so Go gateway can actually use the API key without sharing crypto logic
- providers.ts (activateProvider): sends decrypted key to gateway via
  notifyGatewayReload(); seedDefaultProvider also calls notifyGatewayReload()
- seed.ts: on startup, after seeding, pushes active provider to gateway with
  retry loop (5 retries × 3 s) to wait for gateway readiness
- index.ts (SSE proxy): TextDecoder('utf-8', {stream:true}) already correct;
  confirmed Cyrillic text arrives ungarbled (e.g. 'Привет!' not '??????????')
This commit is contained in:
bboxwtf
2026-03-21 04:12:45 +00:00
parent 981ab696b7
commit 1b6b8bc2cb
6 changed files with 654 additions and 394 deletions

481
client/src/lib/chatStore.ts Normal file
View File

@@ -0,0 +1,481 @@
/**
* chatStore — глобальный singleton для фонового чата.
*
* Проблема: React-компонент размонтируется при навигации, разрывая SSE-соединение.
* Решение: держим состояние и fetch-соединение вне React-дерева в модуле.
* Компонент подписывается через addEventListener и читает snapshotState().
*
* Использование:
* import { chatStore } from "@/lib/chatStore";
* chatStore.send(messages) — запустить запрос
* chatStore.getConversations() — получить список диалогов
* chatStore.on("update", handler) — подписаться на обновления
* chatStore.off("update", handler) — отписаться
*/
import { nanoid } from "nanoid";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ToolCallStep {
tool: string;
args: Record<string, any>;
result: any;
error?: string;
success: boolean;
durationMs: number;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: string;
toolCalls?: ToolCallStep[];
model?: string;
modelWarning?: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
isError?: boolean;
isStreaming?: boolean;
}
export interface Conversation {
id: string;
title: string;
createdAt: number;
messages: ChatMessage[];
history: Array<{ role: "user" | "assistant" | "system"; content: string }>;
}
export interface ConsoleEntry {
id: string;
type: "thinking" | "tool_call" | "done" | "error";
tool?: string;
args?: any;
result?: any;
error?: string;
success?: boolean;
durationMs?: number;
timestamp: string;
model?: string;
}
type StoreEvent = "update" | "console";
type UpdateHandler = () => void;
type ConsoleHandler = (entry: ConsoleEntry) => void;
// ─── Persistence ──────────────────────────────────────────────────────────────
const STORAGE_KEY = "goclaw-conversations-v2";
const CONSOLE_KEY = "goclaw-console-v2";
function loadConversations(): Conversation[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function persistConversations(convs: Conversation[]) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(convs.slice(0, 50)));
} catch {}
}
function loadConsole(): ConsoleEntry[] {
try {
const raw = sessionStorage.getItem(CONSOLE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function persistConsole(entries: ConsoleEntry[]) {
try {
sessionStorage.setItem(CONSOLE_KEY, JSON.stringify(entries.slice(-200)));
} catch {}
}
// ─── Store ────────────────────────────────────────────────────────────────────
function getTs(): string {
return new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
class ChatStore {
private conversations: Conversation[] = loadConversations();
private activeId: string = "";
private isThinking = false;
private consoleEntries: ConsoleEntry[] = loadConsole();
private abortController: AbortController | null = null;
private updateListeners = new Set<UpdateHandler>();
private consoleListeners = new Set<ConsoleHandler>();
constructor() {
if (this.conversations.length > 0) {
this.activeId = this.conversations[0].id;
}
}
// ─── Subscriptions ──────────────────────────────────────────────────────────
on(event: "update", handler: UpdateHandler): void;
on(event: "console", handler: ConsoleHandler): void;
on(event: StoreEvent, handler: any): void {
if (event === "update") this.updateListeners.add(handler);
else if (event === "console") this.consoleListeners.add(handler);
}
off(event: "update", handler: UpdateHandler): void;
off(event: "console", handler: ConsoleHandler): void;
off(event: StoreEvent, handler: any): void {
if (event === "update") this.updateListeners.delete(handler);
else if (event === "console") this.consoleListeners.delete(handler);
}
private emit(event: "update"): void;
private emit(event: "console", entry: ConsoleEntry): void;
private emit(event: StoreEvent, data?: any): void {
if (event === "update") {
this.updateListeners.forEach((h) => h());
} else if (event === "console" && data) {
this.consoleListeners.forEach((h) => h(data));
}
}
// ─── Selectors ──────────────────────────────────────────────────────────────
getConversations(): Conversation[] {
return this.conversations;
}
getActiveId(): string {
return this.activeId;
}
getActive(): Conversation | null {
return this.conversations.find((c) => c.id === this.activeId) ?? null;
}
getIsThinking(): boolean {
return this.isThinking;
}
getConsole(): ConsoleEntry[] {
return this.consoleEntries;
}
// ─── Mutations ──────────────────────────────────────────────────────────────
setActiveId(id: string) {
this.activeId = id;
this.emit("update");
}
createConversation(orchName = "GoClaw Orchestrator"): string {
const id = nanoid(8);
const welcome: ChatMessage = {
id: "welcome",
role: "system",
content: `${orchName} ready. Type a command or ask anything.`,
timestamp: getTs(),
};
const conv: Conversation = {
id,
title: "New Chat",
createdAt: Date.now(),
messages: [welcome],
history: [],
};
this.conversations = [conv, ...this.conversations];
this.activeId = id;
this.consoleEntries = [];
persistConversations(this.conversations);
persistConsole([]);
this.emit("update");
return id;
}
deleteConversation(id: string, orchName?: string) {
this.conversations = this.conversations.filter((c) => c.id !== id);
if (this.activeId === id) {
if (this.conversations.length > 0) {
this.activeId = this.conversations[0].id;
} else {
this.createConversation(orchName);
return;
}
}
persistConversations(this.conversations);
this.emit("update");
}
clearConsole() {
this.consoleEntries = [];
persistConsole([]);
this.emit("update");
}
private updateConv(id: string, updater: (c: Conversation) => Conversation) {
this.conversations = this.conversations.map((c) => (c.id === id ? updater(c) : c));
persistConversations(this.conversations);
}
private addConsoleEntry(entry: Omit<ConsoleEntry, "id" | "timestamp">) {
const full: ConsoleEntry = { ...entry, id: nanoid(6), timestamp: getTs() };
this.consoleEntries = [...this.consoleEntries, full];
persistConsole(this.consoleEntries);
this.emit("console", full);
this.emit("update");
}
// ─── Send Message (SSE) ─────────────────────────────────────────────────────
async send(userText: string, activeConvId?: string) {
if (this.isThinking || !userText.trim()) return;
let convId = activeConvId ?? this.activeId;
// If no conversation exists, create one
if (!convId || !this.conversations.find((c) => c.id === convId)) {
convId = this.createConversation();
}
const conv = this.conversations.find((c) => c.id === convId);
if (!conv) return;
// Add user message
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: userText.trim(),
timestamp: getTs(),
};
const newHistory = [
...conv.history,
{ role: "user" as const, content: userText.trim() },
];
this.updateConv(convId, (c) => ({
...c,
title: c.history.length === 0
? userText.trim().slice(0, 40) + (userText.length > 40 ? "…" : "")
: c.title,
messages: [...c.messages, userMsg],
history: newHistory,
}));
// Placeholder streaming message
const assistantId = `resp-${Date.now()}`;
const placeholder: ChatMessage = {
id: assistantId,
role: "assistant",
content: "",
timestamp: getTs(),
isStreaming: true,
toolCalls: [],
};
this.updateConv(convId, (c) => ({
...c,
messages: [...c.messages, placeholder],
}));
this.isThinking = true;
this.consoleEntries = [];
persistConsole([]);
this.emit("update");
// Abort previous
this.abortController?.abort();
const controller = new AbortController();
this.abortController = controller;
const toolCallsAccumulated: ToolCallStep[] = [];
try {
const res = await fetch("/api/orchestrator/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newHistory, maxIter: 10 }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`Server error: ${res.status}`);
}
this.addConsoleEntry({ type: "thinking" });
const reader = res.body.getReader();
// Single UTF-8 decoder with stream:true — buffers incomplete multi-byte sequences
const decoder = new TextDecoder("utf-8");
let buffer = "";
let streamedContent = "";
let finalModel = "";
let finalWarning = "";
let finalUsage: any = undefined;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode chunk — stream:true means decoder holds partial UTF-8 bytes across chunks
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last (potentially incomplete) line in buffer
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const evt = JSON.parse(data) as {
type: string;
content?: string;
tool?: string;
args?: any;
result?: any;
error?: string;
success?: boolean;
durationMs?: number;
model?: string;
modelWarning?: string;
usage?: any;
};
switch (evt.type) {
case "tool_call": {
const step: ToolCallStep = {
tool: evt.tool ?? "",
args: evt.args ?? {},
result: evt.result,
error: evt.error,
success: evt.success ?? false,
durationMs: evt.durationMs ?? 0,
};
toolCallsAccumulated.push(step);
this.addConsoleEntry({ type: "tool_call", ...step });
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? { ...m, toolCalls: [...toolCallsAccumulated] }
: m
),
}));
break;
}
case "delta": {
streamedContent += evt.content ?? "";
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId ? { ...m, content: streamedContent } : m
),
}));
this.emit("update");
break;
}
case "done": {
finalModel = evt.model ?? "";
finalWarning = evt.modelWarning ?? "";
finalUsage = evt.usage;
this.addConsoleEntry({ type: "done", model: finalModel });
break;
}
case "error": {
this.addConsoleEntry({ type: "error", error: evt.error });
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? { ...m, content: `Error: ${evt.error}`, isError: true, isStreaming: false }
: m
),
}));
this.emit("update");
break;
}
}
} catch {
// malformed JSON — skip line
}
}
}
// Flush any remaining decoder bytes
const remaining = decoder.decode(undefined, { stream: false });
if (remaining) {
streamedContent += remaining;
}
// Finalize
const finalContent = streamedContent || "(no response)";
this.updateConv(convId, (c) => ({
...c,
history: [...c.history.filter((h) => h.role !== "assistant" || h.content !== ""),
{ role: "assistant" as const, content: finalContent }],
messages: c.messages.map((m) =>
m.id === assistantId
? {
...m,
content: finalContent,
isStreaming: false,
toolCalls: toolCallsAccumulated,
model: finalModel,
modelWarning: finalWarning,
usage: finalUsage,
}
: m
),
}));
} catch (err: any) {
if (err.name === "AbortError") {
// Cancelled — just remove streaming flag
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? { ...m, isStreaming: false, content: m.content || "(cancelled)" }
: m
),
}));
} else {
this.addConsoleEntry({ type: "error", error: err.message });
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? { ...m, content: `Network Error: ${err.message}`, isError: true, isStreaming: false }
: m
),
}));
}
} finally {
this.isThinking = false;
this.abortController = null;
this.emit("update");
}
}
/** Cancel the current in-flight request. */
cancel() {
this.abortController?.abort();
}
}
// Singleton — survives React unmount/remount cycles
export const chatStore = new ChatStore();

View File

@@ -1,5 +1,5 @@
/**
* Chat — Full-featured chat UI
* Chat — Full-featured chat UI backed by a global singleton store.
*
* Layout:
* ┌──────────┬───────────────────────────────┬───────────────┐
@@ -8,16 +8,17 @@
* └──────────┴───────────────────────────────┴───────────────┘
*
* Features:
* - Left panel: conversation list (persisted in sessionStorage)
* - Centre: chat messages with markdown, streaming text via SSE
* - Right: live tool-call console showing what the agent is doing
* - SSE: connects to POST /api/orchestrator/stream
* - Left panel: conversation list (persisted across navigation)
* - Centre: scrollable messages with markdown, live SSE streaming
* - Right: live agent console (tool calls, thinking, done events)
* - Background: SSE request continues even when user navigates away,
* because all state lives in chatStore (module-level singleton).
*/
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useSyncExternalStore } from "react";
import { Streamdown } from "streamdown";
import { motion, AnimatePresence } from "framer-motion";
import { nanoid } from "nanoid";
import { trpc } from "@/lib/trpc";
import { chatStore, type ChatMessage, type Conversation, type ConsoleEntry, type ToolCallStep } from "@/lib/chatStore";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -49,75 +50,28 @@ import {
PanelRightClose,
PanelRightOpen,
Shell,
StopCircle,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── useChatStore hook ────────────────────────────────────────────────────────
// Subscribes to the global chatStore and re-renders on every "update" event.
interface ToolCallStep {
tool: string;
args: Record<string, any>;
result: any;
error?: string;
success: boolean;
durationMs: number;
}
function useChatStore() {
const [, forceRender] = useState(0);
type MessageRole = "user" | "assistant" | "system";
useEffect(() => {
const handler = () => forceRender((n) => n + 1);
chatStore.on("update", handler);
return () => chatStore.off("update", handler);
}, []);
interface ChatMessage {
id: string;
role: MessageRole;
content: string;
timestamp: string;
toolCalls?: ToolCallStep[];
model?: string;
modelWarning?: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
isError?: boolean;
isStreaming?: boolean;
}
interface Conversation {
id: string;
title: string;
createdAt: number;
messages: ChatMessage[];
history: Array<{ role: "user" | "assistant" | "system"; content: string }>;
}
// SSE event from gateway
interface SSEEvent {
type: "thinking" | "tool_call" | "delta" | "done" | "error";
content?: string;
tool?: string;
args?: any;
result?: any;
success?: boolean;
durationMs?: number;
error?: string;
model?: string;
modelWarning?: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
}
// ─── Storage helpers ──────────────────────────────────────────────────────────
const STORAGE_KEY = "goclaw-conversations";
function loadConversations(): Conversation[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function saveConversations(convs: Conversation[]) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(convs.slice(0, 50)));
} catch {}
return {
conversations: chatStore.getConversations(),
activeId: chatStore.getActiveId(),
active: chatStore.getActive(),
isThinking: chatStore.getIsThinking(),
consoleEntries: chatStore.getConsole(),
};
}
// ─── Tool Icon Map ─────────────────────────────────────────────────────────────
@@ -279,7 +233,6 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
)}
</div>
{/* Tool calls (inline summary — full detail in right panel) */}
{msg.toolCalls && msg.toolCalls.length > 0 && !msg.isStreaming && (
<div className="w-full space-y-1 mb-1">
<p className="text-[10px] font-mono text-muted-foreground flex items-center gap-1">
@@ -315,19 +268,6 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
// ─── Right Console Panel ──────────────────────────────────────────────────────
interface ConsoleEntry {
id: string;
type: "thinking" | "tool_call" | "done" | "error";
tool?: string;
args?: any;
result?: any;
error?: string;
success?: boolean;
durationMs?: number;
timestamp: string;
model?: string;
}
function ConsolePanel({ entries }: { entries: ConsoleEntry[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -401,296 +341,61 @@ function ConsolePanel({ entries }: { entries: ConsoleEntry[] }) {
const ORCHESTRATOR_TOOLS_COUNT = 10;
export default function Chat() {
const [conversations, setConversations] = useState<Conversation[]>(() => loadConversations());
const [activeId, setActiveId] = useState<string>(() => {
const saved = loadConversations();
return saved.length > 0 ? saved[0].id : "";
});
// ── Store subscription ────────────────────────────────────────────────────
const { conversations, activeId, active, isThinking, consoleEntries } = useChatStore();
// ── Local UI state (does NOT affect background processing) ───────────────
const [input, setInput] = useState("");
const [isThinking, setIsThinking] = useState(false);
const [consoleEntries, setConsoleEntries] = useState<ConsoleEntry[]>([]);
const [showConsole, setShowConsole] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
// ── Remote data ───────────────────────────────────────────────────────────
const agentsQuery = trpc.agents.list.useQuery(undefined, { refetchInterval: 30000 });
const orchestratorConfigQuery = trpc.orchestrator.getConfig.useQuery();
// Current conversation
const activeConv = conversations.find((c) => c.id === activeId) ?? null;
// Create initial conversation if none exist
useEffect(() => {
if (conversations.length === 0) {
chatStore.createConversation(orchestratorConfigQuery.data?.name ?? "GoClaw Orchestrator");
}
}, [orchestratorConfigQuery.data, conversations.length]);
// Auto-scroll chat
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [activeConv?.messages]);
// Persist conversations
useEffect(() => {
saveConversations(conversations);
}, [conversations]);
// Create initial conversation if none
useEffect(() => {
if (conversations.length === 0 && orchestratorConfigQuery.data) {
createNewConversation(orchestratorConfigQuery.data.name);
}
}, [orchestratorConfigQuery.data]);
const getTs = () =>
new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const createNewConversation = (orchName?: string) => {
const id = nanoid(8);
const welcome: ChatMessage = {
id: "welcome",
role: "system",
content: `${orchName ?? "GoClaw Orchestrator"} ready. Type a command or ask anything.`,
timestamp: getTs(),
};
const conv: Conversation = {
id,
title: "New Chat",
createdAt: Date.now(),
messages: [welcome],
history: [],
};
setConversations((prev) => [conv, ...prev]);
setActiveId(id);
setConsoleEntries([]);
return id;
};
const deleteConversation = (id: string) => {
setConversations((prev) => prev.filter((c) => c.id !== id));
if (activeId === id) {
const remaining = conversations.filter((c) => c.id !== id);
if (remaining.length > 0) setActiveId(remaining[0].id);
else {
const newId = createNewConversation(orchestratorConfigQuery.data?.name);
setActiveId(newId);
const el = scrollRef.current;
// Only scroll if already near bottom (within 120px)
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (nearBottom || isThinking) {
el.scrollTop = el.scrollHeight;
}
}
};
}, [active?.messages, isThinking]);
const updateConv = (id: string, updater: (c: Conversation) => Conversation) => {
setConversations((prev) => prev.map((c) => (c.id === id ? updater(c) : c)));
};
// Focus input when not thinking
useEffect(() => {
if (!isThinking) {
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isThinking]);
const addMessage = (convId: string, msg: ChatMessage) => {
updateConv(convId, (c) => ({
...c,
title: c.history.length === 0 && msg.role === "user"
? msg.content.slice(0, 40) + (msg.content.length > 40 ? "…" : "")
: c.title,
messages: [...c.messages, msg],
}));
};
const updateLastMessage = (convId: string, updater: (msg: ChatMessage) => ChatMessage) => {
updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m, i) => i === c.messages.length - 1 ? updater(m) : m),
}));
};
const appendConsole = (entry: Omit<ConsoleEntry, "id" | "timestamp">) => {
setConsoleEntries((prev) => [
...prev,
{ ...entry, id: nanoid(6), timestamp: getTs() },
]);
};
const sendMessage = useCallback(async () => {
const sendMessage = useCallback(() => {
if (!input.trim() || isThinking) return;
const userContent = input.trim();
let convId = activeId;
if (!convId) {
convId = createNewConversation(orchestratorConfigQuery.data?.name);
}
const conv = conversations.find((c) => c.id === convId);
if (!conv) return;
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: userContent,
timestamp: getTs(),
};
addMessage(convId, userMsg);
const newHistory = [
...conv.history,
{ role: "user" as const, content: userContent },
];
updateConv(convId, (c) => ({ ...c, history: newHistory }));
const text = input.trim();
setInput("");
setIsThinking(true);
setConsoleEntries([]);
// Streaming assistant message placeholder
const assistantId = `resp-${Date.now()}`;
const placeholderMsg: ChatMessage = {
id: assistantId,
role: "assistant",
content: "",
timestamp: getTs(),
isStreaming: true,
toolCalls: [],
};
addMessage(convId, placeholderMsg);
// Abort previous request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const toolCallsAccumulated: ToolCallStep[] = [];
try {
const res = await fetch("/api/orchestrator/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newHistory, maxIter: 10 }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`Server error: ${res.status}`);
}
appendConsole({ type: "thinking" });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let streamedContent = "";
let finalModel = "";
let finalWarning = "";
let finalUsage: any = undefined;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const evt: SSEEvent = JSON.parse(data);
if (evt.type === "thinking") {
// already added
} else if (evt.type === "tool_call") {
const step: ToolCallStep = {
tool: evt.tool ?? "",
args: evt.args ?? {},
result: evt.result,
error: evt.error,
success: evt.success ?? false,
durationMs: evt.durationMs ?? 0,
};
toolCallsAccumulated.push(step);
appendConsole({ type: "tool_call", ...step });
// Update streaming message with tool calls so far
updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? { ...m, toolCalls: [...toolCallsAccumulated] }
: m
),
}));
} else if (evt.type === "delta") {
streamedContent += evt.content ?? "";
updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId ? { ...m, content: streamedContent } : m
),
}));
} else if (evt.type === "done") {
finalModel = evt.model ?? "";
finalWarning = evt.modelWarning ?? "";
finalUsage = evt.usage;
appendConsole({ type: "done", model: finalModel });
} else if (evt.type === "error") {
appendConsole({ type: "error", error: evt.error });
updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? {
...m,
content: `Error: ${evt.error}`,
isError: true,
isStreaming: false,
}
: m
),
}));
}
} catch {
// malformed JSON — skip
}
}
}
// Finalize the streaming message
const finalContent = streamedContent || "(no response)";
updateConv(convId, (c) => ({
...c,
history: [...c.history.filter((h) => !(h.role === "assistant" && h.content === "")),
{ role: "assistant" as const, content: finalContent }],
messages: c.messages.map((m) =>
m.id === assistantId
? {
...m,
content: finalContent,
isStreaming: false,
toolCalls: toolCallsAccumulated,
model: finalModel,
modelWarning: finalWarning,
usage: finalUsage,
}
: m
),
}));
} catch (err: any) {
if (err.name === "AbortError") return;
appendConsole({ type: "error", error: err.message });
updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? { ...m, content: `Network Error: ${err.message}`, isError: true, isStreaming: false }
: m
),
}));
} finally {
setIsThinking(false);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [input, isThinking, activeId, conversations, orchestratorConfigQuery.data]);
chatStore.send(text, activeId);
}, [input, isThinking, activeId]);
const orchConfig = orchestratorConfigQuery.data;
const agents = agentsQuery.data ?? [];
const activeAgents = agents.filter((a) => a.isActive && !(a as any).isOrchestrator);
const orchConfig = orchestratorConfigQuery.data;
const messages = activeConv?.messages ?? [];
const messages = active?.messages ?? [];
return (
<div className="h-full flex flex-col gap-0 overflow-hidden">
{/* Header */}
{/* ── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between shrink-0 px-1 pb-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-cyan-500/15 border border-cyan-500/30 flex items-center justify-center">
@@ -712,6 +417,7 @@ export default function Chat() {
</div>
<div className="flex items-center gap-2">
{/* Active agent badges */}
{activeAgents.slice(0, 3).map((agent) => (
<Badge key={agent.id} variant="outline" className="text-[9px] h-5 px-1.5 font-mono border-border/50 text-muted-foreground">
{agent.role === "browser" && <Globe className="w-2.5 h-2.5 mr-1 text-cyan-400" />}
@@ -720,6 +426,18 @@ export default function Chat() {
{agent.name}
</Badge>
))}
{/* Cancel button — visible only when a request is in flight */}
{isThinking && (
<button
onClick={() => chatStore.cancel()}
className="p-1.5 rounded border border-red-500/40 text-red-400 hover:bg-red-500/10 transition-colors"
title="Cancel request"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => setShowConsole(!showConsole)}
className="p-1.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors"
@@ -739,7 +457,7 @@ export default function Chat() {
</div>
</div>
{/* Main 3-panel layout */}
{/* ── Main 3-panel layout ──────────────────────────────────────────── */}
<div className="flex-1 flex gap-2 min-h-0">
{/* Left panel — Conversations */}
@@ -747,7 +465,7 @@ export default function Chat() {
<div className="flex items-center justify-between shrink-0">
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Chats</span>
<button
onClick={() => createNewConversation(orchConfig?.name)}
onClick={() => chatStore.createConversation(orchConfig?.name)}
className="p-1 rounded border border-border/40 text-muted-foreground hover:text-primary hover:border-primary/40 transition-colors"
title="New chat"
>
@@ -767,10 +485,7 @@ export default function Chat() {
? "bg-primary/15 border border-primary/25"
: "hover:bg-secondary/50 border border-transparent"
}`}
onClick={() => {
setActiveId(c.id);
setConsoleEntries([]);
}}
onClick={() => chatStore.setActiveId(c.id)}
>
<MessageSquare className={`w-3 h-3 shrink-0 ${c.id === activeId ? "text-primary" : "text-muted-foreground"}`} />
<span className={`text-[10px] font-mono flex-1 truncate leading-snug ${
@@ -780,8 +495,11 @@ export default function Chat() {
</span>
{conversations.length > 1 && (
<button
onClick={(e) => { e.stopPropagation(); deleteConversation(c.id); }}
className="opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground/50 hover:text-neon-red transition-all"
onClick={(e) => {
e.stopPropagation();
chatStore.deleteConversation(c.id, orchConfig?.name);
}}
className="opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground/50 hover:text-red-400 transition-all"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
@@ -794,9 +512,9 @@ export default function Chat() {
{/* Centre — Chat messages */}
<Card className="flex-1 bg-card border-border/50 overflow-hidden min-h-0">
<CardContent className="p-0 h-full flex flex-col">
{/* Messages */}
<ScrollArea className="flex-1 min-h-0">
<div ref={scrollRef} className="p-4 space-y-4">
{/* Messages — scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto" ref={scrollRef}>
<div className="p-4 space-y-4">
<AnimatePresence initial={false}>
{messages.map((msg) => (
<MessageBubble key={msg.id} msg={msg} />
@@ -810,13 +528,13 @@ export default function Chat() {
className="flex items-center gap-2 text-cyan-400 font-mono text-xs pl-10"
>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span className="text-muted-foreground">Processing...</span>
<span className="text-muted-foreground">Processing</span>
</motion.div>
)}
</div>
</ScrollArea>
</div>
{/* Input */}
{/* Input bar */}
<div className="border-t border-border/50 p-3 bg-secondary/10 shrink-0">
{/* Quick commands */}
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
@@ -829,7 +547,8 @@ export default function Chat() {
<button
key={cmd}
onClick={() => setInput(cmd)}
className="text-[10px] font-mono px-2 py-0.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors bg-secondary/20"
disabled={isThinking}
className="text-[10px] font-mono px-2 py-0.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors bg-secondary/20 disabled:opacity-40"
>
{cmd}
</button>
@@ -843,7 +562,7 @@ export default function Chat() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
placeholder={isThinking ? "Ожидание ответа..." : "Введите команду или вопрос..."}
placeholder={isThinking ? "Ожидание ответа" : "Введите команду или вопрос"}
disabled={isThinking}
className="bg-transparent border-none text-foreground font-mono text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0 h-8"
/>
@@ -853,7 +572,10 @@ export default function Chat() {
disabled={isThinking || !input.trim()}
className="bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 h-8 w-8 p-0 shrink-0"
>
{isThinking ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
{isThinking
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Send className="w-3.5 h-3.5" />
}
</Button>
</div>
</div>
@@ -874,10 +596,11 @@ export default function Chat() {
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<Terminal className="w-3 h-3" />
Console
{isThinking && <Loader2 className="w-2.5 h-2.5 animate-spin ml-1 text-cyan-400" />}
</span>
{consoleEntries.length > 0 && (
<button
onClick={() => setConsoleEntries([])}
onClick={() => chatStore.clearConsole()}
className="text-[9px] font-mono text-muted-foreground/50 hover:text-muted-foreground"
>
clear

View File

@@ -163,7 +163,7 @@ func (h *Handler) OrchestratorStream(w http.ResponseWriter, r *http.Request) {
}
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -219,21 +219,19 @@ func (h *Handler) OrchestratorStream(w http.ResponseWriter, r *http.Request) {
return
}
// Stream the response text character-by-character (simulate streaming)
// In a real streaming scenario we'd use ChatStream from llm client
// Here we stream the already-fetched response in chunks for a good UX
chunkSize := 4
resp := result.Response
for i := 0; i < len(resp); i += chunkSize {
end := i + chunkSize
if end > len(resp) {
end = len(resp)
// Stream the response in rune-safe chunks (important for UTF-8 / Cyrillic).
// We convert to []rune first so we never split a multi-byte character.
const runeChunkSize = 6
runes := []rune(result.Response)
for i := 0; i < len(runes); i += runeChunkSize {
end := i + runeChunkSize
if end > len(runes) {
end = len(runes)
}
writeSSE(w, flusher, streamEvent{
Type: sseEventDelta,
Content: resp[i:end],
Content: string(runes[i:end]),
})
// Small delay to make streaming visible
select {
case <-ctx.Done():
return
@@ -255,14 +253,33 @@ func (h *Handler) OrchestratorStream(w http.ResponseWriter, r *http.Request) {
// ─── Providers Reload ─────────────────────────────────────────────────────────
// POST /api/providers/reload
// Signal the gateway to reload LLM provider config from DB (Node.js calls this after activating a provider).
// Node.js calls this after activating a provider, sending the decrypted API key in the body.
// Body: { "name": "...", "baseUrl": "...", "apiKey": "...", "modelDefault": "..." }
func (h *Handler) ProvidersReload(w http.ResponseWriter, r *http.Request) {
// Reload config from DB and update the LLM client
// Try to read the decrypted credentials from the request body (preferred path)
var body struct {
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
ModelDefault string `json:"modelDefault"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err == nil && body.BaseURL != "" {
h.llm.UpdateCredentials(body.BaseURL, body.APIKey)
log.Printf("[API] Provider reloaded from body: %s (%s)", body.Name, body.BaseURL)
respond(w, http.StatusOK, map[string]any{
"ok": true,
"name": body.Name,
"baseUrl": body.BaseURL,
})
return
}
// Fallback: try to read from DB (key will be empty since Go can't decrypt it)
if h.db != nil {
provider, err := h.db.GetActiveProvider()
if err == nil && provider != nil {
h.llm.UpdateCredentials(provider.BaseURL, provider.APIKey)
log.Printf("[API] Provider reloaded: %s (%s)", provider.Name, provider.BaseURL)
log.Printf("[API] Provider reloaded from DB: %s (%s)", provider.Name, provider.BaseURL)
respond(w, http.StatusOK, map[string]any{
"ok": true,
"name": provider.Name,
@@ -274,7 +291,7 @@ func (h *Handler) ProvidersReload(w http.ResponseWriter, r *http.Request) {
log.Printf("[API] ProvidersReload: DB error: %v", err)
}
}
respond(w, http.StatusOK, map[string]any{"ok": true, "note": "DB not connected or no active provider"})
respond(w, http.StatusOK, map[string]any{"ok": true, "note": "No provider data received"})
}
// ─── Agents ───────────────────────────────────────────────────────────────────

View File

@@ -56,15 +56,16 @@ async function startServer() {
}
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
res.flushHeaders();
// Pipe the response body
// Pipe the response body — use a single TextDecoder with stream:true
// so multi-byte UTF-8 sequences (Cyrillic, CJK, etc.) are never split
const reader = gwRes.body.getReader();
const decoder = new TextDecoder();
const decoder = new TextDecoder("utf-8");
const pump = async () => {
try {

View File

@@ -193,12 +193,36 @@ export async function deleteProvider(id: number): Promise<{ ok: boolean; error?:
return { ok: true };
}
/** Activates a provider and notifies Go Gateway to reload its config. */
/** Activates a provider and notifies Go Gateway to reload its config with the decrypted key. */
export async function activateProvider(id: number): Promise<void> {
await updateProvider(id, { isActive: true });
// Notify gateway to reload (fire-and-forget)
const gwUrl = process.env.GATEWAY_URL || "http://localhost:18789";
fetch(`${gwUrl}/api/providers/reload`, { method: "POST" }).catch(() => {});
// Notify gateway to reload — pass decrypted key so Go can use it without sharing crypto logic
await notifyGatewayReload();
}
/**
* Reads the active provider (with decrypted key) and pushes it to the Go Gateway.
* Called after any activation/seed so the gateway always has a fresh key.
*/
export async function notifyGatewayReload(): Promise<void> {
try {
const provider = await getActiveProvider();
if (!provider) return;
const gwUrl = process.env.GATEWAY_URL || "http://goclaw-gateway:18789";
await fetch(`${gwUrl}/api/providers/reload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: provider.name,
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
modelDefault: provider.modelDefault,
}),
});
console.log(`[Providers] Gateway reloaded with provider: ${provider.name}`);
} catch (err) {
console.warn("[Providers] Failed to notify gateway:", err);
}
}
/**
@@ -220,4 +244,6 @@ export async function seedDefaultProvider(): Promise<void> {
await createProvider({ name, baseUrl, apiKey, setActive: true, modelDefault: "qwen2.5:7b" });
console.log(`[Providers] Seeded default provider: ${name} (${baseUrl})`);
// Push the active provider to the gateway immediately after seeding
await notifyGatewayReload();
}

View File

@@ -372,8 +372,20 @@ export async function seedDefaults(): Promise<void> {
// Seed default LLM provider from env vars if table is empty
try {
const { seedDefaultProvider } = await import("./providers");
const { seedDefaultProvider, notifyGatewayReload } = await import("./providers");
await seedDefaultProvider();
// Always push the active provider to the gateway on startup (even if already seeded)
// We retry a few times to wait for the gateway to become ready
setTimeout(async () => {
for (let i = 0; i < 5; i++) {
try {
await notifyGatewayReload();
break;
} catch {
await new Promise((r) => setTimeout(r, 3000));
}
}
}, 5000);
} catch (error) {
console.error("[Seed] Failed to seed default provider:", error);
}