增加搜索
This commit is contained in:
@@ -30,6 +30,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultOpenAIBaseURL = "https://ark.cn-beijing.volces.com/api/v3"
|
defaultOpenAIBaseURL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
defaultOpenAITimeout = 120
|
defaultOpenAITimeout = 120
|
||||||
|
defaultSearchBaseURL = "https://api.duckduckgo.com/"
|
||||||
|
defaultSearchTimeout = 10
|
||||||
|
defaultSearchCount = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
type OpenAIConfig struct {
|
type OpenAIConfig struct {
|
||||||
@@ -43,6 +46,45 @@ type OpenAIConfig struct {
|
|||||||
|
|
||||||
type OpenAIConfigs []OpenAIConfig
|
type OpenAIConfigs []OpenAIConfig
|
||||||
|
|
||||||
|
type SearchConfig struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Active bool `yaml:"active,omitempty" json:"active"`
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
Provider string `yaml:"provider" json:"provider"`
|
||||||
|
APIKey string `yaml:"api_key" json:"-"`
|
||||||
|
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||||
|
Count int `yaml:"count" json:"count"`
|
||||||
|
Timeout int `yaml:"timeout" json:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchConfigs []SearchConfig
|
||||||
|
|
||||||
|
func (configs *SearchConfigs) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
switch value.Kind {
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var items []SearchConfig
|
||||||
|
if err := value.Decode(&items); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*configs = items
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var item SearchConfig
|
||||||
|
if err := value.Decode(&item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*configs = []SearchConfig{item}
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
if value.Tag == "!!null" {
|
||||||
|
*configs = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("search 配置格式无效")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("search 配置格式无效")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (configs *OpenAIConfigs) UnmarshalYAML(value *yaml.Node) error {
|
func (configs *OpenAIConfigs) UnmarshalYAML(value *yaml.Node) error {
|
||||||
switch value.Kind {
|
switch value.Kind {
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
@@ -75,14 +117,7 @@ type Config struct {
|
|||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
} `yaml:"server"`
|
} `yaml:"server"`
|
||||||
OpenAI OpenAIConfigs `yaml:"openai"`
|
OpenAI OpenAIConfigs `yaml:"openai"`
|
||||||
Search struct {
|
Search SearchConfigs `yaml:"search"`
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
Provider string `yaml:"provider"`
|
|
||||||
APIKey string `yaml:"api_key"`
|
|
||||||
BaseURL string `yaml:"base_url"`
|
|
||||||
Count int `yaml:"count"`
|
|
||||||
Timeout int `yaml:"timeout"`
|
|
||||||
} `yaml:"search"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultOpenAIConfig() OpenAIConfig {
|
func defaultOpenAIConfig() OpenAIConfig {
|
||||||
@@ -94,15 +129,24 @@ func defaultOpenAIConfig() OpenAIConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultSearchConfig() SearchConfig {
|
||||||
|
return SearchConfig{
|
||||||
|
Name: "duckduckgo",
|
||||||
|
Active: true,
|
||||||
|
Enabled: true,
|
||||||
|
Provider: "duckduckgo",
|
||||||
|
BaseURL: defaultSearchBaseURL,
|
||||||
|
Count: defaultSearchCount,
|
||||||
|
Timeout: defaultSearchTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
var cfg Config
|
var cfg Config
|
||||||
cfg.Server.Mode = "tcp"
|
cfg.Server.Mode = "tcp"
|
||||||
cfg.Server.Address = "0.0.0.0:8080"
|
cfg.Server.Address = "0.0.0.0:8080"
|
||||||
cfg.OpenAI = OpenAIConfigs{defaultOpenAIConfig()}
|
cfg.OpenAI = OpenAIConfigs{defaultOpenAIConfig()}
|
||||||
cfg.Search.Provider = "brave"
|
cfg.Search = SearchConfigs{defaultSearchConfig()}
|
||||||
cfg.Search.BaseURL = "https://api.search.brave.com/res/v1/web/search"
|
|
||||||
cfg.Search.Count = 5
|
|
||||||
cfg.Search.Timeout = 10
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +172,15 @@ func loadConfig(path string) (*Config, error) {
|
|||||||
cfg.OpenAI[i].APIKey = key
|
cfg.OpenAI[i].APIKey = key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := normalizeSearchConfigs(&cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if key := os.Getenv("BRAVE_SEARCH_API_KEY"); key != "" {
|
if key := os.Getenv("BRAVE_SEARCH_API_KEY"); key != "" {
|
||||||
cfg.Search.APIKey = key
|
for i := range cfg.Search {
|
||||||
|
if strings.ToLower(cfg.Search[i].Provider) == "brave" {
|
||||||
|
cfg.Search[i].APIKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
@@ -181,35 +232,13 @@ func ensureConfigFile(path string) error {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
search, _ := raw["search"].(map[string]any)
|
if _, ok := raw["search"].([]any); !ok {
|
||||||
if search == nil {
|
changed = true
|
||||||
cfg.Search = defaults.Search
|
}
|
||||||
|
if normalized, err := normalizeSearchConfigs(&cfg); err != nil {
|
||||||
|
return err
|
||||||
|
} else if normalized {
|
||||||
changed = true
|
changed = true
|
||||||
} else {
|
|
||||||
if _, ok := search["enabled"]; !ok {
|
|
||||||
cfg.Search.Enabled = defaults.Search.Enabled
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if _, ok := search["provider"]; !ok {
|
|
||||||
cfg.Search.Provider = defaults.Search.Provider
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if _, ok := search["api_key"]; !ok {
|
|
||||||
cfg.Search.APIKey = defaults.Search.APIKey
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if _, ok := search["base_url"]; !ok {
|
|
||||||
cfg.Search.BaseURL = defaults.Search.BaseURL
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if _, ok := search["count"]; !ok {
|
|
||||||
cfg.Search.Count = defaults.Search.Count
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if _, ok := search["timeout"]; !ok {
|
|
||||||
cfg.Search.Timeout = defaults.Search.Timeout
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
@@ -270,6 +299,74 @@ func normalizeOpenAIConfigs(cfg *Config) (bool, error) {
|
|||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSearchConfigs(cfg *Config) (bool, error) {
|
||||||
|
changed := false
|
||||||
|
if len(cfg.Search) == 0 {
|
||||||
|
cfg.Search = SearchConfigs{defaultSearchConfig()}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
activeIndex := -1
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := range cfg.Search {
|
||||||
|
profile := &cfg.Search[i]
|
||||||
|
profile.Provider = strings.ToLower(strings.TrimSpace(profile.Provider))
|
||||||
|
if profile.Provider == "" {
|
||||||
|
profile.Provider = "duckduckgo"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(profile.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = profile.Provider
|
||||||
|
if seen[name] {
|
||||||
|
name = fmt.Sprintf("%s-%d", profile.Provider, i+1)
|
||||||
|
}
|
||||||
|
profile.Name = name
|
||||||
|
changed = true
|
||||||
|
} else if name != profile.Name {
|
||||||
|
profile.Name = name
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if seen[name] {
|
||||||
|
return changed, fmt.Errorf("search 配置名称重复: %s", name)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
|
||||||
|
if strings.TrimSpace(profile.BaseURL) == "" {
|
||||||
|
switch profile.Provider {
|
||||||
|
case "duckduckgo", "ddg":
|
||||||
|
profile.BaseURL = defaultSearchBaseURL
|
||||||
|
case "brave":
|
||||||
|
profile.BaseURL = "https://api.search.brave.com/res/v1/web/search"
|
||||||
|
default:
|
||||||
|
return changed, fmt.Errorf("暂不支持搜索服务: %s", profile.Provider)
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if profile.Count <= 0 {
|
||||||
|
profile.Count = defaultSearchCount
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if profile.Timeout <= 0 {
|
||||||
|
profile.Timeout = defaultSearchTimeout
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if profile.Active {
|
||||||
|
if activeIndex == -1 {
|
||||||
|
activeIndex = i
|
||||||
|
} else {
|
||||||
|
profile.Active = false
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if activeIndex == -1 {
|
||||||
|
cfg.Search[0].Active = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
func writeConfig(path string, cfg Config) error {
|
func writeConfig(path string, cfg Config) error {
|
||||||
data, err := yaml.Marshal(&cfg)
|
data, err := yaml.Marshal(&cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -323,7 +420,7 @@ type OpenAIState struct {
|
|||||||
activeName string
|
activeName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type openAIActiveRequest struct {
|
type activeProfileRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +429,11 @@ type openAIListResponse struct {
|
|||||||
Profiles []OpenAIConfig `json:"profiles"`
|
Profiles []OpenAIConfig `json:"profiles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type searchListResponse struct {
|
||||||
|
Active string `json:"active"`
|
||||||
|
Profiles []SearchConfig `json:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewOpenAIState(configs []OpenAIConfig) (*OpenAIState, error) {
|
func NewOpenAIState(configs []OpenAIConfig) (*OpenAIState, error) {
|
||||||
state := &OpenAIState{
|
state := &OpenAIState{
|
||||||
profiles: make(map[string]*OpenAIProfile, len(configs)),
|
profiles: make(map[string]*OpenAIProfile, len(configs)),
|
||||||
@@ -433,12 +535,96 @@ func publicOpenAIConfig(profile *OpenAIProfile, active bool) OpenAIConfig {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchState struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
profiles map[string]SearchConfig
|
||||||
|
order []string
|
||||||
|
activeName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSearchState(configs []SearchConfig) (*SearchState, error) {
|
||||||
|
state := &SearchState{
|
||||||
|
profiles: make(map[string]SearchConfig, len(configs)),
|
||||||
|
order: make([]string, 0, len(configs)),
|
||||||
|
}
|
||||||
|
for _, config := range configs {
|
||||||
|
if strings.TrimSpace(config.Name) == "" {
|
||||||
|
return nil, errors.New("search.name 不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.Provider) == "" {
|
||||||
|
return nil, fmt.Errorf("search.%s.provider 未配置", config.Name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.BaseURL) == "" {
|
||||||
|
return nil, fmt.Errorf("search.%s.base_url 未配置", config.Name)
|
||||||
|
}
|
||||||
|
if config.Timeout <= 0 {
|
||||||
|
return nil, fmt.Errorf("search.%s.timeout 必须大于 0", config.Name)
|
||||||
|
}
|
||||||
|
if _, ok := state.profiles[config.Name]; ok {
|
||||||
|
return nil, fmt.Errorf("search 配置名称重复: %s", config.Name)
|
||||||
|
}
|
||||||
|
state.profiles[config.Name] = config
|
||||||
|
state.order = append(state.order, config.Name)
|
||||||
|
if config.Active && state.activeName == "" {
|
||||||
|
state.activeName = config.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(state.order) == 0 {
|
||||||
|
return nil, errors.New("search 配置不能为空")
|
||||||
|
}
|
||||||
|
if state.activeName == "" {
|
||||||
|
state.activeName = state.order[0]
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchState) ActiveProfile() SearchConfig {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.profiles[s.activeName]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchState) SwitchActive(name string) (SearchConfig, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return SearchConfig{}, errors.New("搜索配置名称不能为空")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
profile, ok := s.profiles[name]
|
||||||
|
if !ok {
|
||||||
|
return SearchConfig{}, fmt.Errorf("搜索配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
s.activeName = name
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchState) ListProfiles() searchListResponse {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
profiles := make([]SearchConfig, 0, len(s.order))
|
||||||
|
for _, name := range s.order {
|
||||||
|
profile := s.profiles[name]
|
||||||
|
profile.APIKey = ""
|
||||||
|
profile.Active = name == s.activeName
|
||||||
|
profiles = append(profiles, profile)
|
||||||
|
}
|
||||||
|
return searchListResponse{Active: s.activeName, Profiles: profiles}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicSearchConfig(config SearchConfig, active bool) SearchConfig {
|
||||||
|
config.APIKey = ""
|
||||||
|
config.Active = active
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 全局变量 ─────────────────────────────────────────────
|
// ─── 全局变量 ─────────────────────────────────────────────
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg *Config
|
cfg *Config
|
||||||
aiState *OpenAIState
|
aiState *OpenAIState
|
||||||
store *ConvStore
|
searchState *SearchState
|
||||||
|
store *ConvStore
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── 路由 ─────────────────────────────────────────────────
|
// ─── 路由 ─────────────────────────────────────────────────
|
||||||
@@ -457,7 +643,7 @@ func listOpenAIHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func switchOpenAIHandler(c *gin.Context) {
|
func switchOpenAIHandler(c *gin.Context) {
|
||||||
var req openAIActiveRequest
|
var req activeProfileRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -473,6 +659,27 @@ func switchOpenAIHandler(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listSearchHandler(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, searchState.ListProfiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchSearchHandler(c *gin.Context) {
|
||||||
|
var req activeProfileRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile, err := searchState.SwitchActive(req.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"active": profile.Name,
|
||||||
|
"profile": publicSearchConfig(profile, true),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func listConversationsHandler(c *gin.Context) {
|
func listConversationsHandler(c *gin.Context) {
|
||||||
convs, err := store.List()
|
convs, err := store.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -619,23 +826,39 @@ type braveSearchResponse struct {
|
|||||||
} `json:"web"`
|
} `json:"web"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type duckDuckGoResponse struct {
|
||||||
|
Abstract string `json:"Abstract"`
|
||||||
|
AbstractSource string `json:"AbstractSource"`
|
||||||
|
AbstractURL string `json:"AbstractURL"`
|
||||||
|
Heading string `json:"Heading"`
|
||||||
|
RelatedTopics []struct {
|
||||||
|
Text string `json:"Text"`
|
||||||
|
FirstURL string `json:"FirstURL"`
|
||||||
|
Topics []struct {
|
||||||
|
Text string `json:"Text"`
|
||||||
|
FirstURL string `json:"FirstURL"`
|
||||||
|
} `json:"Topics"`
|
||||||
|
} `json:"RelatedTopics"`
|
||||||
|
Infobox struct {
|
||||||
|
Content []struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"Infobox"`
|
||||||
|
}
|
||||||
|
|
||||||
func enrichMessagesWithSearch(ctx context.Context, messages []ChatMessage) ([]ChatMessage, error) {
|
func enrichMessagesWithSearch(ctx context.Context, messages []ChatMessage) ([]ChatMessage, error) {
|
||||||
if !cfg.Search.Enabled {
|
searchConfig := searchState.ActiveProfile()
|
||||||
|
if !searchConfig.Enabled {
|
||||||
return nil, errors.New("联网搜索未启用,请先在 config.yaml 中配置 search.enabled")
|
return nil, errors.New("联网搜索未启用,请先在 config.yaml 中配置 search.enabled")
|
||||||
}
|
}
|
||||||
if strings.ToLower(cfg.Search.Provider) != "brave" {
|
|
||||||
return nil, fmt.Errorf("暂不支持搜索服务: %s", cfg.Search.Provider)
|
|
||||||
}
|
|
||||||
if cfg.Search.APIKey == "" {
|
|
||||||
return nil, errors.New("联网搜索未配置 API Key,请设置 search.api_key 或环境变量 BRAVE_SEARCH_API_KEY")
|
|
||||||
}
|
|
||||||
|
|
||||||
query := latestUserQuery(messages)
|
query := latestUserQuery(messages)
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, errors.New("联网搜索需要输入文本问题")
|
return nil, errors.New("联网搜索需要输入文本问题")
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := braveWebSearch(ctx, query)
|
results, err := webSearch(ctx, searchConfig, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -643,7 +866,7 @@ func enrichMessagesWithSearch(ctx context.Context, messages []ChatMessage) ([]Ch
|
|||||||
return nil, errors.New("未搜索到相关网页结果")
|
return nil, errors.New("未搜索到相关网页结果")
|
||||||
}
|
}
|
||||||
|
|
||||||
searchContext := buildSearchContext(query, results)
|
searchContext := buildSearchContext(searchConfig, query, results)
|
||||||
withSearch := make([]ChatMessage, 0, len(messages)+1)
|
withSearch := make([]ChatMessage, 0, len(messages)+1)
|
||||||
withSearch = append(withSearch, ChatMessage{Role: "system", Content: searchContext, Hidden: true})
|
withSearch = append(withSearch, ChatMessage{Role: "system", Content: searchContext, Hidden: true})
|
||||||
withSearch = append(withSearch, messages...)
|
withSearch = append(withSearch, messages...)
|
||||||
@@ -659,17 +882,128 @@ func latestUserQuery(messages []ChatMessage) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func braveWebSearch(ctx context.Context, query string) ([]searchResult, error) {
|
func webSearch(ctx context.Context, config SearchConfig, query string) ([]searchResult, error) {
|
||||||
searchCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.Search.Timeout)*time.Second)
|
switch strings.ToLower(config.Provider) {
|
||||||
|
case "duckduckgo", "ddg":
|
||||||
|
return duckDuckGoSearch(ctx, config, query)
|
||||||
|
case "brave":
|
||||||
|
return braveWebSearch(ctx, config, query)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("暂不支持搜索服务: %s", config.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func duckDuckGoSearch(ctx context.Context, config SearchConfig, query string) ([]searchResult, error) {
|
||||||
|
searchCtx, cancel := context.WithTimeout(ctx, time.Duration(config.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
u, err := url.Parse(cfg.Search.BaseURL)
|
u, err := url.Parse(config.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("搜索服务地址无效: %w", err)
|
return nil, fmt.Errorf("搜索服务地址无效: %w", err)
|
||||||
}
|
}
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
q.Set("q", query)
|
q.Set("q", query)
|
||||||
q.Set("count", fmt.Sprintf("%d", cfg.Search.Count))
|
q.Set("format", "json")
|
||||||
|
q.Set("no_html", "1")
|
||||||
|
q.Set("skip_disambig", "1")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(searchCtx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建搜索请求失败: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "aichat/1.0")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("联网搜索失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取搜索响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("搜索服务返回错误 %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed duckDuckGoResponse
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析搜索响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := config.Count
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultSearchCount
|
||||||
|
}
|
||||||
|
results := make([]searchResult, 0, limit)
|
||||||
|
if strings.TrimSpace(parsed.Abstract) != "" {
|
||||||
|
title := strings.TrimSpace(parsed.Heading)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(parsed.AbstractSource)
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "DuckDuckGo 摘要"
|
||||||
|
}
|
||||||
|
results = append(results, searchResult{Title: title, URL: strings.TrimSpace(parsed.AbstractURL), Description: strings.TrimSpace(parsed.Abstract)})
|
||||||
|
}
|
||||||
|
for _, item := range parsed.Infobox.Content {
|
||||||
|
if len(results) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.Label) == "" || strings.TrimSpace(item.Value) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, searchResult{Title: item.Label, Description: item.Value})
|
||||||
|
}
|
||||||
|
appendRelated := func(text, firstURL string) {
|
||||||
|
if len(results) >= limit || strings.TrimSpace(text) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title, desc := splitDuckDuckGoText(text)
|
||||||
|
results = append(results, searchResult{Title: title, URL: strings.TrimSpace(firstURL), Description: desc})
|
||||||
|
}
|
||||||
|
for _, topic := range parsed.RelatedTopics {
|
||||||
|
appendRelated(topic.Text, topic.FirstURL)
|
||||||
|
for _, nested := range topic.Topics {
|
||||||
|
appendRelated(nested.Text, nested.FirstURL)
|
||||||
|
}
|
||||||
|
if len(results) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitDuckDuckGoText(text string) (string, string) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
parts := strings.SplitN(text, " - ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > 42 {
|
||||||
|
return string(runes[:42]) + "...", text
|
||||||
|
}
|
||||||
|
return text, text
|
||||||
|
}
|
||||||
|
|
||||||
|
func braveWebSearch(ctx context.Context, config SearchConfig, query string) ([]searchResult, error) {
|
||||||
|
if config.APIKey == "" {
|
||||||
|
return nil, errors.New("Brave 搜索未配置 API Key,请设置 search.api_key 或环境变量 BRAVE_SEARCH_API_KEY")
|
||||||
|
}
|
||||||
|
searchCtx, cancel := context.WithTimeout(ctx, time.Duration(config.Timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
u, err := url.Parse(config.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("搜索服务地址无效: %w", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("q", query)
|
||||||
|
q.Set("count", fmt.Sprintf("%d", config.Count))
|
||||||
q.Set("search_lang", "zh-hans")
|
q.Set("search_lang", "zh-hans")
|
||||||
q.Set("country", "CN")
|
q.Set("country", "CN")
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
@@ -679,7 +1013,7 @@ func braveWebSearch(ctx context.Context, query string) ([]searchResult, error) {
|
|||||||
return nil, fmt.Errorf("创建搜索请求失败: %w", err)
|
return nil, fmt.Errorf("创建搜索请求失败: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("X-Subscription-Token", cfg.Search.APIKey)
|
req.Header.Set("X-Subscription-Token", config.APIKey)
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -702,15 +1036,20 @@ func braveWebSearch(ctx context.Context, query string) ([]searchResult, error) {
|
|||||||
return parsed.Web.Results, nil
|
return parsed.Web.Results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSearchContext(query string, results []searchResult) string {
|
func buildSearchContext(config SearchConfig, query string, results []searchResult) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "用户开启了联网搜索。请优先根据以下网页搜索结果回答,并在合适位置标注来源链接。\n")
|
fmt.Fprintf(&b, "用户开启了联网搜索。当前搜索源: %s(%s)。请优先根据以下搜索结果回答,并在合适位置标注来源链接。\n", config.Name, config.Provider)
|
||||||
|
if strings.ToLower(config.Provider) == "duckduckgo" || strings.ToLower(config.Provider) == "ddg" {
|
||||||
|
fmt.Fprintln(&b, "注意:DuckDuckGo 即时答案不是全量网页搜索,结果可能较少。")
|
||||||
|
}
|
||||||
fmt.Fprintf(&b, "搜索时间: %s\n", time.Now().Format("2006-01-02 15:04:05"))
|
fmt.Fprintf(&b, "搜索时间: %s\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||||
fmt.Fprintf(&b, "搜索词: %s\n\n", query)
|
fmt.Fprintf(&b, "搜索词: %s\n\n", query)
|
||||||
fmt.Fprintln(&b, "搜索结果:")
|
fmt.Fprintln(&b, "搜索结果:")
|
||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
fmt.Fprintf(&b, "%d. 标题: %s\n", i+1, strings.TrimSpace(r.Title))
|
fmt.Fprintf(&b, "%d. 标题: %s\n", i+1, strings.TrimSpace(r.Title))
|
||||||
fmt.Fprintf(&b, " 链接: %s\n", strings.TrimSpace(r.URL))
|
if strings.TrimSpace(r.URL) != "" {
|
||||||
|
fmt.Fprintf(&b, " 链接: %s\n", strings.TrimSpace(r.URL))
|
||||||
|
}
|
||||||
if strings.TrimSpace(r.Description) != "" {
|
if strings.TrimSpace(r.Description) != "" {
|
||||||
fmt.Fprintf(&b, " 摘要: %s\n", strings.TrimSpace(r.Description))
|
fmt.Fprintf(&b, " 摘要: %s\n", strings.TrimSpace(r.Description))
|
||||||
}
|
}
|
||||||
@@ -1019,6 +1358,11 @@ func main() {
|
|||||||
fmt.Fprintln(os.Stderr, "OpenAI 配置初始化失败:", err)
|
fmt.Fprintln(os.Stderr, "OpenAI 配置初始化失败:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
searchState, err = NewSearchState(cfg.Search)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "搜索配置初始化失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
store = NewConvStore("conversations")
|
store = NewConvStore("conversations")
|
||||||
|
|
||||||
// Gin 路由
|
// Gin 路由
|
||||||
@@ -1030,6 +1374,8 @@ func main() {
|
|||||||
r.POST("/api/chat", chatHandler)
|
r.POST("/api/chat", chatHandler)
|
||||||
r.GET("/api/openai", listOpenAIHandler)
|
r.GET("/api/openai", listOpenAIHandler)
|
||||||
r.POST("/api/openai/active", switchOpenAIHandler)
|
r.POST("/api/openai/active", switchOpenAIHandler)
|
||||||
|
r.GET("/api/search", listSearchHandler)
|
||||||
|
r.POST("/api/search/active", switchSearchHandler)
|
||||||
r.GET("/api/conversations", listConversationsHandler)
|
r.GET("/api/conversations", listConversationsHandler)
|
||||||
r.POST("/api/conversations", createConversationHandler)
|
r.POST("/api/conversations", createConversationHandler)
|
||||||
r.GET("/api/conversations/:id", getConversationHandler)
|
r.GET("/api/conversations/:id", getConversationHandler)
|
||||||
|
|||||||
+55
-3
@@ -144,12 +144,12 @@
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
header .model-select {
|
header .profile-select {
|
||||||
max-width: 260px;
|
max-width: 260px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
header .model-select:disabled {
|
header .profile-select:disabled {
|
||||||
opacity: .65;
|
opacity: .65;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -475,9 +475,10 @@
|
|||||||
{{ .Title }}
|
{{ .Title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<select id="modelSelect" class="model-badge model-select" title="切换 OpenAI 配置">
|
<select id="modelSelect" class="model-badge profile-select" title="切换 OpenAI 配置">
|
||||||
<option value="{{ .OpenAIName }}">{{ .Model }}</option>
|
<option value="{{ .OpenAIName }}">{{ .Model }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="searchSelect" class="model-badge profile-select" title="切换搜索源"></select>
|
||||||
<button id="btnSearch" title="开启后,本轮提问会先联网搜索">联网搜索:关</button>
|
<button id="btnSearch" title="开启后,本轮提问会先联网搜索">联网搜索:关</button>
|
||||||
<button id="btnPreset" title="设置预先提示词">预设</button>
|
<button id="btnPreset" title="设置预先提示词">预设</button>
|
||||||
<button id="btnClear" title="开始新对话">新对话</button>
|
<button id="btnClear" title="开始新对话">新对话</button>
|
||||||
@@ -544,6 +545,8 @@ let pending = false;
|
|||||||
let webSearchEnabled = false;
|
let webSearchEnabled = false;
|
||||||
let openAIProfiles = [];
|
let openAIProfiles = [];
|
||||||
let activeOpenAIName = '{{ .OpenAIName }}';
|
let activeOpenAIName = '{{ .OpenAIName }}';
|
||||||
|
let searchProfiles = [];
|
||||||
|
let activeSearchName = '';
|
||||||
let imageB64 = ''; // 当前待发送图片的 data URI
|
let imageB64 = ''; // 当前待发送图片的 data URI
|
||||||
let imageName = '';
|
let imageName = '';
|
||||||
|
|
||||||
@@ -556,6 +559,7 @@ const btnClear = document.getElementById('btnClear');
|
|||||||
const btnPreset = document.getElementById('btnPreset');
|
const btnPreset = document.getElementById('btnPreset');
|
||||||
const btnSearch = document.getElementById('btnSearch');
|
const btnSearch = document.getElementById('btnSearch');
|
||||||
const modelSelect = document.getElementById('modelSelect');
|
const modelSelect = document.getElementById('modelSelect');
|
||||||
|
const searchSelect = document.getElementById('searchSelect');
|
||||||
const btnNewChat = document.getElementById('btnNewChat');
|
const btnNewChat = document.getElementById('btnNewChat');
|
||||||
const convList = document.getElementById('convList');
|
const convList = document.getElementById('convList');
|
||||||
const presetModal = document.getElementById('presetModal');
|
const presetModal = document.getElementById('presetModal');
|
||||||
@@ -630,6 +634,7 @@ function setInputDisabled(disabled) {
|
|||||||
fileInput.disabled = disabled;
|
fileInput.disabled = disabled;
|
||||||
btnSearch.disabled = disabled;
|
btnSearch.disabled = disabled;
|
||||||
modelSelect.disabled = disabled || openAIProfiles.length <= 1;
|
modelSelect.disabled = disabled || openAIProfiles.length <= 1;
|
||||||
|
searchSelect.disabled = disabled || searchProfiles.length <= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSearchButton() {
|
function updateSearchButton() {
|
||||||
@@ -658,6 +663,27 @@ async function loadOpenAIProfiles() {
|
|||||||
modelSelect.disabled = pending || openAIProfiles.length <= 1;
|
modelSelect.disabled = pending || openAIProfiles.length <= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSearchProfiles() {
|
||||||
|
const res = await fetch('/api/search');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: '加载搜索配置失败' }));
|
||||||
|
throw new Error(err.error || '加载搜索配置失败');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
searchProfiles = Array.isArray(data.profiles) ? data.profiles : [];
|
||||||
|
activeSearchName = data.active || '';
|
||||||
|
|
||||||
|
searchSelect.innerHTML = '';
|
||||||
|
for (const profile of searchProfiles) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = profile.name;
|
||||||
|
opt.textContent = `${profile.name} · ${profile.provider}`;
|
||||||
|
opt.selected = profile.name === activeSearchName;
|
||||||
|
searchSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
searchSelect.disabled = pending || searchProfiles.length <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 对话列表 ──────────────────────────────────────────────
|
// ── 对话列表 ──────────────────────────────────────────────
|
||||||
async function loadConversationList() {
|
async function loadConversationList() {
|
||||||
try {
|
try {
|
||||||
@@ -1063,6 +1089,31 @@ modelSelect.addEventListener('change', async () => {
|
|||||||
alert(e.message);
|
alert(e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
searchSelect.addEventListener('change', async () => {
|
||||||
|
if (pending) {
|
||||||
|
searchSelect.value = activeSearchName;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextName = searchSelect.value;
|
||||||
|
const prevName = activeSearchName;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/search/active', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: nextName }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: '切换搜索源失败' }));
|
||||||
|
throw new Error(err.error || '切换搜索源失败');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
activeSearchName = data.active;
|
||||||
|
searchSelect.value = activeSearchName;
|
||||||
|
} catch (e) {
|
||||||
|
searchSelect.value = prevName;
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
btnNewChat.addEventListener('click', newConversation);
|
btnNewChat.addEventListener('click', newConversation);
|
||||||
btnClear.addEventListener('click', newConversation);
|
btnClear.addEventListener('click', newConversation);
|
||||||
btnPreset.addEventListener('click', openPresetModal);
|
btnPreset.addEventListener('click', openPresetModal);
|
||||||
@@ -1083,6 +1134,7 @@ presetModal.addEventListener('click', e => {
|
|||||||
// 自动聚焦 & 初始化
|
// 自动聚焦 & 初始化
|
||||||
updateSearchButton();
|
updateSearchButton();
|
||||||
loadOpenAIProfiles().catch(e => alert(e.message));
|
loadOpenAIProfiles().catch(e => alert(e.message));
|
||||||
|
loadSearchProfiles().catch(e => alert(e.message));
|
||||||
loadConversationList();
|
loadConversationList();
|
||||||
inputBox.focus();
|
inputBox.focus();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user