- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
(本片自己不开路由)
⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
另有 design/28 的相关注册留给主会话。
287 lines
11 KiB
Go
287 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// 本文件钉住 PLG-4:web-search 的**可变源**(`$DATA_DIR/config/plugins/web-search.json`)
|
||
// 在组合根里的语义 —— 「先读可变源、缺失回落 `.env` 播种」(design/09 §5.7 E):
|
||
// `.env` 从此只是**引导面**(缺失时播种一次),面板文件才是配置面。
|
||
//
|
||
// 单独成文件的理由:`wire_web_search_test.go` 已经接近文件行数上限(AGENTS §10),
|
||
// 而这一组是另一片(PLG-4)的判据,混进去会同时踩两条纪律。
|
||
|
||
// wsPanelPath 返回某份配置对应的可变源落点。
|
||
func wsPanelPath(cfg *config.Config) string {
|
||
return runtimeconfig.WebSearchConfigPath(cfg.DataDir)
|
||
}
|
||
|
||
// wsWriteRawPanel 手写一份面板配置(建好父目录;用于模拟人工救回 / 损坏文件)。
|
||
func wsWriteRawPanel(t *testing.T, path, content string) {
|
||
t.Helper()
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative 是可变源的主判据:
|
||
// - 文件缺失 ⇒ 由 `.env` 播种落盘(本次行为与 `.env` 一致),且**凭据不进日志**;
|
||
// - 播种之后 ⇒ 面板文件是**唯一来源**(`.env` 后来改成关闭也不影响它)。
|
||
func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) {
|
||
t.Parallel()
|
||
dir := t.TempDir()
|
||
path := runtimeconfig.WebSearchConfigPath(dir)
|
||
|
||
first := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||
"WEB_SEARCH_BOCHA_API_KEY=sk-seed",
|
||
"WEB_SEARCH_BOCHA_BASE_URL=https://seed.example",
|
||
)
|
||
first.DataDir = dir
|
||
var buf bytes.Buffer
|
||
if !wsHasTool(wsSurfaceApp(t, first, &buf), "web-search") {
|
||
t.Fatal("首次启动(.env 配好 bocha)应在注册面里")
|
||
}
|
||
out := buf.String()
|
||
if !strings.Contains(out, "已按 .env 播种") {
|
||
t.Fatalf("播种必须留下可诊断日志: %q", out)
|
||
}
|
||
if strings.Contains(out, "sk-seed") {
|
||
t.Fatalf("播种日志泄漏了凭据: %q", out)
|
||
}
|
||
|
||
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||
if err != nil || !found {
|
||
t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err)
|
||
}
|
||
if f.Provider != search.NameBocha {
|
||
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
|
||
}
|
||
if len(f.Providers) != len(search.KnownNames()) {
|
||
t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames()))
|
||
}
|
||
seeded := false
|
||
for _, e := range f.Providers {
|
||
if e.Provider != search.NameBocha {
|
||
continue
|
||
}
|
||
seeded = true
|
||
if e.APIKey != "sk-seed" || e.BaseURL != "https://seed.example" || !e.IsEnabled() {
|
||
t.Fatalf("播种的 bocha 条目 = %+v,want .env 的凭据/地址/启用", e)
|
||
}
|
||
}
|
||
if !seeded {
|
||
t.Fatal("播种里没有 bocha 条目")
|
||
}
|
||
|
||
// 第二次启动:.env 明确关掉搜索 —— 面板文件仍是唯一来源(.env 只在缺失时播种一次)。
|
||
second := wsLoadConfig(t, "WEB_SEARCH_PROVIDER=")
|
||
second.DataDir = dir
|
||
if !wsHasTool(wsSurfaceApp(t, second, nil), "web-search") {
|
||
t.Fatal(".env 改成关闭后,面板文件(bocha 已配)仍应是唯一来源")
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_PanelConfigWinsOverEnv 钉住「面板优先」不是一句口号:同一份 `.env` 下,
|
||
// 面板把 searxng 指到假上游,查询就必须真的打到那里(`.env` 的默认端点 127.0.0.1:38471
|
||
// 根本没被用)。
|
||
func TestWireIOTools_PanelConfigWinsOverEnv(t *testing.T) {
|
||
t.Parallel()
|
||
hits := make(chan *http.Request, 1)
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
hits <- r
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||
Version: runtimeconfig.WebSearchVersion,
|
||
Provider: search.NameSearXNG,
|
||
MaxResults: 5,
|
||
Providers: []runtimeconfig.ProviderEntry{
|
||
{Provider: search.NameSearXNG, BaseURL: srv.URL + "/search", Enabled: runtimeconfig.EnableFlag(true)},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("写面板配置: %v", err)
|
||
}
|
||
|
||
a := wsSurfaceApp(t, cfg, nil)
|
||
tl, err := a.caps.Get("web-search")
|
||
if err != nil {
|
||
t.Fatalf("web-search 不在注册面里: %v", err)
|
||
}
|
||
exec, ok := tl.Value.(interface {
|
||
Execute(context.Context, any) (any, error)
|
||
})
|
||
if !ok {
|
||
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
|
||
}
|
||
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
select {
|
||
case r := <-hits:
|
||
if r.URL.Path != "/search" {
|
||
t.Errorf("请求路径 = %q, want /search(面板里那个端点必须被原样使用)", r.URL.Path)
|
||
}
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("面板里的端点一个请求都没收到 —— 可变源没被读进去(还在用 .env 的默认端点)")
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_PanelConfigCorruptFallsBackToEnv:非法文件 ⇒ 记 WARN、回落 `.env`、
|
||
// **不覆盖**原文件(损坏的配置可以人工救回)。
|
||
func TestWireIOTools_PanelConfigCorruptFallsBackToEnv(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||
)
|
||
path := wsPanelPath(cfg)
|
||
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
|
||
wsWriteRawPanel(t, path, broken)
|
||
|
||
var buf bytes.Buffer
|
||
if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") {
|
||
t.Fatal("面板配置非法时必须回落 .env(这份 .env 里 bocha 是配好的)")
|
||
}
|
||
if out := buf.String(); !strings.Contains(out, "面板配置非法") {
|
||
t.Fatalf("非法文件必须留下可诊断的 WARN: %q", out)
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("read: %v", err)
|
||
}
|
||
if string(raw) != broken {
|
||
t.Fatalf("回落 .env 时不得覆盖原文件:%q", raw)
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_PanelConfigUnknownProviderIsWarned 钉住「配了却不生效必须说出来」:
|
||
// 面板文件里出现本期没登记的家 ⇒ 一条带名字的 WARN(绝不静默忽略),且不拖累整把工具。
|
||
func TestWireIOTools_PanelConfigUnknownProviderIsWarned(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||
Version: runtimeconfig.WebSearchVersion,
|
||
Provider: search.NameSearXNG,
|
||
Providers: []runtimeconfig.ProviderEntry{
|
||
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||
{Provider: "tavily", APIKey: "sk-not-impl"},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("写面板配置: %v", err)
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") {
|
||
t.Fatal("未登记的家不该把整把工具拖下水(它只是被忽略)")
|
||
}
|
||
out := buf.String()
|
||
if !strings.Contains(out, "未登记的提供商") || !strings.Contains(out, "tavily") {
|
||
t.Fatalf("未登记的家必须留下带名字的 WARN: %q", out)
|
||
}
|
||
if strings.Contains(out, "sk-not-impl") {
|
||
t.Fatalf("WARN 泄漏了凭据: %q", out)
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv 钉住「凭据以文件为准」:
|
||
// 面板文件里**没有**这一家(或它的 key 为空)= 这家没配,**不得**从 `.env` 的旧 key 复活
|
||
// —— 否则「我在面板里删了它」会变成一句假话。主 provider 此时没配齐 ⇒ 按既有语义启动报错。
|
||
func TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||
"WEB_SEARCH_BOCHA_BASE_URL=https://env.example",
|
||
)
|
||
// 面板文件里只留 searxng:bocha 在文件里**不存在**。
|
||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||
Version: runtimeconfig.WebSearchVersion,
|
||
Provider: search.NameBocha,
|
||
Providers: []runtimeconfig.ProviderEntry{
|
||
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("写面板配置: %v", err)
|
||
}
|
||
err := wsNewTools(t, cfg)
|
||
if !errors.Is(err, search.ErrNotConfigured) {
|
||
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_PanelSeedNormalizesOffSentinel 钉住播种时的关闭哨兵归一化:
|
||
// `WEB_SEARCH_ENDPOINT=off` 落盘应写成**空地址**(语义相同:不出这家),而不是把
|
||
// 字面量 "off" 当成地址存进面板(面板里不该出现一个不是地址的地址)。
|
||
func TestWireIOTools_PanelSeedNormalizesOffSentinel(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT=off")
|
||
if wsHasTool(wsSurfaceApp(t, cfg, nil), "web-search") {
|
||
t.Fatal("端点显式关闭 ⇒ web-search 不得进注册面")
|
||
}
|
||
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
|
||
if err != nil || !found {
|
||
t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err)
|
||
}
|
||
for _, e := range f.Providers {
|
||
if e.Provider != search.NameSearXNG {
|
||
continue
|
||
}
|
||
if e.BaseURL != "" {
|
||
t.Fatalf("关闭哨兵必须落成空串,得到 %q", e.BaseURL)
|
||
}
|
||
return
|
||
}
|
||
t.Fatal("播种里没有 searxng 条目")
|
||
}
|
||
|
||
// TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured 钉住「停用态真的生效」:
|
||
// 文件里把 searxng 停用(enabled=false)而它又是主 provider ⇒ 工具不进注册面
|
||
// (与 `.env` 里把端点写成 off 的既有语义一致,绝不静默回落到 .env 的端点)。
|
||
func TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||
Version: runtimeconfig.WebSearchVersion,
|
||
Provider: search.NameSearXNG,
|
||
Providers: []runtimeconfig.ProviderEntry{
|
||
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: runtimeconfig.EnableFlag(false)},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("写面板配置: %v", err)
|
||
}
|
||
var buf bytes.Buffer
|
||
a := wsSurfaceApp(t, cfg, &buf)
|
||
if wsHasTool(a, "web-search") {
|
||
t.Fatalf("searxng 被停用后 web-search 不得进注册面,实际 = %v", toolNames(a))
|
||
}
|
||
for _, name := range []string{"file", "http", "web-fetch", "knowledge", "document-read"} {
|
||
if !wsHasTool(a, name) {
|
||
t.Errorf("%q 不得因 web-search 停用而消失", name)
|
||
}
|
||
}
|
||
if out := buf.String(); !strings.Contains(out, "web-search tool not registered") {
|
||
t.Fatalf("关闭必须留一条可诊断的 WARN: %q", out)
|
||
}
|
||
}
|