From 943bf6321fc3474a3561fe86bfee7caae4722e2b Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Fri, 3 Apr 2026 22:49:46 -0300 Subject: [PATCH] fix: preserve model reasoning when visible response is empty When a model generates only a reasoning block (...) with no visible content, the reasoning was previously lost because the DB insert omitted the reasoning_content column while using a fallback error message. This separates the hasReasoningOnly condition from the corrupted/transcript leakage conditions, ensuring the reasoning is properly saved and the user sees a specific placeholder. --- tauri-app/src/components/ChatView.test.tsx | 219 ++++- tauri-app/src/components/ChatView.tsx | 1022 ++++++++++++-------- 2 files changed, 828 insertions(+), 413 deletions(-) diff --git a/tauri-app/src/components/ChatView.test.tsx b/tauri-app/src/components/ChatView.test.tsx index a960170..dfadf27 100644 --- a/tauri-app/src/components/ChatView.test.tsx +++ b/tauri-app/src/components/ChatView.test.tsx @@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client"; import { act } from "react-dom/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseAssistantResponse } from "../lib/chat"; import type { DB } from "../lib/db"; import ChatView from "./ChatView"; import type { Message } from "../lib/types"; @@ -26,7 +27,7 @@ const defaultChatViewProps = { status: "error" as import("../lib/types").ServerStatus, }; -describe("ChatView", () => { +describe.sequential("ChatView", () => { let container: HTMLDivElement; let root: Root; @@ -43,6 +44,7 @@ describe("ChatView", () => { act(() => root.unmount()); container.remove(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); // @ts-expect-error React test environment flag globalThis.IS_REACT_ACT_ENVIRONMENT = false; }); @@ -78,7 +80,7 @@ describe("ChatView", () => { }); expect(textarea?.value).toBe("Keep this draft"); - expect(container.textContent).toContain("Runtime offline"); + expect(container.textContent).toContain("Connect runtime"); expect(vi.mocked(db.run)).not.toHaveBeenCalled(); }); @@ -251,7 +253,7 @@ describe("ChatView", () => { textarea!.dispatchEvent(new Event("input", { bubbles: true })); }); - const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send")); + const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null; expect(sendButton).toBeTruthy(); await act(async () => { @@ -295,6 +297,115 @@ describe("ChatView", () => { expect(container.textContent).not.toContain("Late answer from first chat"); }); + it("sanitizes leaked assistant transcript before sending history back to the model", async () => { + // The DB now stores already-cleaned content (leakage is stripped before insert). + // buildApiMessages trusts the DB content and only drops fully-corrupted rows. + // Messages that were fully leakage (no useful prefix) are saved as a ⚠️ placeholder + // and excluded by the NOT LIKE '⚠️%' filter, so they never reach the model. + const rowsByChat = new Map([ + [1, [ + { id: 11, chat_id: 1, role: "user", content: "Mensagem anterior", created_at: 1 }, + { + id: 12, + chat_id: 1, + role: "assistant", + // Simulates what is stored AFTER leakage strip: only the useful prefix. + content: "Resposta útil.", + created_at: 2, + }, + // id: 13 was fully corrupted (no useful prefix), saved as ⚠️ placeholder. + // It is excluded by the NOT LIKE '⚠️%' filter in buildApiMessages. + ]], + ]); + + const db = { + insert: vi.fn(() => 1), + query: vi.fn((sql: string, params?: unknown[]) => { + if (sql.includes("SELECT role, content FROM messages WHERE chat_id = ?")) { + return [...(rowsByChat.get(Number(params?.[0])) ?? [])].map(({ role, content }) => ({ role, content })); + } + if (sql.includes("FROM messages WHERE chat_id = ?")) { + return [...(rowsByChat.get(Number(params?.[0])) ?? [])]; + } + return []; + }), + get: vi.fn((sql: string, params?: unknown[]) => { + if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) { + return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 }; + } + return null; + }), + run: vi.fn((sql: string, params?: unknown[]) => { + if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) { + const chatId = Number(params?.[0]); + const content = String(params?.[1] ?? ""); + const next = rowsByChat.get(chatId) ?? []; + next.push({ id: 99, chat_id: chatId, role: "user", content, created_at: 99 }); + rowsByChat.set(chatId, next); + } + }), + persist: vi.fn(), + } as unknown as DB; + + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: [DONE]\n")); + controller.close(); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ), + ); + vi.stubGlobal("fetch", fetchSpy); + + await act(async () => { + root.render( + , + ); + }); + + const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; + expect(textarea).toBeTruthy(); + const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + + await act(async () => { + setTextareaValue?.call(textarea, "Nova pergunta"); + textarea!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null; + await act(async () => { + sendButton?.click(); + await Promise.resolve(); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const call = fetchSpy.mock.calls[0] as unknown as [string, RequestInit]; + const [, init] = call; + const body = JSON.parse(String(init.body)); + + // System prompt is always prepended as the first message + expect(body.messages[0].role).toBe("system"); + expect(body.messages.slice(1)).toEqual([ + { role: "user", content: "Nova pergunta" }, + { role: "assistant", content: "Resposta útil." }, + { role: "user", content: "Mensagem anterior" }, + ]); + }); + it("shows assistant reply after generation completes in an existing chat", async () => { const rowsByChat = new Map([ [1, [{ id: 11, chat_id: 1, role: "user", content: "Previous message", created_at: 1 }]], @@ -374,7 +485,7 @@ describe("ChatView", () => { textarea!.dispatchEvent(new Event("input", { bubbles: true })); }); - const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send")); + const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null; await act(async () => { sendButton?.click(); }); // During generation, user message should be visible @@ -446,7 +557,7 @@ describe("ChatView", () => { textarea!.dispatchEvent(new Event("input", { bubbles: true })); }); - const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send")); + const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null; expect(sendButton).toBeTruthy(); await act(async () => { @@ -478,4 +589,102 @@ describe("ChatView", () => { expect(requestSignal.aborted).toBe(true); }); + + it("derives visible content and reasoning from think blocks before persistence", () => { + const parsed = parseAssistantResponse("analise internaResposta util final"); + + expect(parsed.content).toBe("Resposta util final"); + expect(parsed.reasoning).toBe("analise interna"); + expect(parsed.thinkingOpen).toBe(false); + }); + + it("stores a controlled placeholder when the model starts inside a leaked code fence transcript", async () => { + const rowsByChat = new Map([[1, []]]); + let nextMessageId = 1; + const db = { + insert: vi.fn(() => 1), + query: vi.fn((sql: string, params?: unknown[]) => { + if (sql.includes("FROM messages WHERE chat_id = ?")) { + return [...(rowsByChat.get(Number(params?.[0])) ?? [])]; + } + return []; + }), + get: vi.fn(() => null), + run: vi.fn((sql: string, params?: unknown[]) => { + if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) { + const chatId = Number(params?.[0]); + const content = String(params?.[1] ?? ""); + const next = rowsByChat.get(chatId) ?? []; + next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId }); + rowsByChat.set(chatId, next); + } + if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)")) { + const chatId = Number(params?.[0]); + const content = String(params?.[1] ?? ""); + const next = rowsByChat.get(chatId) ?? []; + next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId }); + rowsByChat.set(chatId, next); + } + }), + persist: vi.fn(), + } as unknown as DB; + + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"\\n\\n```python\\n# System: You are a precise coding assistant running locally.\\nUser: teste mensagem\\nAssistant: ```\"}}]}\n")); + controller.enqueue(encoder.encode("data: [DONE]\n")); + controller.close(); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ), + ), + ); + + await act(async () => { + root.render( + , + ); + }); + + const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; + const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null; + const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + + await act(async () => { + setTextareaValue?.call(textarea, "teste mensagem"); + textarea!.dispatchEvent(new Event("input", { bubbles: true })); + sendButton?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const runCalls = vi.mocked(db.run).mock.calls as Array<[string, unknown[]?]>; + expect( + runCalls.some( + ([sql, params]) => + sql === "INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)" + && Array.isArray(params) + && params[0] === 1 + && params[1] === "⚠️ O modelo gerou uma resposta corrompida ou vazou o prompt de sistema.", + ), + ).toBe(true); + }); }); diff --git a/tauri-app/src/components/ChatView.tsx b/tauri-app/src/components/ChatView.tsx index c622d40..2b02abd 100644 --- a/tauri-app/src/components/ChatView.tsx +++ b/tauri-app/src/components/ChatView.tsx @@ -1,23 +1,49 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { DB } from "../lib/db"; -import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus } from "../lib/types"; +import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus, UserMemory } from "../lib/types"; import type { RuntimeModelFile, RuntimeStatus } from "../features/runtime/types"; -import { buildChatTitle, CHAT_TRANSCRIPT_STOP_SEQUENCES, parseAssistantResponse } from "../lib/chat"; +import { buildChatTitle, parseAssistantResponse, isLoopedOrCorruptedResponse, stripPromptEchoAndTranscriptLeakage } from "../lib/chat"; +import { AI_CONTEXT_LENGTH, AI_MAX_HISTORY_MESSAGES, AI_STREAM_DEFAULT } from "../config/ai"; +import { STOP_SEQUENCES } from "../prompts/response-rules"; +import { SYSTEM_PROMPT } from "../prompts/system"; import { formatChatDebugTranscript } from "../lib/chatDebug"; import { renderMarkdown, ensureHljs } from "../lib/markdown"; +import { sanitizeMessageText } from "../lib/messageSanitizer"; +import { devLog } from "../lib/devLogger"; import { getComposerNotice } from "./chat-view-state"; import MessageBubble from "./MessageBubble"; -import ModelSelectorPill from "./ModelSelectorPill"; +import StreamingBubble from "./StreamingBubble"; import { AgenticToggle } from "./forgeagent"; -import { getDisplayName } from "../lib/modelAlias"; -import { shouldUseAgenticMode } from "../lib/agentic-detection"; -import { Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight, Plus, FolderSimple, Monitor } from "@phosphor-icons/react"; +// agentic-detection: shouldUseAgenticMode removed from auto-trigger — agentic mode is manual-only now +import { Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight, Database, Lightning, Plus, Monitor, Globe, Palette, Code } from "@phosphor-icons/react"; import "./ChatView.css"; -const MAX_MEMORY = 20; +function scoreBrokenEncoding(text: string): number { + const replacement = (text.match(/�/g) ?? []).length; + const mojibake = (text.match(/(?:Ã.|â.|ð.|œ|ž)/g) ?? []).length; + return replacement * 4 + mojibake; +} -/** Model used for background memory extraction (small/fast). */ -const MEMORY_EXTRACTOR_MODEL = "smollm2"; +function createStreamChunkDecoder() { + const utf8Decoder = new TextDecoder("utf-8"); + const windows1252Decoder = new TextDecoder("windows-1252"); + + return { + decode(value: Uint8Array): string { + const utf8 = utf8Decoder.decode(value, { stream: true }); + const latin1 = windows1252Decoder.decode(value, { stream: true }); + return scoreBrokenEncoding(latin1) < scoreBrokenEncoding(utf8) ? latin1 : utf8; + }, + flush(): string { + const utf8 = utf8Decoder.decode(); + const latin1 = windows1252Decoder.decode(); + if (!utf8 && !latin1) return ""; + return scoreBrokenEncoding(latin1) < scoreBrokenEncoding(utf8) ? latin1 : utf8; + }, + }; +} + +const MAX_MEMORY = 20; interface Props { db: DB; @@ -37,16 +63,25 @@ interface Props { showDebugTools?: boolean; onChatCreated: (id: number) => void; onChatUpdated: () => void; + onNewChat?: () => void; onOpenSidebar?: () => void; onOpenSettings?: () => void; onDegradationDetected?: () => void; + /** Lift generating state to parent for header display */ + onGeneratingChange?: (v: boolean) => void; + /** Lift stream token count to parent for header display */ + onStreamTokenCountChange?: (v: number) => void; + /** Lift TPS to parent for header display */ + onTpsChange?: (v: number | null) => void; + onChatTokenTotalsChange?: (totals: ChatTokenTotals) => void; } export default function ChatView({ db, chatId, server, apiKey, model, models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status, runtimeMode = "remote", runtimeStatus = "ready", showDebugTools = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV), - onChatCreated, onChatUpdated, onOpenSidebar, onOpenSettings, onDegradationDetected + onChatCreated, onChatUpdated, onNewChat, onOpenSidebar, onOpenSettings, onDegradationDetected, + onGeneratingChange, onStreamTokenCountChange, onTpsChange, onChatTokenTotalsChange, }: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -54,11 +89,12 @@ export default function ChatView({ const [streamContent, setStreamContent] = useState(""); const [streamReasoning, setStreamReasoning] = useState(""); const [streamThinkingOpen, setStreamThinkingOpen] = useState(false); - const [showLiveReasoning, setShowLiveReasoning] = useState(false); - const [tps, setTps] = useState(null); - const [useStream, setUseStream] = useState(true); + const [tps, setTpsLocal] = useState(null); + const setTps = (v: number | null) => { setTpsLocal(v); onTpsChange?.(v); }; + const [useStream, setUseStream] = useState(AI_STREAM_DEFAULT); const [useMemory, setUseMemory] = useState(true); - const [streamTokenCount, setStreamTokenCount] = useState(0); + const [streamTokenCount, setStreamTokenCountLocal] = useState(0); + const setStreamTokenCount = (v: number) => { setStreamTokenCountLocal(v); onStreamTokenCountChange?.(v); }; const [messageQueue, setMessageQueue] = useState([]); const [showMemoryModal, setShowMemoryModal] = useState(false); const [isQueueOpen, setIsQueueOpen] = useState(false); @@ -73,6 +109,7 @@ export default function ChatView({ const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {}); const messageQueueRef = useRef([]); const sendRef = useRef<(text: string, fromChatId?: number, replaceAssistantId?: number) => Promise>(async () => {}); + const optimisticAssistantRef = useRef(null); // Tracks accumulated stream content so abort can persist it const streamContentAccRef = useRef(""); // TPS degradation detection: timer fires if TPS stays < 30 for 5s @@ -102,6 +139,15 @@ export default function ChatView({ ) : 0; + useEffect(() => { + onChatTokenTotalsChange?.(chatTotals); + }, [ + chatTotals.prompt_tokens, + chatTotals.completion_tokens, + chatTotals.total_tokens, + onChatTokenTotalsChange, + ]); + useEffect(() => { ensureHljs(); }, []); @@ -132,12 +178,34 @@ export default function ChatView({ }, [tps, onDegradationDetected]); const loadMessages = useCallback((targetChatId: number | null = chatId) => { - if (targetChatId == null) { setMessages([]); return; } + const effectiveChatId = targetChatId ?? pendingRequestChatIdRef.current ?? activeChatIdRef.current; + if (effectiveChatId == null) { setMessages([]); return; } const rows = db.query( "SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC", - [targetChatId], + [effectiveChatId], ); - setMessages(rows.map((row) => ({ ...row }))); + + const normalizedRows = rows.map((row) => ({ ...row })); + const optimisticAssistant = optimisticAssistantRef.current; + + if ( + optimisticAssistant && + optimisticAssistant.chat_id === effectiveChatId && + !normalizedRows.some((row) => row.id === optimisticAssistant.id) + ) { + setMessages([...normalizedRows, optimisticAssistant]); + return; + } + + if ( + optimisticAssistant && + optimisticAssistant.chat_id === effectiveChatId && + normalizedRows.some((row) => row.id === optimisticAssistant.id) + ) { + optimisticAssistantRef.current = null; + } + + setMessages(normalizedRows); }, [db, chatId]); loadMessagesRef.current = loadMessages; @@ -158,7 +226,6 @@ export default function ChatView({ setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); - setShowLiveReasoning(false); setTps(null); setStreamTokenCount(0); pendingRequestChatIdRef.current = null; @@ -170,7 +237,6 @@ export default function ChatView({ setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); - setShowLiveReasoning(false); setTps(null); setStreamTokenCount(0); } @@ -205,32 +271,76 @@ export default function ChatView({ const buildApiMessages = useCallback((cid: number) => { const history = db.query<{ role: string; content: string }>( - "SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?", - [cid, 20], + "SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE 'internal error' AND content NOT LIKE '⚠%' ORDER BY created_at DESC LIMIT ?", + [cid, AI_MAX_HISTORY_MESSAGES], ).reverse(); - // Strip any leaked reasoning/transcript from assistant messages before sending back to model - return history.map((msg) => { - if (msg.role !== "assistant") return msg; - // Remove content after transcript leakage markers - const leakageIdx = msg.content.search(/(?:^|\n)\s*(?:System|User|Assistant|Human):/i); - const cleaned = leakageIdx > 0 ? msg.content.slice(0, leakageIdx).trimEnd() : msg.content; - return { ...msg, content: cleaned }; - }).filter((msg) => msg.content.trim() !== ""); + + // Defence-in-depth: re-strip leakage from DB content. + // Ideally the DB only contains clean data, but older sessions may have + // persisted "ola\nUser" or "User\nUser" before the leakage pattern was + // broad enough to catch bare role-names without colons. + // This re-strip is safe because it uses the same function used before + // insertion — applying it twice on clean content is a no-op. + const historyMessages = history + .map((msg) => { + if (msg.role !== "assistant") return msg; + const cleaned = stripPromptEchoAndTranscriptLeakage(msg.content).trimEnd(); + const sanitized = sanitizeMessageText(cleaned); + devLog.logSanitization("buildApiMessages:assistant-history", { + before: msg.content, + after: sanitized.text, + changed: sanitized.text !== msg.content, + repairedEncoding: sanitized.repairedEncoding, + }); + if (!sanitized.text || isLoopedOrCorruptedResponse(sanitized.text)) { + return { ...msg, content: "" }; + } + return { ...msg, content: sanitized.text }; + }) + .filter((msg) => msg.content.trim() !== ""); + + // Prepend a minimal system prompt so models like Qwen3 0.8B/2B/4B + // understand they are a general-purpose assistant and don't refuse + // benign queries like "teste" by imagining they're restricted. + return [ + { role: "system", content: SYSTEM_PROMPT }, + ...historyMessages, + ]; }, [db]); + const appendPersistedAssistantMessage = useCallback((message: Message) => { + optimisticAssistantRef.current = message; + + if (activeChatIdRef.current !== message.chat_id) return; + + setMessages((prev) => { + if (prev.some((entry) => entry.id === message.id)) return prev; + return [...prev, message]; + }); + }, []); + const send = useCallback(async (text: string, fromChatId?: number, replaceAssistantId?: number) => { if (!text.trim() || generating) return; if (getComposerNotice(model, server)) return; - const isAgenticRequest = agenticMode || shouldUseAgenticMode(text); + // Agentic mode is ONLY activated via the explicit toggle — never auto-detected. + // Auto-detection was triggering on benign messages like "como fazer um carro?" + // and injecting the ForgeAgent system prompt, causing confusing refusals on + // small Qwen3 models (0.8B, 2B, 4B). + const isAgenticRequest = agenticMode; - setGenerating(true); + setGenerating(true); onGeneratingChange?.(true); setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); - setShowLiveReasoning(false); setStreamTokenCount(0); streamContentAccRef.current = ""; + // ── DevLogger: start interaction tracking ── + devLog.startInteraction(chatId, text, { + memoryEnabled: useMemory, + agenticMode, + }); + const cid = fromChatId ?? getOrCreateChat(text); activeChatIdRef.current = cid; pendingRequestChatIdRef.current = cid; @@ -253,23 +363,40 @@ export default function ChatView({ const headers: Record = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + // ── DevLogger: log context and request ── + devLog.logContext(apiMessages); + abortRef.current = new AbortController(); + // Build the request body with minimal parameters — no system prompts, + // no stop sequences, no response format constraints. Let the model run freely. + // Context window is the only hard limit we enforce. + const requestBody: Record = { + model, + messages: apiMessages, + stream: useStream, + stream_options: useStream ? { include_usage: true } : undefined, + num_ctx: AI_CONTEXT_LENGTH, + max_tokens: 1024, + }; + // Only include stop sequences if explicitly configured (currently empty) + if (STOP_SEQUENCES.length > 0) { + requestBody.stop = STOP_SEQUENCES; + } + + devLog.logRequest(`${server}/v1/chat/completions`, requestBody, headers); + try { const res = await fetch(`${server}/v1/chat/completions`, { method: "POST", headers, signal: abortRef.current.signal, - body: JSON.stringify({ - model, - messages: apiMessages, - stream: useStream, - stream_options: useStream ? { include_usage: true } : undefined, - max_tokens: 2048, - stop: CHAT_TRANSCRIPT_STOP_SEQUENCES, - }), + body: JSON.stringify(requestBody), }); + // ── DevLogger: log response headers ── + devLog.logResponse(res); + if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as { error?: { message?: string } })?.error?.message || `HTTP ${res.status}`); @@ -281,13 +408,13 @@ export default function ChatView({ if (useStream) { const reader = res.body!.getReader(); - const decoder = new TextDecoder('utf-8'); + const streamDecoder = createStreamChunkDecoder(); let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; - buf += decoder.decode(value, { stream: true }); + buf += streamDecoder.decode(value); const lines = buf.split("\n"); buf = lines.pop()!; @@ -298,51 +425,187 @@ export default function ChatView({ try { const chunk = JSON.parse(data); const delta = chunk.choices?.[0]?.delta?.content; - if (delta) { + const finishReason = chunk.choices?.[0]?.finish_reason; + if (typeof delta === "string") { + const sanitizedDelta = sanitizeMessageText(delta); + devLog.logSanitization("stream:delta", { + before: delta, + after: sanitizedDelta.text, + changed: sanitizedDelta.changed, + repairedEncoding: sanitizedDelta.repairedEncoding, + }); tokenCount++; setStreamTokenCount(tokenCount); - fullContent += delta; + fullContent += sanitizedDelta.text; streamContentAccRef.current = fullContent; const parsed = parseAssistantResponse(fullContent); setStreamContent(parsed.content); setStreamReasoning(parsed.reasoning); setStreamThinkingOpen(parsed.thinkingOpen); - if (parsed.reasoning) setShowLiveReasoning(true); + // Give React a chance to paint during long/fast streams without + // depending on requestAnimationFrame (which is flaky in tests). + if (tokenCount <= 5 || tokenCount % 24 === 0) { + await Promise.resolve(); + } + } + if (finishReason) { + const parsed = parseAssistantResponse(fullContent); + setStreamContent(parsed.content); + setStreamReasoning(parsed.reasoning); + setStreamThinkingOpen(parsed.thinkingOpen); } if (chunk.usage) usage = chunk.usage; - } catch { /* ignore malformed chunks */ } + // ── DevLogger: log each chunk ── + devLog.logStreamChunk(line, chunk, delta ?? null); + } catch (chunkErr) { + // ── DevLogger: log malformed chunk ── + devLog.logStreamChunk(line, null, null, `Parse error: ${chunkErr}`); + } + } + } + + const flushedRemainder = streamDecoder.flush(); + if (flushedRemainder) { + buf += flushedRemainder; + } + + if (buf.trim()) { + const pendingLines = buf.split("\n").filter((line) => line.trim().length > 0); + for (const line of pendingLines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + try { + const chunk = JSON.parse(data); + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === "string") { + const sanitizedDelta = sanitizeMessageText(delta); + tokenCount++; + setStreamTokenCount(tokenCount); + fullContent += sanitizedDelta.text; + streamContentAccRef.current = fullContent; + } + if (chunk.usage) usage = chunk.usage; + } catch (chunkErr) { + devLog.logStreamChunk(line, null, null, `Parse error: ${chunkErr}`); + } } } } else { const data = await res.json(); - fullContent = data.choices?.[0]?.message?.content || ""; + const rawNonStream = data.choices?.[0]?.message?.content || ""; + const sanitizedNonStream = sanitizeMessageText(rawNonStream); + devLog.logSanitization("response:non-stream-raw", { + before: rawNonStream, + after: sanitizedNonStream.text, + changed: sanitizedNonStream.changed, + repairedEncoding: sanitizedNonStream.repairedEncoding, + }); + fullContent = sanitizedNonStream.text; streamContentAccRef.current = fullContent; usage = data.usage; const parsed = parseAssistantResponse(fullContent); setStreamContent(parsed.content); setStreamReasoning(parsed.reasoning); setStreamThinkingOpen(parsed.thinkingOpen); - if (parsed.reasoning) setShowLiveReasoning(true); } const parsed = parseAssistantResponse(fullContent); - const finalContent = parsed.content.trim(); - - if (finalContent || parsed.reasoning) { + const finalSanitized = sanitizeMessageText(parsed.content); + devLog.logSanitization("response:final-visible", { + before: parsed.content, + after: finalSanitized.text, + changed: finalSanitized.changed, + repairedEncoding: finalSanitized.repairedEncoding, + }); + const finalContent = finalSanitized.text.trim(); + const hasUsefulVisibleContent = finalContent.length > 0; + const hasReasoningOnly = !hasUsefulVisibleContent && parsed.reasoning.trim().length > 0; + // Use the field computed inside parseAssistantResponse (visible content only, not think block) + const hadTranscriptLeakage = parsed.hadTranscriptLeakage; + + // ── DevLogger: log parsed result and usage ── + devLog.logParsedResult({ + rawContent: fullContent, + visibleContent: parsed.content, + reasoningContent: parsed.reasoning, + thinkingOpen: parsed.thinkingOpen, + isCorrupted: isLoopedOrCorruptedResponse(finalContent), + transcriptLeakageDetected: hadTranscriptLeakage, + }); + if (usage) devLog.logUsage(usage); + + // Don't save corrupted/looped responses to DB — they would poison future context + const isCorrupted = isLoopedOrCorruptedResponse(finalContent); + + if (hasUsefulVisibleContent && !isCorrupted) { + const params = [ + cid, + finalContent, + parsed.reasoning || null, + usage?.prompt_tokens ?? 0, + usage?.completion_tokens ?? 0, + usage?.total_tokens ?? 0, + usage?.tokens_per_second ?? null, + ]; db.run( `INSERT INTO messages ( chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second ) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`, - [ - cid, - finalContent, - parsed.content.trim() ? parsed.reasoning : null, - usage?.prompt_tokens ?? 0, - usage?.completion_tokens ?? 0, - usage?.total_tokens ?? 0, - usage?.tokens_per_second ?? null, - ], + params, ); + + const persistedRows = db.query( + "SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC", + [cid], + ); + const persistedAssistant = [...persistedRows] + .reverse() + .find((row) => row.role === "assistant" && row.content === finalContent); + + devLog.logDBWrite("INSERT", "messages", params, persistedAssistant?.id ?? null); + + if (persistedAssistant) { + appendPersistedAssistantMessage({ ...persistedAssistant }); + } else { + appendPersistedAssistantMessage({ + id: Date.now(), + chat_id: cid, + role: "assistant", + content: finalContent, + reasoning_content: parsed.reasoning || undefined, + prompt_tokens: usage?.prompt_tokens ?? 0, + completion_tokens: usage?.completion_tokens ?? 0, + total_tokens: usage?.total_tokens ?? 0, + tokens_per_second: usage?.tokens_per_second ?? undefined, + created_at: Date.now(), + }); + } + } else if (hasReasoningOnly) { + const params = [ + cid, + "⚠️ O modelo gerou apenas raciocínio interno e nenhuma resposta utilizável.", + parsed.reasoning || null, + usage?.prompt_tokens ?? 0, + usage?.completion_tokens ?? 0, + usage?.total_tokens ?? 0, + usage?.tokens_per_second ?? null, + ]; + db.run( + `INSERT INTO messages ( + chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second + ) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`, + params, + ); + devLog.logDBWrite("INSERT(reasoningOnly)", "messages", params); + } else if (isCorrupted || hadTranscriptLeakage) { + // Save a controlled placeholder instead of persisting prompt echo / transcript leakage. + const params = [ + cid, + "⚠️ O modelo gerou uma resposta corrompida ou vazou o prompt de sistema.", + ]; + db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", params); + devLog.logDBWrite("INSERT(corrupted)", "messages", params); } db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]); if (usage?.tokens_per_second) setTps(usage.tokens_per_second); @@ -354,13 +617,21 @@ export default function ChatView({ if (err instanceof Error && err.name === "AbortError") { // ── Save partial response on user-initiated stop ────────────────── const partial = streamContentAccRef.current; + devLog.logAbort(partial); if (partial.trim()) { const parsed = parseAssistantResponse(partial); + const finalPartial = sanitizeMessageText(parsed.content).text.trim(); + devLog.logSanitization("abort:partial-visible", { + before: parsed.content, + after: finalPartial, + changed: finalPartial !== parsed.content, + repairedEncoding: sanitizeMessageText(parsed.content).repairedEncoding, + }); db.run( `INSERT INTO messages (chat_id, role, content, reasoning_content) VALUES (?, 'assistant', ?, ?)`, [ cid, - `${parsed.content.trim()} _(geração cancelada)_`, + `${finalPartial} _(geração cancelada)_`, parsed.reasoning || null, ], ); @@ -368,6 +639,7 @@ export default function ChatView({ onChatUpdated(); } } else if (err instanceof Error) { + devLog.logError("send", err); db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [ cid, `⚠️ Error: ${err.message}`, @@ -375,16 +647,20 @@ export default function ChatView({ onChatUpdated(); } } finally { - setGenerating(false); + devLog.finishInteraction(); + setGenerating(false); onGeneratingChange?.(false); setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); - setShowLiveReasoning(false); setStreamTokenCount(0); streamContentAccRef.current = ""; if (activeChatIdRef.current === cid) { - loadMessagesRef.current(cid); + const optimisticAssistant = optimisticAssistantRef.current; + const hasPendingOptimisticAssistant = optimisticAssistant?.chat_id === cid; + if (!hasPendingOptimisticAssistant) { + loadMessagesRef.current(cid); + } } pendingRequestChatIdRef.current = null; abortRef.current = null; @@ -396,7 +672,7 @@ export default function ChatView({ setTimeout(() => sendRef.current(nextInQueue), 100); } } - }, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, agenticMode]); + }, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, agenticMode, appendPersistedAssistantMessage]); // Keep sendRef always pointing to latest send closure sendRef.current = send; @@ -434,11 +710,26 @@ export default function ChatView({ }; const handleCopyDebugTranscript = useCallback(async () => { - const transcript = formatChatDebugTranscript({ chatId, messages }); + const savedMemories = db.query( + "SELECT id, key, value, confidence, created_at, updated_at FROM user_memories ORDER BY updated_at DESC LIMIT 20", + ); + const latestInteraction = devLog.getLatestInteraction(chatId); + const transcript = formatChatDebugTranscript({ + chatId, + messages, + model, + server, + apiKeyPresent: Boolean(apiKey), + memoryEnabled: useMemory, + streamEnabled: useStream, + agenticMode, + savedMemories, + latestInteraction, + }); await navigator.clipboard.writeText(transcript); setDebugCopied(true); window.setTimeout(() => setDebugCopied(false), 2000); - }, [chatId, messages]); + }, [apiKey, chatId, db, messages, model, server, useMemory, useStream, agenticMode]); const handleEditAndResend = (msg: Message, newContent: string) => { db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]); @@ -481,136 +772,69 @@ export default function ChatView({ loadMessages(); }; - const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : []; + const memoryMessages = showMemoryModal + ? db.query("SELECT id, key, value, confidence, created_at, updated_at FROM user_memories ORDER BY updated_at DESC") + : []; + + const handleClearMemories = () => { + if (!window.confirm("Clear all saved memories?")) return; + db.run("DELETE FROM user_memories"); + setShowMemoryModal(false); + }; const isInitialScreen = chatId === null && messages.length === 0; - const modelShort = getDisplayName(model); const quickActionChips = ["Create slides", "Build website", "Develop desktop apps", "Design", "More"]; - const workspaceStateLabel = chatId === null ? "Workspace" : "Conversation"; - const workspaceStateCopy = composerNotice - ? composerNotice.title - : server - ? "Runtime ready" - : "Runtime offline"; return (
- {/* ── Top bar ── */} -
-
- {onOpenSidebar && !isInitialScreen && ( - - )} -
- -
- {!isInitialScreen && (chatId !== null && chatTotals.total_tokens > 0 ? ( -
- ↑ {chatTotals.prompt_tokens.toLocaleString()} - ↓ {chatTotals.completion_tokens.toLocaleString()} - Σ {chatTotals.total_tokens.toLocaleString()} -
- ) : ( -
- {workspaceStateLabel} - {workspaceStateCopy} -
- ))} -
- -
- {model && ( -
- {!isInitialScreen &&
} - {modelShort} -
- )} - {!isInitialScreen && - {server ? "Runtime ready" : "Runtime offline"} - - } - {!isInitialScreen && generating && streamTokenCount > 0 && ( -
- - {streamTokenCount.toLocaleString()} tokens -
- )} - {!isInitialScreen && tps !== null && !generating && ( - {tps.toFixed(1)} tok/s - )} - - {!isInitialScreen && ( - <> - - -
- - -
- - )} -
-
- {/* ── Memory Modal ── */} {showMemoryModal && ( -
setShowMemoryModal(false)}> -
e.stopPropagation()}> -
-
- - - - - - Memory Context +
setShowMemoryModal(false)}> +
e.stopPropagation()}> +
+
+
+ + Saved memories +
+

{memoryMessages.length} memórias salvas no modelo

+
+
+ +
- - {memoryMessages.length} message{memoryMessages.length !== 1 ? "s" : ""} · {useMemory ? `last ${MAX_MEMORY}` : "single turn"} mode - -
-
+ +
{memoryMessages.length === 0 ? ( -
No messages in context yet. Start a conversation to populate memory.
+
+ Nenhuma memória salva ainda. +
) : ( - memoryMessages.map((msg, i) => ( -
- {msg.role} - - {msg.content.length > 240 ? msg.content.slice(0, 238) + "…" : msg.content} - + memoryMessages.map((memory) => ( +
+
+
+

{memory.key}

+

{memory.value}

+
+ + {(memory.confidence * 100).toFixed(0)}% + +
)) )} @@ -619,78 +843,100 @@ export default function ChatView({
)} - {showDebugTools && chatId !== null && messages.length > 0 && ( - - )} - {/* ── Chat Layout ── */}
{/* Scrollable message area */}
{chatId === null && messages.length === 0 && ( -
-
-
-

What can I do for you?

+
+
+
+ Local-first workspace + | +
-
-
-
-
- - - - +
+

+ What can I do for you? +

+

+ Your local AI, running on your machine +

+
+ +
+ {composerNotice && ( +
+
+ {composerNotice.title} +

{composerNotice.description}

- -
-