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:
481
client/src/lib/chatStore.ts
Normal file
481
client/src/lib/chatStore.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user