feat: add web search support to channels

This commit is contained in:
QinQin 2026-05-19 16:37:46 -04:00
parent d0b17f0569
commit 0b66a8e12a
4 changed files with 167 additions and 25 deletions

View file

@ -51,6 +51,8 @@ from open_webui.utils.models import (
get_all_models,
get_filtered_models,
)
from open_webui.routers.tasks import generate_queries
from open_webui.routers.retrieval import process_web_search, SearchForm
from open_webui.utils.webhook import post_webhook
from pydantic import BaseModel, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
@ -978,9 +980,67 @@ async def model_response_handler(request, channel, message, user, db=None):
)
tool_ids = _resolve_model_tool_ids(request.app, model_id)
features = _resolve_model_features(request.app, model_id)
model_features = _resolve_model_features(request.app, model_id)
filter_ids = _resolve_model_filter_ids(request.app, model_id)
message_features = (message.data or {}).get('features', {})
features = {**(model_features or {}), **message_features}
# Process web search before pipeline to avoid duplication
web_search_sources = []
if message_features.get('web_search'):
user_message = content if isinstance(content, str) else content[0].get('text', '')
queries = [user_message]
try:
queries_res = await generate_queries(
request,
{
'model': model_id,
'messages': [{'role': 'user', 'content': user_message}],
'prompt': user_message,
'type': 'web_search',
},
user,
)
if isinstance(queries_res, dict) and queries_res.get('choices'):
response = queries_res['choices'][0]['message']['content']
bracket_start = response.rfind('{')
bracket_end = response.rfind('}') + 1
if bracket_start != -1 and bracket_end > bracket_start:
parsed = json.loads(response[bracket_start:bracket_end])
queries = parsed.get('queries', [user_message])
except Exception as e:
log.exception(e)
if queries:
try:
results = await process_web_search(
request,
SearchForm(queries=queries),
user=user,
)
if results and results.get('docs'):
web_search_sources = results.get('filenames', [])[:5]
web_context = '\n\n### Web Search Results:\n'
for i, doc in enumerate(results['docs'][:5], 1):
source_url = web_search_sources[i-1] if i <= len(web_search_sources) else ''
doc_content = doc.get('content', str(doc)) if isinstance(doc, dict) else str(doc)
web_context += f'\n[{i}] {source_url}\n{doc_content[:2000]}\n'
if isinstance(content, str):
content = web_context + '\n\n### User Question:\n' + content
elif isinstance(content, list):
for part in content:
if part.get('type') == 'text':
part['text'] = web_context + '\n\n### User Question:\n' + part['text']
break
except Exception as e:
log.exception(e)
# Remove web_search from features so pipeline doesn't duplicate
features = {k: v for k, v in features.items() if k != 'web_search'}
# Build full form_data — same shape as frontend POST.
# The channel: prefix routes pipeline events to the
# channel emitter in socket/main.py instead of the
@ -1006,6 +1066,14 @@ async def model_response_handler(request, channel, message, user, db=None):
if filter_ids:
form_data['filter_ids'] = filter_ids
# Store sources for emitter to append when done
if web_search_sources:
await Messages.update_message_by_id(
response_message.id,
MessageForm(content='', data={'web_search_sources': web_search_sources}),
db=db,
)
# Call the full chat completion pipeline — streaming,
# tools, filters, RAG — everything. The pipeline runs as
# an async task; the channel emitter handles progressive

View file

@ -844,9 +844,18 @@ async def _make_channel_emitter(request_info):
update_form = MessageForm(content=content)
if done:
# Merge done flag into existing meta (preserve model_id etc.)
msg = await Messages.get_message_by_id(message_id)
existing_meta = (msg.meta or {}) if msg else {}
existing_data = (msg.data or {}) if msg else {}
# Append web search sources if present
web_search_sources = existing_data.get('web_search_sources', [])
if web_search_sources and content:
sources_text = '\n\n---\n**Sources:**\n'
for i, url in enumerate(web_search_sources, 1):
sources_text += f'- [{i}] {url}\n'
content = content + sources_text
update_form = MessageForm(
content=content,
meta={**existing_meta, 'done': True},

View file

@ -30,7 +30,9 @@
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import IntegrationsMenu from '../chat/MessageInput/IntegrationsMenu.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Component from '../icons/Component.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import FileItem from '../common/FileItem.svelte';
@ -40,6 +42,7 @@
import MentionList from './MessageInput/MentionList.svelte';
import Skeleton from '../chat/Messages/Skeleton.svelte';
import XMark from '../icons/XMark.svelte';
import GlobeAlt from '../icons/GlobeAlt.svelte';
export let placeholder = $i18n.t('Type here...');
export let chatInputElement;
@ -75,6 +78,11 @@
let content = '';
let files = [];
let webSearchEnabled = false;
$: showWebSearchButton =
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search);
let filesInputElement;
let inputFiles;
@ -547,7 +555,14 @@
onSubmit({
content,
data: {
files: files
files: files,
features: {
web_search:
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled
: false
}
}
});
@ -1009,6 +1024,53 @@
</button>
</InputMenu>
{/if}
{#if showWebSearchButton}
<div
class="flex self-center w-[1px] h-4 mx-1 bg-gray-200/50 dark:bg-gray-800/50"
/>
<IntegrationsMenu
selectedModels={[]}
fileUploadCapableModels={[]}
toggleFilters={[]}
showToolsButton={false}
{showWebSearchButton}
bind:webSearchEnabled
showImageGenerationButton={false}
imageGenerationEnabled={false}
showCodeInterpreterButton={false}
codeInterpreterEnabled={false}
selectedToolIds={[]}
selectedFilterIds={[]}
onShowValves={() => {}}
onClose={async () => {
await tick();
chatInputElement?.focus();
}}
>
<div
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
>
<Component className="size-4.5" strokeWidth="1.5" />
</div>
</IntegrationsMenu>
{#if webSearchEnabled}
<Tooltip content={$i18n.t('Web Search')} placement="top">
<button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20"
>
<GlobeAlt className="size-4" strokeWidth="1.75" />
<div class="hidden group-hover:block">
<XMark className="size-4" strokeWidth="1.75" />
</div>
</button>
</Tooltip>
{/if}
{/if}
</slot>
</div>

View file

@ -43,6 +43,7 @@
[];
export let selectedFilterIds: string[] = [];
export let showToolsButton = true;
export let showWebSearchButton = false;
export let webSearchEnabled = false;
export let showImageGenerationButton = false;
@ -119,32 +120,34 @@
>
{#if tab === ''}
<div in:fly={{ x: -20, duration: 150 }}>
{#if tools}
{#if Object.keys(tools).length > 0}
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = 'tools';
}}
>
<Wrench />
{#if showToolsButton}
{#if tools}
{#if Object.keys(tools).length > 0}
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = 'tools';
}}
>
<Wrench />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Tools')}
<span class="ml-0.5 text-gray-500">{Object.keys(tools).length}</span>
</div>
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Tools')}
<span class="ml-0.5 text-gray-500">{Object.keys(tools).length}</span>
</div>
<div class="text-gray-500">
<ChevronRight />
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</div>
</button>
</button>
{/if}
{:else}
<div class="py-4">
<Spinner />
</div>
{/if}
{:else}
<div class="py-4">
<Spinner />
</div>
{/if}
{#if toggleFilters && toggleFilters.length > 0}