show reasoning in frontend
This commit is contained in:
@@ -51,6 +51,8 @@ The server is configured around an Ollama-compatible API surface:
|
|||||||
|
|
||||||
`qwen3:14b` is the default model for chat and roleplay. The model selector loads available models from `/tags` and selects `qwen3:14b` when available (otherwise the first returned model). Existing chats retain their saved model selection.
|
`qwen3:14b` is the default model for chat and roleplay. The model selector loads available models from `/tags` and selects `qwen3:14b` when available (otherwise the first returned model). Existing chats retain their saved model selection.
|
||||||
|
|
||||||
|
Chat replies display Ollama's optional `message.thinking` output in a collapsed “Reasoning” section above the answer, updated through SSE `thinking` events during generation. This text is stored separately from the answer and excluded from conversation prompts. An additive SQLite migration preserves it when reopening chats; exports, imports and branches preserve it too. Models without thinking output show no section.
|
||||||
|
|
||||||
The refactor adds explicit upstream timeout handling, cached model listing, and `GET /health` for degraded-state visibility.
|
The refactor adds explicit upstream timeout handling, cached model listing, and `GET /health` for degraded-state visibility.
|
||||||
|
|
||||||
## Schema Notes
|
## Schema Notes
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export async function stream(url, data, handlers = {}) {
|
|||||||
const event = eventLine.slice(6).trim();
|
const event = eventLine.slice(6).trim();
|
||||||
const payload = JSON.parse(dataLine.slice(5).trim());
|
const payload = JSON.parse(dataLine.slice(5).trim());
|
||||||
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
|
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
|
||||||
|
if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload);
|
||||||
if (event === "status" && handlers.onStatus) handlers.onStatus(payload);
|
if (event === "status" && handlers.onStatus) handlers.onStatus(payload);
|
||||||
if (event === "done" && handlers.onDone) handlers.onDone(payload);
|
if (event === "done" && handlers.onDone) handlers.onDone(payload);
|
||||||
if (event === "error" && handlers.onError) handlers.onError(payload);
|
if (event === "error" && handlers.onError) handlers.onError(payload);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 {appendSources, createWebSearchToggle} from "./app/webSearch.js";
|
import {appendSources, createWebSearchToggle} from "./app/webSearch.js";
|
||||||
|
import {updateReasoning} from "./app/reasoning.js";
|
||||||
import {closeChatDrawer, openChatDrawer} from "./app/chatDrawer.js?v=20260909-account-controls";
|
import {closeChatDrawer, openChatDrawer} from "./app/chatDrawer.js?v=20260909-account-controls";
|
||||||
import {onboardingPrompts, requestedChatId} from "./app/constants.js?v=20260909-account-controls";
|
import {onboardingPrompts, requestedChatId} from "./app/constants.js?v=20260909-account-controls";
|
||||||
import {getModelProfile} from "./app/modelProfiles.js?v=20260909-account-controls";
|
import {getModelProfile} from "./app/modelProfiles.js?v=20260909-account-controls";
|
||||||
@@ -741,6 +742,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgDiv.append(header, body);
|
msgDiv.append(header, body);
|
||||||
|
if (!isMessageUser) updateReasoning(msgDiv, message.thinking);
|
||||||
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
||||||
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
@@ -1832,7 +1834,13 @@ async function sendMessage() {
|
|||||||
loadingLabel.textContent = status === "searching" ? "Searching the web..."
|
loadingLabel.textContent = status === "searching" ? "Searching the web..."
|
||||||
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||||
},
|
},
|
||||||
|
onThinking: ({fullThinking}) => {
|
||||||
|
updateReasoning(loadingMsg, fullThinking, true);
|
||||||
|
loadingLabel.textContent = "Thinking…";
|
||||||
|
},
|
||||||
onChunk: ({fullReply}) => {
|
onChunk: ({fullReply}) => {
|
||||||
|
const reasoningSummary = loadingMsg.querySelector(".msg-reasoning summary");
|
||||||
|
if (reasoningSummary) reasoningSummary.textContent = "Reasoning";
|
||||||
if (loadingBody) {
|
if (loadingBody) {
|
||||||
loadingShell?.classList.add("hidden");
|
loadingShell?.classList.add("hidden");
|
||||||
loadingBody.classList.add("has-content");
|
loadingBody.classList.add("has-content");
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
/* Elsewhere design system. */
|
/* Elsewhere design system. */
|
||||||
|
|
||||||
|
.msg-reasoning {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
.msg-reasoning summary { cursor: pointer; font-weight: 600; }
|
||||||
|
.msg-reasoning summary:focus-visible { outline: 2px solid var(--brand); outline-offset: 4px; }
|
||||||
|
.msg-reasoning-content {
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Foundation ---------- */
|
/* ---------- Foundation ---------- */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|||||||
+13
-8
@@ -195,8 +195,9 @@ export function createRepositories(db, config) {
|
|||||||
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||||
updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"),
|
updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"),
|
||||||
updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"),
|
updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"),
|
||||||
|
updateChatMessageThinking: db.prepare("UPDATE chat_messages SET thinking = ? WHERE id = ? AND chat_id = ?"),
|
||||||
getChatMessages: db.prepare(`
|
getChatMessages: db.prepare(`
|
||||||
SELECT sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE chat_id = ?
|
WHERE chat_id = ?
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -236,7 +237,7 @@ export function createRepositories(db, config) {
|
|||||||
JOIN branch child ON child.parent_message_id = parent.id
|
JOIN branch child ON child.parent_message_id = parent.id
|
||||||
)
|
)
|
||||||
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
||||||
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources
|
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
|
||||||
FROM branch
|
FROM branch
|
||||||
WHERE delivery_status = 'complete'
|
WHERE delivery_status = 'complete'
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -252,7 +253,7 @@ export function createRepositories(db, config) {
|
|||||||
JOIN branch child ON child.parent_message_id = parent.id
|
JOIN branch child ON child.parent_message_id = parent.id
|
||||||
)
|
)
|
||||||
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
||||||
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources
|
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
|
||||||
FROM branch ORDER BY id
|
FROM branch ORDER BY id
|
||||||
`),
|
`),
|
||||||
getSiblingChatMessages: db.prepare(`
|
getSiblingChatMessages: db.prepare(`
|
||||||
@@ -271,13 +272,13 @@ export function createRepositories(db, config) {
|
|||||||
SELECT cm.* FROM chat_messages cm JOIN descendants d ON d.id = cm.id ORDER BY cm.id DESC LIMIT 1
|
SELECT cm.* FROM chat_messages cm JOIN descendants d ON d.id = cm.id ORDER BY cm.id DESC LIMIT 1
|
||||||
`),
|
`),
|
||||||
getChatMessageById: db.prepare(`
|
getChatMessageById: db.prepare(`
|
||||||
SELECT sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
AND chat_id = ?
|
AND chat_id = ?
|
||||||
`),
|
`),
|
||||||
getChatMessageByIndex: db.prepare(`
|
getChatMessageByIndex: db.prepare(`
|
||||||
SELECT sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE chat_id = ?
|
WHERE chat_id = ?
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -738,7 +739,7 @@ export function createRepositories(db, config) {
|
|||||||
return statements.deleteUser.run(username);
|
return statements.deleteUser.run(username);
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources) => {
|
const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking) => {
|
||||||
const assistantResult = statements.insertChatMessage.run(
|
const assistantResult = statements.insertChatMessage.run(
|
||||||
chatId,
|
chatId,
|
||||||
"bot",
|
"bot",
|
||||||
@@ -754,6 +755,7 @@ export function createRepositories(db, config) {
|
|||||||
);
|
);
|
||||||
statements.updateChatMessageDelivery.run("complete", null, userMessageId, chatId);
|
statements.updateChatMessageDelivery.run("complete", null, userMessageId, chatId);
|
||||||
if (sources) statements.updateChatMessageSources.run(JSON.stringify(sources), assistantResult.lastInsertRowid, chatId);
|
if (sources) statements.updateChatMessageSources.run(JSON.stringify(sources), assistantResult.lastInsertRowid, chatId);
|
||||||
|
if (thinking) statements.updateChatMessageThinking.run(thinking, assistantResult.lastInsertRowid, chatId);
|
||||||
statements.setChatActiveLeafById.run(assistantResult.lastInsertRowid, chatId);
|
statements.setChatActiveLeafById.run(assistantResult.lastInsertRowid, chatId);
|
||||||
return assistantResult;
|
return assistantResult;
|
||||||
});
|
});
|
||||||
@@ -866,6 +868,7 @@ 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);
|
||||||
}
|
}
|
||||||
statements.updateChatGenerationSettings.run(
|
statements.updateChatGenerationSettings.run(
|
||||||
chat.preferred_model ?? null,
|
chat.preferred_model ?? null,
|
||||||
@@ -1015,8 +1018,10 @@ export function createRepositories(db, config) {
|
|||||||
insertBranchChatMessageTransaction(chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId),
|
insertBranchChatMessageTransaction(chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId),
|
||||||
updateChatMessageDelivery: (chatId, messageId, deliveryStatus, errorMessage = null) =>
|
updateChatMessageDelivery: (chatId, messageId, deliveryStatus, errorMessage = null) =>
|
||||||
statements.updateChatMessageDelivery.run(deliveryStatus, errorMessage, messageId, chatId),
|
statements.updateChatMessageDelivery.run(deliveryStatus, errorMessage, messageId, chatId),
|
||||||
completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null) =>
|
completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null, thinking = null) =>
|
||||||
completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources),
|
completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking),
|
||||||
|
updateChatMessageThinking: (chatId, messageId, thinking) =>
|
||||||
|
statements.updateChatMessageThinking.run(thinking, messageId, chatId),
|
||||||
updateChatMessageSources: (chatId, messageId, sources) =>
|
updateChatMessageSources: (chatId, messageId, sources) =>
|
||||||
statements.updateChatMessageSources.run(JSON.stringify(sources), messageId, chatId),
|
statements.updateChatMessageSources.run(JSON.stringify(sources), messageId, chatId),
|
||||||
failChatTurn: (chatId, userMessageId, errorMessage, parentMessageId = null) =>
|
failChatTurn: (chatId, userMessageId, errorMessage, parentMessageId = null) =>
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
|
|
||||||
const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all();
|
const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all();
|
||||||
ensureColumn("chat_messages", chatMessageColumns, "sources", "ALTER TABLE chat_messages ADD COLUMN sources TEXT");
|
ensureColumn("chat_messages", chatMessageColumns, "sources", "ALTER TABLE chat_messages ADD COLUMN sources TEXT");
|
||||||
|
ensureColumn("chat_messages", chatMessageColumns, "thinking", "ALTER TABLE chat_messages ADD COLUMN thinking TEXT");
|
||||||
const hadParentMessageId = chatMessageColumns.some((column) => column.name === "parent_message_id");
|
const hadParentMessageId = chatMessageColumns.some((column) => column.name === "parent_message_id");
|
||||||
ensureColumn("chat_messages", chatMessageColumns, "model_name", "ALTER TABLE chat_messages ADD COLUMN model_name TEXT");
|
ensureColumn("chat_messages", chatMessageColumns, "model_name", "ALTER TABLE chat_messages ADD COLUMN model_name TEXT");
|
||||||
ensureColumn("chat_messages", chatMessageColumns, "retry_variants", "ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT");
|
ensureColumn("chat_messages", chatMessageColumns, "retry_variants", "ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT");
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
message: req.validatedBody.message,
|
message: req.validatedBody.message,
|
||||||
model: req.validatedBody.model,
|
model: req.validatedBody.model,
|
||||||
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
|
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
|
||||||
|
onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}),
|
||||||
webSearch: req.validatedBody.webSearch,
|
webSearch: req.validatedBody.webSearch,
|
||||||
onStatus: (status) => sendEvent("status", status),
|
onStatus: (status) => sendEvent("status", status),
|
||||||
signal: generationController.signal
|
signal: generationController.signal
|
||||||
|
|||||||
@@ -254,7 +254,9 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
|
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
|
||||||
chat = repositories.getChat(chat.id, user);
|
chat = repositories.getChat(chat.id, user);
|
||||||
try {
|
try {
|
||||||
|
let thinking = "";
|
||||||
const opener = await chatService.generateRoleplayOpener({
|
const opener = await chatService.generateRoleplayOpener({
|
||||||
|
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
|
||||||
selectedModel: "qwen3:14b",
|
selectedModel: "qwen3:14b",
|
||||||
assistantPersona: persona,
|
assistantPersona: persona,
|
||||||
userPersona,
|
userPersona,
|
||||||
@@ -264,12 +266,13 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||||
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
|
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
|
||||||
}
|
}
|
||||||
chatService.insertChatMessage(chat.id, "bot", opener, {
|
const inserted = chatService.insertChatMessage(chat.id, "bot", opener, {
|
||||||
retryVariants: [opener],
|
retryVariants: [opener],
|
||||||
retryActiveIndex: 0,
|
retryActiveIndex: 0,
|
||||||
retryRetriesUsed: 0,
|
retryRetriesUsed: 0,
|
||||||
retryPromptMessageId: null
|
retryPromptMessageId: null
|
||||||
});
|
});
|
||||||
|
if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking);
|
||||||
repositories.touchChat(chat.id, user);
|
repositories.touchChat(chat.id, user);
|
||||||
return res.json({persona, chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
return res.json({persona, chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -307,7 +310,9 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
chat = repositories.getChat(chat.id, user);
|
chat = repositories.getChat(chat.id, user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let thinking = "";
|
||||||
const opener = await chatService.generateRoleplayOpener({
|
const opener = await chatService.generateRoleplayOpener({
|
||||||
|
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
|
||||||
selectedModel: model,
|
selectedModel: model,
|
||||||
assistantPersona,
|
assistantPersona,
|
||||||
userPersona,
|
userPersona,
|
||||||
@@ -317,12 +322,13 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||||
return res.json({chat, existing: false, generatedInitialMessage: false, degraded: true});
|
return res.json({chat, existing: false, generatedInitialMessage: false, degraded: true});
|
||||||
}
|
}
|
||||||
chatService.insertChatMessage(chat.id, "bot", opener, {
|
const inserted = chatService.insertChatMessage(chat.id, "bot", opener, {
|
||||||
retryVariants: [opener],
|
retryVariants: [opener],
|
||||||
retryActiveIndex: 0,
|
retryActiveIndex: 0,
|
||||||
retryRetriesUsed: 0,
|
retryRetriesUsed: 0,
|
||||||
retryPromptMessageId: null
|
retryPromptMessageId: null
|
||||||
});
|
});
|
||||||
|
if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking);
|
||||||
repositories.touchChat(chat.id, user);
|
repositories.touchChat(chat.id, user);
|
||||||
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function formatChatMessage(row) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
content: row.content,
|
content: row.content,
|
||||||
|
...(row.thinking ? {thinking: row.thinking} : {}),
|
||||||
...(row.sources ? {sources: parseSources(row.sources)} : {}),
|
...(row.sources ? {sources: parseSources(row.sources)} : {}),
|
||||||
modelName: row.model_name || null,
|
modelName: row.model_name || null,
|
||||||
retryVariants,
|
retryVariants,
|
||||||
|
|||||||
+31
-17
@@ -168,7 +168,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}) => {
|
const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary, onThinking = null}) => {
|
||||||
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)}] : []),
|
||||||
@@ -176,11 +176,12 @@ 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);
|
let opener = await modelService.generateReply(selectedModel, baseMessages, {onThinking});
|
||||||
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||||
return opener;
|
return opener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onThinking?.("", "");
|
||||||
return modelService.generateReply(selectedModel, [
|
return modelService.generateReply(selectedModel, [
|
||||||
...baseMessages,
|
...baseMessages,
|
||||||
{role: "assistant", content: opener || ""},
|
{role: "assistant", content: opener || ""},
|
||||||
@@ -188,15 +189,16 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
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 = {}}) => {
|
const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}, onThinking = null}) => {
|
||||||
let reply = await modelService.generateReply(selectedModel, messagesPayload, {signal, generation});
|
let reply = await modelService.generateReply(selectedModel, messagesPayload, {signal, generation, onThinking});
|
||||||
if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) {
|
if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) {
|
||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onThinking?.("", "");
|
||||||
reply = await modelService.generateReply(selectedModel, [
|
reply = await modelService.generateReply(selectedModel, [
|
||||||
...messagesPayload,
|
...messagesPayload,
|
||||||
{role: "assistant", content: reply || ""},
|
{role: "assistant", content: reply || ""},
|
||||||
@@ -204,7 +206,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
role: "user",
|
role: "user",
|
||||||
content: "Rewrite your last reply. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, or actions. Stop before the user's reply."
|
content: "Rewrite your last reply. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, or actions. Stop before the user's reply."
|
||||||
}
|
}
|
||||||
], {signal, generation});
|
], {signal, generation, onThinking});
|
||||||
|
|
||||||
if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) {
|
if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -283,7 +285,9 @@ 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; },
|
||||||
selectedModel,
|
selectedModel,
|
||||||
assistantPersona: activePersona,
|
assistantPersona: activePersona,
|
||||||
userPersona: activeUserPersona,
|
userPersona: activeUserPersona,
|
||||||
@@ -293,7 +297,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) {
|
if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) {
|
||||||
return {error: "Failed to regenerate message"};
|
return {error: "Failed to regenerate message"};
|
||||||
}
|
}
|
||||||
return {fullReply: opener, promptMessageId: null};
|
return {fullReply: opener, thinking, promptMessageId: null};
|
||||||
}
|
}
|
||||||
|
|
||||||
const {messagesPayload} = buildConversationContext({
|
const {messagesPayload} = buildConversationContext({
|
||||||
@@ -306,15 +310,17 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
});
|
});
|
||||||
|
|
||||||
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})
|
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking})
|
||||||
: await modelService.generateReply(selectedModel, styledPayload, {signal, generation});
|
: await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking});
|
||||||
|
|
||||||
if (!fullReply) return {error: "Failed to regenerate message"};
|
if (!fullReply) return {error: "Failed to regenerate message"};
|
||||||
return {fullReply, promptMessageId: promptMessage.id};
|
return {fullReply, thinking, promptMessageId: promptMessage.id};
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateChatTurn = ({user, chatId, message, model, webSearch = false, onStatus = null, onChunk = null, signal = null, parentMessageId = undefined}) =>
|
const generateChatTurn = ({user, chatId, message, model, webSearch = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) =>
|
||||||
withChatGenerationLock(chatId, async () => {
|
withChatGenerationLock(chatId, async () => {
|
||||||
await (summaryQueues.get(chatId) || Promise.resolve());
|
await (summaryQueues.get(chatId) || Promise.resolve());
|
||||||
const session = repositories.getChat(chatId, user);
|
const session = repositories.getChat(chatId, user);
|
||||||
@@ -335,6 +341,11 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
const userMessageId = Number(inserted?.lastInsertRowid);
|
const userMessageId = Number(inserted?.lastInsertRowid);
|
||||||
try {
|
try {
|
||||||
let sources = null;
|
let sources = null;
|
||||||
|
let thinking = "";
|
||||||
|
const captureThinking = (chunk, fullThinking) => {
|
||||||
|
thinking = fullThinking;
|
||||||
|
onThinking?.(chunk, fullThinking);
|
||||||
|
};
|
||||||
let payload;
|
let payload;
|
||||||
const fullReply = await withActiveGeneration(chatId, signal, async (activeSignal) => {
|
const fullReply = await withActiveGeneration(chatId, signal, async (activeSignal) => {
|
||||||
if (webSearch) {
|
if (webSearch) {
|
||||||
@@ -375,11 +386,12 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
messagesPayload: payload.messagesPayload,
|
messagesPayload: payload.messagesPayload,
|
||||||
userPersona: payload.activeUserPersona,
|
userPersona: payload.activeUserPersona,
|
||||||
signal: activeSignal,
|
signal: activeSignal,
|
||||||
generation
|
generation,
|
||||||
|
onThinking: captureThinking
|
||||||
})
|
})
|
||||||
: onChunk
|
: onChunk
|
||||||
? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, {signal: activeSignal, generation})
|
? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, {signal: activeSignal, generation, onThinking: captureThinking})
|
||||||
: modelService.generateReply(effectiveModel, payload.messagesPayload, {signal: activeSignal, generation});
|
: modelService.generateReply(effectiveModel, payload.messagesPayload, {signal: activeSignal, generation, onThinking: captureThinking});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fullReply) {
|
if (!fullReply) {
|
||||||
@@ -394,7 +406,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
retryRetriesUsed: 0,
|
retryRetriesUsed: 0,
|
||||||
retryPromptMessageId: null
|
retryPromptMessageId: null
|
||||||
};
|
};
|
||||||
repositories.completeChatTurn(chatId, userMessageId, fullReply, retryState, effectiveModel, sources);
|
repositories.completeChatTurn(chatId, userMessageId, fullReply, retryState, effectiveModel, sources, thinking);
|
||||||
repositories.touchChat(chatId, user);
|
repositories.touchChat(chatId, user);
|
||||||
scheduleContextSummaryUpdate({user, chatId, selectedModel: effectiveModel});
|
scheduleContextSummaryUpdate({user, chatId, selectedModel: effectiveModel});
|
||||||
return {reply: fullReply, userMessageId, ...(sources ? {sources} : {})};
|
return {reply: fullReply, userMessageId, ...(sources ? {sources} : {})};
|
||||||
@@ -471,6 +483,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
);
|
);
|
||||||
parentMessageId = Number(inserted.lastInsertRowid);
|
parentMessageId = Number(inserted.lastInsertRowid);
|
||||||
if (sourceMessage.sources) repositories.updateChatMessageSources(clonedChat.id, parentMessageId, parseSources(sourceMessage.sources));
|
if (sourceMessage.sources) repositories.updateChatMessageSources(clonedChat.id, parentMessageId, parseSources(sourceMessage.sources));
|
||||||
|
if (sourceMessage.thinking) repositories.updateChatMessageThinking(clonedChat.id, parentMessageId, sourceMessage.thinking);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
repositories.deleteChat(clonedChat.id, user);
|
repositories.deleteChat(clonedChat.id, user);
|
||||||
@@ -498,8 +511,8 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
async sendChatMessage({user, chatId, message, model, webSearch = false}) {
|
async sendChatMessage({user, chatId, message, model, webSearch = false}) {
|
||||||
return generateChatTurn({user, chatId, message, model, webSearch});
|
return generateChatTurn({user, chatId, message, model, webSearch});
|
||||||
},
|
},
|
||||||
async streamChatMessage({user, chatId, message, model, webSearch = false, onStatus, onChunk, signal = null}) {
|
async streamChatMessage({user, chatId, message, model, webSearch = false, onStatus, onChunk, onThinking, signal = null}) {
|
||||||
return generateChatTurn({user, chatId, message, model, webSearch, onStatus, onChunk, signal});
|
return generateChatTurn({user, chatId, message, model, webSearch, onStatus, onChunk, onThinking, signal});
|
||||||
},
|
},
|
||||||
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
|
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
|
||||||
return withChatGenerationLock(chatId, async () => {
|
return withChatGenerationLock(chatId, async () => {
|
||||||
@@ -532,6 +545,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
chatId, "bot", retryResult.fullReply, retryState, model, "complete", null, freshTarget.parent_message_id
|
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 (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, Number(inserted.lastInsertRowid)));
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function createModelService(config, overrides = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readStreamingReply = async (response, onChunk = null) => {
|
const readStreamingReply = async (response, onChunk = null, onThinking = null) => {
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned no body.", 502);
|
throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned no body.", 502);
|
||||||
}
|
}
|
||||||
@@ -83,6 +83,18 @@ export function createModelService(config, overrides = {}) {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let fullReply = "";
|
let fullReply = "";
|
||||||
|
let fullThinking = "";
|
||||||
|
const consume = (line) => {
|
||||||
|
const data = JSON.parse(line);
|
||||||
|
if (data.message?.thinking) {
|
||||||
|
fullThinking += data.message.thinking;
|
||||||
|
onThinking?.(data.message.thinking, fullThinking);
|
||||||
|
}
|
||||||
|
if (data.message?.content) {
|
||||||
|
fullReply += data.message.content;
|
||||||
|
onChunk?.(data.message.content, fullReply);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const {value, done} = await reader.read();
|
const {value, done} = await reader.read();
|
||||||
@@ -93,20 +105,12 @@ export function createModelService(config, overrides = {}) {
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const data = JSON.parse(trimmed);
|
consume(trimmed);
|
||||||
if (data.message?.content) {
|
|
||||||
fullReply += data.message.content;
|
|
||||||
if (onChunk) onChunk(data.message.content, fullReply);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
const data = JSON.parse(buffer.trim());
|
consume(buffer.trim());
|
||||||
if (data.message?.content) {
|
|
||||||
fullReply += data.message.content;
|
|
||||||
if (onChunk) onChunk(data.message.content, fullReply);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fullReply.trim();
|
return fullReply.trim();
|
||||||
@@ -146,7 +150,7 @@ export function createModelService(config, overrides = {}) {
|
|||||||
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
return readStreamingReply(response);
|
return readStreamingReply(response, null, options.onThinking);
|
||||||
}, options.signal);
|
}, options.signal);
|
||||||
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt};
|
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt};
|
||||||
return fullReply;
|
return fullReply;
|
||||||
@@ -184,7 +188,7 @@ export function createModelService(config, overrides = {}) {
|
|||||||
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
return readStreamingReply(response, onChunk);
|
return readStreamingReply(response, onChunk, options.onThinking);
|
||||||
}, options.signal);
|
}, options.signal);
|
||||||
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt};
|
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt};
|
||||||
return fullReply;
|
return fullReply;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function validateMessage(message, chatIndex, messageIndex, errors) {
|
|||||||
role: role === "assistant" ? "bot" : role,
|
role: role === "assistant" ? "bot" : role,
|
||||||
content,
|
content,
|
||||||
model_name: optionalText(message.model_name || message.modelName),
|
model_name: optionalText(message.model_name || message.modelName),
|
||||||
|
thinking: optionalText(message.thinking),
|
||||||
...(message.sources ? {sources: parseSources(message.sources)} : {}),
|
...(message.sources ? {sources: parseSources(message.sources)} : {}),
|
||||||
retryVariants,
|
retryVariants,
|
||||||
retryActiveIndex: Number.isInteger(Number(message.retryActiveIndex ?? message.retry_active_index))
|
retryActiveIndex: Number.isInteger(Number(message.retryActiveIndex ?? message.retry_active_index))
|
||||||
|
|||||||
Reference in New Issue
Block a user