diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 11d3a4a871..e39e23708f 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -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 diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 4217124e2b..c1a23fa9e5 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -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}, diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index ec00a944d0..7216615b03 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -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 @@ {/if} + + {#if showWebSearchButton} +
+ + {}} + onClose={async () => { + await tick(); + chatInputElement?.focus(); + }} + > +
+ +
+
+ + {#if webSearchEnabled} + + + + {/if} + {/if}
diff --git a/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte index 2fc2c4bc5f..39acd83cf7 100644 --- a/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte +++ b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte @@ -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 === ''}
- {#if tools} - {#if Object.keys(tools).length > 0} - + + {/if} + {:else} +
+ +
{/if} - {:else} -
- -
{/if} {#if toggleFilters && toggleFilters.length > 0}