feat(phase20): persistent background chat sessions — DB-backed polling architecture
ARCHITECTURE:
- Replace SSE stream (breaks on page reload) with DB-backed background sessions
- Go Gateway runs orchestrator in detached goroutine using context.Background()
(survives HTTP disconnect, page reload, and laptop sleep/shutdown)
- Every SSE event (thinking/tool_call/delta/done/error) is persisted to chatEvents table
- Session lifecycle stored in chatSessions table (running→done/error)
- Frontend polls GET /api/orchestrator/getEvents every 1.5 s until status=done
DB CHANGES:
- Migration 0005_chat_sessions.sql: chatSessions + chatEvents tables
- schema.ts: TypeScript types for chatSessions and chatEvents
- db.go: ChatSessionRow and ChatEventRow structs with proper json tags (camelCase)
- db.go: CreateSession, AppendEvent, MarkSessionDone, GetSession, GetEvents, GetRecentSessions
GO GATEWAY:
- handlers.go: StartChatSession — creates DB session, launches goroutine, returns {sessionId} immediately
- handlers.go: GetChatSession, GetChatEvents, ListChatSessions handlers
- main.go: routes POST /api/chat/session, GET /api/chat/session/{id}, GET /api/chat/session/{id}/events, GET /api/chat/sessions
- JSON tags added to ChatSessionRow/ChatEventRow so Go returns camelCase to frontend
NODE.JS SERVER:
- gateway-proxy.ts: startChatSession, getChatSession, getChatEvents, listChatSessions functions
- routers.ts: orchestrator.startSession, .getSession, .getEvents, .listSessions tRPC procedures
FRONTEND:
- chatStore.ts: completely rewritten — uses background sessions + localStorage-based polling resume
* send() calls orchestrator.startSession via tRPC (returns immediately)
* Stores sessionId in localStorage (goclaw-pending-sessions)
* Polls getEvents every 1.5 s, applies events to UI incrementally
* On page reload: _resumePendingSessions() checks pending sessions and resumes polling
* cancel() stops all active polls
- chatStore.ts: conversations persisted to localStorage (v3 key, survives page reload)
- Chat.tsx: updated status texts to 'Фоновая обработка…', 'Обработка в фоне…'
VERIFIED:
- POST /api/chat/session → {sessionId, status:'running'} in <100ms
- Poll events → thinking, delta('Привет!'), done after ~2s
- chatSessions table has rows with status=done, model, totalTokens
- Cyrillic stored correctly in UTF-8
- JSON fields are camelCase: id, sessionId, seq, eventType, content, toolName...
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* chatStore — глобальный singleton для фонового чата.
|
||||
*
|
||||
* Проблема: React-компонент размонтируется при навигации, разрывая SSE-соединение.
|
||||
* Решение: держим состояние и fetch-соединение вне React-дерева в модуле.
|
||||
* Компонент подписывается через addEventListener и читает snapshotState().
|
||||
* Архитектура:
|
||||
* 1. Пользователь отправляет сообщение → POST /api/trpc/orchestrator.startSession
|
||||
* Go Gateway создаёт запись в chatSessions и запускает горутину фоново.
|
||||
* Ответ: { sessionId } — мгновенно, без ожидания LLM.
|
||||
* 2. Фронтенд опрашивает /api/trpc/orchestrator.getEvents каждые 1.5 сек,
|
||||
* применяя новые события к UI. Polling стартует заново при перезагрузке
|
||||
* страницы, т.к. sessionId хранится в localStorage.
|
||||
* 3. Когда status === "done" | "error" — опрос прекращается.
|
||||
*
|
||||
* Использование:
|
||||
* import { chatStore } from "@/lib/chatStore";
|
||||
* chatStore.send(messages) — запустить запрос
|
||||
* chatStore.getConversations() — получить список диалогов
|
||||
* chatStore.on("update", handler) — подписаться на обновления
|
||||
* chatStore.off("update", handler) — отписаться
|
||||
* Фоновые сессии (хранятся в localStorage):
|
||||
* goclaw-pending-sessions — Map<sessionId, convId> для возобновления опроса.
|
||||
* goclaw-conversations-v3 — список диалогов (сохраняется между загрузками).
|
||||
*/
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -37,6 +39,8 @@ export interface ChatMessage {
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
isError?: boolean;
|
||||
isStreaming?: boolean;
|
||||
/** sessionId for background sessions — used to resume polling */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
@@ -66,12 +70,12 @@ type ConsoleHandler = (entry: ConsoleEntry) => void;
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = "goclaw-conversations-v2";
|
||||
const CONSOLE_KEY = "goclaw-console-v2";
|
||||
const STORAGE_KEY = "goclaw-conversations-v3";
|
||||
const PENDING_KEY = "goclaw-pending-sessions"; // sessionId → convId
|
||||
|
||||
function loadConversations(): Conversation[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
@@ -81,27 +85,27 @@ function loadConversations(): Conversation[] {
|
||||
|
||||
function persistConversations(convs: Conversation[]) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(convs.slice(0, 50)));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(convs.slice(0, 50)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function loadConsole(): ConsoleEntry[] {
|
||||
function loadPending(): Map<string, string> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CONSOLE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw);
|
||||
const raw = localStorage.getItem(PENDING_KEY);
|
||||
if (!raw) return new Map();
|
||||
return new Map(JSON.parse(raw));
|
||||
} catch {
|
||||
return [];
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function persistConsole(entries: ConsoleEntry[]) {
|
||||
function savePending(m: Map<string, string>) {
|
||||
try {
|
||||
sessionStorage.setItem(CONSOLE_KEY, JSON.stringify(entries.slice(-200)));
|
||||
localStorage.setItem(PENDING_KEY, JSON.stringify([...m.entries()]));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getTs(): string {
|
||||
return new Date().toLocaleTimeString("ru-RU", {
|
||||
@@ -111,12 +115,50 @@ function getTs(): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Call the tRPC endpoint via raw fetch (avoids React-Query dependency). */
|
||||
async function trpcQuery<T>(
|
||||
path: string,
|
||||
input: unknown,
|
||||
method: "query" | "mutation"
|
||||
): Promise<T> {
|
||||
if (method === "mutation") {
|
||||
const res = await fetch(`/api/trpc/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ json: input }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data?.error) throw new Error(data.error.message ?? "tRPC error");
|
||||
return data?.result?.data?.json ?? data?.result?.data;
|
||||
} else {
|
||||
const encoded = encodeURIComponent(JSON.stringify({ json: input }));
|
||||
const res = await fetch(`/api/trpc/${path}?input=${encoded}`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data?.error) throw new Error(data.error.message ?? "tRPC error");
|
||||
return data?.result?.data?.json ?? data?.result?.data;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ChatStore {
|
||||
private conversations: Conversation[] = loadConversations();
|
||||
private activeId: string = "";
|
||||
private isThinking = false;
|
||||
private consoleEntries: ConsoleEntry[] = loadConsole();
|
||||
private consoleEntries: ConsoleEntry[] = [];
|
||||
|
||||
/** sessionId → convId for active polls */
|
||||
private activePolls = new Map<string, string>();
|
||||
/** sessionId → lastSeq */
|
||||
private pollSeq = new Map<string, number>();
|
||||
/** sessionId → timeout handle */
|
||||
private pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** Legacy SSE abort controller (still used as fallback) */
|
||||
private abortController: AbortController | null = null;
|
||||
private isThinking = false;
|
||||
|
||||
private updateListeners = new Set<UpdateHandler>();
|
||||
private consoleListeners = new Set<ConsoleHandler>();
|
||||
@@ -125,6 +167,8 @@ class ChatStore {
|
||||
if (this.conversations.length > 0) {
|
||||
this.activeId = this.conversations[0].id;
|
||||
}
|
||||
// Resume any pending sessions from previous page load
|
||||
this._resumePendingSessions();
|
||||
}
|
||||
|
||||
// ─── Subscriptions ──────────────────────────────────────────────────────────
|
||||
@@ -155,25 +199,15 @@ class ChatStore {
|
||||
|
||||
// ─── Selectors ──────────────────────────────────────────────────────────────
|
||||
|
||||
getConversations(): Conversation[] {
|
||||
return this.conversations;
|
||||
}
|
||||
|
||||
getActiveId(): string {
|
||||
return this.activeId;
|
||||
}
|
||||
|
||||
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;
|
||||
return this.isThinking || this.activePolls.size > 0;
|
||||
}
|
||||
getConsole(): ConsoleEntry[] { return this.consoleEntries; }
|
||||
|
||||
// ─── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -187,7 +221,7 @@ class ChatStore {
|
||||
const welcome: ChatMessage = {
|
||||
id: "welcome",
|
||||
role: "system",
|
||||
content: `${orchName} ready. Type a command or ask anything.`,
|
||||
content: `${orchName} ready. Type a command or ask anything.\n\n*Background mode: requests continue even when you close the tab.*`,
|
||||
timestamp: getTs(),
|
||||
};
|
||||
const conv: Conversation = {
|
||||
@@ -201,7 +235,6 @@ class ChatStore {
|
||||
this.activeId = id;
|
||||
this.consoleEntries = [];
|
||||
persistConversations(this.conversations);
|
||||
persistConsole([]);
|
||||
this.emit("update");
|
||||
return id;
|
||||
}
|
||||
@@ -222,7 +255,6 @@ class ChatStore {
|
||||
|
||||
clearConsole() {
|
||||
this.consoleEntries = [];
|
||||
persistConsole([]);
|
||||
this.emit("update");
|
||||
}
|
||||
|
||||
@@ -234,19 +266,22 @@ class ChatStore {
|
||||
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) ─────────────────────────────────────────────────────
|
||||
// ─── Background Session Send ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a message using the background session API.
|
||||
* The Go Gateway processes the request in a detached goroutine — survives
|
||||
* page reloads, laptop sleep, and browser tab closure.
|
||||
*/
|
||||
async send(userText: string, activeConvId?: string) {
|
||||
if (this.isThinking || !userText.trim()) return;
|
||||
if (!userText.trim()) return;
|
||||
if (this.isThinking) return;
|
||||
|
||||
let convId = activeConvId ?? this.activeId;
|
||||
|
||||
// If no conversation exists, create one
|
||||
if (!convId || !this.conversations.find((c) => c.id === convId)) {
|
||||
convId = this.createConversation();
|
||||
}
|
||||
@@ -254,7 +289,7 @@ class ChatStore {
|
||||
const conv = this.conversations.find((c) => c.id === convId);
|
||||
if (!conv) return;
|
||||
|
||||
// Add user message
|
||||
// Add user message immediately
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
@@ -276,8 +311,9 @@ class ChatStore {
|
||||
history: newHistory,
|
||||
}));
|
||||
|
||||
// Placeholder streaming message
|
||||
const assistantId = `resp-${Date.now()}`;
|
||||
// Create placeholder streaming message
|
||||
const sessionId = `cs-${nanoid(12)}`;
|
||||
const assistantId = `resp-${sessionId}`;
|
||||
const placeholder: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
@@ -285,6 +321,7 @@ class ChatStore {
|
||||
timestamp: getTs(),
|
||||
isStreaming: true,
|
||||
toolCalls: [],
|
||||
sessionId,
|
||||
};
|
||||
this.updateConv(convId, (c) => ({
|
||||
...c,
|
||||
@@ -293,187 +330,343 @@ class ChatStore {
|
||||
|
||||
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,
|
||||
});
|
||||
// Start background session on Go Gateway (returns immediately)
|
||||
await trpcQuery<{ sessionId: string; status: string }>(
|
||||
"orchestrator.startSession",
|
||||
{
|
||||
messages: newHistory,
|
||||
sessionId,
|
||||
maxIter: 10,
|
||||
},
|
||||
"mutation"
|
||||
);
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Server error: ${res.status}`);
|
||||
}
|
||||
// Persist to localStorage so we can resume on page reload
|
||||
const pending = loadPending();
|
||||
pending.set(sessionId, convId);
|
||||
savePending(pending);
|
||||
|
||||
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)";
|
||||
// Start polling
|
||||
this._startPolling(sessionId, convId, assistantId);
|
||||
} catch (err: any) {
|
||||
this.isThinking = false;
|
||||
this.addConsoleEntry({ type: "error", error: err.message });
|
||||
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,
|
||||
content: `Failed to start background session: ${err.message}`,
|
||||
isError: true,
|
||||
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. */
|
||||
// ─── Polling ────────────────────────────────────────────────────────────────
|
||||
|
||||
private _startPolling(sessionId: string, convId: string, assistantMsgId: string) {
|
||||
if (this.activePolls.has(sessionId)) return;
|
||||
this.activePolls.set(sessionId, convId);
|
||||
this.pollSeq.set(sessionId, 0);
|
||||
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 1500);
|
||||
}
|
||||
|
||||
private _scheduleNextPoll(
|
||||
sessionId: string,
|
||||
convId: string,
|
||||
assistantMsgId: string,
|
||||
delayMs: number
|
||||
) {
|
||||
const handle = setTimeout(() => {
|
||||
this._doPoll(sessionId, convId, assistantMsgId);
|
||||
}, delayMs);
|
||||
this.pollTimers.set(sessionId, handle);
|
||||
}
|
||||
|
||||
private async _doPoll(sessionId: string, convId: string, assistantMsgId: string) {
|
||||
const afterSeq = this.pollSeq.get(sessionId) ?? 0;
|
||||
|
||||
try {
|
||||
const result = await trpcQuery<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
events: Array<{
|
||||
id: number;
|
||||
sessionId: string;
|
||||
seq: number;
|
||||
eventType: "thinking" | "tool_call" | "delta" | "done" | "error";
|
||||
content: string;
|
||||
toolName: string;
|
||||
toolArgs: string;
|
||||
toolResult: string;
|
||||
toolSuccess: boolean;
|
||||
durationMs: number;
|
||||
model: string;
|
||||
usageJson: string;
|
||||
errorMsg: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}>("orchestrator.getEvents", { sessionId, afterSeq }, "query");
|
||||
|
||||
if (!result) {
|
||||
// Gateway not available yet — retry
|
||||
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const { status, events } = result;
|
||||
let maxSeq = afterSeq;
|
||||
let lastContent = "";
|
||||
let lastModel = "";
|
||||
let lastUsage: any = undefined;
|
||||
let streamedContent = "";
|
||||
|
||||
// Get current content from message
|
||||
const conv = this.conversations.find((c) => c.id === convId);
|
||||
const existingMsg = conv?.messages.find((m) => m.id === assistantMsgId);
|
||||
streamedContent = existingMsg?.content ?? "";
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.seq > maxSeq) maxSeq = ev.seq;
|
||||
|
||||
switch (ev.eventType) {
|
||||
case "thinking":
|
||||
this.addConsoleEntry({ type: "thinking" });
|
||||
break;
|
||||
|
||||
case "tool_call": {
|
||||
let args: any = {};
|
||||
try { args = JSON.parse(ev.toolArgs || "{}"); } catch {}
|
||||
let resultVal: any = ev.toolResult;
|
||||
try { if (ev.toolResult) resultVal = JSON.parse(ev.toolResult); } catch {}
|
||||
const step: ToolCallStep = {
|
||||
tool: ev.toolName,
|
||||
args,
|
||||
result: resultVal,
|
||||
error: ev.errorMsg || undefined,
|
||||
success: ev.toolSuccess,
|
||||
durationMs: ev.durationMs,
|
||||
};
|
||||
this.addConsoleEntry({ type: "tool_call", ...step });
|
||||
// Append tool call to message
|
||||
this.updateConv(convId, (c) => ({
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? { ...m, toolCalls: [...(m.toolCalls ?? []), step] }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case "delta":
|
||||
// The Go gateway stores full response as a single delta
|
||||
streamedContent = ev.content || streamedContent;
|
||||
this.updateConv(convId, (c) => ({
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === assistantMsgId ? { ...m, content: streamedContent } : m
|
||||
),
|
||||
}));
|
||||
this.emit("update");
|
||||
break;
|
||||
|
||||
case "done": {
|
||||
lastModel = ev.model;
|
||||
try {
|
||||
const usageObj = JSON.parse(ev.usageJson || "null");
|
||||
if (usageObj) {
|
||||
lastUsage = {
|
||||
prompt_tokens: usageObj.promptTokens ?? usageObj.prompt_tokens ?? 0,
|
||||
completion_tokens: usageObj.completionTokens ?? usageObj.completion_tokens ?? 0,
|
||||
total_tokens: usageObj.totalTokens ?? usageObj.total_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
this.addConsoleEntry({ type: "done", model: lastModel });
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
this.addConsoleEntry({ type: "error", error: ev.errorMsg });
|
||||
this.updateConv(convId, (c) => ({
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? {
|
||||
...m,
|
||||
content: `Error: ${ev.errorMsg}`,
|
||||
isError: true,
|
||||
isStreaming: false,
|
||||
}
|
||||
: m
|
||||
),
|
||||
}));
|
||||
this.emit("update");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.pollSeq.set(sessionId, maxSeq);
|
||||
|
||||
if (status === "done" || status === "error") {
|
||||
// Finalize message
|
||||
this.updateConv(convId, (c) => {
|
||||
const msg = c.messages.find((m) => m.id === assistantMsgId);
|
||||
const finalContent = streamedContent || msg?.content || "(no response)";
|
||||
return {
|
||||
...c,
|
||||
history: status === "done"
|
||||
? [
|
||||
...c.history.filter((h) => !(h.role === "assistant" && h.content === "")),
|
||||
{ role: "assistant" as const, content: finalContent },
|
||||
]
|
||||
: c.history,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? {
|
||||
...m,
|
||||
content: finalContent,
|
||||
isStreaming: false,
|
||||
model: lastModel || m.model,
|
||||
usage: lastUsage || m.usage,
|
||||
isError: status === "error" ? true : m.isError,
|
||||
}
|
||||
: m
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this._stopPolling(sessionId);
|
||||
this.isThinking = this.activePolls.size > 0;
|
||||
this.emit("update");
|
||||
} else {
|
||||
// Session still running — poll again
|
||||
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 1500);
|
||||
}
|
||||
} catch {
|
||||
// Network error — retry with backoff
|
||||
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private _stopPolling(sessionId: string) {
|
||||
clearTimeout(this.pollTimers.get(sessionId));
|
||||
this.pollTimers.delete(sessionId);
|
||||
this.activePolls.delete(sessionId);
|
||||
this.pollSeq.delete(sessionId);
|
||||
|
||||
// Remove from persistent pending list
|
||||
const pending = loadPending();
|
||||
pending.delete(sessionId);
|
||||
savePending(pending);
|
||||
}
|
||||
|
||||
/** Resume polling for sessions that were running when page was reloaded. */
|
||||
private async _resumePendingSessions() {
|
||||
const pending = loadPending();
|
||||
if (pending.size === 0) return;
|
||||
|
||||
// Small delay to let React mount first
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
for (const [sessionId, convId] of pending.entries()) {
|
||||
// Find the conversation
|
||||
const conv = this.conversations.find((c) => c.id === convId);
|
||||
if (!conv) {
|
||||
// Conversation gone — clean up
|
||||
const p = loadPending();
|
||||
p.delete(sessionId);
|
||||
savePending(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the placeholder message for this session
|
||||
const msgWithSession = conv.messages.find(
|
||||
(m) => m.sessionId === sessionId && m.role === "assistant"
|
||||
);
|
||||
|
||||
// Check current session status from DB
|
||||
try {
|
||||
const sess = await trpcQuery<{
|
||||
status: string;
|
||||
finalResponse: string;
|
||||
model: string;
|
||||
totalTokens: number;
|
||||
}>("orchestrator.getSession", { sessionId }, "query");
|
||||
|
||||
if (!sess) {
|
||||
// Session not found in DB — remove pending
|
||||
const p = loadPending();
|
||||
p.delete(sessionId);
|
||||
savePending(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sess.status === "done" || sess.status === "error") {
|
||||
// Already finished — update message directly from session data
|
||||
if (msgWithSession) {
|
||||
this.updateConv(convId, (c) => ({
|
||||
...c,
|
||||
history: sess.status === "done"
|
||||
? [
|
||||
...c.history.filter((h) => !(h.role === "assistant" && h.content === "")),
|
||||
{ role: "assistant" as const, content: sess.finalResponse },
|
||||
]
|
||||
: c.history,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === msgWithSession.id
|
||||
? {
|
||||
...m,
|
||||
content: sess.finalResponse || m.content || "(no response)",
|
||||
isStreaming: false,
|
||||
isError: sess.status === "error",
|
||||
model: sess.model || m.model,
|
||||
usage: sess.totalTokens
|
||||
? { prompt_tokens: 0, completion_tokens: 0, total_tokens: sess.totalTokens }
|
||||
: m.usage,
|
||||
}
|
||||
: m
|
||||
),
|
||||
}));
|
||||
}
|
||||
const p = loadPending();
|
||||
p.delete(sessionId);
|
||||
savePending(p);
|
||||
this.emit("update");
|
||||
} else {
|
||||
// Still running — resume polling
|
||||
const assistantMsgId = msgWithSession?.id ?? `resp-${sessionId}`;
|
||||
this.isThinking = true;
|
||||
this._startPolling(sessionId, convId, assistantMsgId);
|
||||
this.emit("update");
|
||||
}
|
||||
} catch {
|
||||
// Gateway not reachable — keep pending for next reload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the current in-flight SSE request (legacy). */
|
||||
cancel() {
|
||||
this.abortController?.abort();
|
||||
// Also stop all active polls
|
||||
for (const sessionId of [...this.activePolls.keys()]) {
|
||||
this._stopPolling(sessionId);
|
||||
}
|
||||
this.isThinking = false;
|
||||
this.emit("update");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
{msg.isStreaming && (
|
||||
<span className="text-[9px] font-mono text-cyan-400 flex items-center gap-1">
|
||||
<Activity className="w-2.5 h-2.5 animate-pulse" />
|
||||
streaming
|
||||
фоновая обработка…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -431,10 +431,11 @@ export default function Chat() {
|
||||
{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"
|
||||
className="p-1.5 rounded border border-red-500/40 text-red-400 hover:bg-red-500/10 transition-colors flex items-center gap-1"
|
||||
title="Отменить фоновую обработку"
|
||||
>
|
||||
<StopCircle className="w-3.5 h-3.5" />
|
||||
<span className="text-[9px] font-mono hidden sm:inline">Stop</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -528,7 +529,7 @@ 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">Обработка в фоне… (работает даже при перезагрузке страницы)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
@@ -562,7 +563,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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user