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:
bboxwtf
2026-03-21 16:50:44 +00:00
parent 73bfa99c67
commit 471ca42835
9 changed files with 1159 additions and 212 deletions

View File

@@ -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");
}
}

View File

@@ -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"
/>