implement modular web search capabilities with provider support, content fetching, and file-based caching.

This commit is contained in:
techjarves committed 2026-06-22 10:34:02 +05:30
1 parent ffec37fd0a
commit c280683a9d
8 files changed
+580 -5

No files matched your search

+62 -3
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown } from "lucide-react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown, Globe2 } from "lucide-react";
import {
getDownloadProgress,
getLlmStatus,
@@ -149,6 +149,8 @@ function TextChat({
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [useWebSearch, setUseWebSearch] = useState(false);
const [webTimeFilter, setWebTimeFilter] = useState("any");
const [loadingModel, setLoadingModel] = useState(null);
const [tokenUsage, setTokenUsage] = useState({
prompt_tokens: 0,
@@ -639,7 +641,7 @@ function TextChat({
setMessages([...nextMessages, {
role: "assistant",
content: "",
generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0, vision: requestHasImage },
generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0, vision: requestHasImage, web: useWebSearch },
}]);
const controller = new AbortController();
@@ -691,6 +693,8 @@ function TextChat({
repeatPenalty: textSettings?.repeatPenalty,
seed: textSettings?.seed,
enableThinking: thinkingEnabled,
useWeb: useWebSearch,
timeFilter: webTimeFilter,
signal: controller.signal,
}, (_token, fullText, _reasoningToken, fullReasoning) => {
const now = performance.now();
@@ -790,6 +794,7 @@ function TextChat({
reasoning: processed.reasoning,
thinkingDuration: finalThinkingDuration,
generationStats,
webSources: response.webSources || [],
}];
setMessages(finalMessages);
saveConversationState(convId, finalMessages, selectedModel);
@@ -1073,6 +1078,34 @@ function TextChat({
)}
</div>
)}
{message.role === "assistant" && Array.isArray(message.webSources) && message.webSources.length > 0 && (
<div style={{
marginTop: "8px",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: "8px",
background: "var(--md-sys-color-surface-container)",
fontSize: "0.8rem",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "6px", fontWeight: 700, marginBottom: "6px" }}>
<Globe2 size={13} />
<span>Sources</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
{message.webSources.map((source, sourceIndex) => (
<a
key={`${source.url}-${sourceIndex}`}
href={source.url}
target="_blank"
rel="noreferrer"
style={{ color: "var(--md-sys-color-primary)", textDecoration: "none", overflowWrap: "anywhere" }}
>
[{source.index || sourceIndex + 1}] {source.title || source.url}
</a>
))}
</div>
</div>
)}
{/* Generation stats pill */}
{message.role === "assistant" && message.generationStats && !message.error && (
@@ -1084,7 +1117,7 @@ function TextChat({
)}
<div className={`chat-generation-stats ${message.generationStats.status}`}>
{message.generationStats.status === "starting" ? (
<><LoaderCircle size={11} className="progress-spinner" /> {message.generationStats.vision ? "Processing image..." : "Waiting for first token..."}</>
<><LoaderCircle size={11} className="progress-spinner" /> {message.generationStats.web ? "Searching web..." : message.generationStats.vision ? "Processing image..." : "Waiting for first token..."}</>
) : message.generationStats.status === "streaming" ? (
<><span style={{ opacity: 0.7 }}>⚡</span> {message.generationStats.tokensPerSecond.toFixed(1)} tok/s</>
) : (
@@ -1150,6 +1183,32 @@ function TextChat({
>
<Paperclip size={17} />
</button>
<button
className={`chat-composer-deepthink-btn ${useWebSearch ? "active" : ""}`}
onClick={() => setUseWebSearch((value) => !value)}
disabled={!status.ready || isBusy}
title={useWebSearch ? "Disable web search" : "Enable web search"}
style={{ marginBottom: "2px" }}
>
<Globe2 size={14} />
<span>Web</span>
</button>
{useWebSearch && (
<select
className="m3-input"
value={webTimeFilter}
onChange={(event) => setWebTimeFilter(event.target.value)}
disabled={isBusy}
title="Search recency"
style={{ width: "116px", height: "38px", marginBottom: "2px", fontSize: "0.78rem" }}
>
<option value="any">Any time</option>
<option value="day">Past day</option>
<option value="week">Past week</option>
<option value="month">Past month</option>
<option value="year">Past year</option>
</select>
)}
<div className="chat-composer-middle" style={{ flex: 1, display: "flex", minWidth: 0 }}>
<textarea
+29 -1
View File
@@ -412,6 +412,8 @@ export async function chatWithLlm(messages, options = {}) {
presence_penalty: options.presencePenalty,
seed: options.seed,
stop: options.stop,
useWeb: options.useWeb === true,
timeFilter: options.timeFilter || "any",
}),
});
const data = await readJsonResponse(res, "The local text model returned an invalid response.");
@@ -441,6 +443,8 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
presence_penalty: options.presencePenalty,
seed: options.seed,
stop: options.stop,
useWeb: options.useWeb === true,
timeFilter: options.timeFilter || "any",
}),
};
if (options.signal) {
@@ -462,6 +466,7 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
let usage = null;
let timings = null;
let finishReason = null;
let webSources = [];
const normalizeTextDelta = (value) => {
if (typeof value === "string") return value;
@@ -490,7 +495,16 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
.trim();
if (!data || data === "[DONE]") return data === "[DONE]";
const eventName = eventText
.split(/\r?\n/)
.find((line) => line.startsWith("event:"))
?.slice(6)
.trim();
const parsed = JSON.parse(data);
if (eventName === "web_sources") {
webSources = parsed.sources || [];
return false;
}
const choice = parsed.choices?.[0];
const token = normalizeTextDelta(
choice?.delta?.content ??
@@ -540,7 +554,21 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
if (!finished && buffer.trim()) consumeEvent(buffer);
if (!content && !reasoningContent) throw new Error("The text model returned an empty streamed response.");
return { content, reasoningContent, usage, timings, finishReason };
return { content, reasoningContent, usage, timings, finishReason, webSources };
}
export async function searchWeb(query, options = {}) {
const res = await fetch("/api/web-search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
timeFilter: options.timeFilter || "any",
resultLimit: options.resultLimit,
fetchLimit: options.fetchLimit,
}),
});
return await readJsonResponse(res, "The local server returned an invalid web search response.");
}
export async function downloadLlmModel(url, filename = null, companion = null) {