show reasoning in frontend and some other stuff
This commit is contained in:
+41
-20
@@ -17,12 +17,20 @@ export const post = (url, data) => request(url, data, "POST");
|
||||
export const put = (url, data) => request(url, data, "PUT");
|
||||
export const del = (url) => request(url, null, "DELETE");
|
||||
|
||||
export async function stream(url, data, handlers = {}) {
|
||||
/**
|
||||
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number}}
|
||||
* | {event: "chunk", data: {chunk: string, fullReply: string}}
|
||||
* | {event: "thinking", data: {chunk: string, fullThinking: string}}
|
||||
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[]}}
|
||||
* | {event: "error", data: {error: string, code?: string}}} ChatStreamEvent
|
||||
*/
|
||||
export async function stream(url, data, handlers = {}, signal = undefined) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(data || {})
|
||||
body: JSON.stringify(data || {}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
@@ -43,25 +51,38 @@ export async function stream(url, data, handlers = {}) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let terminal = false;
|
||||
|
||||
while (true) {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const parts = buffer.split("\n\n");
|
||||
buffer = parts.pop() || "";
|
||||
for (const part of parts) {
|
||||
const lines = part.split("\n");
|
||||
const eventLine = lines.find((line) => line.startsWith("event:"));
|
||||
const dataLine = lines.find((line) => line.startsWith("data:"));
|
||||
if (!eventLine || !dataLine) continue;
|
||||
const event = eventLine.slice(6).trim();
|
||||
const payload = JSON.parse(dataLine.slice(5).trim());
|
||||
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 === "done" && handlers.onDone) handlers.onDone(payload);
|
||||
if (event === "error" && handlers.onError) handlers.onError(payload);
|
||||
try {
|
||||
while (true) {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const parts = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = parts.pop() || "";
|
||||
for (const part of parts) {
|
||||
const lines = part.split("\n");
|
||||
const eventLine = lines.find((line) => line.startsWith("event:"));
|
||||
const dataLine = lines.find((line) => line.startsWith("data:"));
|
||||
if (!eventLine || !dataLine) continue;
|
||||
const event = eventLine.slice(6).trim();
|
||||
const payload = JSON.parse(dataLine.slice(5).trim());
|
||||
if (!payload || typeof payload !== "object") throw new Error("Invalid response stream.");
|
||||
const textFields = {chunk: "fullReply", thinking: "fullThinking", status: "status", done: "reply", error: "error"};
|
||||
if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream.");
|
||||
if (terminal) continue;
|
||||
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 === "done" && handlers.onDone) handlers.onDone(payload);
|
||||
if (event === "error" && handlers.onError) handlers.onError(payload);
|
||||
if (event === "done" || event === "error") terminal = true;
|
||||
}
|
||||
if (terminal) break;
|
||||
}
|
||||
if (!terminal) throw new Error("Response was interrupted. Please try again.");
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @typedef {{title: string, url: string, snippet: string}} WebSource */
|
||||
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean}} ChatRequest */
|
||||
/** @typedef {{role: string, content: string, sources?: WebSource[]}} ChatMessage */
|
||||
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
|
||||
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
|
||||
|
||||
export function createWebSearchToggle(button) {
|
||||
let enabled = false;
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
<button id="webSearch" class="icon-button" type="button" aria-label="Search the web" title="Search the web" aria-pressed="false">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/></svg>
|
||||
</button>
|
||||
<button id="reasoning" class="icon-button" type="button" aria-label="Enable reasoning" title="Enable reasoning" aria-pressed="false" disabled>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 18V5a3 3 0 0 0-5.8-1A4 4 0 0 0 3 10a4 4 0 0 0 1 7.8A4 4 0 0 0 12 18ZM12 5a3 3 0 0 1 5.8-1A4 4 0 0 1 21 10a4 4 0 0 1-1 7.8A4 4 0 0 1 12 18M8 8a4 4 0 0 1-5 2m13-2a4 4 0 0 0 5 2M8 14a4 4 0 0 0-4 4m12-4a4 4 0 0 1 4 4"/></svg>
|
||||
</button>
|
||||
<label class="visually-hidden" for="msgInput">Message</label>
|
||||
<textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea>
|
||||
<button id="send" type="button" aria-label="Send message"></button>
|
||||
|
||||
+28
-11
@@ -1,6 +1,6 @@
|
||||
import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls";
|
||||
import {appendSources, createWebSearchToggle} from "./app/webSearch.js";
|
||||
import {updateReasoning} from "./app/reasoning.js";
|
||||
import {createReasoningToggle, finishReasoning, updateReasoning} from "./app/reasoning.js";
|
||||
import {closeChatDrawer, openChatDrawer} from "./app/chatDrawer.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";
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
const headerModelButton = document.getElementById("headerModelButton");
|
||||
const webSearchButton = document.getElementById("webSearch");
|
||||
const webSearchToggle = createWebSearchToggle(webSearchButton);
|
||||
const reasoningToggle = createReasoningToggle(document.getElementById("reasoning"));
|
||||
|
||||
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
|
||||
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
|
||||
@@ -911,6 +912,7 @@ function renderDrawerSummary() {
|
||||
}
|
||||
|
||||
function renderModelSection() {
|
||||
reasoningToggle.setModel(modelSelect.value);
|
||||
updateModelHelp();
|
||||
return modelSelect?.selectedOptions[0]?.textContent || "";
|
||||
}
|
||||
@@ -1361,6 +1363,7 @@ function setLoadingState(loading, overlayOptions = null) {
|
||||
isProcessing = loading;
|
||||
msgInput.disabled = loading;
|
||||
webSearchButton.disabled = loading;
|
||||
reasoningToggle.setBusy(loading);
|
||||
sendBtn.classList.toggle("loading", loading);
|
||||
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
|
||||
button.disabled = loading || button.dataset.retryLimitReached === "true";
|
||||
@@ -1811,6 +1814,7 @@ async function sendMessage() {
|
||||
const message = msgInput.value.trim();
|
||||
if (!message) return;
|
||||
const webSearch = webSearchToggle.enabled;
|
||||
const reasoning = reasoningToggle.enabled;
|
||||
if (!activeChatId) await createNewChat();
|
||||
const currentChat = getChatById(activeChatId);
|
||||
if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
|
||||
@@ -1825,33 +1829,44 @@ async function sendMessage() {
|
||||
const loadingBody = loadingMsg.querySelector(".msg-content");
|
||||
const loadingShell = loadingMsg.querySelector(".loading-shell");
|
||||
const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
|
||||
if (webSearch) loadingLabel.textContent = "Searching the web...";
|
||||
loadingLabel.textContent = webSearch ? "Searching the web..." : "Generating response...";
|
||||
try {
|
||||
let streamError = null;
|
||||
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId, webSearch}, {
|
||||
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId, webSearch, reasoning}, {
|
||||
onStatus: ({status, sourceCount}) => {
|
||||
webSearchToggle.reset();
|
||||
if (status === "accepted") {
|
||||
webSearchToggle.reset();
|
||||
reasoningToggle.reset();
|
||||
return;
|
||||
}
|
||||
loadingLabel.textContent = status === "searching" ? "Searching the web..."
|
||||
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||
},
|
||||
onThinking: ({fullThinking}) => {
|
||||
if (!reasoning) return;
|
||||
const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
|
||||
updateReasoning(loadingMsg, fullThinking, true);
|
||||
loadingLabel.textContent = "Thinking…";
|
||||
loadingShell?.classList.toggle("hidden", Boolean(fullThinking));
|
||||
if (follow) scrollMessagesToBottom();
|
||||
},
|
||||
onChunk: ({fullReply}) => {
|
||||
const reasoningSummary = loadingMsg.querySelector(".msg-reasoning summary");
|
||||
if (reasoningSummary) reasoningSummary.textContent = "Reasoning";
|
||||
const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
|
||||
if (fullReply) finishReasoning(loadingMsg);
|
||||
if (loadingBody) {
|
||||
loadingShell?.classList.add("hidden");
|
||||
loadingShell?.classList.toggle("hidden", Boolean(fullReply));
|
||||
loadingLabel.textContent = "Generating response...";
|
||||
loadingBody.classList.add("has-content");
|
||||
setMessageContent(loadingBody, fullReply);
|
||||
}
|
||||
if (follow) scrollMessagesToBottom();
|
||||
},
|
||||
onDone: () => { webSearchToggle.reset(); },
|
||||
onDone: () => { webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); },
|
||||
onError: ({error}) => {
|
||||
streamError = error || "Failed to generate reply.";
|
||||
}
|
||||
});
|
||||
const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
|
||||
const previousScrollTop = messagesDiv.scrollTop;
|
||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||
if (streamError) {
|
||||
if (webSearch) {
|
||||
@@ -1865,15 +1880,17 @@ async function sendMessage() {
|
||||
await setActiveChat(activeChatId);
|
||||
await loadSummary();
|
||||
markOnboardingCompleted();
|
||||
if (!followOutput) requestAnimationFrame(() => { messagesDiv.scrollTop = previousScrollTop; });
|
||||
maybeShowPersonaNudge();
|
||||
setNotice("Reply received.", "success");
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||
if (webSearch) {
|
||||
msgInput.value = message;
|
||||
resizeComposerInput();
|
||||
}
|
||||
addMessage("[Error: Network error]"); setNotice("Network error while sending message.", "error");
|
||||
const errorMessage = error?.message || "Network error while sending message.";
|
||||
addMessage(`[Error: ${errorMessage}]`); setNotice(errorMessage, "error");
|
||||
} finally {
|
||||
setLoadingState(false); msgInput.focus(); updateChatActionState();
|
||||
}
|
||||
|
||||
+5
-3
@@ -456,9 +456,11 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
||||
.input-meta, .input-container { width: min(940px, 100%); margin-inline: auto; }
|
||||
.input-meta { margin-bottom: 5px; padding-left: 10px; }
|
||||
.composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; }
|
||||
.input-container { padding: 7px; display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; grid-template-areas: "search message send"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
|
||||
#webSearch { grid-area: search; width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
|
||||
#webSearch[aria-pressed="true"] { color: var(--brand-strong); background: var(--brand-soft); border-color: var(--brand); box-shadow: inset 0 0 0 1px var(--brand); }
|
||||
.input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "search reasoning message send"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
|
||||
#webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
|
||||
#webSearch { grid-area: search; }
|
||||
#reasoning { grid-area: reasoning; }
|
||||
#webSearch[aria-pressed="true"], #reasoning[aria-pressed="true"] { color: var(--brand-strong); background: var(--brand-soft); border-color: var(--brand); box-shadow: inset 0 0 0 1px var(--brand); }
|
||||
.msg-sources { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--border); min-width: 0; font-size: .8rem; }
|
||||
.msg-sources-heading { color: var(--text-muted); margin-bottom: 6px; }
|
||||
.msg-source { display: flex; min-width: 0; flex-direction: column; gap: 2px; margin-top: 6px; }
|
||||
|
||||
Reference in New Issue
Block a user