feat(ui): include runtimeLastError in debug transcript
Passes the last_error.message from the runtime snapshot down through App to ChatView and into the chat debug formatter. This ensures that any specific errors that occur during local API startup, model loading, or operation are captured directly in the debug export.
This commit is contained in:
1 parent
d14f4adb0b
commit
99aff0be6f
3 files changed
+149
-58
No files matched your search
+134
-51
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import ChatView from "./components/ChatView";
|
||||
import AppHeader from "./components/AppHeader";
|
||||
import SettingsPage from "./features/settings/SettingsPage";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import { useDB } from "./hooks/useDB";
|
||||
@@ -8,19 +9,60 @@ import { useModels } from "./hooks/useModels";
|
||||
import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
|
||||
import { useRuntimeReadyModelRefresh } from "./features/runtime/useRuntimeReadyModelRefresh";
|
||||
import { swapModel } from "./features/runtime/api";
|
||||
import type { Chat } from "./lib/types";
|
||||
import type { Chat, ChatTokenTotals } from "./lib/types";
|
||||
import type { RuntimeModelFile } from "./features/runtime/types";
|
||||
import "./App.css";
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "forgelm.sidebar.open";
|
||||
|
||||
function readStoredSidebarOpen(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
|
||||
const getItem = window.localStorage?.getItem;
|
||||
if (typeof getItem !== "function") return true;
|
||||
|
||||
try {
|
||||
const saved = getItem.call(window.localStorage, SIDEBAR_STORAGE_KEY);
|
||||
return saved === null ? true : saved === "true";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSidebarOpen(value: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const setItem = window.localStorage?.setItem;
|
||||
if (typeof setItem !== "function") return;
|
||||
|
||||
try {
|
||||
setItem.call(window.localStorage, SIDEBAR_STORAGE_KEY, String(value));
|
||||
} catch {
|
||||
// Ignore storage failures and keep the in-memory UI state functional.
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { db, ready } = useDB();
|
||||
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(readStoredSidebarOpen);
|
||||
const [view, setView] = useState<"chat" | "settings">("chat");
|
||||
|
||||
// Header live-state (lifted from ChatView via callbacks)
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [streamTokenCount, setStreamTokenCount] = useState(0);
|
||||
const [tps, setTps] = useState<number | null>(null);
|
||||
const [chatTokenTotals, setChatTokenTotals] = useState<ChatTokenTotals>({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
|
||||
const runtime = useRuntimeManager();
|
||||
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
|
||||
const {
|
||||
models,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
status,
|
||||
refresh: refreshModels,
|
||||
} = useModels(
|
||||
runtime.connection.serverUrl,
|
||||
runtime.connection.apiKey,
|
||||
Boolean(runtime.connection.serverUrl),
|
||||
@@ -36,12 +78,15 @@ export default function App() {
|
||||
|
||||
useEffect(() => { loadChats(); }, [loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
persistSidebarOpen(sidebarOpen);
|
||||
}, [sidebarOpen]);
|
||||
|
||||
const newChat = useCallback(() => {
|
||||
if (!db) return;
|
||||
const id = db.insert("INSERT INTO chats (title) VALUES ('New Chat')");
|
||||
if (id > 0) {
|
||||
setCurrentChatId(id);
|
||||
setSidebarOpen(false);
|
||||
loadChats();
|
||||
}
|
||||
}, [db, loadChats]);
|
||||
@@ -51,13 +96,11 @@ export default function App() {
|
||||
if (!window.confirm("Delete this conversation and all of its messages?")) return;
|
||||
db.run("DELETE FROM chats WHERE id = ?", [id]);
|
||||
if (currentChatId === id) setCurrentChatId(null);
|
||||
setSidebarOpen(false);
|
||||
loadChats();
|
||||
}, [db, currentChatId, loadChats]);
|
||||
|
||||
const handleSelectChat = useCallback((id: number) => {
|
||||
setCurrentChatId(id);
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
@@ -77,9 +120,13 @@ export default function App() {
|
||||
}
|
||||
}, [setSelectedModel]);
|
||||
|
||||
const localModels: RuntimeModelFile[] = useMemo(() => {
|
||||
return runtime.scannedModels ?? [];
|
||||
}, [runtime.scannedModels]);
|
||||
const localModels: RuntimeModelFile[] = useMemo(() => runtime.scannedModels ?? [], [runtime.scannedModels]);
|
||||
|
||||
// Current chat title for breadcrumb
|
||||
const currentChatTitle = useMemo(
|
||||
() => chats.find((c) => c.id === currentChatId)?.title ?? undefined,
|
||||
[chats, currentChatId],
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
@@ -92,56 +139,92 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const serverConnected = Boolean(runtime.connection.serverUrl);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className={`app-layout${sidebarOpen && view !== "settings" ? " sidebar-open" : ""}`}>
|
||||
{view !== "settings" && (
|
||||
<Sidebar
|
||||
chats={chats}
|
||||
currentChatId={currentChatId}
|
||||
initialScreen={currentChatId === null}
|
||||
onSelectChat={handleSelectChat}
|
||||
onNewChat={newChat}
|
||||
onDeleteChat={deleteChat}
|
||||
<div className={`app-layout with-app-header${sidebarOpen && view !== "settings" ? " sidebar-open" : ""}`}>
|
||||
|
||||
{/* ── Global header — always visible (settings mode shows back button) ── */}
|
||||
<AppHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
||||
onOpenSettings={() => setView("settings")}
|
||||
onCloseMobile={() => setSidebarOpen(false)}
|
||||
onBackToChat={view === "settings" ? () => setView("chat") : undefined}
|
||||
models={models}
|
||||
localModels={localModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
onModelSwap={handleModelSwap}
|
||||
isSwapping={isSwapping}
|
||||
status={status}
|
||||
runtimeStatus={runtime.snapshot.status}
|
||||
serverConnected={serverConnected}
|
||||
generating={generating}
|
||||
streamTokenCount={streamTokenCount}
|
||||
tps={tps}
|
||||
chatTitle={view === "settings" ? "Settings" : currentChatTitle}
|
||||
chatTokenTotals={chatTokenTotals}
|
||||
/>
|
||||
)}
|
||||
{view !== "settings" && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-sidebar-backdrop"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="app-main">
|
||||
{view === "settings" ? (
|
||||
<SettingsPage runtime={runtime} onClose={() => setView("chat")} onModelSwapped={setSelectedModel} />
|
||||
) : (
|
||||
<ChatView
|
||||
db={db!}
|
||||
chatId={currentChatId}
|
||||
server={runtime.connection.serverUrl}
|
||||
apiKey={runtime.connection.apiKey}
|
||||
model={selectedModel}
|
||||
models={models}
|
||||
localModels={localModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
onModelSwap={handleModelSwap}
|
||||
isSwapping={isSwapping}
|
||||
status={status}
|
||||
runtimeMode={runtime.snapshot.config.connection_mode}
|
||||
runtimeStatus={runtime.snapshot.status}
|
||||
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
|
||||
onChatUpdated={loadChats}
|
||||
onOpenSidebar={() => setSidebarOpen(true)}
|
||||
|
||||
{view !== "settings" && sidebarOpen && (
|
||||
<Sidebar
|
||||
chats={chats}
|
||||
currentChatId={currentChatId}
|
||||
initialScreen={currentChatId === null}
|
||||
onSelectChat={handleSelectChat}
|
||||
onNewChat={newChat}
|
||||
onDeleteChat={deleteChat}
|
||||
onOpenSettings={() => setView("settings")}
|
||||
onCloseMobile={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{view !== "settings" && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-sidebar-backdrop"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="app-main">
|
||||
{view === "settings" ? (
|
||||
<SettingsPage
|
||||
runtime={runtime}
|
||||
onClose={() => setView("chat")}
|
||||
onModelSwapped={setSelectedModel}
|
||||
/>
|
||||
) : (
|
||||
<ChatView
|
||||
db={db!}
|
||||
chatId={currentChatId}
|
||||
server={runtime.connection.serverUrl}
|
||||
apiKey={runtime.connection.apiKey}
|
||||
model={selectedModel}
|
||||
models={models}
|
||||
localModels={localModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
onModelSwap={handleModelSwap}
|
||||
isSwapping={isSwapping}
|
||||
status={status}
|
||||
runtimeMode={runtime.snapshot.config.connection_mode}
|
||||
runtimeStatus={runtime.snapshot.status}
|
||||
runtimeLastError={runtime.snapshot.last_error?.message}
|
||||
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); }}
|
||||
onChatUpdated={loadChats}
|
||||
onNewChat={newChat}
|
||||
onOpenSidebar={() => setSidebarOpen(true)}
|
||||
onOpenSettings={() => setView("settings")}
|
||||
onGeneratingChange={setGenerating}
|
||||
onStreamTokenCountChange={setStreamTokenCount}
|
||||
onTpsChange={setTps}
|
||||
onChatTokenTotalsChange={setChatTokenTotals}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ interface Props {
|
||||
status: ServerStatus;
|
||||
runtimeMode?: "local" | "remote";
|
||||
runtimeStatus?: RuntimeStatus;
|
||||
runtimeLastError?: string;
|
||||
showDebugTools?: boolean;
|
||||
onChatCreated: (id: number) => void;
|
||||
onChatUpdated: () => void;
|
||||
@@ -74,16 +75,15 @@ interface Props {
|
||||
/** 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,
|
||||
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),
|
||||
runtimeMode = "remote", runtimeStatus = "ready", runtimeLastError, showDebugTools = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV),
|
||||
onChatCreated, onChatUpdated, onNewChat, onOpenSidebar, onOpenSettings, onDegradationDetected,
|
||||
onGeneratingChange, onStreamTokenCountChange, onTpsChange, onChatTokenTotalsChange,
|
||||
}: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
}: Props) { const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [streamContent, setStreamContent] = useState("");
|
||||
@@ -727,6 +727,7 @@ export default function ChatView({
|
||||
latestInteraction,
|
||||
runtimeMode,
|
||||
runtimeStatus,
|
||||
runtimeLastError,
|
||||
});
|
||||
await navigator.clipboard.writeText(transcript);
|
||||
setDebugCopied(true);
|
||||
|
||||
@@ -14,6 +14,7 @@ interface FormatChatDebugTranscriptOptions {
|
||||
latestInteraction?: InteractionLog | null;
|
||||
runtimeMode?: "local" | "remote";
|
||||
runtimeStatus?: string;
|
||||
runtimeLastError?: string;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: number | null | undefined): string {
|
||||
@@ -113,6 +114,7 @@ export function formatChatDebugTranscript({
|
||||
latestInteraction,
|
||||
runtimeMode,
|
||||
runtimeStatus,
|
||||
runtimeLastError,
|
||||
}: FormatChatDebugTranscriptOptions): string {
|
||||
const lines = [
|
||||
"# Chat Debug Export",
|
||||
@@ -127,9 +129,14 @@ export function formatChatDebugTranscript({
|
||||
`Agentic mode: ${agenticMode ? "on" : "off"}`,
|
||||
`Runtime mode: ${runtimeMode ?? "unknown"}`,
|
||||
`Runtime status: ${runtimeStatus ?? "unknown"}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (runtimeLastError) {
|
||||
lines.push(`Runtime error: ${runtimeLastError}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
|
||||
if (savedMemories.length > 0) {
|
||||
lines.push("## Saved Memories Considered");
|
||||
for (const memory of savedMemories) {
|
||||
|
||||
Reference in new issue
Block a user