From 349b9e167f73af36192366892de464ac602202c8 Mon Sep 17 00:00:00 2001 From: techjarves Date: Fri, 26 Jun 2026 07:00:17 +0530 Subject: [PATCH] add cross-platform reset scripts, portable server manager, and frontend scaffolding --- .gitignore | 1 + app/frontend/src/App.jsx | 77 ++++++++++++---- app/frontend/src/services/api.js | 25 +++++ scripts/reset/reset.ps1 | 14 ++- scripts/reset/reset.sh | 12 ++- scripts/server/serve.cjs | 151 +++++++++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 7cb43f3..bbe008b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ app/config/ app/cache/ app/dist/ app/outputs/ +app/chat-history/ app/transcriptions/ app/tts-outputs/ app/tts-cache/ diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index dc92a01..0872619 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -7,7 +7,7 @@ import Settings from "./components/Settings"; import TextChat from "./components/TextChat"; import SpeechTranscriber from "./components/SpeechTranscriber"; import TextToSpeech from "./components/TextToSpeech"; -import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api"; +import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listLlmConversations, saveLlmConversation, deleteLlmConversation, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api"; import "./App.css"; function App() { @@ -240,17 +240,6 @@ function App() { const [selectedTtsOutput, setSelectedTtsOutput] = useState(null); const [showTtsHistory, setShowTtsHistory] = useState(false); - useEffect(() => { - const saved = localStorage.getItem("chat_conversations"); - if (saved) { - try { - setConversations(JSON.parse(saved)); - } catch (_) { - localStorage.removeItem("chat_conversations"); - } - } - }, []); - const sanitizeMessageForStorage = (message) => { if (!Array.isArray(message?.content)) return message; return { @@ -272,37 +261,83 @@ function App() { : [], }); - const persistConversations = (list) => { - const compactList = list.map(sanitizeConversationForStorage); + const persistConversation = (conversation) => { + const compactConversation = sanitizeConversationForStorage(conversation); try { - localStorage.setItem("chat_conversations", JSON.stringify(compactList)); + saveLlmConversation(compactConversation).catch((err) => { + console.warn("Could not save chat history to app folder:", err); + }); } catch (err) { console.warn("Could not save chat history:", err); } }; + useEffect(() => { + let cancelled = false; + async function loadConversations() { + let legacyConversations = []; + const legacySaved = localStorage.getItem("chat_conversations"); + if (legacySaved) { + try { + legacyConversations = JSON.parse(legacySaved); + } catch (_) { + localStorage.removeItem("chat_conversations"); + } + } + + try { + const diskConversations = await listLlmConversations(); + const diskIds = new Set(diskConversations.map((conversation) => conversation.id)); + const missingLegacy = legacyConversations + .map(sanitizeConversationForStorage) + .filter((conversation) => conversation.id && !diskIds.has(conversation.id)); + + if (missingLegacy.length > 0) { + await Promise.all(missingLegacy.map((conversation) => saveLlmConversation(conversation))); + } + + const merged = [...diskConversations, ...missingLegacy] + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)); + if (!cancelled) setConversations(merged); + if (legacySaved) localStorage.removeItem("chat_conversations"); + } catch (err) { + console.warn("Could not load chat history from app folder:", err); + if (!cancelled && legacyConversations.length > 0) { + setConversations(legacyConversations.map(sanitizeConversationForStorage)); + } + } + } + loadConversations(); + return () => { + cancelled = true; + }; + }, []); + const saveConversationState = useCallback((id, msgs, modelName, newTitle = null) => { setConversations((prev) => { const list = [...prev]; const idx = list.findIndex(c => c.id === id); + let conversation; if (idx !== -1) { - list[idx] = { + conversation = { ...list[idx], messages: msgs, timestamp: Date.now(), model: modelName, ...(newTitle ? { title: newTitle } : {}) }; + list[idx] = conversation; } else { - list.unshift({ + conversation = { id, title: newTitle || "Chat Session", model: modelName, messages: msgs, timestamp: Date.now() - }); + }; + list.unshift(conversation); } - persistConversations(list); + persistConversation(conversation); return list; }); }, []); @@ -311,9 +346,11 @@ function App() { if (e) e.stopPropagation(); setConversations((prev) => { const filtered = prev.filter((c) => c.id !== id); - persistConversations(filtered); return filtered; }); + deleteLlmConversation(id).catch((err) => { + console.warn("Could not delete chat history from app folder:", err); + }); if (activeConversationId === id) { setActiveConversationId(null); } diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js index a719f05..8598305 100644 --- a/app/frontend/src/services/api.js +++ b/app/frontend/src/services/api.js @@ -365,6 +365,31 @@ export async function listLlmModels() { return (data.models || []).map(normalizeModel); } +export async function listLlmConversations() { + const res = await fetch("/api/llm/conversations"); + const data = await readJsonResponse(res, "The local server returned invalid chat history."); + return data.conversations || []; +} + +export async function saveLlmConversation(conversation) { + const res = await fetch("/api/llm/save-conversation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversation }), + }); + const data = await readJsonResponse(res, "The local server returned an invalid chat save response."); + return data.conversation; +} + +export async function deleteLlmConversation(id) { + const res = await fetch("/api/llm/delete-conversation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + return await readJsonResponse(res, "The local server returned an invalid chat delete response."); +} + export async function searchHuggingFaceModels(query = "", filters = [], page = 1) { const params = new URLSearchParams(); if (query.trim()) params.set("query", query.trim()); diff --git a/scripts/reset/reset.ps1 b/scripts/reset/reset.ps1 index 6c9d24f..b9f005c 100644 --- a/scripts/reset/reset.ps1 +++ b/scripts/reset/reset.ps1 @@ -78,6 +78,18 @@ if (Test-Path $ttsModelsDir) { Write-Host " >> Preserving TTS models in app/tts-models." -ForegroundColor Cyan } +# Preserve generated image outputs +$outputsDir = Join-Path $appDir "outputs" +if (Test-Path $outputsDir) { + Write-Host " >> Preserving generated image outputs in app/outputs." -ForegroundColor Cyan +} + +# Preserve chat history +$chatHistoryDir = Join-Path $appDir "chat-history" +if (Test-Path $chatHistoryDir) { + Write-Host " >> Preserving chat history in app/chat-history." -ForegroundColor Cyan +} + # Preserve transcriptions $transcriptionsDir = Join-Path $appDir "transcriptions" if (Test-Path $transcriptionsDir) { @@ -125,7 +137,7 @@ if (Test-Path $lockFile) { Write-Host "" Write-Host " ============================================================" -ForegroundColor Green -Write-Host " Reset complete. Image models, text models, speech models, TTS models, transcripts, TTS outputs, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green +Write-Host " Reset complete. Models, generated images, chat history, transcripts, TTS outputs, TTS cache, and OpenVINO models were preserved." -ForegroundColor Green Write-Host " ============================================================" -ForegroundColor Green Write-Host "" Read-Host " Press Enter to close..." diff --git a/scripts/reset/reset.sh b/scripts/reset/reset.sh index 7dfef68..a16ffea 100644 --- a/scripts/reset/reset.sh +++ b/scripts/reset/reset.sh @@ -72,6 +72,16 @@ if [[ -d "$APP_DIR/tts-models" ]]; then echo " >> Preserving TTS models in app/tts-models." fi +# Preserve generated image outputs +if [[ -d "$APP_DIR/outputs" ]]; then + echo " >> Preserving generated image outputs in app/outputs." +fi + +# Preserve chat history +if [[ -d "$APP_DIR/chat-history" ]]; then + echo " >> Preserving chat history in app/chat-history." +fi + # Preserve transcriptions if [[ -d "$APP_DIR/transcriptions" ]]; then echo " >> Preserving speech transcripts in app/transcriptions." @@ -111,7 +121,7 @@ fi echo "" echo " ============================================================" -echo " Reset complete. Image models, text models, speech models, TTS models, transcripts, TTS outputs, OpenVINO models, and generated outputs were preserved." +echo " Reset complete. Models, generated images, chat history, transcripts, TTS outputs, TTS cache, and OpenVINO models were preserved." echo " ============================================================" echo "" read -rp " Press Enter to close..." diff --git a/scripts/server/serve.cjs b/scripts/server/serve.cjs index b0cbb0d..20200a3 100644 --- a/scripts/server/serve.cjs +++ b/scripts/server/serve.cjs @@ -94,6 +94,10 @@ const LLM_MODELS = path.join(ROOT, "app", "llm-models"); if (!fs.existsSync(LLM_MODELS)) { fs.mkdirSync(LLM_MODELS, { recursive: true }); } +const CHAT_HISTORY = path.join(ROOT, "app", "chat-history"); +if (!fs.existsSync(CHAT_HISTORY)) { + fs.mkdirSync(CHAT_HISTORY, { recursive: true }); +} const LLM_BACKEND_PATHS = { winCuda: path.join(ROOT, "app", "llm-backend", "win", "cuda", "llama-server.exe"), winVulkan: path.join(ROOT, "app", "llm-backend", "win", "vulkan", "llama-server.exe"), @@ -2706,6 +2710,9 @@ function getSetupPaths() { cpuBackend: BACKEND_PATHS.cpu, models: MODELS, outputs: OUTPUTS, + chatHistory: CHAT_HISTORY, + transcriptions: TRANSCRIPTIONS, + ttsOutputs: TTS_OUTPUTS, }; } else if (osPlatform === "darwin") { return { @@ -2715,6 +2722,9 @@ function getSetupPaths() { macBackend: BACKEND_PATHS.mac, models: MODELS, outputs: OUTPUTS, + chatHistory: CHAT_HISTORY, + transcriptions: TRANSCRIPTIONS, + ttsOutputs: TTS_OUTPUTS, }; } else { // Linux / WSL @@ -2727,6 +2737,9 @@ function getSetupPaths() { linuxRocmBackend: BACKEND_PATHS.linuxRocm, models: MODELS, outputs: OUTPUTS, + chatHistory: CHAT_HISTORY, + transcriptions: TRANSCRIPTIONS, + ttsOutputs: TTS_OUTPUTS, }; } } @@ -2739,6 +2752,9 @@ async function getHealth() { getPathInfo("Frontend build", paths.distIndex), getDirInfo("Models folder", paths.models), getDirInfo("Outputs folder", paths.outputs), + getDirInfo("Chat history folder", paths.chatHistory), + getDirInfo("Transcriptions folder", paths.transcriptions), + getDirInfo("TTS outputs folder", paths.ttsOutputs), ]; if (osPlatform === "win32") { @@ -2811,6 +2827,16 @@ async function getHealth() { totalBytes: getPathSize(OUTPUTS), totalSize: formatBytes(getPathSize(OUTPUTS)), }, + chat: { + count: fs.existsSync(CHAT_HISTORY) ? fs.readdirSync(CHAT_HISTORY).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, + totalBytes: getPathSize(CHAT_HISTORY), + totalSize: formatBytes(getPathSize(CHAT_HISTORY)), + }, + speech: { + transcriptions: fs.existsSync(TRANSCRIPTIONS) ? fs.readdirSync(TRANSCRIPTIONS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, + totalBytes: getPathSize(TRANSCRIPTIONS), + totalSize: formatBytes(getPathSize(TRANSCRIPTIONS)), + }, tts: { models: fs.existsSync(TTS_MODELS) ? fs.readdirSync(TTS_MODELS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, outputs: fs.existsSync(TTS_OUTPUTS) ? fs.readdirSync(TTS_OUTPUTS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, @@ -2827,6 +2853,8 @@ function addCleanupCandidate(candidates, id, targetPath, reason, options = {}) { if (!options.allowUserData && ( pathInside(targetPath, MODELS) || pathInside(targetPath, OUTPUTS) || + pathInside(targetPath, CHAT_HISTORY) || + pathInside(targetPath, TRANSCRIPTIONS) || pathInside(targetPath, TTS_MODELS) || pathInside(targetPath, TTS_OUTPUTS) )) return; @@ -5402,6 +5430,101 @@ function safeOutputName(value) { .slice(0, 120); } +function sanitizeChatMessageForStorage(message) { + if (!message || typeof message !== "object") return null; + const role = String(message.role || "").trim(); + if (!["system", "user", "assistant"].includes(role)) return null; + let content = message.content; + if (Array.isArray(content)) { + content = content.map((item) => { + if (!item || typeof item !== "object") return item; + if (item.type === "image_url") { + return { type: "text", text: "[Attached image omitted from saved chat history]" }; + } + return item; + }); + } else if (typeof content !== "string") { + content = content === undefined || content === null ? "" : String(content); + } + return { + ...message, + role, + content, + }; +} + +function sanitizeChatConversationForStorage(conversation = {}) { + const now = Date.now(); + const id = safeOutputName(conversation.id || `chat_${now}`) || `chat_${now}`; + const messages = Array.isArray(conversation.messages) + ? conversation.messages.map(sanitizeChatMessageForStorage).filter(Boolean) + : []; + const timestamp = Number(conversation.timestamp) || now; + return { + id, + title: String(conversation.title || "Chat Session").slice(0, 160), + model: String(conversation.model || ""), + messages, + timestamp, + createdAt: conversation.createdAt || new Date(timestamp).toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +function getChatConversationPath(id) { + const safeId = safeOutputName(id); + if (!safeId) throw new Error("A chat id is required."); + const filePath = path.join(CHAT_HISTORY, `${safeId}.json`); + if (!pathInside(filePath, CHAT_HISTORY)) { + throw new Error("Invalid chat id."); + } + return filePath; +} + +function saveChatConversation(conversation = {}) { + const saved = sanitizeChatConversationForStorage(conversation); + const filePath = getChatConversationPath(saved.id); + fs.writeFileSync(filePath, JSON.stringify(saved, null, 2), "utf8"); + return saved; +} + +function listChatConversations() { + try { + return fs.readdirSync(CHAT_HISTORY) + .filter((file) => file.toLowerCase().endsWith(".json")) + .map((file) => { + try { + const filePath = path.join(CHAT_HISTORY, file); + if (!pathInside(filePath, CHAT_HISTORY)) return null; + const conversation = JSON.parse(fs.readFileSync(filePath, "utf8")); + const stat = fs.statSync(filePath); + return { + ...conversation, + filename: file, + modifiedAt: stat.mtime.toISOString(), + sizeBytes: stat.size, + size: formatBytes(stat.size), + }; + } catch (_) { + return null; + } + }) + .filter(Boolean) + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)); + } catch (_) { + return []; + } +} + +function deleteChatConversation(id) { + const filePath = getChatConversationPath(id); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; +} + function saveGeneratedOutput(imageDataUrl, metadata = {}) { const match = String(imageDataUrl || "").match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); if (!match) { @@ -5699,6 +5822,34 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, model: filename, settings: updated }); } + if (req.url === "/api/llm/conversations" && req.method === "GET") { + return json(res, 200, { ok: true, conversations: listChatConversations() }); + } + + if (req.url === "/api/llm/save-conversation" && req.method === "POST") { + const body = await readJsonBody(req, res); + if (!body) return; + try { + const conversation = saveChatConversation(body.conversation || body); + return json(res, 200, { ok: true, conversation }); + } catch (err) { + console.error(" [api] Failed to save chat conversation:", err); + return json(res, 500, { ok: false, error: err.message || String(err) }); + } + } + + if (req.url === "/api/llm/delete-conversation" && req.method === "POST") { + const body = await readJsonBody(req, res); + if (!body) return; + try { + const deleted = deleteChatConversation(body.id); + return json(res, 200, { ok: true, deleted }); + } catch (err) { + console.error(" [api] Failed to delete chat conversation:", err); + return json(res, 500, { ok: false, error: err.message || String(err) }); + } + } + if (req.url === "/api/llm/models" && req.method === "GET") { return json(res, 200, { models: getLlmModels() }); }