add optional web search functionality with UI updates and backend integration
This commit is contained in:
@@ -58,6 +58,7 @@ export async function stream(url, data, handlers = {}) {
|
||||
const event = eventLine.slice(6).trim();
|
||||
const payload = JSON.parse(dataLine.slice(5).trim());
|
||||
if (event === "chunk" && handlers.onChunk) handlers.onChunk(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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** @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 */
|
||||
|
||||
export function createWebSearchToggle(button) {
|
||||
let enabled = false;
|
||||
const reset = () => {
|
||||
enabled = false;
|
||||
render();
|
||||
};
|
||||
const render = () => {
|
||||
button.setAttribute("aria-pressed", String(enabled));
|
||||
button.title = enabled ? "Web search enabled" : "Search the web";
|
||||
button.setAttribute("aria-label", button.title);
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
enabled = !enabled;
|
||||
render();
|
||||
});
|
||||
render();
|
||||
return {get enabled() { return enabled; }, reset};
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} container @param {WebSource[] | undefined} sources */
|
||||
export function appendSources(container, sources) {
|
||||
if (!Array.isArray(sources) || !sources.length) return;
|
||||
const section = document.createElement("section");
|
||||
section.className = "msg-sources";
|
||||
section.setAttribute("aria-label", "Sources");
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "msg-sources-heading";
|
||||
heading.textContent = "Sources";
|
||||
section.appendChild(heading);
|
||||
sources.slice(0, 5).forEach((source, index) => {
|
||||
let url;
|
||||
try { url = new URL(source.url); } catch { return; }
|
||||
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = url.href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.className = "msg-source";
|
||||
const title = document.createElement("span");
|
||||
title.textContent = `[${index + 1}] ${source.title}`;
|
||||
title.title = source.title;
|
||||
const domain = document.createElement("small");
|
||||
domain.textContent = url.hostname;
|
||||
link.append(title, domain);
|
||||
section.appendChild(link);
|
||||
});
|
||||
if (section.childElementCount > 1) container.appendChild(section);
|
||||
}
|
||||
@@ -197,6 +197,9 @@
|
||||
<p class="composer-hint">Enter to send · Shift+Enter for new line</p>
|
||||
</div>
|
||||
<div class="input-container">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
+29
-2
@@ -1,4 +1,5 @@
|
||||
import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls";
|
||||
import {appendSources, createWebSearchToggle} from "./app/webSearch.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";
|
||||
@@ -102,6 +103,8 @@ import {
|
||||
} from "./app/dom.js?v=20260909-account-controls";
|
||||
|
||||
const headerModelButton = document.getElementById("headerModelButton");
|
||||
const webSearchButton = document.getElementById("webSearch");
|
||||
const webSearchToggle = createWebSearchToggle(webSearchButton);
|
||||
|
||||
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
|
||||
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
|
||||
@@ -738,6 +741,13 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
||||
}
|
||||
|
||||
msgDiv.append(header, body);
|
||||
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
||||
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "status";
|
||||
error.textContent = message.errorMessage;
|
||||
msgDiv.appendChild(error);
|
||||
}
|
||||
if (actions.childElementCount) {
|
||||
msgDiv.appendChild(actions);
|
||||
}
|
||||
@@ -1348,6 +1358,7 @@ async function unpublishPersona(id) { const res = await post(`/personas/${id}/un
|
||||
function setLoadingState(loading, overlayOptions = null) {
|
||||
isProcessing = loading;
|
||||
msgInput.disabled = loading;
|
||||
webSearchButton.disabled = loading;
|
||||
sendBtn.classList.toggle("loading", loading);
|
||||
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
|
||||
button.disabled = loading || button.dataset.retryLimitReached === "true";
|
||||
@@ -1797,6 +1808,7 @@ async function sendMessage() {
|
||||
if (isProcessing) return;
|
||||
const message = msgInput.value.trim();
|
||||
if (!message) return;
|
||||
const webSearch = webSearchToggle.enabled;
|
||||
if (!activeChatId) await createNewChat();
|
||||
const currentChat = getChatById(activeChatId);
|
||||
if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
|
||||
@@ -1810,9 +1822,16 @@ async function sendMessage() {
|
||||
const loadingMsg = addMessage({role: "bot", content: ""}, false, true, {isNewest: true});
|
||||
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...";
|
||||
try {
|
||||
let streamError = null;
|
||||
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId}, {
|
||||
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId, webSearch}, {
|
||||
onStatus: ({status, sourceCount}) => {
|
||||
webSearchToggle.reset();
|
||||
loadingLabel.textContent = status === "searching" ? "Searching the web..."
|
||||
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||
},
|
||||
onChunk: ({fullReply}) => {
|
||||
if (loadingBody) {
|
||||
loadingShell?.classList.add("hidden");
|
||||
@@ -1820,13 +1839,17 @@ async function sendMessage() {
|
||||
setMessageContent(loadingBody, fullReply);
|
||||
}
|
||||
},
|
||||
onDone: () => {},
|
||||
onDone: () => { webSearchToggle.reset(); },
|
||||
onError: ({error}) => {
|
||||
streamError = error || "Failed to generate reply.";
|
||||
}
|
||||
});
|
||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||
if (streamError) {
|
||||
if (webSearch) {
|
||||
msgInput.value = message;
|
||||
resizeComposerInput();
|
||||
}
|
||||
addMessage(`[Error: ${streamError}]`);
|
||||
setNotice(streamError, "error");
|
||||
return;
|
||||
@@ -1838,6 +1861,10 @@ async function sendMessage() {
|
||||
setNotice("Reply received.", "success");
|
||||
} catch {
|
||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||
if (webSearch) {
|
||||
msgInput.value = message;
|
||||
resizeComposerInput();
|
||||
}
|
||||
addMessage("[Error: Network error]"); setNotice("Network error while sending message.", "error");
|
||||
} finally {
|
||||
setLoadingState(false); msgInput.focus(); updateChatActionState();
|
||||
|
||||
+8
-1
@@ -436,7 +436,14 @@ 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: minmax(0, 1fr) 44px; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
|
||||
.input-container { padding: 7px; display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
|
||||
#webSearch { 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); }
|
||||
.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; }
|
||||
.msg-source span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.msg-source small { color: var(--text-muted); }
|
||||
#msgInput { min-height: 44px; max-height: 160px; padding: 11px 13px; resize: none; background: transparent; border: 0; box-shadow: none; }
|
||||
#send { width: 44px; min-width: 44px; height: 44px; min-height: 44px; padding: 0; display: grid; place-items: center; border-radius: 17px; }
|
||||
#send::after { content: "\2191"; font-size: 1.1rem; font-weight: 800; }
|
||||
|
||||
Reference in New Issue
Block a user