feat: Update web search providers and enhance knowledge base management

- Added DuckDuckGo as a new web search provider in the configuration.
- Removed deprecated web search providers (Baidu, Bing, Bocha, Exa, Google, Kuaisou, Searxng, Tavily, Zhipu) to streamline the codebase.
- Enhanced knowledge base management by introducing temporary knowledge base handling in the session service.
- Updated the web search service to support RAG-based compression for improved search result processing.
- Refactored session and knowledge service interfaces to accommodate new functionalities.
This commit is contained in:
wizardchen
2025-11-10 22:45:27 +08:00
parent ac8c8e9af2
commit 244aaab328
40 changed files with 968 additions and 1027 deletions
+5 -46
View File
@@ -618,57 +618,16 @@ agent:
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"
- id: "duckduckgo"
name: "DuckDuckGo"
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"
description: "DuckDuckGo API"
# 默认配置
default:
provider: "kuaisou"
provider: "duckduckgo"
max_results: 5
include_date: true
compression_method: "none"
@@ -44,7 +44,7 @@
</div>
<!-- Answer Event -->
<div v-else-if="event.type === 'answer'" class="answer-event">
<div v-else-if="event.type === 'answer' && event.content && event.content.trim()" class="answer-event">
<div
class="answer-content-wrapper"
:class="{
@@ -32,9 +32,6 @@
<div class="provider-option">
<span class="provider-name">{{ provider.name }}</span>
</div>
<div v-if="provider.description" class="provider-desc">
{{ provider.description }}
</div>
</div>
</t-option>
</t-select>
+2
View File
@@ -5,6 +5,7 @@ go 1.24.0
toolchain go1.24.2
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/elastic/go-elasticsearch/v7 v7.17.10
github.com/elastic/go-elasticsearch/v8 v8.18.0
github.com/gin-contrib/cors v1.7.5
@@ -45,6 +46,7 @@ require (
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
+69
View File
@@ -4,9 +4,13 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -103,6 +107,7 @@ github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg6
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
@@ -285,6 +290,7 @@ github.com/yanyiwu/gojieba v1.4.5 h1:VyZogGtdFSnJbACHvDRvDreXPPVPCg8axKFUdblU/JI
github.com/yanyiwu/gojieba v1.4.5/go.mod h1:JUq4DddFVGdHXJHxxepxRmhrKlDpaBxR8O28v6fKYLY=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
@@ -315,22 +321,85 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
@@ -52,7 +52,7 @@ func (r *knowledgeBaseRepository) ListKnowledgeBasesByTenantID(
ctx context.Context, tenantID uint,
) ([]*types.KnowledgeBase, error) {
var kbs []*types.KnowledgeBase
if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).
if err := r.db.WithContext(ctx).Where("tenant_id = ? AND is_temporary = ?", tenantID, false).
Order("created_at DESC").Find(&kbs).Error; err != nil {
return nil, err
}
@@ -2,6 +2,7 @@ package chatpipline
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
@@ -11,30 +12,37 @@ import (
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/redis/go-redis/v9"
)
// PluginSearch implements search functionality for chat pipeline
type PluginSearch struct {
knowledgeBaseService interfaces.KnowledgeBaseService
knowledgeService interfaces.KnowledgeService
modelService interfaces.ModelService
config *config.Config
webSearchService interfaces.WebSearchService
tenantService interfaces.TenantService
redisClient *redis.Client
}
func NewPluginSearch(eventManager *EventManager,
knowledgeBaseService interfaces.KnowledgeBaseService,
knowledgeService interfaces.KnowledgeService,
modelService interfaces.ModelService,
config *config.Config,
webSearchService interfaces.WebSearchService,
tenantService interfaces.TenantService,
redisClient *redis.Client,
) *PluginSearch {
res := &PluginSearch{
knowledgeBaseService: knowledgeBaseService,
knowledgeService: knowledgeService,
modelService: modelService,
config: config,
webSearchService: webSearchService,
tenantService: tenantService,
redisClient: redisClient,
}
eventManager.Register(res)
return res
@@ -62,44 +70,37 @@ func (p *PluginSearch) OnEvent(ctx context.Context,
return ErrSearch.WithError(nil)
}
// Run KB search and web search concurrently
logger.Infof(ctx, "Searching across %d knowledge base(s): %v", len(knowledgeBaseIDs), knowledgeBaseIDs)
// Prepare search parameters
searchParams := types.SearchParams{
QueryText: strings.TrimSpace(chatManage.RewriteQuery),
VectorThreshold: chatManage.VectorThreshold,
KeywordThreshold: chatManage.KeywordThreshold,
MatchCount: chatManage.EmbeddingTopK,
}
logger.Infof(ctx, "Search parameters: %v", searchParams)
// Parallel search across multiple knowledge bases
var wg sync.WaitGroup
var mu sync.Mutex
var allResults []*types.SearchResult
for _, kbID := range knowledgeBaseIDs {
wg.Add(1)
go func(knowledgeBaseID string) {
defer wg.Done()
results, err := p.knowledgeBaseService.HybridSearch(ctx, knowledgeBaseID, searchParams)
if err != nil {
logger.Errorf(ctx, "Failed to search KB %s: %v", knowledgeBaseID, err)
return
}
logger.Infof(ctx, "KB %s search results count: %d", knowledgeBaseID, len(results))
allResults := make([]*types.SearchResult, 0)
wg.Add(2)
// Goroutine 1: Knowledge base search (rewrite + processed)
go func() {
defer wg.Done()
kbResults := p.searchKnowledgeBases(ctx, knowledgeBaseIDs, chatManage)
if len(kbResults) > 0 {
mu.Lock()
allResults = append(allResults, results...)
allResults = append(allResults, kbResults...)
mu.Unlock()
}(kbID)
}
}
}()
// Goroutine 2: Web search (if enabled)
go func() {
defer wg.Done()
webResults := p.searchWebIfEnabled(ctx, chatManage)
if len(webResults) > 0 {
mu.Lock()
allResults = append(allResults, webResults...)
mu.Unlock()
}
}()
wg.Wait()
logger.Infof(ctx, "Total search results from all KBs: %d", len(allResults))
chatManage.SearchResult = allResults
// Add relevant results from chat history
@@ -109,67 +110,6 @@ func (p *PluginSearch) OnEvent(ctx context.Context,
chatManage.SearchResult = append(chatManage.SearchResult, historyResult...)
}
// Try search with processed query if different from rewrite query
if chatManage.RewriteQuery != chatManage.ProcessedQuery {
searchParams.QueryText = strings.TrimSpace(chatManage.ProcessedQuery)
logger.Infof(ctx, "Searching with processed query: %s", searchParams.QueryText)
var wg2 sync.WaitGroup
var mu2 sync.Mutex
var processedResults []*types.SearchResult
for _, kbID := range knowledgeBaseIDs {
wg2.Add(1)
go func(knowledgeBaseID string) {
defer wg2.Done()
results, err := p.knowledgeBaseService.HybridSearch(ctx, knowledgeBaseID, searchParams)
if err != nil {
logger.Errorf(ctx, "Failed to search KB %s with processed query: %v", knowledgeBaseID, err)
return
}
logger.Infof(ctx, "KB %s processed query results count: %d", knowledgeBaseID, len(results))
mu2.Lock()
processedResults = append(processedResults, results...)
mu2.Unlock()
}(kbID)
}
wg2.Wait()
logger.Infof(ctx, "Total processed query results from all KBs: %d", len(processedResults))
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)
@@ -216,6 +156,136 @@ func removeDuplicateResults(results []*types.SearchResult) []*types.SearchResult
return uniqueResults
}
// searchKnowledgeBases performs KB searches for rewrite and processed queries across KB IDs
func (p *PluginSearch) searchKnowledgeBases(ctx context.Context, knowledgeBaseIDs []string, chatManage *types.ChatManage) []*types.SearchResult {
// Build base params for rewrite query
baseParams := types.SearchParams{
QueryText: strings.TrimSpace(chatManage.RewriteQuery),
VectorThreshold: chatManage.VectorThreshold,
KeywordThreshold: chatManage.KeywordThreshold,
MatchCount: chatManage.EmbeddingTopK,
}
var wg sync.WaitGroup
var mu sync.Mutex
var results []*types.SearchResult
// Search with rewrite query
for _, kbID := range knowledgeBaseIDs {
wg.Add(1)
go func(knowledgeBaseID string) {
defer wg.Done()
res, err := p.knowledgeBaseService.HybridSearch(ctx, knowledgeBaseID, baseParams)
if err != nil {
logger.Errorf(ctx, "Failed to search KB %s: %v", knowledgeBaseID, err)
return
}
logger.Infof(ctx, "KB %s search results count: %d", knowledgeBaseID, len(res))
mu.Lock()
results = append(results, res...)
mu.Unlock()
}(kbID)
}
wg.Wait()
// If processed query differs, search again
if chatManage.RewriteQuery != chatManage.ProcessedQuery {
paramsProcessed := baseParams
paramsProcessed.QueryText = strings.TrimSpace(chatManage.ProcessedQuery)
logger.Infof(ctx, "Searching with processed query: %s", paramsProcessed.QueryText)
wg = sync.WaitGroup{}
for _, kbID := range knowledgeBaseIDs {
wg.Add(1)
go func(knowledgeBaseID string) {
defer wg.Done()
res, err := p.knowledgeBaseService.HybridSearch(ctx, knowledgeBaseID, paramsProcessed)
if err != nil {
logger.Errorf(ctx, "Failed to search KB %s with processed query: %v", knowledgeBaseID, err)
return
}
logger.Infof(ctx, "KB %s processed query results count: %d", knowledgeBaseID, len(res))
mu.Lock()
results = append(results, res...)
mu.Unlock()
}(kbID)
}
wg.Wait()
}
logger.Infof(ctx, "Total KB results (rewrite + processed): %d", len(results))
return results
}
// searchWebIfEnabled executes web search when enabled and returns converted results
func (p *PluginSearch) searchWebIfEnabled(ctx context.Context, chatManage *types.ChatManage) []*types.SearchResult {
if !(chatManage.WebSearchEnabled && p.webSearchService != nil && p.tenantService != nil && chatManage.TenantID > 0) {
return nil
}
tenant := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil || tenant.WebSearchConfig == nil || tenant.WebSearchConfig.Provider == "" {
logger.Warnf(ctx, "Web search enabled but no valid configuration found for tenant %d", chatManage.TenantID)
return nil
}
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)
return nil
}
// Build questions (rewrite + processed if different)
questions := []string{strings.TrimSpace(chatManage.RewriteQuery)}
if chatManage.ProcessedQuery != "" && chatManage.ProcessedQuery != chatManage.RewriteQuery {
questions = append(questions, strings.TrimSpace(chatManage.ProcessedQuery))
}
// Load session-scoped temp KB state from Redis
var tempKBID string
seen := map[string]bool{}
ids := []string{}
stateKey := fmt.Sprintf("tempkb:%s", chatManage.SessionID)
if raw, getErr := p.redisClient.Get(ctx, stateKey).Bytes(); getErr == nil && len(raw) > 0 {
var state struct {
KBID string `json:"kbID"`
KnowledgeIDs []string `json:"knowledgeIDs"`
SeenURLs map[string]bool `json:"seenURLs"`
}
if err := json.Unmarshal(raw, &state); err == nil {
tempKBID = state.KBID
ids = state.KnowledgeIDs
if state.SeenURLs != nil {
seen = state.SeenURLs
}
}
}
compressed, kbID, newSeen, newIDs, err := p.webSearchService.CompressWithRAG(
ctx, chatManage.SessionID, tempKBID, questions, webResults, tenant.WebSearchConfig,
p.knowledgeBaseService, p.knowledgeService, seen, ids,
)
if err != nil {
logger.Warnf(ctx, "RAG compression failed, falling back to raw: %v", err)
} else {
webResults = compressed
// Persist temp KB state back into Redis
state := struct {
KBID string `json:"kbID"`
KnowledgeIDs []string `json:"knowledgeIDs"`
SeenURLs map[string]bool `json:"seenURLs"`
}{
KBID: kbID,
KnowledgeIDs: newIDs,
SeenURLs: newSeen,
}
if b, mErr := json.Marshal(state); mErr == nil {
_ = p.redisClient.Set(ctx, stateKey, b, 0).Err()
}
}
res := convertWebSearchResults(webResults)
logger.Infof(ctx, "Web search returned %d results", len(res))
return res
}
// 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 {
+35 -7
View File
@@ -351,7 +351,26 @@ func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
func (s *knowledgeService) CreateKnowledgeFromPassage(ctx context.Context,
kbID string, passage []string,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating knowledge from passage")
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, false)
}
// CreateKnowledgeFromPassageSync creates a knowledge entry from text passages and waits for indexing to complete.
func (s *knowledgeService) CreateKnowledgeFromPassageSync(ctx context.Context,
kbID string, passage []string,
) (*types.Knowledge, error) {
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, true)
}
// createKnowledgeFromPassageInternal consolidates the common logic for creating knowledge from passages.
// When syncMode is true, chunk processing is performed synchronously; otherwise, it's processed asynchronously.
func (s *knowledgeService) createKnowledgeFromPassageInternal(ctx context.Context,
kbID string, passage []string, syncMode bool,
) (*types.Knowledge, error) {
if syncMode {
logger.Info(ctx, "Start creating knowledge from passage (sync)")
} else {
logger.Info(ctx, "Start creating knowledge from passage")
}
logger.Infof(ctx, "Knowledge base ID: %s, passage count: %d", kbID, len(passage))
// 验证段落内容安全性
@@ -374,7 +393,11 @@ func (s *knowledgeService) CreateKnowledgeFromPassage(ctx context.Context,
}
// Create knowledge record
logger.Info(ctx, "Creating knowledge record")
if syncMode {
logger.Info(ctx, "Creating knowledge record (sync)")
} else {
logger.Info(ctx, "Creating knowledge record")
}
knowledge := &types.Knowledge{
ID: uuid.New().String(),
TenantID: ctx.Value(types.TenantIDContextKey).(uint),
@@ -394,11 +417,16 @@ func (s *knowledgeService) CreateKnowledgeFromPassage(ctx context.Context,
return nil, err
}
// Process passages asynchronously
logger.Info(ctx, "Starting asynchronous passage processing")
go s.processDocumentFromPassage(ctx, kb, knowledge, safePassages)
logger.Infof(ctx, "Knowledge from passage created successfully, ID: %s", knowledge.ID)
// Process passages
if syncMode {
logger.Info(ctx, "Processing passage synchronously")
s.processDocumentFromPassage(ctx, kb, knowledge, safePassages)
logger.Infof(ctx, "Knowledge from passage created successfully (sync), ID: %s", knowledge.ID)
} else {
logger.Info(ctx, "Starting asynchronous passage processing")
go s.processDocumentFromPassage(ctx, kb, knowledge, safePassages)
logger.Infof(ctx, "Knowledge from passage created successfully, ID: %s", knowledge.ID)
}
return knowledge, nil
}
+32
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -16,6 +17,7 @@ import (
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
@@ -36,6 +38,8 @@ type sessionService struct {
eventManager *chatpipline.EventManager // Event manager for chat pipeline
agentService interfaces.AgentService // Service for agent operations
sessionStorage llmcontext.ContextStorage // Session storage
knowledgeService interfaces.KnowledgeService // Service for knowledge operations
redisClient *redis.Client // Redis client for temp KB state
}
// NewSessionService creates a new session service instance with all required dependencies
@@ -43,22 +47,26 @@ func NewSessionService(cfg *config.Config,
sessionRepo interfaces.SessionRepository,
messageRepo interfaces.MessageRepository,
knowledgeBaseService interfaces.KnowledgeBaseService,
knowledgeService interfaces.KnowledgeService,
modelService interfaces.ModelService,
tenantService interfaces.TenantService,
eventManager *chatpipline.EventManager,
agentService interfaces.AgentService,
sessionStorage llmcontext.ContextStorage,
redisClient *redis.Client,
) interfaces.SessionService {
return &sessionService{
cfg: cfg,
sessionRepo: sessionRepo,
messageRepo: messageRepo,
knowledgeBaseService: knowledgeBaseService,
knowledgeService: knowledgeService,
modelService: modelService,
tenantService: tenantService,
eventManager: eventManager,
agentService: agentService,
sessionStorage: sessionStorage,
redisClient: redisClient,
}
}
@@ -202,6 +210,30 @@ func (s *sessionService) DeleteSession(ctx context.Context, id string) error {
tenantID := ctx.Value(types.TenantIDContextKey).(uint)
logger.Infof(ctx, "Deleting session, ID: %s, tenant ID: %d", id, tenantID)
// Cleanup temporary KB stored in Redis for this session
if s.redisClient != nil {
stateKey := fmt.Sprintf("tempkb:%s", id)
if raw, getErr := s.redisClient.Get(ctx, stateKey).Bytes(); getErr == nil && len(raw) > 0 {
var state struct {
KBID string `json:"kbID"`
KnowledgeIDs []string `json:"knowledgeIDs"`
SeenURLs map[string]bool `json:"seenURLs"`
}
if err := json.Unmarshal(raw, &state); err == nil && strings.TrimSpace(state.KBID) != "" {
logger.Infof(ctx, "Cleaning temporary KB for session %s: %s", id, state.KBID)
for _, kid := range state.KnowledgeIDs {
if delErr := s.knowledgeService.DeleteKnowledge(ctx, kid); delErr != nil {
logger.Warnf(ctx, "Failed to delete temp knowledge %s: %v", kid, delErr)
}
}
if delErr := s.knowledgeBaseService.DeleteKnowledgeBase(ctx, state.KBID); delErr != nil {
logger.Warnf(ctx, "Failed to delete temp knowledge base %s: %v", state.KBID, delErr)
}
_ = s.redisClient.Del(ctx, stateKey).Err()
}
}
}
// Delete session from repository
err := s.sessionRepo.Delete(ctx, tenantID, id)
if err != nil {
+228 -59
View File
@@ -7,23 +7,238 @@ import (
"strings"
"time"
"github.com/Tencent/WeKnora/internal/application/service/web_search"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// 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
providers map[string]interfaces.WebSearchProvider
config *config.WebSearchConfig
}
// CompressWithRAG performs RAG-based compression using a temporary, hidden knowledge base.
// The temporary knowledge base is deleted after use. The UI will not list it due to repo filtering.
func (s *WebSearchService) CompressWithRAG(
ctx context.Context, sessionID string, tempKBID string, questions []string,
webSearchResults []*types.WebSearchResult, cfg *types.WebSearchConfig,
kbSvc interfaces.KnowledgeBaseService, knowSvc interfaces.KnowledgeService,
seenURLs map[string]bool, knowledgeIDs []string,
) (compressed []*types.WebSearchResult, kbID string, newSeen map[string]bool, newIDs []string, err error) {
if len(webSearchResults) == 0 || len(questions) == 0 {
return
}
if cfg == nil {
return nil, tempKBID, seenURLs, knowledgeIDs, fmt.Errorf("web search config is required for RAG compression")
}
if cfg.EmbeddingModelID == "" {
return nil, tempKBID, seenURLs, knowledgeIDs, fmt.Errorf("embedding_model_id is required for RAG compression")
}
var createdKB *types.KnowledgeBase
// reuse or create temp KB
if strings.TrimSpace(tempKBID) != "" {
createdKB, err = kbSvc.GetKnowledgeBaseByID(ctx, tempKBID)
if err != nil {
logger.Warnf(ctx, "Temp KB %s not available, recreating: %v", tempKBID, err)
createdKB = nil
}
}
if createdKB == nil {
kb := &types.KnowledgeBase{
Name: fmt.Sprintf("tmp-websearch-%d", time.Now().UnixNano()),
Description: "Ephemeral search compression KB",
IsTemporary: true,
EmbeddingModelID: cfg.EmbeddingModelID,
RerankModelID: cfg.RerankModelID,
}
createdKB, err = kbSvc.CreateKnowledgeBase(ctx, kb)
if err != nil {
return nil, tempKBID, seenURLs, knowledgeIDs, fmt.Errorf("failed to create temporary knowledge base: %w", err)
}
tempKBID = createdKB.ID
}
// Ingest all web results as passages synchronously
// dedupe by URL across queries within the same temp KB for this request/session
if seenURLs == nil {
seenURLs = map[string]bool{}
}
for _, r := range webSearchResults {
sourceURL := r.URL
title := strings.TrimSpace(r.Title)
snippet := strings.TrimSpace(r.Snippet)
body := strings.TrimSpace(r.Content)
// skip if already ingested for this KB
if sourceURL != "" && seenURLs[sourceURL] {
continue
}
contentLines := make([]string, 0, 4)
contentLines = append(contentLines, fmt.Sprintf("[sourceUrl]: %s", sourceURL))
if title != "" {
contentLines = append(contentLines, title)
}
if snippet != "" {
contentLines = append(contentLines, snippet)
}
if body != "" {
contentLines = append(contentLines, body)
}
knowledge, err := knowSvc.CreateKnowledgeFromPassageSync(ctx, createdKB.ID, contentLines)
if err != nil {
logger.Warnf(ctx, "failed to ingest passage into temp KB: %v", err)
continue
}
if sourceURL != "" {
seenURLs[sourceURL] = true
}
knowledgeIDs = append(knowledgeIDs, knowledge.ID)
}
// Retrieve references for questions
matchCount := cfg.DocumentFragments
if matchCount <= 0 {
matchCount = 3
}
var allRefs []*types.SearchResult
for _, q := range questions {
params := types.SearchParams{
QueryText: q,
VectorThreshold: 0.5,
KeywordThreshold: 0.5,
MatchCount: matchCount,
}
results, err := kbSvc.HybridSearch(ctx, tempKBID, params)
if err != nil {
logger.Warnf(ctx, "hybrid search failed for temp KB: %v", err)
continue
}
allRefs = append(allRefs, results...)
}
// Round-robin select references across the original results by source URL
selected := s.selectReferencesRoundRobin(webSearchResults, allRefs, matchCount*len(webSearchResults))
// Consolidate by URL back into the web results
compressedResults := s.consolidateReferencesByURL(webSearchResults, selected)
return compressedResults, tempKBID, seenURLs, knowledgeIDs, nil
}
// selectReferencesRoundRobin selects up to limit references, distributing fairly across source URLs.
func (s *WebSearchService) selectReferencesRoundRobin(
raw []*types.WebSearchResult,
refs []*types.SearchResult,
limit int,
) []*types.SearchResult {
if limit <= 0 || len(refs) == 0 {
return nil
}
// group refs by url marker in content
urlToRefs := map[string][]*types.SearchResult{}
for _, r := range refs {
url := extractSourceURLFromContent(r.Content)
if url == "" {
continue
}
urlToRefs[url] = append(urlToRefs[url], r)
}
// preserve order based on raw results
order := make([]string, 0, len(raw))
seen := map[string]bool{}
for _, r := range raw {
if r.URL != "" && !seen[r.URL] {
order = append(order, r.URL)
seen[r.URL] = true
}
}
var out []*types.SearchResult
for len(out) < limit {
progress := false
for _, url := range order {
if len(out) >= limit {
break
}
list := urlToRefs[url]
if len(list) == 0 {
continue
}
out = append(out, list[0])
urlToRefs[url] = list[1:]
progress = true
}
if !progress {
break
}
}
return out
}
// consolidateReferencesByURL merges selected references back into the original results grouped by URL.
func (s *WebSearchService) consolidateReferencesByURL(
raw []*types.WebSearchResult,
selected []*types.SearchResult,
) []*types.WebSearchResult {
if len(selected) == 0 {
return raw
}
agg := map[string][]string{}
for _, ref := range selected {
url := extractSourceURLFromContent(ref.Content)
if url == "" {
continue
}
// strip the first marker line to avoid duplication
agg[url] = append(agg[url], stripMarker(ref.Content))
}
// build outputs, preserving raw ordering and metadata
out := make([]*types.WebSearchResult, 0, len(raw))
for _, r := range raw {
parts := agg[r.URL]
if len(parts) == 0 {
out = append(out, r)
continue
}
merged := strings.Join(parts, "\n---\n")
out = append(out, &types.WebSearchResult{
Title: r.Title,
URL: r.URL,
Snippet: r.Snippet,
Content: merged,
Source: r.Source,
PublishedAt: r.PublishedAt,
})
}
return out
}
func extractSourceURLFromContent(content string) string {
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
if len(lines) == 0 {
return ""
}
first := strings.TrimSpace(lines[0])
const prefix = "[sourceUrl]: "
if strings.HasPrefix(first, prefix) {
return strings.TrimSpace(strings.TrimPrefix(first, prefix))
}
return ""
}
func stripMarker(content string) string {
lines := strings.Split(content, "\n")
if len(lines) == 0 {
return content
}
if strings.HasPrefix(strings.TrimSpace(lines[0]), "[sourceUrl]: ") {
return strings.Join(lines[1:], "\n")
}
return content
}
// 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) {
@@ -36,11 +251,6 @@ func (s *WebSearchService) Search(ctx context.Context, config *types.WebSearchCo
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 {
@@ -75,44 +285,24 @@ func NewWebSearchService(cfg *config.Config) (*WebSearchService, error) {
}
service := &WebSearchService{
providers: make(map[string]WebSearchProvider),
providers: make(map[string]interfaces.WebSearchProvider),
config: cfg.WebSearch,
}
// Initialize providers based on config
for _, providerConfig := range cfg.WebSearch.Providers {
var provider WebSearchProvider
var provider interfaces.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)
case "duckduckgo":
provider, err = web_search.NewDuckDuckGoProvider(providerConfig)
default:
logger.Warnf(context.Background(), "Unknown web search provider: %s", providerConfig.ID)
continue
return nil, fmt.Errorf("unknown web search provider: %s", providerConfig.ID)
}
if err != nil {
logger.Warnf(context.Background(), "Failed to initialize provider %s: %v", providerConfig.ID, err)
continue
return nil, fmt.Errorf("failed to initialize provider %s: %v", providerConfig.ID, err)
}
service.providers[providerConfig.ID] = provider
logger.Infof(context.Background(), "Initialized web search provider: %s", providerConfig.ID)
}
@@ -120,27 +310,6 @@ func NewWebSearchService(cfg *config.Config) (*WebSearchService, error) {
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 {
@@ -1,32 +0,0 @@
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")
}
@@ -1,32 +0,0 @@
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")
}
@@ -1,38 +0,0 @@
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,230 @@
package web_search
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// DuckDuckGoProvider implements web search using DuckDuckGo (HTML first, API fallback)
type DuckDuckGoProvider struct {
client *http.Client
}
// NewDuckDuckGoProvider creates a new DuckDuckGo provider
func NewDuckDuckGoProvider(_ config.WebSearchProviderConfig) (interfaces.WebSearchProvider, error) {
return &DuckDuckGoProvider{
client: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// Name returns the provider name
func (p *DuckDuckGoProvider) Name() string {
return "duckduckgo"
}
// Search performs a web search using DuckDuckGo HTML endpoint with API fallback
func (p *DuckDuckGoProvider) Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error) {
if maxResults <= 0 {
maxResults = 5
}
// Try HTML scraping first (more reliable for general results)
htmlResults, err := p.searchHTML(ctx, query, maxResults)
if err == nil && len(htmlResults) > 0 {
return htmlResults, nil
}
// Fallback to Instant Answer API
apiResults, apiErr := p.searchAPI(ctx, query, maxResults)
if apiErr == nil && len(apiResults) > 0 {
return apiResults, nil
}
if err != nil {
return nil, fmt.Errorf("duckduckgo HTML search failed: %w", err)
}
return nil, fmt.Errorf("duckduckgo API search failed: %w", apiErr)
}
func (p *DuckDuckGoProvider) searchHTML(ctx context.Context, query string, maxResults int) ([]*types.WebSearchResult, error) {
baseURL := "https://html.duckduckgo.com/html/"
params := url.Values{}
params.Set("q", query)
// Prefer Chinese results if applicable; otherwise DDG will auto-detect
params.Set("kl", "cn-zh")
reqURL := baseURL + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Use a realistic UA to avoid blocks
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("duckduckgo HTML returned status %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to parse HTML: %w", err)
}
results := make([]*types.WebSearchResult, 0, maxResults)
// Structure based on DDG HTML page
doc.Find(".web-result").Each(func(i int, s *goquery.Selection) {
if len(results) >= maxResults {
return
}
titleNode := s.Find(".result__a")
title := strings.TrimSpace(titleNode.Text())
var link string
if href, exists := titleNode.Attr("href"); exists {
link = cleanDDGURL(href)
}
snippet := strings.TrimSpace(s.Find(".result__snippet").Text())
if title != "" && link != "" {
results = append(results, &types.WebSearchResult{
Title: title,
URL: link,
Snippet: snippet,
Source: "duckduckgo",
})
}
})
logger.Infof(ctx, "DuckDuckGo HTML search returned %d results for query: %s", len(results), query)
return results, nil
}
func (p *DuckDuckGoProvider) searchAPI(ctx context.Context, query string, maxResults int) ([]*types.WebSearchResult, error) {
baseURL := "https://api.duckduckgo.com/"
params := url.Values{}
params.Set("q", query)
params.Set("format", "json")
params.Set("no_html", "1")
params.Set("skip_disambig", "1")
reqURL := baseURL + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "WeKnora/1.0")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("duckduckgo API returned status %d: %s", resp.StatusCode, string(body))
}
var apiResponse struct {
AbstractText string `json:"AbstractText"`
AbstractURL string `json:"AbstractURL"`
Heading string `json:"Heading"`
RelatedTopics []struct {
FirstURL string `json:"FirstURL"`
Text string `json:"Text"`
} `json:"RelatedTopics"`
Results []struct {
FirstURL string `json:"FirstURL"`
Text string `json:"Text"`
} `json:"Results"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil {
return nil, fmt.Errorf("failed to decode API response: %w", err)
}
results := make([]*types.WebSearchResult, 0, maxResults)
if apiResponse.AbstractText != "" && apiResponse.AbstractURL != "" {
results = append(results, &types.WebSearchResult{
Title: apiResponse.Heading,
URL: apiResponse.AbstractURL,
Snippet: apiResponse.AbstractText,
Source: "duckduckgo",
})
}
for _, topic := range apiResponse.RelatedTopics {
if len(results) >= maxResults {
break
}
if topic.Text != "" && topic.FirstURL != "" {
results = append(results, &types.WebSearchResult{
Title: extractTitle(topic.Text),
URL: topic.FirstURL,
Snippet: topic.Text,
Source: "duckduckgo",
})
}
}
for _, r := range apiResponse.Results {
if len(results) >= maxResults {
break
}
if r.Text != "" && r.FirstURL != "" {
results = append(results, &types.WebSearchResult{
Title: extractTitle(r.Text),
URL: r.FirstURL,
Snippet: r.Text,
Source: "duckduckgo",
})
}
}
logger.Infof(ctx, "DuckDuckGo API search returned %d results for query: %s", len(results), query)
return results, nil
}
func cleanDDGURL(urlStr string) string {
if strings.HasPrefix(urlStr, "//duckduckgo.com/l/?uddg=") {
trimmed := strings.TrimPrefix(urlStr, "//duckduckgo.com/l/?uddg=")
if idx := strings.Index(trimmed, "&rut="); idx != -1 {
decodedStr, err := url.PathUnescape(trimmed[:idx])
if err == nil {
return decodedStr
}
return ""
}
}
if strings.HasPrefix(urlStr, "https://duckduckgo.com/l/?uddg=") {
if parsedURL, err := url.Parse(urlStr); err == nil {
if uddg := parsedURL.Query().Get("uddg"); uddg != "" {
return uddg
}
}
}
return urlStr
}
func extractTitle(text string) string {
lines := strings.Split(text, "\n")
if len(lines) > 0 {
title := strings.TrimSpace(lines[0])
if len(title) > 100 {
title = title[:100] + "..."
}
return title
}
return strings.TrimSpace(text)
}
@@ -0,0 +1,154 @@
package web_search
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/config"
)
// testRoundTripper rewrites outgoing requests that target DuckDuckGo hosts
// to the provided test server, preserving path and query.
type testRoundTripper struct {
base *url.URL
next http.RoundTripper
}
func (t *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Only rewrite requests to duckduckgo hosts used by the provider
if req.URL.Host == "html.duckduckgo.com" || req.URL.Host == "api.duckduckgo.com" {
cloned := *req
u := *req.URL
u.Scheme = t.base.Scheme
u.Host = t.base.Host
// Keep original path; our test server handlers should register for the same paths.
cloned.URL = &u
req = &cloned
}
return t.next.RoundTrip(req)
}
func newTestClient(ts *httptest.Server) *http.Client {
baseURL, _ := url.Parse(ts.URL)
return &http.Client{
Timeout: 5 * time.Second,
Transport: &testRoundTripper{
base: baseURL,
next: http.DefaultTransport,
},
}
}
func TestDuckDuckGoProvider_Name(t *testing.T) {
p, _ := NewDuckDuckGoProvider(config.WebSearchProviderConfig{})
if p.Name() != "duckduckgo" {
t.Fatalf("expected provider name duckduckgo, got %s", p.Name())
}
}
func TestDuckDuckGoProvider_Search_HTMLSuccess(t *testing.T) {
// Minimal HTML page with two results, matching selectors used in searchHTML
html := `
<html>
<body>
<div class="web-result">
<a class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage1&rut=">Example One</a>
<div class="result__snippet">Snippet one</div>
</div>
<div class="web-result">
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fpage2&rut=">Example Two</a>
<div class="result__snippet">Snippet two</div>
</div>
</body>
</html>`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Provider requests GET https://html.duckduckgo.com/html/?q=...&kl=...
if r.URL.Path == "/html/" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(html))
return
}
t.Fatalf("unexpected request path: %s", r.URL.Path)
}))
defer ts.Close()
// Build provider and inject our test client
prov, _ := NewDuckDuckGoProvider(config.WebSearchProviderConfig{})
dp := prov.(*DuckDuckGoProvider)
dp.client = newTestClient(ts)
ctx := context.Background()
results, err := dp.Search(ctx, "weknora", 5, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].Title != "Example One" || !strings.HasPrefix(results[0].URL, "https://example.com/") || results[0].Snippet != "Snippet one" {
t.Fatalf("unexpected first result: %+v", results[0])
}
if results[1].Title != "Example Two" || !strings.HasPrefix(results[1].URL, "https://example.org/") || results[1].Snippet != "Snippet two" {
t.Fatalf("unexpected second result: %+v", results[1])
}
}
func TestDuckDuckGoProvider_Search_APIFallback(t *testing.T) {
// Simulate HTML returning non-OK to force API fallback, then a minimal API JSON
apiResp := struct {
AbstractText string `json:"AbstractText"`
AbstractURL string `json:"AbstractURL"`
Heading string `json:"Heading"`
Results []struct {
FirstURL string `json:"FirstURL"`
Text string `json:"Text"`
} `json:"Results"`
}{
AbstractText: "Abstract snippet",
AbstractURL: "https://example.com/abstract",
Heading: "Abstract Heading",
Results: []struct {
FirstURL string `json:"FirstURL"`
Text string `json:"Text"`
}{
{FirstURL: "https://example.net/x", Text: "Title X - Detail X"},
},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/html/":
// Force fallback by returning 500
w.WriteHeader(http.StatusInternalServerError)
default:
// API endpoint path "/"
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
_ = enc.Encode(apiResp)
}
}))
defer ts.Close()
prov, _ := NewDuckDuckGoProvider(config.WebSearchProviderConfig{})
dp := prov.(*DuckDuckGoProvider)
dp.client = newTestClient(ts)
ctx := context.Background()
results, err := dp.Search(ctx, "weknora", 3, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected some results from API fallback")
}
if results[0].URL != "https://example.com/abstract" || results[0].Title != "Abstract Heading" {
t.Fatalf("unexpected first API result: %+v", results[0])
}
}
@@ -1,38 +0,0 @@
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
}
@@ -1,40 +0,0 @@
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
}
@@ -1,112 +0,0 @@
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
}
@@ -1,32 +0,0 @@
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")
}
@@ -1,38 +0,0 @@
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
}
@@ -1,38 +0,0 @@
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
}
@@ -1,33 +0,0 @@
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")
}
@@ -1,33 +0,0 @@
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")
}
@@ -1,39 +0,0 @@
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
}
@@ -1,39 +0,0 @@
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
}
@@ -1,41 +0,0 @@
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
}
@@ -1,113 +0,0 @@
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
}
@@ -1,33 +0,0 @@
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")
}
@@ -1,39 +0,0 @@
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
}
@@ -1,39 +0,0 @@
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
}
+5 -3
View File
@@ -128,6 +128,11 @@ func BuildContainer(container *dig.Container) *dig.Container {
// Chat pipeline components for processing chat requests
must(container.Provide(chatpipline.NewEventManager))
// Ensure Async task components are registered before invoking plugins that depend on KnowledgeService
// KnowledgeService depends on *asynq.Client, and plugins (like PluginSearch) are invoked below.
must(container.Provide(router.NewAsyncqClient))
must(container.Provide(router.NewAsynqServer))
must(container.Invoke(router.RunAsynqServer))
must(container.Invoke(chatpipline.NewPluginTracing))
must(container.Invoke(chatpipline.NewPluginSearch))
must(container.Invoke(chatpipline.NewPluginRerank))
@@ -159,9 +164,6 @@ func BuildContainer(container *dig.Container) *dig.Container {
// Router configuration
must(container.Provide(router.NewRouter))
must(container.Provide(router.NewAsyncqClient))
must(container.Provide(router.NewAsynqServer))
must(container.Invoke(router.RunAsynqServer))
return container
}
+11 -2
View File
@@ -203,11 +203,16 @@ func (h *Handler) AgentQA(c *gin.Context) {
}
// Check if agent mode has changed
currentAgentEnabled := session.AgentConfig.Enabled
currentAgentEnabled := session.AgentConfig.AgentModeEnabled
if request.AgentEnabled != currentAgentEnabled {
logger.Infof(ctx, "Agent mode changed from %v to %v", currentAgentEnabled, request.AgentEnabled)
configChanged = true
}
currentWebSearchEnabled := session.AgentConfig.AgentModeEnabled
if request.WebSearchEnabled != currentWebSearchEnabled {
logger.Infof(ctx, "Web search mode changed from %v to %v", currentWebSearchEnabled, request.WebSearchEnabled)
configChanged = true
}
// If configuration changed, clear context and update session
if configChanged {
@@ -219,8 +224,12 @@ func (h *Handler) AgentQA(c *gin.Context) {
// Continue anyway - this is not a fatal error
}
}
if knowledgeBasesChanged {
// todo clear temp kb
}
session.AgentConfig.KnowledgeBases = request.KnowledgeBaseIDs
session.AgentConfig.Enabled = request.AgentEnabled
session.AgentConfig.AgentModeEnabled = request.AgentEnabled
session.AgentConfig.WebSearchEnabled = request.WebSearchEnabled
// Persist the session changes
if err := h.sessionService.UpdateSession(ctx, session); err != nil {
logger.Errorf(ctx, "Failed to update session %s: %v", sessionID, err)
+6 -2
View File
@@ -29,10 +29,14 @@ func getAsynqRedisClientOpt() *asynq.RedisClientOpt {
return opt
}
func NewAsyncqClient() *asynq.Client {
func NewAsyncqClient() (*asynq.Client, error) {
opt := getAsynqRedisClientOpt()
client := asynq.NewClient(opt)
return client
err := client.Ping()
if err != nil {
return nil, err
}
return client, nil
}
func NewAsynqServer() *asynq.Server {
+3 -2
View File
@@ -24,8 +24,9 @@ type AgentConfig struct {
// SessionAgentConfig represents session-level agent configuration
// Sessions only store Enabled and KnowledgeBases; other configs are read from Tenant at runtime
type SessionAgentConfig struct {
Enabled bool `json:"enabled"` // Whether agent mode is enabled for this session
KnowledgeBases []string `json:"knowledge_bases"` // Accessible knowledge base IDs for this session
AgentModeEnabled bool `json:"agent_mode_enabled"` // Whether agent mode is enabled for this session
WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this session
KnowledgeBases []string `json:"knowledge_bases"` // Accessible knowledge base IDs for this session
}
// Value implements driver.Valuer interface for AgentConfig
+2
View File
@@ -22,6 +22,8 @@ type KnowledgeService interface {
CreateKnowledgeFromURL(ctx context.Context, kbID string, url string, enableMultimodel *bool) (*types.Knowledge, error)
// CreateKnowledgeFromPassage creates knowledge from text passages.
CreateKnowledgeFromPassage(ctx context.Context, kbID string, passage []string) (*types.Knowledge, error)
// CreateKnowledgeFromPassageSync creates knowledge from text passages and waits until chunks are indexed.
CreateKnowledgeFromPassageSync(ctx context.Context, kbID string, passage []string) (*types.Knowledge, error)
// GetKnowledgeByID retrieves knowledge by ID.
GetKnowledgeByID(ctx context.Context, id string) (*types.Knowledge, error)
// GetKnowledgeBatch retrieves a batch of knowledge by IDs.
+11 -1
View File
@@ -6,7 +6,17 @@ import (
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchService defines the interface for web search service
// 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
}
type WebSearchService interface {
Search(ctx context.Context, config *types.WebSearchConfig, query string) ([]*types.WebSearchResult, error)
CompressWithRAG(ctx context.Context, sessionID string, tempKBID string, questions []string,
webSearchResults []*types.WebSearchResult, cfg *types.WebSearchConfig,
kbSvc KnowledgeBaseService, knowSvc KnowledgeService,
seenURLs map[string]bool, knowledgeIDs []string,
) (compressed []*types.WebSearchResult, kbID string, newSeen map[string]bool, newIDs []string, err error)
}
+2
View File
@@ -18,6 +18,8 @@ type KnowledgeBase struct {
ID string `yaml:"id" json:"id" gorm:"type:varchar(36);primaryKey"`
// Name of the knowledge base
Name string `yaml:"name" json:"name"`
// Whether this knowledge base is temporary (ephemeral) and should be hidden from UI
IsTemporary bool `yaml:"is_temporary" json:"is_temporary" gorm:"default:false"`
// Description of the knowledge base
Description string `yaml:"description" json:"description"`
// Tenant ID
+1 -1
View File
@@ -44,7 +44,7 @@ type WebSearchResult struct {
URL string `json:"url"` // 结果URL
Snippet string `json:"snippet"` // 摘要片段
Content string `json:"content"` // 完整内容(可选,需要额外抓取)
Source string `json:"source"` // 来源(如:kuaisou, baidu等)
Source string `json:"source"` // 来源(如:duckduckgo等)
PublishedAt *time.Time `json:"published_at,omitempty"` // 发布时间(如果有)
}
+5
View File
@@ -0,0 +1,5 @@
-- Add is_temporary flag to knowledge_bases to support ephemeral KBs
ALTER TABLE knowledge_bases
ADD COLUMN IF NOT EXISTS is_temporary TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Temporary/hidden KB';
@@ -0,0 +1,5 @@
-- Add is_temporary flag to knowledge_bases to support ephemeral KBs
ALTER TABLE knowledge_bases
ADD COLUMN IF NOT EXISTS is_temporary BOOLEAN NOT NULL DEFAULT FALSE;