add response versioning support in messages for regeneration improvements
This commit is contained in:
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
-42
@@ -1,4 +1,5 @@
|
|||||||
import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls";
|
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 {appendSources, createWebSearchToggle} from "./app/webSearch.js";
|
||||||
import {appendAttachments, createAttachmentComposer} from "./app/attachments.js";
|
import {appendAttachments, createAttachmentComposer} from "./app/attachments.js";
|
||||||
import {createReasoningToggle, finishReasoning, updateReasoning} from "./app/reasoning.js?v=20260921-reasoning-capability";
|
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"));
|
const reasoningToggle = createReasoningToggle(document.getElementById("reasoning"));
|
||||||
|
|
||||||
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
|
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
|
||||||
|
let regenerationChatId = null;
|
||||||
const attachmentComposer = createAttachmentComposer({onChange: updateSendState, onError: message => setNotice(message, "error")});
|
const attachmentComposer = createAttachmentComposer({onChange: updateSendState, onError: message => setNotice(message, "error")});
|
||||||
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
|
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
|
||||||
let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set();
|
let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set();
|
||||||
@@ -568,45 +570,70 @@ async function deleteChatMessage(messageId, fallbackIndex = -1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
|
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
|
||||||
|
if (isProcessing) return;
|
||||||
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||||
|
if (isProcessing) return;
|
||||||
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
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");
|
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) {
|
if (retriesUsed >= 5) {
|
||||||
return setNotice("Retry limit reached (5).", "error");
|
return setNotice("Retry limit reached (5).", "error");
|
||||||
}
|
}
|
||||||
setLoadingState(true, {
|
const chatId = activeChatId;
|
||||||
eyebrow: "Regenerating",
|
const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`);
|
||||||
title: "Generating another version",
|
if (!card) return;
|
||||||
detail: "The assistant is rewriting the latest reply."
|
const payload = {model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled};
|
||||||
});
|
regenerationChatId = chatId;
|
||||||
setNotice("Regenerating reply...");
|
setLoadingState(true, false);
|
||||||
try {
|
try {
|
||||||
const endpoint = getMessageEndpoint(target, "/retry");
|
await regenerateInline({
|
||||||
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
|
card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload,
|
||||||
const style = await promptPopup({
|
renderContent: setMessageContent,
|
||||||
eyebrow: "Retry style",
|
complete: message => {
|
||||||
title: "Retry modifier",
|
if (activeChatId !== chatId) return;
|
||||||
description: "Leave empty for a normal retry, or use shorter, direct, emotional, stay-in-character, or dialogue.",
|
const index = getMessageIndexById(message.id);
|
||||||
label: "Style",
|
if (index < 0) return;
|
||||||
value: "",
|
currentMessages[index] = {...currentMessages[index], ...message, thinking: message.thinking, sources: message.sources};
|
||||||
placeholder: "shorter",
|
syncRetryStateFromMessage(currentMessages[index]);
|
||||||
confirmLabel: "Retry"
|
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()});
|
webSearchToggle.reset();
|
||||||
if (res.error || !res.message?.content) {
|
reasoningToggle.reset();
|
||||||
return setNotice(res.error || "Unable to retry message.", "error");
|
setNotice("Reply regenerated.", "success");
|
||||||
}
|
} catch (error) {
|
||||||
const updateIndex = target.message?.id ? getMessageIndexById(target.message.id) : target.index;
|
setNotice(error.message || "Unable to regenerate message.", "error");
|
||||||
if (updateIndex >= 0) {
|
|
||||||
currentMessages[updateIndex] = {...currentMessages[updateIndex], ...res.message};
|
|
||||||
syncRetryStateFromMessage(currentMessages[updateIndex]);
|
|
||||||
}
|
|
||||||
await setActiveChat(activeChatId);
|
|
||||||
setNotice(`Reply regenerated (${retriesUsed + 1}/5).`, "success");
|
|
||||||
} finally {
|
} finally {
|
||||||
|
regenerationChatId = null;
|
||||||
setLoadingState(false);
|
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
|
? contentOrMessage
|
||||||
: {id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "")};
|
: {id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "")};
|
||||||
const isMessageUser = message.role === "user";
|
const isMessageUser = message.role === "user";
|
||||||
|
msgDiv.dataset.messageId = message.id;
|
||||||
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
|
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
|
||||||
msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message");
|
msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message");
|
||||||
const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div");
|
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);
|
actions.appendChild(branchBtn);
|
||||||
|
|
||||||
if (message.role === "bot") {
|
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");
|
const retryBtn = document.createElement("button");
|
||||||
retryBtn.type = "button";
|
retryBtn.type = "button";
|
||||||
retryBtn.className = "msg-action-btn";
|
retryBtn.className = "msg-action-btn";
|
||||||
@@ -717,30 +745,32 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
actions.appendChild(retryBtn);
|
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");
|
const prevBtn = document.createElement("button");
|
||||||
prevBtn.type = "button";
|
prevBtn.type = "button";
|
||||||
prevBtn.className = "msg-action-btn";
|
prevBtn.className = "msg-action-btn";
|
||||||
prevBtn.textContent = "Prev";
|
prevBtn.textContent = "Prev";
|
||||||
prevBtn.setAttribute("aria-label", "Show previous retry variant");
|
prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch");
|
||||||
prevBtn.disabled = !message.previousSiblingId;
|
prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId;
|
||||||
prevBtn.addEventListener("click", () => {
|
prevBtn.addEventListener("click", () => {
|
||||||
void switchRetryVariant(message.id, -1, options.messageIndex ?? -1);
|
void navigate(message.id, -1, options.messageIndex ?? -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextBtn = document.createElement("button");
|
const nextBtn = document.createElement("button");
|
||||||
nextBtn.type = "button";
|
nextBtn.type = "button";
|
||||||
nextBtn.className = "msg-action-btn";
|
nextBtn.className = "msg-action-btn";
|
||||||
nextBtn.textContent = "Next";
|
nextBtn.textContent = "Next";
|
||||||
nextBtn.setAttribute("aria-label", "Show next retry variant");
|
nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch");
|
||||||
nextBtn.disabled = !message.nextSiblingId;
|
nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId;
|
||||||
nextBtn.addEventListener("click", () => {
|
nextBtn.addEventListener("click", () => {
|
||||||
void switchRetryVariant(message.id, 1, options.messageIndex ?? -1);
|
void navigate(message.id, 1, options.messageIndex ?? -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexBadge = document.createElement("span");
|
const indexBadge = document.createElement("span");
|
||||||
indexBadge.className = "msg-variant-index";
|
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);
|
actions.append(prevBtn, indexBadge, nextBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,8 +789,10 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
msgDiv.appendChild(actions);
|
msgDiv.appendChild(actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messagesDiv.appendChild(msgDiv);
|
if (!options.detached) {
|
||||||
scrollMessagesToBottom();
|
messagesDiv.appendChild(msgDiv);
|
||||||
|
scrollMessagesToBottom();
|
||||||
|
}
|
||||||
return msgDiv;
|
return msgDiv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1388,8 +1420,9 @@ function setLoadingState(loading, overlayOptions = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stopGeneration() {
|
async function stopGeneration() {
|
||||||
if (!isProcessing || !activeChatId) return;
|
const chatId = regenerationChatId ?? activeChatId;
|
||||||
const res = await post(`/chats/${activeChatId}/stop`, {});
|
if (!isProcessing || !chatId) return;
|
||||||
|
const res = await post(`/chats/${chatId}/stop`, {});
|
||||||
if (res.error) return setNotice(res.error, "error");
|
if (res.error) return setNotice(res.error, "error");
|
||||||
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const files = [
|
|||||||
"public/settings.js",
|
"public/settings.js",
|
||||||
"public/market.js",
|
"public/market.js",
|
||||||
"public/app/api.js",
|
"public/app/api.js",
|
||||||
|
"public/app/regeneration.js",
|
||||||
"public/app/webSearch.js",
|
"public/app/webSearch.js",
|
||||||
"public/app/reasoning.js",
|
"public/app/reasoning.js",
|
||||||
"public/app/modelCapabilities.js",
|
"public/app/modelCapabilities.js",
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ export function createConfig(overrides = {}) {
|
|||||||
chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
|
chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
|
||||||
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5),
|
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5),
|
||||||
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
|
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)),
|
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))),
|
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)),
|
modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)),
|
||||||
|
|||||||
@@ -871,6 +871,10 @@ export function createRepositories(db, config) {
|
|||||||
if (message.sourceId != null) importedMessageIds.set(message.sourceId, Number(result.lastInsertRowid));
|
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.sources) statements.updateChatMessageSources.run(JSON.stringify(message.sources), result.lastInsertRowid, chatId);
|
||||||
if (message.thinking) statements.updateChatMessageThinking.run(message.thinking, 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()) {
|
for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) {
|
||||||
// JSON backups contain references, not files. Only restore existing, owned images.
|
// 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'")
|
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
|
username
|
||||||
),
|
),
|
||||||
updateChatPreferredModel: (chatId, username, model) => statements.updateChatPreferredModel.run(model || null, chatId, 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),
|
updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId),
|
||||||
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
|
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
|
||||||
payload.content,
|
payload.content,
|
||||||
@@ -1278,6 +1305,10 @@ export function createRepositories(db, config) {
|
|||||||
persona_state: this.getPersonaChatState(chat.id, username),
|
persona_state: this.getPersonaChatState(chat.id, username),
|
||||||
messages: this.listChatMessages(chat.id).map((message) => ({
|
messages: this.listChatMessages(chat.id).map((message) => ({
|
||||||
...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),
|
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)
|
retryVariants: parseJsonArray(message.retry_variants)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -236,6 +236,16 @@ export function initializeSchema(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
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) => {
|
const ensureColumn = (tableName, columns, name, sql) => {
|
||||||
if (!columns.some((column) => column.name === name)) {
|
if (!columns.some((column) => column.name === name)) {
|
||||||
db.prepare(sql).run();
|
db.prepare(sql).run();
|
||||||
|
|||||||
+53
-3
@@ -105,6 +105,14 @@ const preferredModelValidator = (body) => {
|
|||||||
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireLogin);
|
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) => {
|
router.get("/models", async (req, res) => {
|
||||||
try {
|
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 chatId = Number(req.params.id);
|
||||||
const messageId = Number(req.params.messageId);
|
const messageId = Number(req.params.messageId);
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
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) 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"});
|
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 {
|
try {
|
||||||
const result = await chatService.retryMessage({
|
const result = await chatService.retryMessage({
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
chatId,
|
chatId,
|
||||||
selectedModel: String(req.body?.model || "qwen3:14b"),
|
selectedModel: String(req.body?.model || "qwen3:14b"),
|
||||||
targetMessage,
|
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});
|
if (result.error) return res.status(result.status).json({error: result.error});
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
|
if (streaming) { sendEvent("error", mapped.body); return; }
|
||||||
return res.status(mapped.status).json(mapped.body);
|
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,
|
chatId,
|
||||||
selectedModel: String(req.body?.model || "qwen3:14b"),
|
selectedModel: String(req.body?.model || "qwen3:14b"),
|
||||||
targetMessage,
|
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});
|
if (result.error) return res.status(result.status).json({error: result.error});
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
|
|||||||
+71
-34
@@ -24,12 +24,22 @@ import {supportsReasoning} from "../../public/app/modelCapabilities.js";
|
|||||||
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null) {
|
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null) {
|
||||||
const formatChatMessage = row => {
|
const formatChatMessage = row => {
|
||||||
const message = formatBaseChatMessage(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);
|
const attachments = row && attachmentService?.list(row.id);
|
||||||
return message && attachments?.length ? {...message, attachments} : message;
|
return message && attachments?.length ? {...message, attachments} : message;
|
||||||
};
|
};
|
||||||
const chatGenerationQueues = new Map();
|
const chatGenerationQueues = new Map();
|
||||||
const summaryQueues = new Map();
|
const summaryQueues = new Map();
|
||||||
const activeGenerations = new Map();
|
const activeGenerations = new Map();
|
||||||
|
const regeneratingMessages = new Map();
|
||||||
const maxRetries = Number(config.chatMaxRetries ?? 5);
|
const maxRetries = Number(config.chatMaxRetries ?? 5);
|
||||||
const now = typeof config.now === "function" ? config.now : () => new Date();
|
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 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, {
|
repositories.updateChatMessageRetryState(chatId, messageId, {
|
||||||
content,
|
content,
|
||||||
retryVariants: retryVariants || [content || ""],
|
retryVariants: retryVariants || [content || ""],
|
||||||
@@ -91,6 +103,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
retryRetriesUsed: Number(retryRetriesUsed || 0),
|
retryRetriesUsed: Number(retryRetriesUsed || 0),
|
||||||
retryPromptMessageId: retryPromptMessageId ?? null
|
retryPromptMessageId: retryPromptMessageId ?? null
|
||||||
});
|
});
|
||||||
|
if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChatMessageByIndex = (chatId, index) => {
|
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);
|
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 = [
|
const baseMessages = [
|
||||||
{role: "system", content: buildPersonaPrompt(assistantPersona)},
|
{role: "system", content: buildPersonaPrompt(assistantPersona)},
|
||||||
...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []),
|
...(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})}
|
{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)) {
|
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||||
return opener;
|
return opener;
|
||||||
}
|
}
|
||||||
|
|
||||||
onThinking?.("", "");
|
onThinking?.("", "");
|
||||||
return modelService.generateReply(selectedModel, [
|
onChunk?.("", "");
|
||||||
|
return generate([
|
||||||
...baseMessages,
|
...baseMessages,
|
||||||
{role: "assistant", content: opener || ""},
|
{role: "assistant", content: opener || ""},
|
||||||
{
|
{
|
||||||
role: "user",
|
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."
|
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}) => {
|
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};
|
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
|
const parentMessage = targetMessage.parent_message_id
|
||||||
? repositories.getChatMessage(chatId, targetMessage.parent_message_id)
|
? repositories.getChatMessage(chatId, targetMessage.parent_message_id)
|
||||||
: null;
|
: null;
|
||||||
const promptMessage = parentMessage?.role === "user"
|
const promptMessage = parentMessage?.role === "user"
|
||||||
? parentMessage
|
? 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 activePersona = repositories.getAssistantPersonaForChat(chatId, user);
|
||||||
const activeUserPersona = getUserPersonaForSession(chatId, user, session, activePersona);
|
const activeUserPersona = getUserPersonaForSession(chatId, user, session, activePersona);
|
||||||
|
|
||||||
@@ -299,9 +323,8 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
if (!activePersona || !session.scenario_summary) {
|
if (!activePersona || !session.scenario_summary) {
|
||||||
return {error: "No previous user prompt found for retry"};
|
return {error: "No previous user prompt found for retry"};
|
||||||
}
|
}
|
||||||
let thinking = "";
|
|
||||||
const opener = await generateRoleplayOpener({
|
const opener = await generateRoleplayOpener({
|
||||||
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
|
onThinking: captureThinking, onChunk, signal, generation, reasoning,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
assistantPersona: activePersona,
|
assistantPersona: activePersona,
|
||||||
userPersona: activeUserPersona,
|
userPersona: activeUserPersona,
|
||||||
@@ -314,25 +337,38 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
return {fullReply: opener, thinking, promptMessageId: null};
|
return {fullReply: opener, thinking, promptMessageId: null};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachmentService?.list(promptMessage.id).length) onStatus?.({status: "analyzing"});
|
||||||
await attachmentService?.analyze(promptMessage.id, promptMessage.content, signal);
|
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({
|
const {messagesPayload} = buildConversationContext({
|
||||||
user,
|
user,
|
||||||
chatId,
|
chatId,
|
||||||
session,
|
session,
|
||||||
message: null,
|
message: null,
|
||||||
upToMessageId: promptMessage.id,
|
upToMessageId: promptMessage.id,
|
||||||
sources: targetMessage.sources ? parseSources(targetMessage.sources) : null
|
sources
|
||||||
});
|
});
|
||||||
|
|
||||||
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
|
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
|
||||||
let thinking = "";
|
|
||||||
const onThinking = (_chunk, fullThinking) => { thinking = fullThinking; };
|
|
||||||
const fullReply = activePersona
|
const fullReply = activePersona
|
||||||
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking})
|
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking: captureThinking, onChunk, reasoning})
|
||||||
: await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking});
|
: 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"};
|
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}) =>
|
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}) {
|
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});
|
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 () => {
|
return withChatGenerationLock(chatId, async () => {
|
||||||
|
await (summaryQueues.get(chatId) || Promise.resolve());
|
||||||
const session = repositories.getChat(chatId, user);
|
const session = repositories.getChat(chatId, user);
|
||||||
if (!session) return {error: "Chat not found", status: 404};
|
if (!session) return {error: "Chat not found", status: 404};
|
||||||
|
|
||||||
@@ -545,35 +586,31 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
const existingMessage = formatChatMessage(freshTarget);
|
const existingMessage = formatChatMessage(freshTarget);
|
||||||
if (!existingMessage) return {error: "Message not found", status: 404};
|
if (!existingMessage) return {error: "Message not found", status: 404};
|
||||||
if (existingMessage.role !== "bot") return {error: "Only assistant messages can be regenerated", status: 400};
|
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 (existingMessage.versionCount - 1 >= maxRetries) {
|
||||||
if (siblings.length - 1 >= maxRetries) {
|
|
||||||
return {error: `Maximum of ${maxRetries} retries reached`, status: 400};
|
return {error: `Maximum of ${maxRetries} retries reached`, status: 400};
|
||||||
}
|
}
|
||||||
const {model, generation} = getGenerationSettings(session, selectedModel);
|
const {model, generation} = getGenerationSettings(session, selectedModel);
|
||||||
return withActiveGeneration(chatId, null, async (activeSignal) => {
|
return withActiveGeneration(chatId, signal, async (activeSignal) => {
|
||||||
|
activeSignal.throwIfAborted();
|
||||||
const retryResult = await buildRetryPayload({
|
const retryResult = await buildRetryPayload({
|
||||||
chatId, user, session, selectedModel: model, targetMessage: freshTarget, retryStyle,
|
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};
|
if (retryResult.error) return {error: retryResult.error, status: 400};
|
||||||
const retryVariants = [...siblings.map((sibling) => sibling.content), retryResult.fullReply];
|
activeSignal.throwIfAborted();
|
||||||
const retryState = {
|
repositories.saveMessageVersion(chatId, freshTarget.id, {
|
||||||
retryVariants,
|
content: retryResult.fullReply, thinking: retryResult.thinking,
|
||||||
retryActiveIndex: retryVariants.length - 1,
|
sources: retryResult.sources, modelName: model
|
||||||
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);
|
|
||||||
repositories.updateChatContextSummary(chatId, user, null, 0);
|
repositories.updateChatContextSummary(chatId, user, null, 0);
|
||||||
repositories.touchChat(chatId, user);
|
repositories.touchChat(chatId, user);
|
||||||
const message = formatChatMessage(repositories.getChatMessage(chatId, Number(inserted.lastInsertRowid)));
|
const message = formatChatMessage(repositories.getChatMessage(chatId, freshTarget.id));
|
||||||
return {message, promptMessageId: retryResult.promptMessageId, retryStyle, branchedFromMessageId: 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}) {
|
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};
|
if (!targetMessage || targetMessage.role !== "user") return {error: "Only user messages can be edited and resent", status: 400};
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ function validateMessage(message, chatIndex, messageIndex, errors) {
|
|||||||
content,
|
content,
|
||||||
model_name: optionalText(message.model_name || message.modelName),
|
model_name: optionalText(message.model_name || message.modelName),
|
||||||
thinking: optionalText(message.thinking),
|
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})),
|
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)} : {}),
|
...(message.sources ? {sources: parseSources(message.sources)} : {}),
|
||||||
retryVariants,
|
retryVariants,
|
||||||
|
|||||||
+5
-2
@@ -455,8 +455,11 @@ test("conversation branches, edit-resend, and generation settings work", async (
|
|||||||
});
|
});
|
||||||
assert.equal(regenerated.status, 200);
|
assert.equal(regenerated.status, 200);
|
||||||
let active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
let active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
||||||
assert.equal(active.json.messages.length, 2);
|
assert.equal(active.json.messages.length, 4);
|
||||||
assert.equal(active.json.messages[1].siblingCount, 2);
|
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});
|
await request(server.baseUrl, `/chats/${chatId}/branches/${originalLeaf.id}/activate`, {method: "POST", body: {}, jar});
|
||||||
active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
||||||
|
|||||||
@@ -151,7 +151,11 @@ test("images feed dedicated vision, cache, streamed reasoning, reload and shared
|
|||||||
const exported = await (await s.api("/exports/workspace")).json();
|
const exported = await (await s.api("/exports/workspace")).json();
|
||||||
assert.deepEqual(exported.workspace.chats[0].messages[0].attachments, uploads);
|
assert.deepEqual(exported.workspace.chats[0].messages[0].attachments, uploads);
|
||||||
const botId = history.messages.at(-1).id;
|
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");
|
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});
|
const branchResponse = await s.api(`/chats/${chatId}/messages/${botId}/branch`, {confirmed: true});
|
||||||
assert.equal(branchResponse.status, 200);
|
assert.equal(branchResponse.status, 200);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ test("thinking streams separately and survives reload, retry, branch and import"
|
|||||||
assert.equal(bot.content, "Answer");
|
assert.equal(bot.content, "Answer");
|
||||||
const retry = await api(`/chats/${chatId}/messages/${bot.id}/retry`, {});
|
const retry = await api(`/chats/${chatId}/messages/${bot.id}/retry`, {});
|
||||||
assert.equal(retry.message.thinking, undefined);
|
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});
|
const branch = await api(`/chats/${chatId}/messages/${bot.id}/branch`, {confirmed: true});
|
||||||
assert.equal(branch.messages.at(-1).thinking, bot.thinking);
|
assert.equal(branch.messages.at(-1).thinking, bot.thinking);
|
||||||
const exported = await api(`/exports/chats/${chatId}?format=json`);
|
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.match(roleplayWire, /event: done/);
|
||||||
assert.ok(requests.at(-1).messages.some(message => message.content.includes("Guide")));
|
assert.ok(requests.at(-1).messages.some(message => message.content.includes("Guide")));
|
||||||
assert.equal(requests.at(-1).think, true);
|
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) => {
|
test("reasoning disclosure is collapsed, preserves open state and renders text safely", (t) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -217,9 +217,10 @@ test("chat search integrates with streaming, persistence, personas, settings, re
|
|||||||
const botId = messages.at(-1).id;
|
const botId = messages.at(-1).id;
|
||||||
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {});
|
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {});
|
||||||
assert.equal(retry.status, 200);
|
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.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});
|
const branch = await api(`/chats/${chatId}/messages/${botId}/branch`, {confirmed: true});
|
||||||
assert.deepEqual(branch.body.messages.at(-1).sources, [source]);
|
assert.deepEqual(branch.body.messages.at(-1).sources, [source]);
|
||||||
const exported = (await api(`/exports/chats/${chatId}?format=json`)).body;
|
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});
|
const imported = await api("/imports/workspace", {workspace, allowDuplicates: true});
|
||||||
assert.equal(imported.status, 200);
|
assert.equal(imported.status, 200);
|
||||||
const allMessages = app.locals.db.prepare("SELECT sources FROM chat_messages WHERE sources IS NOT NULL").all();
|
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]);
|
for (const message of allMessages) assert.deepEqual(JSON.parse(message.sources), [source]);
|
||||||
|
|
||||||
emptySearch = true;
|
emptySearch = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user