feat: Add web search functionality and enhance agent mode integration

- Introduced web search configuration in config.yaml, allowing multiple providers with specific settings.
- Updated InputField component to manage agent mode and web search toggles, improving user interaction.
- Enhanced chat API to include web search status in requests, enabling dynamic query handling.
- Implemented backend support for web search integration, merging results with knowledge base queries.
- Added tenant-level web search configuration management, allowing customization per tenant.
This commit is contained in:
wizardchen
2025-11-10 03:14:12 +08:00
parent 1313f4dfbb
commit 07a8c1de4c
46 changed files with 2574 additions and 56 deletions
+63
View File
@@ -613,3 +613,66 @@ agent:
- get_related_chunks
- query_knowledge_graph
- get_document_info
# WebSearch 配置
web_search:
# 可用搜索引擎列表
providers:
- id: "kuaisou"
name: "快搜"
free: false
requires_api_key: true
description: "高性能实时网页搜索"
api_url: "https://platform.kuaisou.com/api/v1/search"
- id: "google"
name: "Google"
free: true
requires_api_key: false
description: "Google搜索(免费)"
api_url: "https://www.googleapis.com/customsearch/v1"
- id: "baidu"
name: "百度"
free: true
requires_api_key: false
description: "百度搜索(免费)"
- id: "bing"
name: "Bing"
free: true
requires_api_key: false
description: "Bing搜索(免费)"
- id: "bocha"
name: "博查"
free: false
requires_api_key: true
description: "专为AI应用设计的搜索API"
- id: "zhipu"
name: "智谱"
free: false
requires_api_key: true
description: "智谱搜索API"
- id: "tavily"
name: "Tavily"
free: false
requires_api_key: true
description: "Tavily AI搜索API"
- id: "searxng"
name: "Searxng"
free: true
requires_api_key: false
description: "Searxng开源元搜索引擎"
- id: "exa"
name: "Exa"
free: false
requires_api_key: true
description: "Exa AI搜索API"
# 默认配置
default:
provider: "kuaisou"
max_results: 5
include_date: true
compression_method: "none"
blacklist: []
# 全局超时设置
timeout: 10
+9 -1
View File
@@ -28,7 +28,7 @@ export function useStream() {
let renderTimer: number | null = null
// 启动流式请求
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; agent_enabled?: boolean; summary_model_id?: string; method: string; url: string }) => {
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; agent_enabled?: boolean; web_search_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; method: string; url: string }) => {
// 重置状态
output.value = '';
error.value = null;
@@ -69,10 +69,18 @@ export function useStream() {
if (params.knowledge_base_ids !== undefined && params.knowledge_base_ids.length > 0) {
postBody.knowledge_base_ids = params.knowledge_base_ids;
}
// Include web_search_enabled if provided
if (params.web_search_enabled !== undefined) {
postBody.web_search_enabled = params.web_search_enabled;
}
// Include summary_model_id if provided (for non-Agent mode)
if (params.summary_model_id) {
postBody.summary_model_id = params.summary_model_id;
}
// Include mcp_service_ids if provided (for Agent mode)
if (params.mcp_service_ids !== undefined && params.mcp_service_ids.length > 0) {
postBody.mcp_service_ids = params.mcp_service_ids;
}
await fetchEventSource(url, {
method: params.method,
+41
View File
@@ -0,0 +1,41 @@
import { get, put } from '@/utils/request'
// WebSearchProviderConfig represents information about a web search provider
export interface WebSearchProviderConfig {
id: string
name: string
free: boolean
requires_api_key: boolean
description?: string
api_url?: string
}
// WebSearchConfig represents the web search configuration for a tenant
export interface WebSearchConfig {
provider: string
api_key?: string
max_results: number
include_date: boolean
compression_method: string
blacklist: string[]
embedding_model_id?: string
embedding_dimension?: number
rerank_model_id?: string
document_fragments?: number
}
// Get web search providers
export function getWebSearchProviders() {
return get('/api/v1/web-search/providers')
}
// Get tenant web search config
export function getTenantWebSearchConfig() {
return get('/api/v1/tenants/web-search-config')
}
// Update tenant web search config
export function updateTenantWebSearchConfig(config: WebSearchConfig) {
return put('/api/v1/tenants/web-search-config', config)
}
+458 -46
View File
@@ -17,6 +17,9 @@ const uiStore = useUIStore();
let query = ref("");
const showKbSelector = ref(false);
const atButtonRef = ref<HTMLElement>();
const showAgentModeSelector = ref(false);
const agentModeButtonRef = ref<HTMLElement>();
const agentModeDropdownStyle = ref<Record<string, string>>({});
const props = defineProps({
isReplying: {
@@ -34,6 +37,7 @@ const props = defineProps({
});
const isAgentEnabled = computed(() => settingsStore.isAgentEnabled);
const isWebSearchEnabled = computed(() => settingsStore.isWebSearchEnabled);
const selectedKbIds = computed(() => settingsStore.settings.selectedKnowledgeBases || []);
// 获取已选择的知识库信息
@@ -193,6 +197,11 @@ const closeModelSelector = () => {
showModelSelector.value = false;
};
// 关闭 Agent 模式选择器(点击外部)
const closeAgentModeSelector = () => {
showAgentModeSelector.value = false;
};
onMounted(() => {
loadKnowledgeBases();
@@ -211,16 +220,21 @@ onMounted(() => {
// 监听点击外部关闭下拉菜单
document.addEventListener('click', closeModelSelector);
document.addEventListener('click', closeAgentModeSelector);
// 监听窗口大小变化,重新计算位置
window.addEventListener('resize', () => {
if (showModelSelector.value) {
updateModelDropdownPosition();
}
if (showAgentModeSelector.value) {
updateAgentModeDropdownPosition();
}
});
});
onUnmounted(() => {
document.removeEventListener('click', closeModelSelector);
document.removeEventListener('click', closeAgentModeSelector);
});
// 监听路由变化
@@ -317,6 +331,51 @@ const updateModelDropdownPosition = () => {
};
};
// 计算 Agent 模式下拉菜单位置
const updateAgentModeDropdownPosition = () => {
const anchor = agentModeButtonRef.value;
if (!anchor) {
agentModeDropdownStyle.value = {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
};
return;
}
const rect = anchor.getBoundingClientRect();
const dropdownWidth = 200;
const offsetY = 8;
// 计算位置:默认在按钮下方
let top = rect.bottom + offsetY;
let left = rect.left;
// 检查右侧边界
if (left + dropdownWidth > window.innerWidth) {
left = window.innerWidth - dropdownWidth - 10;
}
// 检查下方空间
const dropdownMaxHeight = 150;
if (top + dropdownMaxHeight > window.innerHeight) {
// 如果下方空间不足,显示在按钮上方
top = rect.top - dropdownMaxHeight - offsetY;
if (top < 10) {
top = 10;
}
}
agentModeDropdownStyle.value = {
position: 'fixed',
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
width: `${dropdownWidth}px`
};
};
const toggleModelSelector = () => {
showModelSelector.value = !showModelSelector.value;
if (showModelSelector.value) {
@@ -326,6 +385,36 @@ const toggleModelSelector = () => {
}
}
const toggleAgentModeSelector = () => {
// 如果 Agent 未就绪,显示提示
if (!settingsStore.isAgentReady && !isAgentEnabled.value) {
toggleAgentMode();
return;
}
showAgentModeSelector.value = !showAgentModeSelector.value;
if (showAgentModeSelector.value) {
nextTick(() => {
updateAgentModeDropdownPosition();
});
}
}
const selectAgentMode = (mode: 'normal' | 'agent') => {
if (mode === 'agent' && !settingsStore.isAgentReady) {
toggleAgentMode();
showAgentModeSelector.value = false;
return;
}
const shouldEnableAgent = mode === 'agent';
if (shouldEnableAgent !== isAgentEnabled.value) {
settingsStore.toggleAgent(shouldEnableAgent);
MessagePlugin.success(shouldEnableAgent ? '已切换到 Agent 模式' : '已切换到普通模式');
}
showAgentModeSelector.value = false;
}
const clearvalue = () => {
query.value = "";
}
@@ -389,6 +478,13 @@ const toggleAgentMode = () => {
MessagePlugin.success(message);
}
const toggleWebSearch = () => {
const currentValue = settingsStore.isWebSearchEnabled;
const newValue = !currentValue;
settingsStore.toggleWebSearch(newValue);
MessagePlugin.success(newValue ? '网络搜索已开启' : '网络搜索已关闭');
}
const toggleKbSelector = () => {
showKbSelector.value = !showKbSelector.value;
}
@@ -437,41 +533,142 @@ onBeforeRouteUpdate((to, from, next) => {
<div class="control-bar">
<!-- 左侧控制按钮 -->
<div class="control-left">
<!-- Agent 模式按钮 -->
<t-tooltip
placement="top"
:show-arrow="true"
:duration="0"
<!-- Agent 模式切换按钮 -->
<div
ref="agentModeButtonRef"
class="control-btn agent-mode-btn"
:class="{
'active': isAgentEnabled,
'agent-active': isAgentEnabled
}"
@click.stop="toggleAgentModeSelector"
>
<template #content>
<div style="display: flex; flex-direction: column; gap: 4px; align-items: flex-start;">
<span>
{{ isAgentEnabled ? 'Agent 模式(已启用)' : (settingsStore.isAgentReady ? 'Agent 模式(已禁用,点击启用)' : 'Agent 未就绪,请先完成配置') }}
</span>
<a
href="#"
@click.prevent="handleGoToAgentSettings"
style="color: #07C05F; text-decoration: none; font-size: 12px; cursor: pointer;"
@mouseenter="(e) => e.target.style.textDecoration = 'underline'"
@mouseleave="(e) => e.target.style.textDecoration = 'none'"
>
去设置 Agent →
</a>
</div>
</template>
<div
class="control-btn agent-btn"
:class="{
'active': isAgentEnabled,
'disabled': !isAgentEnabled && !settingsStore.isAgentReady
}"
@click="toggleAgentMode"
<img
v-if="isAgentEnabled"
:src="getImgSrc('agent-active.svg')"
alt="Agent模式"
class="control-icon agent-icon"
/>
<svg
v-else
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
class="control-icon normal-mode-icon"
>
<img
:src="isAgentEnabled ? getImgSrc('agent-active.svg') : getImgSrc('agent.svg')"
alt="Agent"
class="control-icon"
/>
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM1.5 8a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0z"/>
<path d="M8 4.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zM6 8a2 2 0 1 1 4 0 2 2 0 0 1-4 0z"/>
</svg>
<span class="agent-mode-text">
{{ isAgentEnabled ? 'Agent 模式' : '普通模式' }}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="currentColor"
class="dropdown-arrow"
:class="{ 'rotate': showAgentModeSelector }"
>
<path d="M2.5 4.5L6 8L9.5 4.5H2.5Z"/>
</svg>
</div>
<!-- Agent 模式选择下拉菜单 -->
<Teleport to="body">
<div v-if="showAgentModeSelector" class="agent-mode-selector-overlay" @click="closeAgentModeSelector">
<div
class="agent-mode-selector-dropdown"
:style="agentModeDropdownStyle"
@click.stop
>
<div
class="agent-mode-option"
:class="{ 'selected': !isAgentEnabled }"
@click="selectAgentMode('normal')"
>
<div class="agent-mode-option-main">
<span class="agent-mode-option-name">普通模式</span>
<span class="agent-mode-option-desc">基于知识库的 RAG 问答</span>
</div>
<svg
v-if="!isAgentEnabled"
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
class="check-icon"
>
<path d="M13.5 4.5L6 12L2.5 8.5L3.5 7.5L6 10L12.5 3.5L13.5 4.5Z"/>
</svg>
</div>
<div
class="agent-mode-option"
:class="{
'selected': isAgentEnabled,
'disabled': !settingsStore.isAgentReady && !isAgentEnabled
}"
@click="selectAgentMode('agent')"
>
<div class="agent-mode-option-main">
<span class="agent-mode-option-name">Agent 模式</span>
<span class="agent-mode-option-desc">ReAct 推理框架,多步思考</span>
</div>
<svg
v-if="isAgentEnabled"
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
class="check-icon"
>
<path d="M13.5 4.5L6 12L2.5 8.5L3.5 7.5L6 10L12.5 3.5L13.5 4.5Z"/>
</svg>
<div v-if="!settingsStore.isAgentReady && !isAgentEnabled" class="agent-mode-warning">
<t-tooltip content="Agent 未就绪,请先在设置中完成配置" placement="left">
<t-icon name="error-circle" class="warning-icon" />
</t-tooltip>
</div>
</div>
<div v-if="!settingsStore.isAgentReady && !isAgentEnabled" class="agent-mode-footer">
<a
href="#"
@click.prevent="handleGoToAgentSettings"
class="agent-mode-link"
>
前往设置 →
</a>
</div>
</div>
</div>
</Teleport>
<!-- WebSearch 开关按钮 -->
<t-tooltip
:content="isWebSearchEnabled ? '关闭网络搜索' : '开启网络搜索'"
placement="top"
>
<div
class="control-btn websearch-btn"
:class="{ 'active': isWebSearchEnabled }"
@click.stop="toggleWebSearch"
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="control-icon websearch-icon"
:class="{ 'active': isWebSearchEnabled }"
>
<circle cx="9" cy="9" r="7" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 0 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 1 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<line x1="2.94" y1="5.5" x2="15.06" y2="5.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<line x1="2.94" y1="12.5" x2="15.06" y2="12.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</div>
</t-tooltip>
@@ -719,30 +916,76 @@ const getImgSrc = (url: string) => {
}
}
.agent-btn {
width: 28px;
.agent-mode-btn {
height: 28px;
padding: 0;
&.active {
background: rgba(7, 192, 95, 0.1);
padding: 0 10px;
min-width: auto;
font-weight: 500;
border: 1.5px solid transparent;
transition: all 0.2s ease;
position: relative;
&.active,
&.agent-active {
background: linear-gradient(135deg, rgba(7, 192, 95, 0.15) 0%, rgba(7, 192, 95, 0.1) 100%);
border-color: rgba(7, 192, 95, 0.4);
box-shadow: 0 2px 6px rgba(7, 192, 95, 0.12);
.agent-mode-text {
color: #07C05F;
font-weight: 600;
}
.agent-icon {
filter: brightness(0) saturate(100%) invert(58%) sepia(87%) saturate(1234%) hue-rotate(95deg) brightness(98%) contrast(89%);
}
.dropdown-arrow {
color: #07C05F;
}
&:hover {
background: rgba(7, 192, 95, 0.15);
background: linear-gradient(135deg, rgba(7, 192, 95, 0.2) 0%, rgba(7, 192, 95, 0.15) 100%);
border-color: rgba(7, 192, 95, 0.6);
box-shadow: 0 3px 10px rgba(7, 192, 95, 0.2);
transform: translateY(-1px);
}
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
&:not(.agent-active) {
background: rgba(255, 255, 255, 0.8);
border-color: #e0e0e0;
.agent-mode-text {
color: #666;
}
.normal-mode-icon {
color: #666;
}
&:hover {
background: #f5f5f5;
transform: none;
background: rgba(255, 255, 255, 1);
border-color: #b0b0b0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
}
}
.agent-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.agent-mode-text {
font-size: 13px;
color: #666;
font-weight: 500;
white-space: nowrap;
margin: 0 4px;
}
.control-icon {
width: 18px;
height: 18px;
@@ -775,6 +1018,48 @@ const getImgSrc = (url: string) => {
color: #07C05F;
}
.websearch-btn {
width: 28px;
height: 28px;
padding: 0;
min-width: auto;
display: flex;
align-items: center;
justify-content: center;
&.active {
background: rgba(7, 192, 95, 0.1);
.websearch-icon {
color: #07C05F;
}
&:hover {
background: rgba(7, 192, 95, 0.15);
}
}
&:not(.active) {
.websearch-icon {
color: #666;
}
&:hover {
background: #f0f0f0;
.websearch-icon {
color: #333;
}
}
}
}
.websearch-icon {
width: 18px;
height: 18px;
transition: all 0.2s ease;
}
.dropdown-arrow {
transition: transform 0.2s ease;
width: 10px;
@@ -1124,4 +1409,131 @@ const getImgSrc = (url: string) => {
background: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
/* Agent 模式选择下拉菜单 */
.agent-mode-selector-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9998;
}
.agent-mode-selector-dropdown {
z-index: 9999;
background: white;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(7, 192, 95, 0.08);
border: 1px solid #e5e7eb;
overflow: hidden;
padding: 4px;
min-width: 200px;
}
.agent-mode-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 6px;
position: relative;
margin: 2px 0;
&:hover:not(.disabled) {
background: rgba(7, 192, 95, 0.1);
transform: translateX(2px);
}
&:active:not(.disabled) {
transform: translateX(1px);
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
background: transparent;
transform: none;
}
}
// 选中状态的选项有更明显的背景
&.selected {
background: rgba(7, 192, 95, 0.06);
.agent-mode-option-name {
color: #07C05F;
font-weight: 700;
}
}
}
.agent-mode-option-main {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.agent-mode-option-name {
font-size: 14px;
font-weight: 600;
color: #333;
line-height: 1.4;
transition: color 0.2s ease;
}
.agent-mode-option-desc {
font-size: 12px;
color: #999;
line-height: 1.3;
}
.check-icon {
width: 16px;
height: 16px;
color: #07C05F;
flex-shrink: 0;
margin-left: 8px;
}
.agent-mode-warning {
display: flex;
align-items: center;
margin-left: 8px;
.warning-icon {
color: #ff9800;
font-size: 16px;
}
}
.agent-mode-footer {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
margin-top: 4px;
}
.agent-mode-link {
color: #07C05F;
text-decoration: none;
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
&:hover {
color: #00a651;
text-decoration: underline;
}
}
</style>
+13 -2
View File
@@ -10,6 +10,7 @@ interface Settings {
selectedKnowledgeBases: string[]; // 当前选中的知识库ID列表
modelConfig: ModelConfig; // 模型配置
ollamaConfig: OllamaConfig; // Ollama配置
webSearchEnabled: boolean; // 网络搜索是否启用
}
// Agent 配置接口
@@ -71,7 +72,8 @@ const defaultSettings: Settings = {
ollamaConfig: {
baseUrl: "http://localhost:11434",
enabled: true
}
},
webSearchEnabled: false // 默认关闭网络搜索
};
export const useSettingsStore = defineStore("settings", {
@@ -97,6 +99,9 @@ export const useSettingsStore = defineStore("settings", {
// 获取模型配置
modelConfig: (state) => state.settings.modelConfig || defaultSettings.modelConfig,
// 网络搜索是否启用
isWebSearchEnabled: (state) => state.settings.webSearchEnabled || false,
},
actions: {
@@ -224,7 +229,7 @@ export const useSettingsStore = defineStore("settings", {
// 移除单个知识库
removeKnowledgeBase(kbId: string) {
this.settings.selectedKnowledgeBases =
this.settings.selectedKnowledgeBases.filter(id => id !== kbId);
this.settings.selectedKnowledgeBases.filter((id: string) => id !== kbId);
localStorage.setItem("WeKnora_settings", JSON.stringify(this.settings));
},
@@ -238,5 +243,11 @@ export const useSettingsStore = defineStore("settings", {
getSelectedKnowledgeBases(): string[] {
return this.settings.selectedKnowledgeBases || [];
},
// 启用/禁用网络搜索
toggleWebSearch(enabled: boolean) {
this.settings.webSearchEnabled = enabled;
localStorage.setItem("WeKnora_settings", JSON.stringify(this.settings));
},
},
});
+4
View File
@@ -269,6 +269,9 @@ const sendMsg = async (value, modelId = '') => {
// Get agent mode status from settings store
const agentEnabled = useSettingsStoreInstance.isAgentEnabled;
// Get web search status from settings store
const webSearchEnabled = useSettingsStoreInstance.isWebSearchEnabled;
// Get knowledge_base_ids from settings store (selected by user via KnowledgeBaseSelector)
const kbIds = useSettingsStoreInstance.settings.selectedKnowledgeBases || [];
@@ -294,6 +297,7 @@ const sendMsg = async (value, modelId = '') => {
session_id: session_id.value,
knowledge_base_ids: kbIds,
agent_enabled: agentEnabled,
web_search_enabled: webSearchEnabled,
summary_model_id: modelId,
mcp_service_ids: mcpServiceIds,
query: value,
+24 -1
View File
@@ -26,7 +26,23 @@
}]"
@click="handleNavClick(item)"
>
<t-icon :name="item.icon" class="nav-icon" />
<!-- 网络搜索使用自定义 SVG 图标 -->
<svg
v-if="item.key === 'websearch'"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="nav-icon"
>
<circle cx="9" cy="9" r="7" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 0 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 1 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<line x1="2.94" y1="5.5" x2="15.06" y2="5.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<line x1="2.94" y1="12.5" x2="15.06" y2="12.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
<t-icon v-else :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
<t-icon
v-if="item.children && item.children.length > 0"
@@ -78,6 +94,11 @@
<AgentSettings />
</div>
<!-- 网络搜索配置 -->
<div v-if="currentSection === 'websearch'" class="section">
<WebSearchSettings />
</div>
<!-- 系统信息 -->
<div v-if="currentSection === 'system'" class="section">
<SystemInfo />
@@ -118,6 +139,7 @@ import GeneralSettings from './GeneralSettings.vue'
import ModelSettings from './ModelSettings.vue'
import OllamaSettings from './OllamaSettings.vue'
import McpSettings from './McpSettings.vue'
import WebSearchSettings from './WebSearchSettings.vue'
const route = useRoute()
const router = useRouter()
@@ -142,6 +164,7 @@ const navItems = [
},
{ key: 'ollama', icon: 'server', label: 'Ollama' },
{ key: 'agent', icon: 'chat', label: 'Agent 配置' },
{ key: 'websearch', icon: 'search', label: '网络搜索' },
{ key: 'mcp', icon: 'link', label: 'MCP 服务' },
{ key: 'system', icon: 'info-circle', label: '系统信息' },
{ key: 'tenant', icon: 'user-circle', label: '租户信息' },
@@ -0,0 +1,405 @@
<template>
<div class="websearch-settings">
<div class="section-header">
<h2>网络搜索配置</h2>
<p class="section-description">配置网络搜索功能,在回答问题时可以从互联网获取实时信息补充知识库内容</p>
</div>
<div class="settings-group">
<!-- 搜索引擎提供商 -->
<div class="setting-row">
<div class="setting-info">
<label>搜索引擎提供商</label>
<p class="desc">选择用于网络搜索的搜索引擎服务</p>
</div>
<div class="setting-control">
<t-select
v-model="localProvider"
:loading="loadingProviders"
filterable
placeholder="选择搜索引擎..."
@change="handleProviderChange"
@focus="loadProviders"
style="width: 280px;"
>
<t-option
v-for="provider in providers"
:key="provider.id"
:value="provider.id"
:label="provider.name"
>
<div class="provider-option-wrapper">
<div class="provider-option">
<span class="provider-name">{{ provider.name }}</span>
<div class="provider-tags">
<t-tag v-if="provider.free" size="small" theme="success">免费</t-tag>
<t-tag v-else size="small" theme="warning">付费</t-tag>
<t-tag v-if="provider.requires_api_key" size="small" theme="default">需API密钥</t-tag>
</div>
</div>
<div v-if="provider.description" class="provider-desc">
{{ provider.description }}
</div>
</div>
</t-option>
</t-select>
</div>
</div>
<!-- API 密钥 -->
<div v-if="selectedProvider && selectedProvider.requires_api_key" class="setting-row">
<div class="setting-info">
<label>API 密钥</label>
<p class="desc">输入所选搜索引擎的 API 密钥</p>
</div>
<div class="setting-control">
<t-input
v-model="localAPIKey"
type="password"
placeholder="请输入 API 密钥"
@change="handleAPIKeyChange"
style="width: 400px;"
:show-password="true"
/>
</div>
</div>
<!-- 最大结果数 -->
<div class="setting-row">
<div class="setting-info">
<label>最大结果数</label>
<p class="desc">每次搜索返回的最大结果数量(1-50)</p>
</div>
<div class="setting-control">
<div class="slider-with-value">
<t-slider
v-model="localMaxResults"
:min="1"
:max="50"
:step="1"
:marks="{ 1: '1', 10: '10', 20: '20', 30: '30', 40: '40', 50: '50' }"
@change="handleMaxResultsChange"
style="width: 200px;"
/>
<span class="value-display">{{ localMaxResults }}</span>
</div>
</div>
</div>
<!-- 包含日期 -->
<div class="setting-row">
<div class="setting-info">
<label>包含发布日期</label>
<p class="desc">在搜索结果中包含内容的发布日期信息</p>
</div>
<div class="setting-control">
<t-switch
v-model="localIncludeDate"
@change="handleIncludeDateChange"
/>
</div>
</div>
<!-- 压缩方法 -->
<div class="setting-row">
<div class="setting-info">
<label>压缩方法</label>
<p class="desc">对搜索结果内容的压缩处理方法</p>
</div>
<div class="setting-control">
<t-select
v-model="localCompressionMethod"
@change="handleCompressionMethodChange"
style="width: 280px;"
>
<t-option value="none" label="无压缩">无压缩</t-option>
<t-option value="llm_summary" label="LLM 摘要">LLM 摘要</t-option>
</t-select>
</div>
</div>
<!-- 黑名单 -->
<div class="setting-row vertical">
<div class="setting-info">
<label>URL 黑名单</label>
<p class="desc">排除特定域名或 URL 的搜索结果,每行一个。支持通配符(*)和正则表达式(以/开头和结尾)</p>
</div>
<div class="setting-control">
<t-textarea
v-model="localBlacklistText"
placeholder="例如:&#10;*://*.example.com/*&#10;/example\.(net|org)/"
:autosize="{ minRows: 4, maxRows: 8 }"
@change="handleBlacklistChange"
style="width: 500px;"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { getWebSearchProviders, getTenantWebSearchConfig, updateTenantWebSearchConfig, type WebSearchProviderConfig, type WebSearchConfig } from '@/api/web-search'
// 本地状态
const loadingProviders = ref(false)
const providers = ref<WebSearchProviderConfig[]>([])
const localProvider = ref<string>('')
const localAPIKey = ref<string>('')
const localMaxResults = ref<number>(5)
const localIncludeDate = ref<boolean>(true)
const localCompressionMethod = ref<string>('none')
const localBlacklistText = ref<string>('')
// 计算属性:当前选中的提供商
const selectedProvider = computed(() => {
return providers.value.find(p => p.id === localProvider.value)
})
// 加载提供商列表
const loadProviders = async () => {
if (providers.value.length > 0) {
return // 已加载过
}
loadingProviders.value = true
try {
const response = await getWebSearchProviders()
// request拦截器已经处理了响应,直接使用data字段
if (response.data && Array.isArray(response.data)) {
providers.value = response.data
}
} catch (error: any) {
console.error('Failed to load web search providers:', error)
MessagePlugin.error('加载搜索引擎列表失败: ' + (error.message || '未知错误'))
} finally {
loadingProviders.value = false
}
}
// 加载租户配置
const loadTenantConfig = async () => {
try {
const response = await getTenantWebSearchConfig()
// request拦截器已经处理了响应,直接使用data字段
if (response.data) {
const config = response.data
localProvider.value = config.provider || ''
// API key 在响应中被隐藏,如果是 "***",说明已配置但未返回实际值
localAPIKey.value = config.api_key === '***' ? '***' : config.api_key || ''
localMaxResults.value = config.max_results || 5
localIncludeDate.value = config.include_date !== undefined ? config.include_date : true
localCompressionMethod.value = config.compression_method || 'none'
localBlacklistText.value = (config.blacklist || []).join('\n')
}
} catch (error: any) {
console.error('Failed to load tenant web search config:', error)
// 如果配置不存在,使用默认值(不显示错误)
}
}
// 保存配置
const saveConfig = async () => {
try {
const blacklist = localBlacklistText.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
const config: WebSearchConfig = {
provider: localProvider.value,
api_key: localAPIKey.value,
max_results: localMaxResults.value,
include_date: localIncludeDate.value,
compression_method: localCompressionMethod.value,
blacklist: blacklist
}
await updateTenantWebSearchConfig(config)
MessagePlugin.success('网络搜索配置已保存')
} catch (error: any) {
console.error('Failed to save web search config:', error)
MessagePlugin.error('保存配置失败: ' + (error.message || '未知错误'))
throw error
}
}
// 防抖保存
let saveTimer: number | null = null
const debouncedSave = () => {
if (saveTimer) {
clearTimeout(saveTimer)
}
saveTimer = window.setTimeout(() => {
saveConfig().catch(() => {
// 错误已在 saveConfig 中处理
})
}, 500)
}
// 处理变化
const handleProviderChange = () => {
debouncedSave()
}
const handleAPIKeyChange = () => {
debouncedSave()
}
const handleMaxResultsChange = () => {
debouncedSave()
}
const handleIncludeDateChange = () => {
debouncedSave()
}
const handleCompressionMethodChange = () => {
debouncedSave()
}
const handleBlacklistChange = () => {
debouncedSave()
}
// 初始化
onMounted(async () => {
await loadProviders()
await loadTenantConfig()
})
</script>
<style lang="less" scoped>
.websearch-settings {
width: 100%;
}
.section-header {
margin-bottom: 32px;
h2 {
font-size: 20px;
font-weight: 600;
color: #333333;
margin: 0 0 8px 0;
}
.section-description {
font-size: 14px;
color: #666666;
margin: 0;
line-height: 1.5;
}
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
border-bottom: 1px solid #e5e7eb;
&:last-child {
border-bottom: none;
}
&.vertical {
flex-direction: column;
gap: 12px;
.setting-control {
width: 100%;
max-width: 100%;
}
}
}
.setting-info {
flex: 1;
max-width: 65%;
padding-right: 24px;
label {
font-size: 15px;
font-weight: 500;
color: #333333;
display: block;
margin-bottom: 4px;
}
.desc {
font-size: 13px;
color: #666666;
margin: 0;
line-height: 1.5;
}
}
.setting-control {
flex-shrink: 0;
min-width: 280px;
display: flex;
justify-content: flex-end;
align-items: center;
}
.slider-with-value {
display: flex;
align-items: center;
gap: 12px;
}
.value-display {
min-width: 40px;
text-align: right;
font-size: 14px;
font-weight: 500;
color: #333333;
}
.provider-option-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
padding: 2px 0;
}
.provider-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.provider-name {
font-weight: 500;
font-size: 14px;
color: #333;
flex-shrink: 0;
}
.provider-tags {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
flex-shrink: 0;
}
.provider-desc {
font-size: 12px;
color: #999;
line-height: 1.4;
margin-top: 2px;
}
</style>
@@ -2,8 +2,10 @@ package chatpipline
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
@@ -16,17 +18,23 @@ type PluginSearch struct {
knowledgeBaseService interfaces.KnowledgeBaseService
modelService interfaces.ModelService
config *config.Config
webSearchService interfaces.WebSearchService
tenantService interfaces.TenantService
}
func NewPluginSearch(eventManager *EventManager,
knowledgeBaseService interfaces.KnowledgeBaseService,
modelService interfaces.ModelService,
config *config.Config,
webSearchService interfaces.WebSearchService,
tenantService interfaces.TenantService,
) *PluginSearch {
res := &PluginSearch{
knowledgeBaseService: knowledgeBaseService,
modelService: modelService,
config: config,
webSearchService: webSearchService,
tenantService: tenantService,
}
eventManager.Register(res)
return res
@@ -135,6 +143,33 @@ func (p *PluginSearch) OnEvent(ctx context.Context,
chatManage.SearchResult = append(chatManage.SearchResult, processedResults...)
}
// Perform web search if enabled and merge results with KB search results
if chatManage.WebSearchEnabled && p.webSearchService != nil && p.tenantService != nil && chatManage.TenantID > 0 {
// Get tenant to retrieve web search config
tenant, err := p.tenantService.GetTenantByID(ctx, chatManage.TenantID)
if err != nil {
logger.Warnf(ctx, "Failed to get tenant for web search: %v", err)
} else if tenant != nil && tenant.WebSearchConfig != nil && tenant.WebSearchConfig.Provider != "" {
// Perform web search in parallel with KB search (already completed)
logger.Infof(ctx, "Performing web search with provider: %s", tenant.WebSearchConfig.Provider)
webResults, err := p.webSearchService.Search(ctx, tenant.WebSearchConfig, chatManage.RewriteQuery)
if err != nil {
logger.Warnf(ctx, "Web search failed: %v", err)
} else {
// Convert web search results to SearchResult
webSearchResults := convertWebSearchResults(webResults)
logger.Infof(ctx, "Web search returned %d results", len(webSearchResults))
// Merge web search results with KB search results
if len(webSearchResults) > 0 {
chatManage.SearchResult = append(chatManage.SearchResult, webSearchResults...)
logger.Infof(ctx, "Merged web search results, total results: %d", len(chatManage.SearchResult))
}
}
} else {
logger.Warnf(ctx, "Web search enabled but no valid configuration found for tenant %d", chatManage.TenantID)
}
}
// Remove duplicate results
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
@@ -180,3 +215,71 @@ func removeDuplicateResults(results []*types.SearchResult) []*types.SearchResult
}
return uniqueResults
}
// convertWebSearchResults converts WebSearchResult to SearchResult
// This is a duplicate of the function in service/web_search.go to avoid circular imports
func convertWebSearchResults(webResults []*types.WebSearchResult) []*types.SearchResult {
results := make([]*types.SearchResult, 0, len(webResults))
for i, webResult := range webResults {
// Use URL as ChunkID for web search results
chunkID := webResult.URL
if chunkID == "" {
chunkID = fmt.Sprintf("web_search_%d", i)
}
// Combine title and snippet as content
content := webResult.Title
if webResult.Snippet != "" {
if content != "" {
content += "\n\n" + webResult.Snippet
} else {
content = webResult.Snippet
}
}
if webResult.Content != "" {
if content != "" {
content += "\n\n" + webResult.Content
} else {
content = webResult.Content
}
}
// Set a default score for web search results (0.6, indicating medium relevance)
score := 0.6
result := &types.SearchResult{
ID: chunkID,
Content: content,
KnowledgeID: "", // Web search results don't have knowledge ID
ChunkIndex: 0,
KnowledgeTitle: webResult.Title,
StartAt: 0,
EndAt: len(content),
Seq: i,
Score: score,
MatchType: types.MatchTypeWebSearch,
SubChunkID: []string{},
Metadata: map[string]string{
"url": webResult.URL,
"source": webResult.Source,
"title": webResult.Title,
"snippet": webResult.Snippet,
},
ChunkType: "web_search",
ParentChunkID: "",
ImageInfo: "",
KnowledgeFilename: "",
KnowledgeSource: "web_search",
}
// Add published date to metadata if available
if webResult.PublishedAt != nil {
result.Metadata["published_at"] = webResult.PublishedAt.Format(time.RFC3339)
}
results = append(results, result)
}
return results
}
+7 -2
View File
@@ -400,8 +400,8 @@ func (s *sessionService) GenerateTitleAsync(ctx context.Context, session *types.
// KnowledgeQA performs knowledge base question answering with LLM summarization
// Events are emitted through eventBus (references, answer chunks, completion)
func (s *sessionService) KnowledgeQA(ctx context.Context, session *types.Session, query string, knowledgeBaseIDs []string, assistantMessageID string, summaryModelID string, eventBus *event.EventBus) error {
logger.Infof(ctx, "Knowledge base question answering parameters, session ID: %s, query: %s", session.ID, query)
func (s *sessionService) KnowledgeQA(ctx context.Context, session *types.Session, query string, knowledgeBaseIDs []string, assistantMessageID string, summaryModelID string, webSearchEnabled bool, eventBus *event.EventBus) error {
logger.Infof(ctx, "Knowledge base question answering parameters, session ID: %s, query: %s, webSearchEnabled: %v", session.ID, query, webSearchEnabled)
// If no knowledge base IDs provided, fall back to session's default
if len(knowledgeBaseIDs) == 0 {
@@ -456,6 +456,11 @@ func (s *sessionService) KnowledgeQA(ctx context.Context, session *types.Session
EventBus: eventBus.AsEventBusInterface(), // NEW: For pipeline to emit events directly
}
// Store web search configuration in chatManage for pipeline processing
// PluginSearch will handle web search integration
chatManage.TenantID = session.TenantID
chatManage.WebSearchEnabled = webSearchEnabled
// Start knowledge QA event processing
logger.Info(ctx, "Triggering knowledge base question answering event")
err = s.KnowledgeQAByEvent(ctx, chatManage, types.Pipline["rag_stream"])
+260
View File
@@ -0,0 +1,260 @@
package service
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchProvider defines the interface for web search providers
type WebSearchProvider interface {
Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error)
Name() string
}
// WebSearchService provides web search functionality
type WebSearchService struct {
providers map[string]WebSearchProvider
config *config.WebSearchConfig
}
// Search performs web search using the specified provider
// This method implements the interface expected by PluginSearch
func (s *WebSearchService) Search(ctx context.Context, config *types.WebSearchConfig, query string) ([]*types.WebSearchResult, error) {
if config == nil {
return nil, fmt.Errorf("web search config is required")
}
provider, ok := s.providers[config.Provider]
if !ok {
return nil, fmt.Errorf("web search provider %s is not available", config.Provider)
}
// Set API key for providers that need it
if config.APIKey != "" {
s.setProviderAPIKey(config.Provider, provider, config)
}
// Set timeout
timeout := time.Duration(s.config.Timeout) * time.Second
if timeout == 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Perform search
results, err := provider.Search(ctx, query, config.MaxResults, config.IncludeDate)
if err != nil {
return nil, fmt.Errorf("web search failed: %w", err)
}
// Apply blacklist filtering
results = s.filterBlacklist(results, config.Blacklist)
// Apply compression if needed
if config.CompressionMethod != "none" && config.CompressionMethod != "" {
// Compression will be handled later in the integration layer
// For now, we just return the results
}
return results, nil
}
// NewWebSearchService creates a new web search service
func NewWebSearchService(cfg *config.Config) (*WebSearchService, error) {
if cfg.WebSearch == nil {
return nil, fmt.Errorf("web search config is not available")
}
service := &WebSearchService{
providers: make(map[string]WebSearchProvider),
config: cfg.WebSearch,
}
// Initialize providers based on config
for _, providerConfig := range cfg.WebSearch.Providers {
var provider WebSearchProvider
var err error
switch providerConfig.ID {
case "kuaisou":
provider, err = NewKuaisouProvider(providerConfig)
case "baidu":
provider, err = NewBaiduProvider(providerConfig)
case "google":
provider, err = NewGoogleProvider(providerConfig)
case "bing":
provider, err = NewBingProvider(providerConfig)
case "bocha":
provider, err = NewBochaProvider(providerConfig)
case "zhipu":
provider, err = NewZhipuProvider(providerConfig)
case "tavily":
provider, err = NewTavilyProvider(providerConfig)
case "searxng":
provider, err = NewSearxngProvider(providerConfig)
case "exa":
provider, err = NewExaProvider(providerConfig)
default:
logger.Warnf(context.Background(), "Unknown web search provider: %s", providerConfig.ID)
continue
}
if err != nil {
logger.Warnf(context.Background(), "Failed to initialize provider %s: %v", providerConfig.ID, err)
continue
}
service.providers[providerConfig.ID] = provider
logger.Infof(context.Background(), "Initialized web search provider: %s", providerConfig.ID)
}
return service, nil
}
// setProviderAPIKey sets the API key for a provider based on its type
func (s *WebSearchService) setProviderAPIKey(providerID string, provider WebSearchProvider, config *types.WebSearchConfig) {
switch p := provider.(type) {
case *KuaisouProvider:
p.SetAPIKey(config.APIKey)
case *BochaProvider:
p.SetAPIKey(config.APIKey)
case *ZhipuProvider:
p.SetAPIKey(config.APIKey)
case *TavilyProvider:
p.SetAPIKey(config.APIKey)
case *ExaProvider:
p.SetAPIKey(config.APIKey)
case *GoogleProvider:
// Google needs both API key and search engine ID
// For now, we'll use API key as search engine ID if not provided separately
// This can be extended later to support separate fields
p.SetAPIKey(config.APIKey, config.APIKey) // TODO: Add search engine ID to config
}
}
// filterBlacklist filters results based on blacklist rules
func (s *WebSearchService) filterBlacklist(results []*types.WebSearchResult, blacklist []string) []*types.WebSearchResult {
if len(blacklist) == 0 {
return results
}
filtered := make([]*types.WebSearchResult, 0, len(results))
for _, result := range results {
shouldFilter := false
for _, rule := range blacklist {
if s.matchesBlacklistRule(result.URL, rule) {
shouldFilter = true
break
}
}
if !shouldFilter {
filtered = append(filtered, result)
}
}
return filtered
}
// matchesBlacklistRule checks if a URL matches a blacklist rule
// Supports both pattern matching (e.g., *://*.example.com/*) and regex patterns (e.g., /example\.(net|org)/)
func (s *WebSearchService) matchesBlacklistRule(url, rule string) bool {
// Check if it's a regex pattern (starts and ends with /)
if strings.HasPrefix(rule, "/") && strings.HasSuffix(rule, "/") {
pattern := rule[1 : len(rule)-1]
matched, err := regexp.MatchString(pattern, url)
if err != nil {
logger.Warnf(context.Background(), "Invalid regex pattern in blacklist: %s, error: %v", rule, err)
return false
}
return matched
}
// Pattern matching (e.g., *://*.example.com/*)
pattern := strings.ReplaceAll(rule, "*", ".*")
pattern = "^" + pattern + "$"
matched, err := regexp.MatchString(pattern, url)
if err != nil {
logger.Warnf(context.Background(), "Invalid pattern in blacklist: %s, error: %v", rule, err)
return false
}
return matched
}
// ConvertWebSearchResults converts WebSearchResult to SearchResult
func ConvertWebSearchResults(webResults []*types.WebSearchResult) []*types.SearchResult {
results := make([]*types.SearchResult, 0, len(webResults))
for i, webResult := range webResults {
// Use URL as ChunkID for web search results
chunkID := webResult.URL
if chunkID == "" {
chunkID = fmt.Sprintf("web_search_%d", i)
}
// Combine title and snippet as content
content := webResult.Title
if webResult.Snippet != "" {
if content != "" {
content += "\n\n" + webResult.Snippet
} else {
content = webResult.Snippet
}
}
if webResult.Content != "" {
if content != "" {
content += "\n\n" + webResult.Content
} else {
content = webResult.Content
}
}
// Set a default score for web search results (0.6, indicating medium relevance)
score := 0.6
result := &types.SearchResult{
ID: chunkID,
Content: content,
KnowledgeID: "", // Web search results don't have knowledge ID
ChunkIndex: 0,
KnowledgeTitle: webResult.Title,
StartAt: 0,
EndAt: len(content),
Seq: i,
Score: score,
MatchType: types.MatchTypeWebSearch,
SubChunkID: []string{},
Metadata: map[string]string{
"url": webResult.URL,
"source": webResult.Source,
"title": webResult.Title,
"snippet": webResult.Snippet,
},
ChunkType: "web_search",
ParentChunkID: "",
ImageInfo: "",
KnowledgeFilename: "",
KnowledgeSource: "web_search",
}
// Add published date to metadata if available
if webResult.PublishedAt != nil {
result.Metadata["published_at"] = webResult.PublishedAt.Format(time.RFC3339)
}
results = append(results, result)
}
return results
}
@@ -0,0 +1,32 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// BaiduProvider implements web search using Baidu API
type BaiduProvider struct {
apiURL string
}
// NewBaiduProvider creates a new Baidu provider
func NewBaiduProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &BaiduProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *BaiduProvider) Name() string {
return "baidu"
}
// Search performs a web search using Baidu API
func (p *BaiduProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Baidu search API
return nil, fmt.Errorf("baidu search provider is not yet implemented")
}
@@ -0,0 +1,32 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// BingProvider implements web search using Bing Search API
type BingProvider struct {
apiURL string
}
// NewBingProvider creates a new Bing provider
func NewBingProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &BingProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *BingProvider) Name() string {
return "bing"
}
// Search performs a web search using Bing Search API
func (p *BingProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Bing Search API
return nil, fmt.Errorf("bing search provider is not yet implemented")
}
@@ -0,0 +1,38 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// BochaProvider implements web search using Bocha API
type BochaProvider struct {
apiKey string
apiURL string
}
// NewBochaProvider creates a new Bocha provider
func NewBochaProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &BochaProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *BochaProvider) Name() string {
return "bocha"
}
// Search performs a web search using Bocha API
func (p *BochaProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Bocha search API
return nil, fmt.Errorf("bocha search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *BochaProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,38 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// ExaProvider implements web search using Exa API
type ExaProvider struct {
apiKey string
apiURL string
}
// NewExaProvider creates a new Exa provider
func NewExaProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &ExaProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *ExaProvider) Name() string {
return "exa"
}
// Search performs a web search using Exa API
func (p *ExaProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Exa search API
return nil, fmt.Errorf("exa search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *ExaProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,40 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// GoogleProvider implements web search using Google Custom Search API
type GoogleProvider struct {
apiKey string
searchEngineID string
apiURL string
}
// NewGoogleProvider creates a new Google provider
func NewGoogleProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &GoogleProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *GoogleProvider) Name() string {
return "google"
}
// Search performs a web search using Google Custom Search API
func (p *GoogleProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Google Custom Search API
return nil, fmt.Errorf("google search provider is not yet implemented")
}
// SetAPIKey sets the API key and search engine ID
func (p *GoogleProvider) SetAPIKey(apiKey, searchEngineID string) {
p.apiKey = apiKey
p.searchEngineID = searchEngineID
}
@@ -0,0 +1,112 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// KuaisouProvider implements web search using Kuaisou API
type KuaisouProvider struct {
apiKey string
apiURL string
client *http.Client
}
// NewKuaisouProvider creates a new Kuaisou provider
func NewKuaisouProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
if cfg.APIURL == "" {
return nil, fmt.Errorf("kuaisou API URL is required")
}
return &KuaisouProvider{
apiKey: "", // Will be set from tenant config
apiURL: cfg.APIURL,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
// Name returns the provider name
func (p *KuaisouProvider) Name() string {
return "kuaisou"
}
// Search performs a web search using Kuaisou API
func (p *KuaisouProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
if p.apiKey == "" {
return nil, fmt.Errorf("kuaisou API key is required")
}
// Prepare request
reqBody := map[string]interface{}{
"query": query,
"max_results": maxResults,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", p.apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
// Send request
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("kuaisou API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var response struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Convert to WebSearchResult
results := make([]*types.WebSearchResult, 0, len(response.Results))
for _, item := range response.Results {
results = append(results, &types.WebSearchResult{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Source: "kuaisou",
})
}
logger.Infof(ctx, "Kuaisou search returned %d results for query: %s", len(results), query)
return results, nil
}
// SetAPIKey sets the API key for the provider
func (p *KuaisouProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,32 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// SearxngProvider implements web search using Searxng API
type SearxngProvider struct {
apiURL string
}
// NewSearxngProvider creates a new Searxng provider
func NewSearxngProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &SearxngProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *SearxngProvider) Name() string {
return "searxng"
}
// Search performs a web search using Searxng API
func (p *SearxngProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Searxng search API
return nil, fmt.Errorf("searxng search provider is not yet implemented")
}
@@ -0,0 +1,38 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// TavilyProvider implements web search using Tavily API
type TavilyProvider struct {
apiKey string
apiURL string
}
// NewTavilyProvider creates a new Tavily provider
func NewTavilyProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &TavilyProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *TavilyProvider) Name() string {
return "tavily"
}
// Search performs a web search using Tavily API
func (p *TavilyProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Tavily search API
return nil, fmt.Errorf("tavily search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *TavilyProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,38 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// ZhipuProvider implements web search using Zhipu API
type ZhipuProvider struct {
apiKey string
apiURL string
}
// NewZhipuProvider creates a new Zhipu provider
func NewZhipuProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &ZhipuProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *ZhipuProvider) Name() string {
return "zhipu"
}
// Search performs a web search using Zhipu API
func (p *ZhipuProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Zhipu search API
return nil, fmt.Errorf("zhipu search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *ZhipuProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,33 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// BaiduProvider implements web search using Baidu API
type BaiduProvider struct {
apiURL string
}
// NewBaiduProvider creates a new Baidu provider
func NewBaiduProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &BaiduProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *BaiduProvider) Name() string {
return "baidu"
}
// Search performs a web search using Baidu API
func (p *BaiduProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Baidu search API
return nil, fmt.Errorf("baidu search provider is not yet implemented")
}
@@ -0,0 +1,33 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// BingProvider implements web search using Bing Search API
type BingProvider struct {
apiURL string
}
// NewBingProvider creates a new Bing provider
func NewBingProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &BingProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *BingProvider) Name() string {
return "bing"
}
// Search performs a web search using Bing Search API
func (p *BingProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Bing Search API
return nil, fmt.Errorf("bing search provider is not yet implemented")
}
@@ -0,0 +1,39 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// BochaProvider implements web search using Bocha API
type BochaProvider struct {
apiKey string
apiURL string
}
// NewBochaProvider creates a new Bocha provider
func NewBochaProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &BochaProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *BochaProvider) Name() string {
return "bocha"
}
// Search performs a web search using Bocha API
func (p *BochaProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Bocha search API
return nil, fmt.Errorf("bocha search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *BochaProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,39 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// ExaProvider implements web search using Exa API
type ExaProvider struct {
apiKey string
apiURL string
}
// NewExaProvider creates a new Exa provider
func NewExaProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &ExaProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *ExaProvider) Name() string {
return "exa"
}
// Search performs a web search using Exa API
func (p *ExaProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Exa search API
return nil, fmt.Errorf("exa search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *ExaProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,41 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// GoogleProvider implements web search using Google Custom Search API
type GoogleProvider struct {
apiKey string
searchEngineID string
apiURL string
}
// NewGoogleProvider creates a new Google provider
func NewGoogleProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &GoogleProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *GoogleProvider) Name() string {
return "google"
}
// Search performs a web search using Google Custom Search API
func (p *GoogleProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Google Custom Search API
return nil, fmt.Errorf("google search provider is not yet implemented")
}
// SetAPIKey sets the API key and search engine ID
func (p *GoogleProvider) SetAPIKey(apiKey, searchEngineID string) {
p.apiKey = apiKey
p.searchEngineID = searchEngineID
}
@@ -0,0 +1,113 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// KuaisouProvider implements web search using Kuaisou API
type KuaisouProvider struct {
apiKey string
apiURL string
client *http.Client
}
// NewKuaisouProvider creates a new Kuaisou provider
func NewKuaisouProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
if cfg.APIURL == "" {
return nil, fmt.Errorf("kuaisou API URL is required")
}
return &KuaisouProvider{
apiKey: "", // Will be set from tenant config
apiURL: cfg.APIURL,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
// Name returns the provider name
func (p *KuaisouProvider) Name() string {
return "kuaisou"
}
// Search performs a web search using Kuaisou API
func (p *KuaisouProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
if p.apiKey == "" {
return nil, fmt.Errorf("kuaisou API key is required")
}
// Prepare request
reqBody := map[string]interface{}{
"query": query,
"max_results": maxResults,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", p.apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
// Send request
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("kuaisou API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var response struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Convert to WebSearchResult
results := make([]*types.WebSearchResult, 0, len(response.Results))
for _, item := range response.Results {
results = append(results, &types.WebSearchResult{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Source: "kuaisou",
})
}
logger.Infof(ctx, "Kuaisou search returned %d results for query: %s", len(results), query)
return results, nil
}
// SetAPIKey sets the API key for the provider
func (p *KuaisouProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,33 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// SearxngProvider implements web search using Searxng API
type SearxngProvider struct {
apiURL string
}
// NewSearxngProvider creates a new Searxng provider
func NewSearxngProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &SearxngProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *SearxngProvider) Name() string {
return "searxng"
}
// Search performs a web search using Searxng API
func (p *SearxngProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Searxng search API
return nil, fmt.Errorf("searxng search provider is not yet implemented")
}
@@ -0,0 +1,39 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// TavilyProvider implements web search using Tavily API
type TavilyProvider struct {
apiKey string
apiURL string
}
// NewTavilyProvider creates a new Tavily provider
func NewTavilyProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &TavilyProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *TavilyProvider) Name() string {
return "tavily"
}
// Search performs a web search using Tavily API
func (p *TavilyProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Tavily search API
return nil, fmt.Errorf("tavily search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *TavilyProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
@@ -0,0 +1,39 @@
package service
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// ZhipuProvider implements web search using Zhipu API
type ZhipuProvider struct {
apiKey string
apiURL string
}
// NewZhipuProvider creates a new Zhipu provider
func NewZhipuProvider(cfg config.WebSearchProviderConfig) (WebSearchProvider, error) {
return &ZhipuProvider{
apiURL: cfg.APIURL,
}, nil
}
// Name returns the provider name
func (p *ZhipuProvider) Name() string {
return "zhipu"
}
// Search performs a web search using Zhipu API
func (p *ZhipuProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
// TODO: Implement Zhipu search API
return nil, fmt.Errorf("zhipu search provider is not yet implemented")
}
// SetAPIKey sets the API key for the provider
func (p *ZhipuProvider) SetAPIKey(apiKey string) {
p.apiKey = apiKey
}
+27
View File
@@ -24,6 +24,7 @@ type Config struct {
StreamManager *StreamManagerConfig `yaml:"stream_manager" json:"stream_manager"`
ExtractManager *ExtractManagerConfig `yaml:"extract" json:"extract"`
Agent *AgentGlobalConfig `yaml:"agent" json:"agent"`
WebSearch *WebSearchConfig `yaml:"web_search" json:"web_search"`
}
type DocReaderConfig struct {
@@ -198,3 +199,29 @@ type AgentGlobalConfig struct {
ReflectionEnabled bool `yaml:"reflection_enabled" json:"reflection_enabled"`
DefaultTools []string `yaml:"default_tools" json:"default_tools"`
}
// WebSearchConfig represents the web search configuration
type WebSearchConfig struct {
Providers []WebSearchProviderConfig `yaml:"providers" json:"providers"`
Default WebSearchDefaultConfig `yaml:"default" json:"default"`
Timeout int `yaml:"timeout" json:"timeout"` // 超时时间(秒)
}
// WebSearchProviderConfig represents configuration for a web search provider
type WebSearchProviderConfig struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Free bool `yaml:"free" json:"free"`
RequiresAPIKey bool `yaml:"requires_api_key" json:"requires_api_key"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
APIURL string `yaml:"api_url,omitempty" json:"api_url,omitempty"`
}
// WebSearchDefaultConfig represents the default web search configuration
type WebSearchDefaultConfig struct {
Provider string `yaml:"provider" json:"provider"`
MaxResults int `yaml:"max_results" json:"max_results"`
IncludeDate bool `yaml:"include_date" json:"include_date"`
CompressionMethod string `yaml:"compression_method" json:"compression_method"`
Blacklist []string `yaml:"blacklist" json:"blacklist"`
}
+8
View File
@@ -119,6 +119,13 @@ func BuildContainer(container *dig.Container) *dig.Container {
// Session service (depends on agent service)
must(container.Provide(service.NewSessionService))
// Web search service
must(container.Provide(service.NewWebSearchService))
// Register WebSearchService as interface for dependency injection
must(container.Provide(func(ws *service.WebSearchService) interfaces.WebSearchService {
return ws
}))
// Chat pipeline components for processing chat requests
must(container.Provide(chatpipline.NewEventManager))
must(container.Invoke(chatpipline.NewPluginTracing))
@@ -148,6 +155,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(handler.NewAuthHandler))
must(container.Provide(handler.NewSystemHandler))
must(container.Provide(handler.NewMCPServiceHandler))
must(container.Provide(handler.NewWebSearchHandler))
// Router configuration
must(container.Provide(router.NewRouter))
+4 -3
View File
@@ -118,7 +118,7 @@ func (h *Handler) KnowledgeQA(c *gin.Context) {
}
// Use shared function to handle KnowledgeQA request
h.handleKnowledgeQARequest(ctx, c, session, request.Query, knowledgeBaseIDs, assistantMessage, true, request.SummaryModelID)
h.handleKnowledgeQARequest(ctx, c, session, request.Query, knowledgeBaseIDs, assistantMessage, true, request.SummaryModelID, request.WebSearchEnabled)
}
// AgentQA handles agent-based question answering with conversation history and streaming
@@ -256,7 +256,7 @@ func (h *Handler) AgentQA(c *gin.Context) {
logger.Infof(ctx, "Delegating to KnowledgeQA with knowledge bases: %v", knowledgeBaseIDs)
// Use shared function to handle KnowledgeQA request (no title generation for AgentQA fallback)
h.handleKnowledgeQARequest(ctx, c, session, request.Query, knowledgeBaseIDs, assistantMessage, false, request.SummaryModelID)
h.handleKnowledgeQARequest(ctx, c, session, request.Query, knowledgeBaseIDs, assistantMessage, false, request.SummaryModelID, request.WebSearchEnabled)
return
}
@@ -353,6 +353,7 @@ func (h *Handler) handleKnowledgeQARequest(
assistantMessage *types.Message,
generateTitle bool, // Whether to generate title if session has no title
summaryModelID string, // Optional summary model ID (overrides session default)
webSearchEnabled bool, // Whether web search is enabled
) {
sessionID := session.ID
requestID := getRequestID(c)
@@ -421,7 +422,7 @@ func (h *Handler) handleKnowledgeQARequest(
logger.ErrorWithFields(asyncCtx, errors.NewInternalServerError("Knowledge QA service panicked"), nil)
}
}()
err := h.sessionService.KnowledgeQA(asyncCtx, session, query, knowledgeBaseIDs, assistantMessage.ID, summaryModelID, eventBus)
err := h.sessionService.KnowledgeQA(asyncCtx, session, query, knowledgeBaseIDs, assistantMessage.ID, summaryModelID, webSearchEnabled, eventBus)
if err != nil {
logger.ErrorWithFields(asyncCtx, err, nil)
// Emit error event to dedicated EventBus
+1
View File
@@ -56,6 +56,7 @@ type CreateKnowledgeQARequest struct {
Query string `json:"query" binding:"required"` // Query text for knowledge base search
KnowledgeBaseIDs []string `json:"knowledge_base_ids"` // Selected knowledge base ID for this request
AgentEnabled bool `json:"agent_enabled"` // Whether agent mode is enabled for this request
WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this request
SummaryModelID string `json:"summary_model_id"` // Optional summary model ID for this request (overrides session default)
MCPServiceIDs []string `json:"mcp_service_ids"` // Optional MCP service IDs to use in agent mode
}
+142
View File
@@ -410,3 +410,145 @@ func (h *TenantHandler) UpdateTenantAgentConfig(c *gin.Context) {
"message": "Agent configuration updated successfully",
})
}
// GetTenantWebSearchConfig returns the web search configuration for a tenant
// Tenant ID is obtained from the authentication context
func (h *TenantHandler) GetTenantWebSearchConfig(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start getting tenant web search config")
// Get tenant ID from authentication context
tenantID := c.GetUint(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
// Get tenant
tenant, err := h.service.GetTenantByID(ctx, tenantID)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to retrieve tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to retrieve tenant").WithDetails(err.Error()))
}
return
}
// Hide API key in response
var responseConfig *types.WebSearchConfig
if tenant.WebSearchConfig != nil {
responseConfig = &types.WebSearchConfig{
Provider: tenant.WebSearchConfig.Provider,
APIKey: "", // Hide API key
MaxResults: tenant.WebSearchConfig.MaxResults,
IncludeDate: tenant.WebSearchConfig.IncludeDate,
CompressionMethod: tenant.WebSearchConfig.CompressionMethod,
Blacklist: tenant.WebSearchConfig.Blacklist,
EmbeddingModelID: tenant.WebSearchConfig.EmbeddingModelID,
EmbeddingDimension: tenant.WebSearchConfig.EmbeddingDimension,
RerankModelID: tenant.WebSearchConfig.RerankModelID,
DocumentFragments: tenant.WebSearchConfig.DocumentFragments,
}
// If API key exists, show a masked version
if tenant.WebSearchConfig.APIKey != "" {
responseConfig.APIKey = "***" // Masked API key
}
}
logger.Infof(ctx, "Tenant web search config retrieved successfully, Tenant ID: %d", tenantID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responseConfig,
})
}
// UpdateTenantWebSearchConfig updates the web search configuration for a tenant
// Tenant ID is obtained from the authentication context
func (h *TenantHandler) UpdateTenantWebSearchConfig(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating tenant web search config")
// Get tenant ID from authentication context
tenantID := c.GetUint(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
var req types.WebSearchConfig
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
// Validate configuration
if req.MaxResults < 1 || req.MaxResults > 50 {
c.Error(errors.NewBadRequestError("max_results must be between 1 and 50"))
return
}
// Get existing tenant
tenant, err := h.service.GetTenantByID(ctx, tenantID)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to retrieve tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to retrieve tenant").WithDetails(err.Error()))
}
return
}
// Update web search configuration
// If API key is "***", keep the existing API key
if req.APIKey == "***" && tenant.WebSearchConfig != nil {
req.APIKey = tenant.WebSearchConfig.APIKey
}
tenant.WebSearchConfig = &req
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to update tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update tenant web search config").WithDetails(err.Error()))
}
return
}
// Hide API key in response
responseConfig := &types.WebSearchConfig{
Provider: updatedTenant.WebSearchConfig.Provider,
APIKey: "", // Hide API key
MaxResults: updatedTenant.WebSearchConfig.MaxResults,
IncludeDate: updatedTenant.WebSearchConfig.IncludeDate,
CompressionMethod: updatedTenant.WebSearchConfig.CompressionMethod,
Blacklist: updatedTenant.WebSearchConfig.Blacklist,
EmbeddingModelID: updatedTenant.WebSearchConfig.EmbeddingModelID,
EmbeddingDimension: updatedTenant.WebSearchConfig.EmbeddingDimension,
RerankModelID: updatedTenant.WebSearchConfig.RerankModelID,
DocumentFragments: updatedTenant.WebSearchConfig.DocumentFragments,
}
if updatedTenant.WebSearchConfig.APIKey != "" {
responseConfig.APIKey = "***" // Masked API key
}
logger.Infof(ctx, "Tenant web search config updated successfully, Tenant ID: %d", tenantID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responseConfig,
"message": "Web search configuration updated successfully",
})
}
+63
View File
@@ -0,0 +1,63 @@
package handler
import (
"net/http"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// WebSearchHandler handles web search related requests
type WebSearchHandler struct {
cfg *config.Config
}
// NewWebSearchHandler creates a new web search handler
func NewWebSearchHandler(cfg *config.Config) *WebSearchHandler {
return &WebSearchHandler{
cfg: cfg,
}
}
// GetProviders returns the list of available web search providers
// @Summary Get available web search providers
// @Description Returns the list of available web search providers from configuration
// @Tags web-search
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "List of providers"
// @Router /api/v1/web-search/providers [get]
func (h *WebSearchHandler) GetProviders(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Getting web search providers")
if h.cfg.WebSearch == nil || len(h.cfg.WebSearch.Providers) == 0 {
logger.Warn(ctx, "No web search providers configured")
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []types.WebSearchProviderInfo{},
})
return
}
// Convert config providers to API response format
providers := make([]types.WebSearchProviderInfo, 0, len(h.cfg.WebSearch.Providers))
for _, provider := range h.cfg.WebSearch.Providers {
providers = append(providers, types.WebSearchProviderInfo{
ID: provider.ID,
Name: provider.Name,
Free: provider.Free,
RequiresAPIKey: provider.RequiresAPIKey,
Description: provider.Description,
APIURL: provider.APIURL,
})
}
logger.Infof(ctx, "Returning %d web search providers", len(providers))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": providers,
})
}
+16
View File
@@ -40,6 +40,7 @@ type RouterParams struct {
InitializationHandler *handler.InitializationHandler
SystemHandler *handler.SystemHandler
MCPServiceHandler *handler.MCPServiceHandler
WebSearchHandler *handler.WebSearchHandler
}
// NewRouter 创建新的路由
@@ -87,6 +88,7 @@ func NewRouter(params RouterParams) *gin.Engine {
RegisterInitializationRoutes(v1, params.InitializationHandler)
RegisterSystemRoutes(v1, params.SystemHandler)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler)
RegisterWebSearchRoutes(v1, params.WebSearchHandler)
}
return r
@@ -224,6 +226,10 @@ func RegisterTenantRoutes(r *gin.RouterGroup, handler *handler.TenantHandler) {
// Tenant ID is obtained from authentication context
tenantRoutes.GET("/agent-config", handler.GetTenantAgentConfig)
tenantRoutes.PUT("/agent-config", handler.UpdateTenantAgentConfig)
// Web search configuration management (tenant-level)
// Tenant ID is obtained from authentication context
tenantRoutes.GET("/web-search-config", handler.GetTenantWebSearchConfig)
tenantRoutes.PUT("/web-search-config", handler.UpdateTenantWebSearchConfig)
}
}
@@ -319,3 +325,13 @@ func RegisterMCPServiceRoutes(r *gin.RouterGroup, handler *handler.MCPServiceHan
mcpServices.GET("/:id/resources", handler.GetMCPServiceResources)
}
}
// RegisterWebSearchRoutes registers web search routes
func RegisterWebSearchRoutes(r *gin.RouterGroup, webSearchHandler *handler.WebSearchHandler) {
// Web search providers
webSearch := r.Group("/web-search")
{
// Get available providers
webSearch.GET("/providers", webSearchHandler.GetProviders)
}
}
+4
View File
@@ -37,6 +37,10 @@ type ChatManage struct {
// Event system for streaming responses
EventBus EventBusInterface `json:"-"` // EventBus for emitting streaming events
MessageID string `json:"-"` // Assistant message ID for event emission
// Web search configuration (internal use)
TenantID uint `json:"-"` // Tenant ID for retrieving web search config
WebSearchEnabled bool `json:"-"` // Whether web search is enabled for this request
}
// Clone creates a deep copy of the ChatManage object
+1
View File
@@ -20,6 +20,7 @@ const (
MatchTypeParentChunk // 父Chunk匹配类型
MatchTypeRelationChunk // 关系Chunk匹配类型
MatchTypeGraph
MatchTypeWebSearch // 网络搜索匹配类型
)
// IndexInfo contains information about indexed content
+2 -1
View File
@@ -29,9 +29,10 @@ type SessionService interface {
// KnowledgeQA performs knowledge-based question answering
// knowledgeBaseIDs: list of knowledge base IDs to search (supports multi-KB)
// summaryModelID: optional summary model ID override (if empty, uses session/KB default)
// webSearchEnabled: whether to enable web search to supplement knowledge base results
// Events are emitted through eventBus (references, answer chunks, completion)
KnowledgeQA(ctx context.Context,
session *types.Session, query string, knowledgeBaseIDs []string, assistantMessageID string, summaryModelID string, eventBus *event.EventBus,
session *types.Session, query string, knowledgeBaseIDs []string, assistantMessageID string, summaryModelID string, webSearchEnabled bool, eventBus *event.EventBus,
) error
// KnowledgeQAByEvent performs knowledge-based question answering by event
KnowledgeQAByEvent(ctx context.Context, chatManage *types.ChatManage, eventList []types.EventType) error
+12
View File
@@ -0,0 +1,12 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchService defines the interface for web search service
type WebSearchService interface {
Search(ctx context.Context, config *types.WebSearchConfig, query string) ([]*types.WebSearchResult, error)
}
+2
View File
@@ -32,6 +32,8 @@ type Tenant struct {
AgentConfig *AgentConfig `yaml:"agent_config" json:"agent_config" gorm:"type:jsonb"`
// Global Context configuration for this tenant (default for all sessions)
ContextConfig *ContextConfig `yaml:"context_config" json:"context_config" gorm:"type:jsonb"`
// Global WebSearch configuration for this tenant
WebSearchConfig *WebSearchConfig `yaml:"web_search_config" json:"web_search_config" gorm:"type:jsonb"`
// Creation time
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
// Last updated time
+59
View File
@@ -0,0 +1,59 @@
package types
import (
"database/sql/driver"
"encoding/json"
"time"
)
// WebSearchConfig represents the web search configuration for a tenant
type WebSearchConfig struct {
Provider string `json:"provider"` // 搜索引擎提供商ID
APIKey string `json:"api_key"` // API密钥(如果需要)
MaxResults int `json:"max_results"` // 最大搜索结果数
IncludeDate bool `json:"include_date"` // 是否包含日期
CompressionMethod string `json:"compression_method"` // 压缩方法:none, summary, extract, rag
Blacklist []string `json:"blacklist"` // 黑名单规则列表
// RAG压缩相关配置
EmbeddingModelID string `json:"embedding_model_id,omitempty"` // 嵌入模型ID(用于RAG压缩)
EmbeddingDimension int `json:"embedding_dimension,omitempty"` // 嵌入维度(用于RAG压缩)
RerankModelID string `json:"rerank_model_id,omitempty"` // 重排模型ID(用于RAG压缩)
DocumentFragments int `json:"document_fragments,omitempty"` // 文档片段数量(用于RAG压缩)
}
// Value implements driver.Valuer interface for WebSearchConfig
func (c WebSearchConfig) Value() (driver.Value, error) {
return json.Marshal(c)
}
// Scan implements sql.Scanner interface for WebSearchConfig
func (c *WebSearchConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(b, c)
}
// WebSearchResult represents a single web search result
type WebSearchResult struct {
Title string `json:"title"` // 搜索结果标题
URL string `json:"url"` // 结果URL
Snippet string `json:"snippet"` // 摘要片段
Content string `json:"content"` // 完整内容(可选,需要额外抓取)
Source string `json:"source"` // 来源(如:kuaisou, baidu等)
PublishedAt *time.Time `json:"published_at,omitempty"` // 发布时间(如果有)
}
// WebSearchProviderInfo represents information about a web search provider
type WebSearchProviderInfo struct {
ID string `json:"id"` // 提供商ID
Name string `json:"name"` // 提供商名称
Free bool `json:"free"` // 是否免费
RequiresAPIKey bool `json:"requires_api_key"` // 是否需要API密钥
Description string `json:"description"` // 描述
APIURL string `json:"api_url,omitempty"` // API地址(可选)
}
@@ -0,0 +1,6 @@
-- Add web_search_config column to tenants table
-- This migration adds support for web search configuration at tenant level
ALTER TABLE tenants
ADD COLUMN web_search_config JSON DEFAULT NULL COMMENT 'Web search configuration for the tenant';
@@ -0,0 +1,6 @@
-- Add web_search_config column to tenants table
-- This migration adds support for web search configuration at tenant level
ALTER TABLE tenants
ADD COLUMN web_search_config JSONB DEFAULT NULL;
@@ -0,0 +1,11 @@
-- 000005_add_web_search_config.down.sql
-- Remove web_search_config column from tenants table
BEGIN;
-- Remove web_search_config column
ALTER TABLE tenants
DROP COLUMN IF EXISTS web_search_config;
COMMIT;
@@ -0,0 +1,14 @@
-- 000005_add_web_search_config.up.sql
-- Add web_search_config column to tenants table for web search configuration
BEGIN;
-- Add web_search_config column to tenants table
ALTER TABLE tenants
ADD COLUMN web_search_config JSONB DEFAULT NULL;
-- Add comment
COMMENT ON COLUMN tenants.web_search_config IS 'Web search configuration for the tenant';
COMMIT;