add cross-platform reset scripts, portable server manager, and frontend scaffolding
This commit is contained in:
1 parent
1d28391243
commit
349b9e167f
6 files changed
+258
-22
No files matched your search
@@ -17,6 +17,7 @@ app/config/
|
||||
app/cache/
|
||||
app/dist/
|
||||
app/outputs/
|
||||
app/chat-history/
|
||||
app/transcriptions/
|
||||
app/tts-outputs/
|
||||
app/tts-cache/
|
||||
|
||||
+57
-20
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
+13
-1
@@ -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..."
|
||||
+11
-1
@@ -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..."
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user