diff --git a/public/app/regeneration.js b/public/app/regeneration.js new file mode 100644 index 0000000..0cd15c1 --- /dev/null +++ b/public/app/regeneration.js @@ -0,0 +1,52 @@ +import {stream} from "./api.js"; +import {updateReasoning, finishReasoning} from "./reasoning.js"; +import {appendSources} from "./webSearch.js"; + +// Keep the original nodes (including action handlers) until a committed answer arrives. +export async function regenerateInline({card, scroller, endpoint, payload, renderContent, complete}) { + const original = [...card.childNodes]; + const status = document.createElement("div"); + status.className = "loading-shell"; + status.setAttribute("role", "status"); + status.textContent = "Generating..."; + const body = document.createElement("div"); + body.className = "msg-content streaming-content"; + let committed = false; + const update = operation => { + const bottom = card.getBoundingClientRect().bottom; + const viewport = scroller.getBoundingClientRect(); + const follow = bottom >= viewport.top && Math.abs(bottom - viewport.bottom) < 100; + operation(); + if (follow) scroller.scrollTop += card.getBoundingClientRect().bottom - bottom; + }; + // Anchor to this response, including when a long old answer collapses. + update(() => card.replaceChildren(original[0].cloneNode(true), status, body)); + card.setAttribute("aria-busy", "true"); + try { + await stream(endpoint, payload, { + onStatus: data => update(() => { + status.textContent = data.status === "searching" ? "Searching the web..." + : data.status === "analyzing" ? "Analyzing attachments..." : "Generating..."; + if ("sources" in data) { + card.querySelector(".msg-sources")?.remove(); + appendSources(card, data.sources); + } + }), + onThinking: ({fullThinking}) => update(() => updateReasoning(card, fullThinking, true)), + onChunk: ({fullReply}) => update(() => { + status.hidden = Boolean(fullReply); + if (fullReply) finishReasoning(card); + renderContent(body, fullReply); + }), + onError: ({error}) => { throw new Error(error || "Unable to regenerate message."); }, + onDone: ({message}) => { + if (!message) throw new Error("Missing regenerated response."); + update(() => complete(message)); + committed = true; + } + }); + } finally { + if (!committed) update(() => card.replaceChildren(...original)); + card.removeAttribute("aria-busy"); + } +} diff --git a/public/script.js b/public/script.js index 5620e23..d84d4c1 100644 --- a/public/script.js +++ b/public/script.js @@ -1,4 +1,5 @@ import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls"; +import {regenerateInline} from "./app/regeneration.js"; import {appendSources, createWebSearchToggle} from "./app/webSearch.js"; import {appendAttachments, createAttachmentComposer} from "./app/attachments.js"; import {createReasoningToggle, finishReasoning, updateReasoning} from "./app/reasoning.js?v=20260921-reasoning-capability"; @@ -110,6 +111,7 @@ const webSearchToggle = createWebSearchToggle(webSearchButton); const reasoningToggle = createReasoningToggle(document.getElementById("reasoning")); let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = ""; +let regenerationChatId = null; const attachmentComposer = createAttachmentComposer({onChange: updateSendState, onError: message => setNotice(message, "error")}); let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = []; let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set(); @@ -568,45 +570,70 @@ async function deleteChatMessage(messageId, fallbackIndex = -1) { } async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) { + if (isProcessing) return; const target = await resolveMessageTarget(messageId, fallbackIndex); + if (isProcessing) return; if (!target) return setNotice("Message is not ready yet. Try again.", "error"); if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error"); - const retriesUsed = Math.max(0, Number(target.message.siblingCount || 1) - 1); + const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1); if (retriesUsed >= 5) { return setNotice("Retry limit reached (5).", "error"); } - setLoadingState(true, { - eyebrow: "Regenerating", - title: "Generating another version", - detail: "The assistant is rewriting the latest reply." - }); - setNotice("Regenerating reply..."); + const chatId = activeChatId; + const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`); + if (!card) return; + const payload = {model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled}; + regenerationChatId = chatId; + setLoadingState(true, false); try { - const endpoint = getMessageEndpoint(target, "/retry"); - if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error"); - const style = await promptPopup({ - eyebrow: "Retry style", - title: "Retry modifier", - description: "Leave empty for a normal retry, or use shorter, direct, emotional, stay-in-character, or dialogue.", - label: "Style", - value: "", - placeholder: "shorter", - confirmLabel: "Retry" + await regenerateInline({ + card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload, + renderContent: setMessageContent, + complete: message => { + if (activeChatId !== chatId) return; + const index = getMessageIndexById(message.id); + if (index < 0) return; + currentMessages[index] = {...currentMessages[index], ...message, thinking: message.thinking, sources: message.sources}; + syncRetryStateFromMessage(currentMessages[index]); + const rendered = addMessage(currentMessages[index], false, false, {messageIndex: index, detached: true}); + card.replaceChildren(...rendered.childNodes); + } }); - const res = await post(endpoint, {model: modelSelect.value, style: String(style || "").trim()}); - if (res.error || !res.message?.content) { - return setNotice(res.error || "Unable to retry message.", "error"); - } - const updateIndex = target.message?.id ? getMessageIndexById(target.message.id) : target.index; - if (updateIndex >= 0) { - currentMessages[updateIndex] = {...currentMessages[updateIndex], ...res.message}; - syncRetryStateFromMessage(currentMessages[updateIndex]); - } - await setActiveChat(activeChatId); - setNotice(`Reply regenerated (${retriesUsed + 1}/5).`, "success"); + webSearchToggle.reset(); + reasoningToggle.reset(); + setNotice("Reply regenerated.", "success"); + } catch (error) { + setNotice(error.message || "Unable to regenerate message.", "error"); } finally { + regenerationChatId = null; setLoadingState(false); - renderMessages(); + } +} + +async function switchResponseVersion(messageId, direction, fallbackIndex = -1) { + const target = await resolveMessageTarget(messageId, fallbackIndex); + if (!target) return setNotice("Message is not ready yet. Try again.", "error"); + if (Number(target.message.versionCount) > 1) { + const chatId = activeChatId; + const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`); + if (!card || card.getAttribute("aria-busy") === "true") return; + const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]); + card.setAttribute("aria-busy", "true"); + buttons.forEach(([button]) => { button.disabled = true; }); + try { + const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {}); + if (res.error) return setNotice(res.error, "error"); + if (activeChatId !== chatId) return; + const index = getMessageIndexById(messageId); + if (index < 0) return; + currentMessages[index] = {...currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources}; + syncRetryStateFromMessage(currentMessages[index]); + const rendered = addMessage(currentMessages[index], false, false, {messageIndex: index, detached: true}); + card.replaceChildren(...rendered.childNodes); + } finally { + card.removeAttribute("aria-busy"); + buttons.forEach(([button, disabled]) => { button.disabled = disabled; }); + } } } @@ -664,6 +691,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options ? contentOrMessage : {id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "")}; const isMessageUser = message.role === "user"; + msgDiv.dataset.messageId = message.id; msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`; msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message"); const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div"); @@ -702,7 +730,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options actions.appendChild(branchBtn); if (message.role === "bot") { - const retriesUsed = Math.max(0, Number(message.siblingCount || 1) - 1); + const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1); const retryBtn = document.createElement("button"); retryBtn.type = "button"; retryBtn.className = "msg-action-btn"; @@ -717,30 +745,32 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options actions.appendChild(retryBtn); } - if (Number(message.siblingCount || 1) > 1) { + for (const versioned of [true, false]) { + if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue; + const navigate = versioned ? switchResponseVersion : switchRetryVariant; const prevBtn = document.createElement("button"); prevBtn.type = "button"; prevBtn.className = "msg-action-btn"; prevBtn.textContent = "Prev"; - prevBtn.setAttribute("aria-label", "Show previous retry variant"); - prevBtn.disabled = !message.previousSiblingId; + prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch"); + prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId; prevBtn.addEventListener("click", () => { - void switchRetryVariant(message.id, -1, options.messageIndex ?? -1); + void navigate(message.id, -1, options.messageIndex ?? -1); }); const nextBtn = document.createElement("button"); nextBtn.type = "button"; nextBtn.className = "msg-action-btn"; nextBtn.textContent = "Next"; - nextBtn.setAttribute("aria-label", "Show next retry variant"); - nextBtn.disabled = !message.nextSiblingId; + nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch"); + nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId; nextBtn.addEventListener("click", () => { - void switchRetryVariant(message.id, 1, options.messageIndex ?? -1); + void navigate(message.id, 1, options.messageIndex ?? -1); }); const indexBadge = document.createElement("span"); indexBadge.className = "msg-variant-index"; - indexBadge.textContent = `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`; + indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`; actions.append(prevBtn, indexBadge, nextBtn); } @@ -759,8 +789,10 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options msgDiv.appendChild(actions); } } - messagesDiv.appendChild(msgDiv); - scrollMessagesToBottom(); + if (!options.detached) { + messagesDiv.appendChild(msgDiv); + scrollMessagesToBottom(); + } return msgDiv; } @@ -1388,8 +1420,9 @@ function setLoadingState(loading, overlayOptions = null) { } async function stopGeneration() { - if (!isProcessing || !activeChatId) return; - const res = await post(`/chats/${activeChatId}/stop`, {}); + const chatId = regenerationChatId ?? activeChatId; + if (!isProcessing || !chatId) return; + const res = await post(`/chats/${chatId}/stop`, {}); if (res.error) return setNotice(res.error, "error"); setNotice(res.stopped ? "Stopping generation..." : "Generation already finished."); } diff --git a/scripts/lint.mjs b/scripts/lint.mjs index 04ee0e9..df2c11a 100644 --- a/scripts/lint.mjs +++ b/scripts/lint.mjs @@ -35,6 +35,7 @@ const files = [ "public/settings.js", "public/market.js", "public/app/api.js", + "public/app/regeneration.js", "public/app/webSearch.js", "public/app/reasoning.js", "public/app/modelCapabilities.js", diff --git a/src/config.js b/src/config.js index 4212556..e33639b 100644 --- a/src/config.js +++ b/src/config.js @@ -58,7 +58,7 @@ export function createConfig(overrides = {}) { chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200), chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5), modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", - modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), + modelRequestYTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), modelConnectTimeoutMs: Math.max(1, int(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000)), modelInactivityTimeoutMs: Math.max(1, int(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000))), modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)), diff --git a/src/db/repositories.js b/src/db/repositories.js index 40d8272..98d0987 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -871,6 +871,10 @@ export function createRepositories(db, config) { if (message.sourceId != null) importedMessageIds.set(message.sourceId, Number(result.lastInsertRowid)); if (message.sources) statements.updateChatMessageSources.run(JSON.stringify(message.sources), result.lastInsertRowid, chatId); if (message.thinking) statements.updateChatMessageThinking.run(message.thinking, result.lastInsertRowid, chatId); + for (const version of message.responseVersions || []) { + db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name, created_at) VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))") + .run(result.lastInsertRowid, version.content, version.thinking, JSON.stringify(version.sources), version.modelName, version.createdAt); + } for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) { // JSON backups contain references, not files. Only restore existing, owned images. db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status='attached'") @@ -1046,6 +1050,29 @@ export function createRepositories(db, config) { username ), updateChatPreferredModel: (chatId, username, model) => statements.updateChatPreferredModel.run(model || null, chatId, username), + listMessageVersions: (messageId) => db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId), + saveMessageVersion: (chatId, messageId, version = null, selectedIndex = null) => db.transaction(() => { + const row = statements.getChatMessageById.get(messageId, chatId); + if (!row) throw new Error("Message not found"); + let versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId); + const insert = db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name) VALUES (?, ?, ?, ?, ?)"); + if (!versions.length) { + db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name, created_at) VALUES (?, ?, ?, ?, ?, ?)") + .run(messageId, row.content, row.thinking, row.sources, row.model_name, row.created_at); + } else { + // Edits keep their existing semantics: update the selected answer. + db.prepare("UPDATE chat_message_versions SET content = ?, thinking = ?, sources = ?, model_name = ? WHERE id = ?") + .run(row.content, row.thinking, row.sources, row.model_name, versions[row.retry_active_index]?.id ?? versions[0].id); + } + if (version) insert.run(messageId, version.content, version.thinking || null, JSON.stringify(version.sources ?? null), version.modelName); + versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId); + const index = version ? versions.length - 1 : selectedIndex; + if (!Number.isInteger(index) || !versions[index]) throw new Error("Version is out of range"); + const active = versions[index]; + db.prepare(`UPDATE chat_messages SET content = ?, thinking = ?, sources = ?, model_name = ?, + retry_variants = ?, retry_active_index = ?, retry_retries_used = ? WHERE id = ? AND chat_id = ?`) + .run(active.content, active.thinking, active.sources, active.model_name, JSON.stringify(versions.map(item => item.content)), index, versions.length - 1, messageId, chatId); + })(), updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId), updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run( payload.content, @@ -1278,6 +1305,10 @@ export function createRepositories(db, config) { persona_state: this.getPersonaChatState(chat.id, username), messages: this.listChatMessages(chat.id).map((message) => ({ ...message, + responseVersions: this.listMessageVersions(message.id).map(version => ({ + content: version.content, thinking: version.thinking, + sources: parseJsonArray(version.sources), modelName: version.model_name, createdAt: version.created_at + })), attachments: db.prepare("SELECT a.* FROM attachments a JOIN message_attachments m ON a.id=m.attachment_id WHERE m.message_id=? ORDER BY m.position").all(message.id).map(attachmentMetadata), retryVariants: parseJsonArray(message.retry_variants) })) diff --git a/src/db/schema.js b/src/db/schema.js index f98a26c..bef91af 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -236,6 +236,16 @@ export function initializeSchema(db) { } export function runMigrations(db, {dropLegacyChats = true} = {}) { + db.exec(`CREATE TABLE IF NOT EXISTS chat_message_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, + content TEXT NOT NULL, + thinking TEXT, + sources TEXT, + model_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS chat_message_versions_message ON chat_message_versions(message_id);`); const ensureColumn = (tableName, columns, name, sql) => { if (!columns.some((column) => column.name === name)) { db.prepare(sql).run(); diff --git a/src/routes/chats.js b/src/routes/chats.js index 4af41cc..ae88ace 100644 --- a/src/routes/chats.js +++ b/src/routes/chats.js @@ -105,6 +105,14 @@ const preferredModelValidator = (body) => { export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) { const router = express.Router(); router.use(requireLogin); + router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => { + const chatId = Number(req.params.id); + const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id; + if (["PUT", "DELETE", "POST"].includes(req.method) && chatService.isRegeneratingMessage(chatId, messageId)) { + return res.status(409).json({error: "This response is being regenerated. Wait for it to finish."}); + } + next(); + }); router.get("/models", async (req, res) => { try { @@ -475,7 +483,21 @@ export function createChatsRouter({repositories, chatService, modelService, conf } }); - router.post("/chats/:id/messages/:messageId/retry", ...chatRateLimiters, async (req, res) => { + router.post("/chats/:id/messages/:messageId/versions/:index", (req, res) => { + const chatId = Number(req.params.id), messageId = Number(req.params.messageId); + if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"}); + const message = repositories.getChatMessage(chatId, messageId); + if (!message || message.role !== "bot") return res.status(404).json({error: "Assistant message not found"}); + if (chatService.isGenerating(chatId)) return res.status(409).json({error: "Wait for generation to finish"}); + const index = Number(req.params.index); + const versions = repositories.listMessageVersions(messageId); + if (!Number.isInteger(index) || index < 0 || index >= versions.length) return res.status(400).json({error: "Version is out of range"}); + repositories.saveMessageVersion(chatId, messageId, null, index); + repositories.updateChatContextSummary(chatId, req.session.user, null, 0); + res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))}); + }); + + router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, async (req, res) => { const chatId = Number(req.params.id); const messageId = Number(req.params.messageId); const chat = repositories.getChat(chatId, req.session.user); @@ -484,19 +506,45 @@ export function createChatsRouter({repositories, chatService, modelService, conf if (!targetMessage) return res.status(404).json({error: "Message not found"}); if (targetMessage.role !== "bot") return res.status(400).json({error: "Only assistant messages can be regenerated"}); + const streaming = req.path.endsWith("/stream"); + const controller = new AbortController(); + let finished = false; + const sendEvent = (event, data) => { + if (!res.destroyed) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + if (streaming) { + res.once("close", () => { if (!finished) controller.abort(); }); + res.writeHead(200, {"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no"}); + } try { const result = await chatService.retryMessage({ user: req.session.user, chatId, selectedModel: String(req.body?.model || "qwen3:14b"), targetMessage, - retryStyle: String(req.body?.style || "").trim() || null + retryStyle: String(req.body?.style || "").trim() || null, + webSearch: req.body?.webSearch === true, + reasoning: req.body?.reasoning === true, + signal: controller.signal, + ...(streaming ? { + onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}), + onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}), + onStatus: status => sendEvent("status", status) + } : {}) }); + if (streaming) { + sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, searchState: result.searchState} : {...result, reply: result.message.content}); + return; + } if (result.error) return res.status(result.status).json({error: result.error}); return res.json(result); } catch (error) { const mapped = modelService.mapError(error); + if (streaming) { sendEvent("error", mapped.body); return; } return res.status(mapped.status).json(mapped.body); + } finally { + finished = true; + if (streaming) res.end(); } }); @@ -514,7 +562,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf chatId, selectedModel: String(req.body?.model || "qwen3:14b"), targetMessage, - retryStyle: String(req.body?.style || "").trim() || null + retryStyle: String(req.body?.style || "").trim() || null, + webSearch: req.body?.webSearch === true, + reasoning: req.body?.reasoning === true }); if (result.error) return res.status(result.status).json({error: result.error}); return res.json(result); diff --git a/src/services/chatService.js b/src/services/chatService.js index d4b58d7..c3b6b8c 100644 --- a/src/services/chatService.js +++ b/src/services/chatService.js @@ -24,12 +24,22 @@ import {supportsReasoning} from "../../public/app/modelCapabilities.js"; export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null) { const formatChatMessage = row => { const message = formatBaseChatMessage(row); + if (message?.role === "bot") { + const versions = repositories.listMessageVersions(row.id); + message.versionCount = versions.length || 1; + message.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0; + if (versions.length) message.responseVersions = versions.map(version => ({ + content: version.content, thinking: version.thinking, + sources: parseSources(version.sources), modelName: version.model_name, createdAt: version.created_at + })); + } const attachments = row && attachmentService?.list(row.id); return message && attachments?.length ? {...message, attachments} : message; }; const chatGenerationQueues = new Map(); const summaryQueues = new Map(); const activeGenerations = new Map(); + const regeneratingMessages = new Map(); const maxRetries = Number(config.chatMaxRetries ?? 5); const now = typeof config.now === "function" ? config.now : () => new Date(); @@ -84,6 +94,8 @@ export function createChatService(repositories, modelService, config, webSearchP }); const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => { + const versioned = repositories.listMessageVersions(messageId).length > 0; + if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex); repositories.updateChatMessageRetryState(chatId, messageId, { content, retryVariants: retryVariants || [content || ""], @@ -91,6 +103,7 @@ export function createChatService(repositories, modelService, config, webSearchP retryRetriesUsed: Number(retryRetriesUsed || 0), retryPromptMessageId: retryPromptMessageId ?? null }); + if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex); }; const getChatMessageByIndex = (chatId, index) => { @@ -174,7 +187,7 @@ export function createChatService(repositories, modelService, config, webSearchP return repositories.createChat(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary); }; - const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary, onThinking = null}) => { + const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary, onThinking = null, onChunk = null, signal = null, generation = {}, reasoning = false}) => { const baseMessages = [ {role: "system", content: buildPersonaPrompt(assistantPersona)}, ...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []), @@ -182,20 +195,24 @@ export function createChatService(repositories, modelService, config, webSearchP {role: "user", content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})} ]; - let opener = await modelService.generateReply(selectedModel, baseMessages, {onThinking}); + const generate = messages => onChunk + ? modelService.streamReply(selectedModel, messages, onChunk, {onThinking, signal, generation, reasoning}) + : modelService.generateReply(selectedModel, messages, {onThinking, signal, generation, reasoning}); + let opener = await generate(baseMessages); if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) { return opener; } onThinking?.("", ""); - return modelService.generateReply(selectedModel, [ + onChunk?.("", ""); + return generate([ ...baseMessages, {role: "assistant", content: opener || ""}, { role: "user", content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, actions, or labels like 'User:'/'You:'. Stop before the user's reply." } - ], {onThinking}); + ]); }; const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}, onThinking = null, onChunk = null, reasoning = false}) => { @@ -285,13 +302,20 @@ export function createChatService(repositories, modelService, config, webSearchP return {session: activeSession, activePersona, activeUserPersona, messagesPayload}; }; - const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}}) => { + const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}, webSearch = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null}) => { + let thinking = ""; + const captureThinking = (chunk, fullThinking) => { + if (!reasoning) return; + thinking = fullThinking; + onThinking?.(chunk, fullThinking); + }; + onStatus?.({status: "accepted"}); const parentMessage = targetMessage.parent_message_id ? repositories.getChatMessage(chatId, targetMessage.parent_message_id) : null; const promptMessage = parentMessage?.role === "user" ? parentMessage - : repositories.getPreviousUserMessage(chatId, targetMessage.id); + : repositories.listChatBranchThroughMessage(chatId, targetMessage.id).slice(0, -1).reverse().find(entry => entry.role === "user"); const activePersona = repositories.getAssistantPersonaForChat(chatId, user); const activeUserPersona = getUserPersonaForSession(chatId, user, session, activePersona); @@ -299,9 +323,8 @@ export function createChatService(repositories, modelService, config, webSearchP if (!activePersona || !session.scenario_summary) { return {error: "No previous user prompt found for retry"}; } - let thinking = ""; const opener = await generateRoleplayOpener({ - onThinking: (_chunk, fullThinking) => { thinking = fullThinking; }, + onThinking: captureThinking, onChunk, signal, generation, reasoning, selectedModel, assistantPersona: activePersona, userPersona: activeUserPersona, @@ -314,25 +337,38 @@ export function createChatService(repositories, modelService, config, webSearchP return {fullReply: opener, thinking, promptMessageId: null}; } + if (attachmentService?.list(promptMessage.id).length) onStatus?.({status: "analyzing"}); await attachmentService?.analyze(promptMessage.id, promptMessage.content, signal); + let sources = null; + let searchState = "skipped"; + if (webSearch) { + const search = await retrieveWebSearch(promptMessage.content, webSearchProvider, { + signal, + referenceText: attachmentService?.searchContext(promptMessage.id) || "", + onSearching: () => onStatus?.({status: "searching"}) + }); + sources = search.sources; + searchState = search.state; + } + onStatus?.({status: "generating", searchState, sources}); const {messagesPayload} = buildConversationContext({ user, chatId, session, message: null, upToMessageId: promptMessage.id, - sources: targetMessage.sources ? parseSources(targetMessage.sources) : null + sources }); const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle); - let thinking = ""; - const onThinking = (_chunk, fullThinking) => { thinking = fullThinking; }; const fullReply = activePersona - ? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking}) - : await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking}); + ? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking: captureThinking, onChunk, reasoning}) + : onChunk + ? await modelService.streamReply(selectedModel, styledPayload, onChunk, {signal, generation, onThinking: captureThinking, reasoning}) + : await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking: captureThinking, reasoning}); if (!fullReply) return {error: "Failed to regenerate message"}; - return {fullReply, thinking, promptMessageId: promptMessage.id}; + return {fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id}; }; const generateChatTurn = ({user, chatId, message, model, attachments = [], reuseChatId = null, webSearch = false, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) => @@ -536,8 +572,13 @@ export function createChatService(repositories, modelService, config, webSearchP async streamChatMessage({user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null}) { return generateChatTurn({user, chatId, message, model, attachments, webSearch, reasoning, onStatus, onChunk, onThinking, signal}); }, - async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) { + isGenerating: (chatId) => chatGenerationQueues.has(chatId), + isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId, + async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null, webSearch = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null, signal = null}) { + if (chatGenerationQueues.has(chatId)) return {error: "A response is already being generated", status: 409}; + regeneratingMessages.set(chatId, targetMessage.id); return withChatGenerationLock(chatId, async () => { + await (summaryQueues.get(chatId) || Promise.resolve()); const session = repositories.getChat(chatId, user); if (!session) return {error: "Chat not found", status: 404}; @@ -545,35 +586,31 @@ export function createChatService(repositories, modelService, config, webSearchP const existingMessage = formatChatMessage(freshTarget); if (!existingMessage) return {error: "Message not found", status: 404}; if (existingMessage.role !== "bot") return {error: "Only assistant messages can be regenerated", status: 400}; - const siblings = repositories.listSiblingChatMessages(chatId, "bot", freshTarget.parent_message_id); - if (siblings.length - 1 >= maxRetries) { + if (existingMessage.versionCount - 1 >= maxRetries) { return {error: `Maximum of ${maxRetries} retries reached`, status: 400}; } const {model, generation} = getGenerationSettings(session, selectedModel); - return withActiveGeneration(chatId, null, async (activeSignal) => { + return withActiveGeneration(chatId, signal, async (activeSignal) => { + activeSignal.throwIfAborted(); const retryResult = await buildRetryPayload({ chatId, user, session, selectedModel: model, targetMessage: freshTarget, retryStyle, - signal: activeSignal, generation + signal: activeSignal, generation, webSearch, reasoning: reasoning === true && supportsReasoning(model), onChunk, onThinking, onStatus }); if (retryResult.error) return {error: retryResult.error, status: 400}; - const retryVariants = [...siblings.map((sibling) => sibling.content), retryResult.fullReply]; - const retryState = { - retryVariants, - retryActiveIndex: retryVariants.length - 1, - retryRetriesUsed: siblings.length, - retryPromptMessageId: retryResult.promptMessageId - }; - const inserted = repositories.insertBranchChatMessage( - chatId, "bot", retryResult.fullReply, retryState, model, "complete", null, freshTarget.parent_message_id - ); - if (freshTarget.sources) repositories.updateChatMessageSources(chatId, Number(inserted.lastInsertRowid), parseSources(freshTarget.sources)); - if (retryResult.thinking) repositories.updateChatMessageThinking(chatId, Number(inserted.lastInsertRowid), retryResult.thinking); + activeSignal.throwIfAborted(); + repositories.saveMessageVersion(chatId, freshTarget.id, { + content: retryResult.fullReply, thinking: retryResult.thinking, + sources: retryResult.sources, modelName: model + }); repositories.updateChatContextSummary(chatId, user, null, 0); repositories.touchChat(chatId, user); - const message = formatChatMessage(repositories.getChatMessage(chatId, Number(inserted.lastInsertRowid))); - return {message, promptMessageId: retryResult.promptMessageId, retryStyle, branchedFromMessageId: freshTarget.id}; + const message = formatChatMessage(repositories.getChatMessage(chatId, freshTarget.id)); + return {message, promptMessageId: retryResult.promptMessageId, retryStyle}; }); - }); + }).catch(error => { + if (error instanceof WebSearchError) return {error: error.message, status: error.status, searchState: "failed"}; + throw error; + }).finally(() => regeneratingMessages.delete(chatId)); }, async editAndResendMessage({user, chatId, targetMessage, content, model, webSearch = false, onChunk = null, signal = null}) { if (!targetMessage || targetMessage.role !== "user") return {error: "Only user messages can be edited and resent", status: 400}; diff --git a/src/services/workspaceImportService.js b/src/services/workspaceImportService.js index 857c384..442f6b8 100644 --- a/src/services/workspaceImportService.js +++ b/src/services/workspaceImportService.js @@ -86,6 +86,10 @@ function validateMessage(message, chatIndex, messageIndex, errors) { content, model_name: optionalText(message.model_name || message.modelName), thinking: optionalText(message.thinking), + responseVersions: asArray(message.responseVersions).filter(version => version && typeof version.content === "string").slice(0, 100).map(version => ({ + content: version.content, thinking: optionalText(version.thinking), sources: parseSources(version.sources), + modelName: optionalText(version.modelName), createdAt: optionalText(version.createdAt) + })), attachments: asArray(message.attachments).filter(item => item && typeof item.id === "string" && /^[a-f0-9-]{36}$/.test(item.id)).slice(0, 100).map(item => ({id: item.id})), ...(message.sources ? {sources: parseSources(message.sources)} : {}), retryVariants, diff --git a/test/app.test.js b/test/app.test.js index 25ffcc6..59de1ff 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -455,8 +455,11 @@ test("conversation branches, edit-resend, and generation settings work", async ( }); assert.equal(regenerated.status, 200); let active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar}); - assert.equal(active.json.messages.length, 2); - assert.equal(active.json.messages[1].siblingCount, 2); + assert.equal(active.json.messages.length, 4); + assert.equal(active.json.messages[1].id, firstAssistant.id); + assert.equal(active.json.messages[1].versionCount, 2); + assert.equal(active.json.messages[1].siblingCount, 1); + await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/versions/0`, {method: "POST", body: {}, jar}); await request(server.baseUrl, `/chats/${chatId}/branches/${originalLeaf.id}/activate`, {method: "POST", body: {}, jar}); active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar}); diff --git a/test/attachments.test.js b/test/attachments.test.js index 47a60c3..074132e 100644 --- a/test/attachments.test.js +++ b/test/attachments.test.js @@ -151,7 +151,11 @@ test("images feed dedicated vision, cache, streamed reasoning, reload and shared const exported = await (await s.api("/exports/workspace")).json(); assert.deepEqual(exported.workspace.chats[0].messages[0].attachments, uploads); const botId = history.messages.at(-1).id; - assert.equal((await s.api(`/chats/${chatId}/messages/${botId}/retry`, {model: "qwen3:14b"})).status, 200); + const retryWire = await (await s.api(`/chats/${chatId}/messages/${botId}/retry/stream`, {model: "qwen3:14b", reasoning: true})).text(); + assert.match(retryWire, /event: chunk/); + assert.match(retryWire, /event: thinking/); + assert.match(retryWire, /event: done/); + assert.ok(s.requests.at(-1).messages.some(v => v.content.includes("A blue screenshot"))); assert.equal(s.requests.filter(v => v.model === "gemma3:4b").length, 2, "Retry reuses vision cache"); const branchResponse = await s.api(`/chats/${chatId}/messages/${botId}/branch`, {confirmed: true}); assert.equal(branchResponse.status, 200); diff --git a/test/reasoning.test.js b/test/reasoning.test.js index 62e75d7..8fa7a21 100644 --- a/test/reasoning.test.js +++ b/test/reasoning.test.js @@ -77,6 +77,7 @@ test("thinking streams separately and survives reload, retry, branch and import" assert.equal(bot.content, "Answer"); const retry = await api(`/chats/${chatId}/messages/${bot.id}/retry`, {}); assert.equal(retry.message.thinking, undefined); + await api(`/chats/${chatId}/messages/${bot.id}/versions/0`, {}); const branch = await api(`/chats/${chatId}/messages/${bot.id}/branch`, {confirmed: true}); assert.equal(branch.messages.at(-1).thinking, bot.thinking); const exported = await api(`/exports/chats/${chatId}?format=json`); @@ -125,6 +126,19 @@ test("thinking streams separately and survives reload, retry, branch and import" assert.match(roleplayWire, /event: done/); assert.ok(requests.at(-1).messages.some(message => message.content.includes("Guide"))); assert.equal(requests.at(-1).think, true); + const roleplayMessages = (await api(`/chats/${roleplay.chat.id}/messages`)).messages; + for (const target of [roleplayMessages[0], roleplayMessages.at(-1)]) { + const retryResponse = await fetch(base + `/chats/${roleplay.chat.id}/messages/${target.id}/retry/stream`, { + method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie}, + body: JSON.stringify({model: "qwen3:14b", reasoning: true}) + }); + const retryWire = await retryResponse.text(); + assert.match(retryWire, /event: thinking/); + assert.match(retryWire, /event: chunk/); + assert.match(retryWire, /event: done/); + assert.ok(requests.at(-1).messages.some(message => message.content.includes("Guide"))); + assert.equal(requests.at(-1).think, true); + } }); test("reasoning disclosure is collapsed, preserves open state and renders text safely", (t) => { diff --git a/test/regeneration.test.js b/test/regeneration.test.js new file mode 100644 index 0000000..30a17e8 --- /dev/null +++ b/test/regeneration.test.js @@ -0,0 +1,170 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {createServer} from "node:http"; +import {createApp} from "../src/app.js"; +import Database from "better-sqlite3"; +import {runMigrations} from "../src/db/schema.js"; + +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; }; + +async function fixture(t) { + let mode = "success", sequence = 0, searches = 0; + let started = deferred(), release = deferred(); + const calls = []; + const search = createServer((_req, res) => { + searches++; + if (mode === "search-fail") { res.writeHead(503); res.end("Unavailable"); return; } + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({results: [{title: "Satellite launch news", url: `https://example.org/news/${searches}`, content: "Latest satellite launch news today: a research satellite launched successfully."}]})); + }); + await new Promise(resolve => search.listen(0, "127.0.0.1", resolve)); + const modelService = { + async generateReply() { return `Original ${++sequence}`; }, + async streamReply(model, messages, chunk, options) { + calls.push({model, messages, options}); + if (options.reasoning) options.onThinking("New reasoning", "New reasoning"); + chunk("Partial", "Partial"); + started.resolve(); + if (mode === "hold") await new Promise((resolve, reject) => { + release.promise.then(resolve); + options.signal.addEventListener("abort", () => reject(new Error("Generation stopped")), {once: true}); + }); + if (mode === "fail") throw new Error("Model failed after partial output"); + if (mode === "empty") return ""; + chunk(" answer", "Partial answer"); + return "Partial answer"; + }, + mapError(error) { return {status: 500, body: {error: error.message}}; } + }; + const app = createApp({modelService, config: {dbPath: ":memory:", testMode: true, sessionSecret: "regeneration-test", + chatSummaryUpdateEveryMessages: 0, eventLoopLagMonitorEnabled: false, structuredRequestLoggingEnabled: false, + slowRequestLoggingEnabled: false, searxngUrl: `http://127.0.0.1:${search.address().port}`}}); + const server = app.listen(0, "127.0.0.1"); + await new Promise(resolve => server.once("listening", resolve)); + t.after(async () => { + release.resolve(); + await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }); + await new Promise(resolve => { search.closeAllConnections(); search.close(resolve); }); + await app.locals.close(); + }); + let cookie; + const api = async (path, body, method = body ? "POST" : "GET") => { + const response = await fetch(`http://127.0.0.1:${server.address().port}${path}`, {method, + headers: {"Content-Type": "application/json", ...(cookie ? {Cookie: cookie} : {})}, body: body ? JSON.stringify(body) : undefined}); + if (response.headers.get("set-cookie")) cookie = response.headers.get("set-cookie").split(";")[0]; + return response; + }; + await api("/register", {username: "regenerator", password: "test-password"}); + const chatId = (await (await api("/chats", {title: "Versions"})).json()).chat.id; + await api("/chat", {chatId, message: "Search the web for the latest satellite launch news today"}); + await api("/chat", {chatId, message: "Later private question"}); + const messages = (await (await api(`/chats/${chatId}/messages`)).json()).messages; + const bot = messages[1]; + app.locals.repositories.updateChatMessageThinking(chatId, bot.id, "Original reasoning"); + app.locals.repositories.updateChatMessageSources(chatId, bot.id, [{title: "Old", url: "https://example.org/old", snippet: "Old evidence"}]); + return {app, api, chatId, bot, messages, calls, + get searches() { return searches; }, + path: `/chats/${chatId}/messages/${bot.id}`, + hold() { mode = "hold"; started = deferred(); release = deferred(); return started.promise; }, + setMode(value) { mode = value; }, release: () => release.resolve(), + history: async () => (await (await api(`/chats/${chatId}/messages`)).json()).messages}; +} + +test("regeneration streams before commit in the same message; versions retain sources and reasoning and branch from selection", async t => { + const s = await fixture(t); + const started = s.hold(); + const response = await s.api(`${s.path}/retry/stream`, {reasoning: true, webSearch: true}); + await started; + const reader = response.body.getReader(); + let wire = new TextDecoder().decode((await reader.read()).value); + while (!wire.includes("Partial")) wire += new TextDecoder().decode((await reader.read()).value); + assert.match(wire, /event: thinking/); + assert.match(wire, /Partial/); + assert.doesNotMatch(wire, /event: done/); + let history = await s.history(); + assert.equal(history[1].content, s.bot.content, "database retains original while streaming"); + assert.equal(history[1].versionCount, 1); + assert.equal(history.length, 4); + assert.equal((await s.api(`${s.path}/retry`, {})).status, 409); + assert.equal((await s.api(s.path, {content: "unsafe edit"}, "PUT")).status, 409); + assert.equal((await s.api(s.path, null, "DELETE")).status, 409); + assert.equal((await s.api(`/chats/${s.chatId}/messages/${s.messages[3].id}`, {content: "Other answer edited"}, "PUT")).status, 200); + s.release(); + while (true) { const result = await reader.read(); if (result.done) break; wire += new TextDecoder().decode(result.value); } + assert.match(wire, /event: done/); + assert.ok(wire.indexOf('"status":"searching"') < wire.indexOf("event: chunk")); + history = await s.history(); + assert.deepEqual(history.map(m => m.id), s.messages.map(m => m.id)); + assert.equal(history[1].content, "Partial answer"); + assert.equal(history[1].versionCount, 2); + assert.equal(history[1].versionIndex, 1); + assert.equal(history[1].thinking, "New reasoning"); + assert.equal(history[1].sources[0].url, "https://example.org/news/1"); + assert.equal(s.searches, 1); + assert.ok(s.calls[0].messages.every(m => !m.content.includes("Later private question") && !m.content.includes("Original 2"))); + assert.equal(s.calls[0].messages.at(-1).content, s.messages[0].content); + await s.api(`${s.path}/versions/0`, {}); + history = await s.history(); + assert.equal(history[1].content, s.bot.content); + assert.equal(history[1].thinking, "Original reasoning"); + assert.equal(history[1].sources[0].url, "https://example.org/old"); + const branch = await (await s.api(`${s.path}/branch`, {confirmed: true})).json(); + assert.equal(branch.messages.at(-1).content, s.bot.content); + assert.equal(branch.messages.at(-1).thinking, "Original reasoning"); + assert.deepEqual(branch.messages.at(-1).sources, history[1].sources); + await s.api(s.path, {content: "Edited original"}, "PUT"); + await s.api(`${s.path}/versions/1`, {}); + assert.equal((await s.history())[1].content, "Partial answer"); + await s.api(`${s.path}/versions/0`, {}); + assert.equal((await s.history())[1].content, "Edited original"); + assert.equal((await s.api(`${s.path}/versions/99`, {})).status, 400); + s.setMode("success"); + const third = await (await s.api(`${s.path}/retry/stream`, {})).text(); + assert.match(third, /event: done/); + const thirdMessage = (await s.history())[1]; + assert.equal(thirdMessage.versionCount, 3); + assert.equal(thirdMessage.versionIndex, 2); + assert.equal(thirdMessage.thinking, undefined); + assert.deepEqual(thirdMessage.sources, []); + const backup = (await (await s.api("/exports/workspace")).json()).workspace; + assert.equal((await s.api("/imports/workspace", {workspace: backup, allowDuplicates: true})).status, 200); + const copiedVersions = s.app.locals.db.prepare("SELECT * FROM chat_message_versions WHERE message_id != ?").all(s.bot.id); + assert.equal(copiedVersions.length, 3); + assert.equal(copiedVersions[0].thinking, "Original reasoning"); + assert.equal(copiedVersions[1].thinking, "New reasoning"); + const reopened = new Database(s.app.locals.db.serialize()); + try { + runMigrations(reopened, {dropLegacyChats: false}); + assert.equal(reopened.prepare("SELECT count(*) n FROM chat_message_versions WHERE message_id = ?").get(s.bot.id).n, 3); + assert.equal(reopened.prepare("SELECT retry_active_index FROM chat_messages WHERE id = ?").get(s.bot.id).retry_active_index, 2); + } finally { reopened.close(); } + await s.api(s.path, null, "DELETE"); + assert.equal(s.app.locals.repositories.listMessageVersions(s.bot.id).length, 0); +}); + +test("failed, empty, stopped and disconnected regeneration preserve the saved answer and versions", async t => { + const s = await fixture(t); + const original = (await s.history())[1]; + for (const mode of ["fail", "empty", "search-fail"]) { + s.setMode(mode); + const wire = await (await s.api(`${s.path}/retry/stream`, {reasoning: true, webSearch: mode === "search-fail"})).text(); + assert.match(wire, /event: error/); + assert.doesNotMatch(wire, /event: done/); + assert.deepEqual((await s.history())[1], original); + assert.equal(s.app.locals.repositories.listMessageVersions(s.bot.id).length, 0); + if (mode === "search-fail") assert.match(wire, /"searchState":"failed"/); + } + const started = s.hold(); + const response = await s.api(`${s.path}/retry/stream`, {}); + await started; + assert.equal((await (await s.api(`/chats/${s.chatId}/stop`, {})).json()).stopped, true); + assert.match(await response.text(), /event: error/); + assert.deepEqual((await s.history())[1], original); + const disconnected = s.hold(); + const disconnectResponse = await s.api(`${s.path}/retry/stream`, {}); + await disconnected; + await disconnectResponse.body.cancel(); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.deepEqual((await s.history())[1], original); + assert.equal(s.app.locals.repositories.listMessageVersions(s.bot.id).length, 0); +}); diff --git a/test/regenerationUi.test.js b/test/regenerationUi.test.js new file mode 100644 index 0000000..1c9749b --- /dev/null +++ b/test/regenerationUi.test.js @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {readFile} from "node:fs/promises"; +import {regenerateInline} from "../public/app/regeneration.js"; + +class Element { + constructor(tag = "div") { + this.tag = tag; this.children = []; this.style = {}; this.dataset = {}; this.attributes = {}; + this.scrollTop = 0; this.bottom = 500; this.height = 200; this.top = 0; + } + get childNodes() { return this.children; } + get childElementCount() { return this.children.length; } + append(...children) { children.forEach(child => { child.parent = this; this.children.push(child); }); } + appendChild(child) { this.append(child); } + replaceChildren(...children) { this.children = []; this.append(...children); } + insertBefore(child, before) { child.parent = this; this.children.splice(this.children.indexOf(before), 0, child); } + remove() { this.parent.children = this.parent.children.filter(child => child !== this); } + cloneNode() { const clone = new Element(this.tag); clone.textContent = this.textContent; return clone; } + setAttribute(key, value) { this.attributes[key] = value; } + removeAttribute(key) { delete this.attributes[key]; } + getBoundingClientRect() { return {top: this.top, bottom: this.bottom, height: this.height}; } + querySelector(selector) { + for (const child of this.children) { + if (selector === child.tag || child.className?.split(" ").some(name => selector === `.${name}`)) return child; + const nested = child.querySelector(selector); if (nested) return nested; + } + return null; + } +} + +test("inline renderer streams in place, follows only nearby output, commits or restores all original nodes", async t => { + const oldDocument = globalThis.document, oldFetch = globalThis.fetch; + t.after(() => { globalThis.document = oldDocument; globalThis.fetch = oldFetch; }); + globalThis.document = {createElement: tag => new Element(tag)}; + for (const outcome of ["success", "error", "disconnect"]) { + const scroller = new Element(), card = new Element(), later = new Element(); + const header = new Element(), oldBody = new Element(), actions = new Element(), oldSources = new Element(); + oldBody.textContent = "Old answer"; oldBody.className = "msg-content"; + oldSources.className = "msg-sources"; + card.append(header, oldBody, oldSources, actions); + scroller.append(card, later); + const original = [...card.childNodes]; + let controller; + globalThis.fetch = async () => new Response(new ReadableStream({start(value) { controller = value; }})); + let completed; + const pending = regenerateInline({card, scroller, endpoint: "/retry/stream", payload: {}, + renderContent: (body, content) => { body.textContent = content; card.bottom += 20; }, + complete: message => { completed = message; card.replaceChildren(header, new Element(), actions); }}); + const event = async (name, data) => { + controller.enqueue(new TextEncoder().encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)); + await new Promise(resolve => setTimeout(resolve, 0)); + }; + assert.equal(card.attributes["aria-busy"], "true"); + assert.equal(card.querySelector(".msg-sources"), null); + assert.ok(!card.childNodes.includes(actions)); + await event("status", {status: "searching"}); + assert.equal(card.querySelector(".loading-shell").textContent, "Searching the web..."); + await event("status", {status: "generating", sources: [{title: "New source", url: "https://example.org/new"}]}); + assert.notEqual(card.querySelector(".msg-sources"), oldSources); + await event("thinking", {fullThinking: "New reasoning"}); + assert.equal(card.querySelector(".msg-reasoning-content").textContent, "New reasoning"); + await event("chunk", {fullReply: "Partial"}); + assert.equal(card.querySelector(".msg-content").textContent, "Partial"); + assert.equal(scroller.scrollTop, 20); + assert.equal(completed, undefined); + assert.deepEqual(scroller.childNodes, [card, later]); + card.bottom = 1000; // User has scrolled away from this response. + await event("chunk", {fullReply: "Partial answer"}); + assert.equal(scroller.scrollTop, 20); + if (outcome === "success") { + await event("done", {reply: "Partial answer", message: {id: 2, content: "Partial answer"}}); + await pending; + assert.equal(completed.content, "Partial answer"); + assert.notDeepEqual(card.childNodes, original); + } else { + const rejection = assert.rejects(pending, outcome === "error" ? /Model failed/ : /interrupted/); + if (outcome === "error") await event("error", {error: "Model failed"}); + else controller.close(); + await rejection; + assert.deepEqual(card.childNodes, original); + } + assert.equal(card.attributes["aria-busy"], undefined); + assert.equal(card.style.minHeight, undefined); + assert.deepEqual(scroller.childNodes, [card, later]); + } +}); + +test("Regenerate uses inline activity and never reloads or appends a chat message", async () => { + const source = await readFile(new URL("../public/script.js", import.meta.url), "utf8"); + const regenerate = source.slice(source.indexOf("async function retryLatestAssistantMessage"), source.indexOf("async function switchResponseVersion")); + assert.match(regenerate, /setLoadingState\(true, false\)/); + assert.match(regenerate, /regenerateInline/); + assert.match(regenerate, /detached: true/); + assert.doesNotMatch(regenerate, /setActiveChat\(|renderMessages\(|Generating another version/); +}); diff --git a/test/webSearch.test.js b/test/webSearch.test.js index 34115bf..1a3276b 100644 --- a/test/webSearch.test.js +++ b/test/webSearch.test.js @@ -217,9 +217,10 @@ test("chat search integrates with streaming, persistence, personas, settings, re const botId = messages.at(-1).id; const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {}); assert.equal(retry.status, 200); - assert.deepEqual(retry.body.message.sources, [source]); + assert.deepEqual(retry.body.message.sources, []); + await api(`/chats/${chatId}/messages/${botId}/versions/0`, {}); assert.equal(searchCount, 1); - assert.ok(calls.at(-1).messages.some((message) => message.content.includes(source.snippet))); + assert.ok(!calls.at(-1).messages.some((message) => message.content.includes(source.snippet))); const branch = await api(`/chats/${chatId}/messages/${botId}/branch`, {confirmed: true}); assert.deepEqual(branch.body.messages.at(-1).sources, [source]); const exported = (await api(`/exports/chats/${chatId}?format=json`)).body; @@ -228,7 +229,7 @@ test("chat search integrates with streaming, persistence, personas, settings, re const imported = await api("/imports/workspace", {workspace, allowDuplicates: true}); assert.equal(imported.status, 200); const allMessages = app.locals.db.prepare("SELECT sources FROM chat_messages WHERE sources IS NOT NULL").all(); - assert.ok(allMessages.length >= 6); + assert.ok(allMessages.length >= 4); for (const message of allMessages) assert.deepEqual(JSON.parse(message.sources), [source]); emptySearch = true;