feat(search): provider 热重载 + primary/fallback(W3)
- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool 窄方法,保留运行期 Policy);下一轮即生效,不要求重启 - 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable - 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填) - 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷) - 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
This commit is contained in:
1 parent
5ee5f9126f
commit
a30f4a49ff
12 files changed
+1696
-16
No files matched your search
@@ -48,6 +48,13 @@ func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.A
|
||||
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
|
||||
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
|
||||
//
|
||||
// 同一次提交里还可能带两个**文件级**字段(`primary` / `fallback`,切片 W3):它们不是这一条
|
||||
// provider 的属性,而是「谁先用 / 失败后按什么顺序降级」,因此合并到文件级而不是条目上
|
||||
// (解析与校验在 websearch_selection.go)。缺位 = 不改动,与其余字段同一套哨兵语义。
|
||||
//
|
||||
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
|
||||
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
|
||||
//
|
||||
// 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取),
|
||||
// 那会让「我明明配了 tavily」变成一句假话 —— 所以在这里就拒。
|
||||
func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
@@ -72,7 +79,11 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action
|
||||
return nil, err
|
||||
}
|
||||
entry.Provider = name
|
||||
if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(cur, entry)); err != nil {
|
||||
next, err := e.applySelectionPatch(cur, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(next, entry)); err != nil {
|
||||
e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err)
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
|
||||
"面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err)
|
||||
@@ -84,6 +95,10 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action
|
||||
"provider": name,
|
||||
"created": !existed,
|
||||
"enabled": entry.IsEnabled(),
|
||||
// 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值):
|
||||
// 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。
|
||||
"primary": strings.ToLower(strings.TrimSpace(next.Provider)),
|
||||
"fallback": selectionNames(next.Fallback),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -157,6 +172,9 @@ func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.Acti
|
||||
"baseUrl": entry.BaseURL,
|
||||
"enabled": !exists || entry.IsEnabled(),
|
||||
}
|
||||
// 两个文件级字段一并回填:不然用户「清空降级链」在前端的三态写回里会落成缺位
|
||||
// (= 不改动),「我清掉了它」就成了一句假话。
|
||||
addSelectionValues(values, f)
|
||||
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
|
||||
values["apiKey"] = masked.APIKey
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package main
|
||||
|
||||
// 本文件是「搜索提供商」页的**热重载**(切片 W3;`DECISIONS-2026-10-06` 裁决 #1):
|
||||
// 面板保存后,**重建** web-search 工具并**替换**能力注册表里的条目,使改动在下一轮对话
|
||||
// 即生效 —— 不再要求「重启后端」。
|
||||
//
|
||||
// 为什么必须做(而不是把「重启后生效」写进面板文案):面板的意义就是「不改 `.env`、
|
||||
// 不重启」,留着「重启后生效」等于把配置面做成摆设(裁决 #1 的理由原文)。design/09 §5.5
|
||||
// 的端到端判据也正是「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// 1. **一次保存 = 一次事务**:写可变源 → 重建工具 → 替换注册表条目。任何一步失败都把
|
||||
// 文件**回滚**到保存前那一份,绝不留「盘上是新的、内存是旧的」或「盘上的配置让后端
|
||||
// 下次起不来」这两种半状态。
|
||||
// 2. **重建走启动期同一条路**(`buildWebSearchTool` → `webSearchOptions`):重载后的工具
|
||||
// 与启动期构造的逐字段同源,不是第二份接线逻辑(避免两处语义漂移)。
|
||||
// 3. **替换是原子的、保留策略**:走 `registry.ReplaceTool` 而不是 Remove+Register
|
||||
// (详见该方法注释:中间无空窗,且分组/陌生人可达标记不被重置)。
|
||||
//
|
||||
// 触发面只有一处:`wire_plugin_actions.go` 把**写可变源**的那个动作(`plugin.saveProvider`)
|
||||
// 包上 `webSearchReloader.wrap`。动作本体(websearch_action_data.go 的 saveProvider)保持
|
||||
// 「只负责把这条存进去」的单一职责,重载与回滚都在本文件。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// webSearchReloadLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||
const webSearchReloadLogSource = "cmd/cyrene/websearch_reload"
|
||||
|
||||
// actionCodeConfigUnstartable 报告「这次保存的结果会让后端**下一次启动直接失败**」——
|
||||
// 文件已回滚,改动**没有**生效(406 / 409 这类「与当前状态冲突」的档,不是 500 内部错)。
|
||||
const actionCodeConfigUnstartable = "config_unstartable"
|
||||
|
||||
// webSearchRollbackFileMode 是回滚写回时的文件权限:与 runtimeconfig 落盘可变源的 0600
|
||||
// 同口径(那份文件带 API Key)。硬编码成 0600 而不是读对方的常量,是因为那个常量未导出,
|
||||
// 而变化方向是「收紧」,不存在比 0600 更松的风险。
|
||||
const webSearchRollbackFileMode os.FileMode = 0o600
|
||||
|
||||
// webSearchReloadOutcome 报告一次热重载的结局,并作为动作响应里可机读的事实。
|
||||
type webSearchReloadOutcome struct {
|
||||
// Applied 报告这次保存**真的触发了**热重载(false = 没有能力注册表可换,
|
||||
// 例如测试夹具;此时只落了盘,运行中的工具没动)。
|
||||
Applied bool `json:"applied"`
|
||||
// Registered 报告保存之后 web-search **在不在模型的工具面里**(Applied 为 true
|
||||
// 时才有意义):关掉主 provider / 端点会让它整把消失,这是设计语义,不是失败。
|
||||
Registered bool `json:"registered"`
|
||||
}
|
||||
|
||||
// webSearchReloader 把「保存可变源 → 重建工具 → 换注册表条目」做成一次事务。
|
||||
//
|
||||
// 锁的粒度是**整次保存**(含动作本体那一步):两个并发的面板保存如果交错,可能出现
|
||||
// 「A 的写 + B 的重载」这种盘上与内存对不上的组合。面板保存是低频动作,串行化的代价
|
||||
// 可以忽略,换来的是「最后一次保存定终局」这条可证的顺序。
|
||||
type webSearchReloader struct {
|
||||
mu sync.Mutex
|
||||
app *app
|
||||
path string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// webSearchActionHandlersWithHotReload 在既有的动作处理器表之上,给**写可变源**的动作
|
||||
// 套上热重载事务(其余动作不动:`testProvider` / `reauthProvider` 不改文件,无需重载)。
|
||||
//
|
||||
// 包在 `wireBuiltinPluginActions` 里调用(登记入口一处决定「谁会被热重载」),因此
|
||||
// 动作名到处理器的映射关系在这里仍然可见,不额外引入第二张表。
|
||||
func webSearchActionHandlersWithHotReload(
|
||||
env webSearchActionEnv, a *app, log *slog.Logger,
|
||||
) map[string]pluginmgr.ActionHandler {
|
||||
handlers := webSearchActionHandlers(env)
|
||||
r := &webSearchReloader{app: a, path: env.path, log: logger.WithSource(log, webSearchReloadLogSource)}
|
||||
for _, action := range []string{search.ActionSaveProvider} {
|
||||
if inner, ok := handlers[action]; ok && inner != nil {
|
||||
handlers[action] = r.wrap(inner)
|
||||
}
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
// wrap 把一次「写可变源的保存」变成事务:
|
||||
//
|
||||
// ① 快照文件(回滚的凭据);
|
||||
// ② 跑动作本体(它负责把改动落盘);
|
||||
// ③ 按**落盘后的配置**重建工具并替换注册表条目;
|
||||
// ④ ③ 失败 ⇒ 回滚文件(内存没动过,因此回滚后两处一致)并回结构化错误。
|
||||
func (r *webSearchReloader) wrap(inner pluginmgr.ActionHandler) pluginmgr.ActionHandler {
|
||||
return func(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
snap, err := r.snapshot()
|
||||
if err != nil {
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigUnreadable,
|
||||
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
|
||||
}
|
||||
res, err := inner(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome, rerr := r.reload()
|
||||
if rerr == nil {
|
||||
return withReloadOutcome(res, outcome), nil
|
||||
}
|
||||
if rberr := r.rollback(snap); rberr != nil {
|
||||
// 回滚都失败了 ⇒ 只能如实说「盘上可能已经变了」,绝不假装改动没发生。
|
||||
r.log.Error("web-search config rollback failed after an unstartable save",
|
||||
"path", r.path, "reloadError", rerr, "rollbackError", rberr)
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
|
||||
"这次改动会让 web-search 起不来,且回滚也失败了:请人工检查 "+
|
||||
r.path+"(详见服务端日志)", http.StatusInternalServerError, errors.Join(rerr, rberr))
|
||||
}
|
||||
r.log.Warn("web-search panel save rolled back: the resulting config cannot start",
|
||||
"path", r.path, "error", rerr)
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigUnstartable,
|
||||
unstartableMessage(rerr), http.StatusConflict, rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// reload 按落盘后的配置重建工具并替换注册表条目。
|
||||
//
|
||||
// 三种结局:换掉(工具该在)/摘掉(当前配置下它不该在注册面里,与启动期语义一致)/
|
||||
// 报错(这份配置起不来 ⇒ 调用方回滚)。没有能力注册表时如实报告 Applied=false。
|
||||
func (r *webSearchReloader) reload() (webSearchReloadOutcome, error) {
|
||||
if r.app == nil || r.app.cfg == nil || r.app.caps == nil {
|
||||
// 没有注册表可换(测试夹具 / 装配未完成):只落了盘,工具没动。
|
||||
// 不静默当成「换过了」——否则响应里的 registered 会是一句假话。
|
||||
r.log.Debug("web-search hot reload skipped: no capability registry",
|
||||
"path", r.path, "effect", "the running tool keeps the startup wiring")
|
||||
return webSearchReloadOutcome{}, nil
|
||||
}
|
||||
tool, err := r.app.buildWebSearchTool(webSearchCausePanelSave)
|
||||
if err != nil {
|
||||
return webSearchReloadOutcome{}, err
|
||||
}
|
||||
if tool == nil {
|
||||
return webSearchReloadOutcome{Applied: true}, r.app.removeSearchTool(r.log)
|
||||
}
|
||||
if err := r.app.replaceSearchTool(tool); err != nil {
|
||||
return webSearchReloadOutcome{}, err
|
||||
}
|
||||
r.log.Info("web-search tool hot-reloaded after panel save",
|
||||
"path", r.path, "action", search.ActionSaveProvider,
|
||||
"effect", "the next turn uses the new wiring; no restart needed")
|
||||
return webSearchReloadOutcome{Applied: true, Registered: true}, nil
|
||||
}
|
||||
|
||||
// replaceSearchTool 把重建出来的工具放进注册面:**已登记就就地替换**(原子、保留策略),
|
||||
// 之前不在注册面里(例如上一次保存把它关了)就新增。
|
||||
func (a *app) replaceSearchTool(t sdk.Tool) error {
|
||||
err := a.caps.ReplaceTool(t)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, registry.ErrNotFound) {
|
||||
return fmt.Errorf("web-search: replace tool: %w", err)
|
||||
}
|
||||
if rerr := a.caps.RegisterTool(t); rerr != nil {
|
||||
return fmt.Errorf("web-search: register tool: %w", rerr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeSearchTool 把 web-search 从注册面摘掉(当前配置下它不该在:端点/主 provider 被
|
||||
// 显式关闭,或选定 provider 没配齐)。本来就不在时是幂等的。
|
||||
func (a *app) removeSearchTool(log *slog.Logger) error {
|
||||
err := a.caps.Remove(webSearchToolName)
|
||||
if err != nil && !errors.Is(err, registry.ErrNotFound) {
|
||||
return fmt.Errorf("web-search: remove tool: %w", err)
|
||||
}
|
||||
log.Info("web-search tool removed from the registry after panel save",
|
||||
"action", search.ActionSaveProvider,
|
||||
"effect", "the model no longer sees a web-search tool aimed at a closed provider")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSnapshot 是可变源在**一次保存之前**的样子(回滚的凭据)。
|
||||
//
|
||||
// 存原始字节而不是解析后的结构:回滚的语义是「一字不差地回到那一份」。经结构往返
|
||||
// (Load + Save)会丢掉人工写在文件里的、本包不认识的键 —— 那正是「损坏配置可人工救回」
|
||||
// 场景里最不该丢的东西。
|
||||
type fileSnapshot struct {
|
||||
// exists 报告保存前文件在不在(不存在时回滚 = 把它删掉)。
|
||||
exists bool
|
||||
// raw 是保存前的原始内容(exists 为 false 时无意义)。
|
||||
raw []byte
|
||||
}
|
||||
|
||||
// snapshot 读一份保存前的快照。文件不存在 = 合法的「还没配过」状态,不是错误。
|
||||
func (r *webSearchReloader) snapshot() (fileSnapshot, error) {
|
||||
raw, err := os.ReadFile(r.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fileSnapshot{}, nil
|
||||
}
|
||||
return fileSnapshot{}, fmt.Errorf("web-search: snapshot %s: %w", r.path, err)
|
||||
}
|
||||
return fileSnapshot{exists: true, raw: raw}, nil
|
||||
}
|
||||
|
||||
// rollback 把文件恢复成快照那一份(temp+rename,与 runtimeconfig 的落盘口径同源:
|
||||
// 并发读者永远看不到半个文件)。
|
||||
func (r *webSearchReloader) rollback(snap fileSnapshot) error {
|
||||
if !snap.exists {
|
||||
if err := os.Remove(r.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("web-search: rollback remove %s: %w", r.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tmp := r.path + ".rollback.tmp"
|
||||
if err := os.WriteFile(tmp, snap.raw, webSearchRollbackFileMode); err != nil {
|
||||
return fmt.Errorf("web-search: rollback write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, r.path); err != nil {
|
||||
return fmt.Errorf("web-search: rollback rename %s: %w", r.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unstartableMessage 把「重建失败」的病因翻成**能照着改**的一句话:主 provider 未配齐
|
||||
// 与名字未登记是两种不同的修法,笼统说一句「配置不可用」等于让用户自己猜。
|
||||
func unstartableMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, search.ErrNotConfigured):
|
||||
return "这次改动会让 web-search 没有可用的后端(主提供商未配齐:缺 API Key 或接口地址)," +
|
||||
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。请先给主提供商补齐凭据,或改选一家已配齐的。"
|
||||
case errors.Is(err, search.ErrUnknownProvider):
|
||||
return "这次改动指向了一个未登记的搜索提供商(名字写错了 / 本期没做这一家)," +
|
||||
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。"
|
||||
default:
|
||||
return "这次改动会让 web-search 起不来(后端下次启动会直接失败)⇒ 已回滚,改动**没有**生效;" +
|
||||
"详见服务端日志。"
|
||||
}
|
||||
}
|
||||
|
||||
// withReloadOutcome 把热重载的结局挂到动作响应上(响应体是对象时追加一个 `hotReload`
|
||||
// 字段;不是对象时原样返回 —— 宁可少一个字段,也不把结果包成另一种形状)。
|
||||
func withReloadOutcome(res any, outcome webSearchReloadOutcome) any {
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
return res
|
||||
}
|
||||
m["hotReload"] = outcome
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住切片 W3 的**热重载**(`DECISIONS-2026-10-06` 裁决 #1):
|
||||
//
|
||||
// - 面板保存 ⇒ 重建 web-search 工具 ⇒ 替换注册表条目 ⇒ **下一轮即生效**
|
||||
// (design/09 §5.5 的端到端判据:不改 `.env`、不重启,下一次搜索真的走新配置);
|
||||
// - 主 provider / 降级链经同一个表单落到文件级字段后**立刻接上工具**;
|
||||
// - 保存结果会让后端**下次起不来**时:非 2xx + 结构化码 + **按字节回滚**;
|
||||
// - 用户真正拿到的那一面(管理端点 HTTP)也逐条核对。
|
||||
//
|
||||
// 夹具与假上游在 websearch_testenv_test.go;面板选择字段与解析口径在
|
||||
// websearch_selection_test.go。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// --- ① 保存后热重载:下一次搜索真的走新配置 -------------------------------
|
||||
|
||||
// TestWebSearchHotReloadPointsAtSavedEndpoint 是热重载的主判据(design/09 §5.5):
|
||||
// 面板把 searxng 的地址改成另一个上游 ⇒ **不重启** ⇒ 下一轮搜索必须打到新那家,
|
||||
// 且旧那家不再收到请求。同时钉住「注册表里的值是真的换了一把」(不是改了旧对象)。
|
||||
func TestWebSearchHotReloadPointsAtSavedEndpoint(t *testing.T) {
|
||||
old := newWSFakeSearxng(t, "old")
|
||||
fresh := newWSFakeSearxng(t, "fresh")
|
||||
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+old.url,
|
||||
)
|
||||
if _, err := env.search(t, "cyrene"); err != nil {
|
||||
t.Fatalf("启动期搜索失败: %v", err)
|
||||
}
|
||||
if len(old.hits) == 0 {
|
||||
t.Fatal("启动期没打到配置里的上游 —— 接线本身有问题")
|
||||
}
|
||||
before := env.toolValue(t)
|
||||
old.drain()
|
||||
|
||||
outcome := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": fresh.url, "enabled": true,
|
||||
})
|
||||
if !outcome.Applied || !outcome.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
|
||||
}
|
||||
if env.toolValue(t) == before {
|
||||
t.Fatal("注册表里的 web-search 还是启动期那一把(没被替换)")
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("保存后搜索失败: %v", err)
|
||||
}
|
||||
if len(fresh.hits) == 0 {
|
||||
t.Fatal("保存后的搜索没打到新上游 —— 改动没生效(还得重启)")
|
||||
}
|
||||
old.noHit(t)
|
||||
if got["provider"] != search.NameSearXNG {
|
||||
t.Fatalf("provider = %v, want %q", got["provider"], search.NameSearXNG)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchHotReloadKeepsCapabilityPolicy:热替换必须**保留**能力面策略
|
||||
// (分组/陌生人可达标记是运行期配置,不属于工具实现)。丢了它 = 一次面板保存让某个群的
|
||||
// 工具面悄悄缩水。
|
||||
func TestWebSearchHotReloadKeepsCapabilityPolicy(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
if err := env.app.caps.SetAllowInStranger(webSearchToolName, true); err != nil {
|
||||
t.Fatalf("SetAllowInStranger: %v", err)
|
||||
}
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
})
|
||||
pol, err := env.app.caps.Policy(webSearchToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("Policy: %v", err)
|
||||
}
|
||||
if !pol.AllowInStranger {
|
||||
t.Fatal("热替换丢掉了 AllowInStranger(策略是运行期配置,必须沿用)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ② 主 provider / 降级链:面板可配且立刻生效 -----------------------------
|
||||
|
||||
// TestWebSearchHotReloadSwitchesPrimaryAndFallback 钉住裁决 #2 的端到端语义:
|
||||
// 面板选中谁当主家,下一次搜索就走谁;主家不可用时按降级链前进并**如实标注**实际应答的家。
|
||||
func TestWebSearchHotReloadSwitchesPrimaryAndFallback(t *testing.T) {
|
||||
searxng := newWSFakeUpstream(t, http.StatusInternalServerError, `{"error":"boom"}`)
|
||||
bocha := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+searxng.url,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-bocha",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
|
||||
)
|
||||
|
||||
// 选 bocha 当主家(文件级 primary),降级链留 searxng:两者都在盘上、都已配齐。
|
||||
outcome := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameBocha, "apiKey": "sk-bocha", "baseUrl": bocha.url, "enabled": true,
|
||||
"primary": search.NameBocha,
|
||||
"fallback": []any{search.NameSearXNG},
|
||||
})
|
||||
if !outcome.Applied {
|
||||
t.Fatalf("hotReload = %+v, want applied", outcome)
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("切换主家后搜索失败: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("provider = %v, want %q(面板选的主家必须立刻生效)", got["provider"], search.NameBocha)
|
||||
}
|
||||
if len(bocha.hits) == 0 {
|
||||
t.Fatal("选中的新主家一个请求都没收到")
|
||||
}
|
||||
|
||||
// 把主家换回 searxng(它 500)⇒ 必须**降级到 bocha** 并如实标注是谁答的。
|
||||
bocha.drain()
|
||||
searxng.drain()
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": searxng.url, "enabled": true,
|
||||
"primary": search.NameSearXNG,
|
||||
"fallback": []any{search.NameBocha},
|
||||
})
|
||||
got, err = env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("降级链搜索失败: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("provider = %v, want %q(主力 500 时必须前进一格并如实标注)", got["provider"], search.NameBocha)
|
||||
}
|
||||
if len(searxng.hits) == 0 {
|
||||
t.Fatal("降级链没先试主力 searxng")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchHotReloadTogglesToolByPrimary 钉住「关掉就整把消失、再开就回来」:
|
||||
// 主 provider 写成 off 哨兵 ⇒ 模型看不见这把工具(与启动期语义一致);改回一家可用的
|
||||
// ⇒ 同一进程内它又回到注册面。
|
||||
func TestWebSearchHotReloadTogglesToolByPrimary(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
off := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"primary": "off",
|
||||
})
|
||||
if !off.Applied || off.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied=true registered=false", off)
|
||||
}
|
||||
if wsHasTool(env.app, webSearchToolName) {
|
||||
t.Fatalf("主 provider 写成 off 后 web-search 不得留在注册面里: %v", toolNames(env.app))
|
||||
}
|
||||
back := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"primary": search.NameSearXNG,
|
||||
})
|
||||
if !back.Applied || !back.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied=true registered=true", back)
|
||||
}
|
||||
if _, err := env.search(t, "cyrene"); err != nil {
|
||||
t.Fatalf("改回可用主家后搜索应当成功: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ③ 回滚:不让面板存出一份「下次起不来」的配置 ---------------------------
|
||||
|
||||
// TestWebSearchHotReloadRollsBackUnstartableSave 是安全网的判据:面板把**当前主家**的
|
||||
// 凭据清空后,这份配置会让后端下次启动直接失败(design/27 §3 的 fail-fast)⇒ 保存必须
|
||||
// 诚实失败、把文件**按字节**回滚,并且运行中的工具必须仍是原来那一把。
|
||||
func TestWebSearchHotReloadRollsBackUnstartableSave(t *testing.T) {
|
||||
bocha := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
|
||||
)
|
||||
before := env.rawFile(t)
|
||||
toolBefore := env.toolValue(t)
|
||||
|
||||
_, err := env.save(map[string]any{"provider": search.NameBocha, "apiKey": ""})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v,期望结构化 ActionError(清空主家凭据会让后端起不来)", err)
|
||||
}
|
||||
if ae.Code != actionCodeConfigUnstartable {
|
||||
t.Fatalf("失败码 = %q, want %q", ae.Code, actionCodeConfigUnstartable)
|
||||
}
|
||||
if ae.Status < 400 || ae.Status > 599 {
|
||||
t.Fatalf("失败状态 = %d,必须是非 2xx 的结构化失败", ae.Status)
|
||||
}
|
||||
if strings.Contains(ae.Message, "sk-keep") {
|
||||
t.Fatalf("失败文案泄漏了凭据: %q", ae.Message)
|
||||
}
|
||||
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("回滚后文件与保存前不逐字节相同:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
if env.toolValue(t) != toolBefore {
|
||||
t.Fatal("被回滚的保存却把注册表里的工具换掉了(盘上与内存不一致)")
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("回滚后搜索应当照常成功: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("回滚后 provider = %v, want %q", got["provider"], search.NameBocha)
|
||||
}
|
||||
if out := env.out.String(); !strings.Contains(out, "rolled back") {
|
||||
t.Fatalf("回滚必须留下可诊断的日志: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReloadWithoutRegistryIsHonest:没有能力注册表(夹具 / 装配未完成)时,
|
||||
// 热重载**如实报告 applied=false**,绝不假装换过了 —— 否则响应里的 registered 会是一句假话。
|
||||
func TestWebSearchReloadWithoutRegistryIsHonest(t *testing.T) {
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
// 故意不设 caps:这正是既有动作夹具(actionTestEnv)的形状。
|
||||
a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr}
|
||||
wirePanelHub(a)
|
||||
res, err := mgr.Panels().Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionSaveProvider,
|
||||
Payload: map[string]any{"values": map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": "http://127.0.0.1:38471", "enabled": true,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
outcome, ok := res.(map[string]any)["hotReload"].(webSearchReloadOutcome)
|
||||
if !ok {
|
||||
t.Fatalf("响应里没有 hotReload: %#v", res)
|
||||
}
|
||||
if outcome.Applied || outcome.Registered {
|
||||
t.Fatalf("没有注册表可换时必须如实报 applied=false: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ④ 用户真正拿到的那一面:管理端点(HTTP)---------------------------------
|
||||
|
||||
// adminActionHTTP 把组合根装配出来的动作端点架起来,返回 base URL 与 owner 令牌。
|
||||
//
|
||||
// 网关这一段(鉴权 → 声明 → 分发 → 状态码映射)不是本片改的,但用户拿到的是它:
|
||||
// 只在动作中枢上验,没法证明「契约里那个 409 真的到得了客户端」。
|
||||
func adminActionHTTP(t *testing.T, env *reloadTestEnv) (string, string) {
|
||||
t.Helper()
|
||||
jwtSvc := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
|
||||
gw, err := gateway.New(&config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: obsTestSecret},
|
||||
}, gateway.Deps{Log: testLog(), Auth: jwtSvc, Plugins: env.app.plugins})
|
||||
if err != nil {
|
||||
t.Fatalf("gateway.New: %v", err)
|
||||
}
|
||||
t.Cleanup(gw.Close)
|
||||
ts := httptest.NewServer(gw.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
tok, err := jwtSvc.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return ts.URL + "/api/v1/admin/plugins/" + search.PanelPluginID + "/actions/" + search.ActionSaveProvider, tok
|
||||
}
|
||||
|
||||
// TestWebSearchHotReloadOverAdminHTTP 核对成功路径的 HTTP 契约:200 信封里的
|
||||
// `result.hotReload` 必须如实,而且保存后下一次搜索**真的**打到新上游。
|
||||
func TestWebSearchHotReloadOverAdminHTTP(t *testing.T) {
|
||||
cloud := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
|
||||
)
|
||||
url, tok := adminActionHTTP(t, env)
|
||||
|
||||
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": cloud.url, "enabled": true,
|
||||
"primary": search.NameBocha,
|
||||
}})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("保存状态 = %d, want 200(body=%s)", status, raw)
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Action string `json:"action"`
|
||||
Kind string `json:"kind"`
|
||||
Result struct {
|
||||
Saved bool `json:"saved"`
|
||||
HotReload struct {
|
||||
Applied bool `json:"applied"`
|
||||
Registered bool `json:"registered"`
|
||||
} `json:"hotReload"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, raw)
|
||||
}
|
||||
if body.Data.Kind != string(pluginmgr.ActionWrite) {
|
||||
t.Errorf("kind = %q, want %q(写动作必须按写面鉴权)", body.Data.Kind, pluginmgr.ActionWrite)
|
||||
}
|
||||
if !body.Data.Result.Saved || !body.Data.Result.HotReload.Applied || !body.Data.Result.HotReload.Registered {
|
||||
t.Fatalf("响应 envelope 里的结果不如实: %s", raw)
|
||||
}
|
||||
got, err := env.search(t, "cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP 保存后搜索失败: %v", err)
|
||||
}
|
||||
if got["provider"] != search.NameBocha {
|
||||
t.Fatalf("provider = %v, want %q(HTTP 保存也必须热重载)", got["provider"], search.NameBocha)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchSaveOverAdminHTTPRollsBackUnstartable 核对失败路径的 HTTP 契约:
|
||||
// **非 2xx** + `{error:{code,message}}`,且盘上文件真的回到了保存前。
|
||||
func TestWebSearchSaveOverAdminHTTPRollsBackUnstartable(t *testing.T) {
|
||||
cloud := newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
|
||||
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
|
||||
)
|
||||
url, tok := adminActionHTTP(t, env)
|
||||
before := env.rawFile(t)
|
||||
|
||||
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
|
||||
"provider": search.NameBocha, "apiKey": "",
|
||||
}})
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("状态 = %d, want 409(body=%s)", status, raw)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode error body: %v (body=%s)", err, raw)
|
||||
}
|
||||
if body.Error.Code != actionCodeConfigUnstartable {
|
||||
t.Fatalf("错误码 = %q, want %q(body=%s)", body.Error.Code, actionCodeConfigUnstartable, raw)
|
||||
}
|
||||
if strings.Contains(body.Error.Message, "sk-keep") || body.Error.Message == "" {
|
||||
t.Fatalf("错误文案 = %q(不得为空、不得含凭据)", body.Error.Message)
|
||||
}
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("HTTP 层的回滚不彻底:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// wsPostAction 向管理动作端点发一次 POST(Bearer + JSON 体),返回状态码与响应体。
|
||||
func wsPostAction(t *testing.T, url, token string, payload any) (int, []byte) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", url, err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
// 本文件是「搜索提供商」页表单里**两个文件级字段**的解析与落盘(切片 W3;
|
||||
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`)。
|
||||
//
|
||||
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
|
||||
//
|
||||
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
|
||||
// - `primary` / `fallback` 描述**整份配置的选择**(文件级的 `provider` / `fallback`,
|
||||
// 即 design/27 §3 的「主后端 + 降级链」)。
|
||||
//
|
||||
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
|
||||
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
|
||||
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这两项作为文件级
|
||||
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
|
||||
//
|
||||
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
|
||||
// 两个语义不同的东西共用一个 key 会让「我在改谁」变成一句要猜的话。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 表单里两个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / search.FieldFallback):
|
||||
// 它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),而
|
||||
// 「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
||||
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
|
||||
const (
|
||||
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
|
||||
selectionPrimaryKey = search.FieldPrimary
|
||||
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
|
||||
selectionFallbackKey = search.FieldFallback
|
||||
)
|
||||
|
||||
// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」——
|
||||
// 这不是一次可以放着不管的保存:那份配置会让后端**下次启动直接失败**(design/27 §3 的
|
||||
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
|
||||
const actionCodePrimaryNotConfigured = "primary_not_configured"
|
||||
|
||||
// applySelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**)。
|
||||
//
|
||||
// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释):
|
||||
// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与
|
||||
// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。
|
||||
//
|
||||
// 校验放在写盘之前(主 provider 必须已配齐),因为「先存下来、下次启动才发现起不来」
|
||||
// 是本项目明确要避免的失败形态(AGENTS §10 的「诚实性」与 design/27 §3 的 fail-fast)。
|
||||
func (e webSearchActionEnv) applySelectionPatch(
|
||||
f runtimeconfig.WebSearchFile, values map[string]any,
|
||||
) (runtimeconfig.WebSearchFile, error) {
|
||||
primary, setPrimary, err := selectionPrimary(values)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
fallback, setFallback, err := selectionFallback(values)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
if setPrimary {
|
||||
f.Provider = primary
|
||||
}
|
||||
if setFallback {
|
||||
f.Fallback = fallback
|
||||
}
|
||||
if setPrimary {
|
||||
if err := e.checkPrimaryUsable(f, f.Provider); err != nil {
|
||||
return f, err
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
|
||||
//
|
||||
// 判定口径与工具接线同源(searchProviderSpecs + effectiveBaseURL):云家看 API Key、
|
||||
// searxng 看接口地址,基地址缺位时回落到代码默认值;条目被显式停用视同没配
|
||||
// (effectiveSearchConfig 会跳过它)。
|
||||
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
|
||||
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
|
||||
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
|
||||
if config.IsWebSearchOffSentinel(name) {
|
||||
return nil
|
||||
}
|
||||
if err := requireKnownProvider(name); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, found := findProviderEntry(f, name)
|
||||
base := ""
|
||||
if found {
|
||||
base = e.effectiveBaseURL(name, entry)
|
||||
}
|
||||
if !found || !entry.IsEnabled() || !search.Configured(name, entry.APIKey, base) {
|
||||
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
|
||||
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
|
||||
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
|
||||
http.StatusBadRequest, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
|
||||
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
|
||||
func selectionNames(names []string) []string {
|
||||
if names == nil {
|
||||
return []string{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// addSelectionValues 把盘上的文件级选择回填进 `reauth` 的 values。
|
||||
//
|
||||
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
|
||||
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
|
||||
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
||||
values[selectionPrimaryKey] = f.Provider
|
||||
fb := f.Fallback
|
||||
if fb == nil {
|
||||
fb = []string{}
|
||||
}
|
||||
values[selectionFallbackKey] = fb
|
||||
}
|
||||
|
||||
// selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。
|
||||
func selectionPrimary(values map[string]any) (string, bool, error) {
|
||||
raw, ok := values[selectionPrimaryKey]
|
||||
if !ok || raw == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", false, invalidPayloadActionError(
|
||||
fmt.Sprintf("%s 必须是字符串(提供商名)", selectionPrimaryKey))
|
||||
}
|
||||
return normalizeProviderName(s), true, nil
|
||||
}
|
||||
|
||||
// selectionFallback 解析降级链字段:缺位 ⇒ (…, false, nil);形状不对 / 名字未登记 ⇒ 400。
|
||||
func selectionFallback(values map[string]any) ([]string, bool, error) {
|
||||
raw, ok := values[selectionFallbackKey]
|
||||
if !ok || raw == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
items, err := selectionItems(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
name := normalizeProviderName(item)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
// 未登记的家存进降级链也永远不会被选中(注册表按名字取)⇒ 这里就拒,与
|
||||
// 条目保存的 requireKnownProvider 同一个码与同一句文案。
|
||||
if err := requireKnownProvider(name); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// selectionItems 把多选值规范化成字符串切片。
|
||||
//
|
||||
// 三种形状都接受:
|
||||
// - `[]string`(Go 侧直接构造的载荷,测试与内部调用用);
|
||||
// - `[]any`(JSON 解码后的数组 —— 前端 multiSelect 送的就是它);
|
||||
// - 逗号 / 顿号 / 换行分隔的字符串(与前端控件「逗号分隔」的呈现同源,人工 curl 友好)。
|
||||
//
|
||||
// 其余类型一律 400:静默当空数组 = 一次保存悄悄清空降级链(与 apiKey 的类型错同一条口径)。
|
||||
func selectionItems(raw any) ([]string, error) {
|
||||
switch v := raw.(type) {
|
||||
case []string:
|
||||
return v, nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for i, item := range v {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, invalidPayloadActionError(
|
||||
fmt.Sprintf("%s[%d] 必须是字符串(提供商名)", selectionFallbackKey, i))
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
case string:
|
||||
return strings.FieldsFunc(v, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == '、' || r == ';' || r == '\n'
|
||||
}), nil
|
||||
default:
|
||||
return nil, invalidPayloadActionError(
|
||||
fmt.Sprintf("%s 必须是字符串数组(如 [\"bocha\",\"zhipu\"])", selectionFallbackKey))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住切片 W3 的另外三条判据(`DECISIONS-2026-10-06` 裁决 #2 / 点 ③ / 点 ④):
|
||||
//
|
||||
// - **主 provider / 降级链可配**:两个文件级字段(`primary` / `fallback`)经表单落到
|
||||
// 可变源,回填(reauth)也带着它们;没配齐的主家**写盘之前**就拒掉;
|
||||
// - **主 provider 解析口径**(配了可变源 / 只配 env / 都没配)各选谁;
|
||||
// - **探针失败如实**:非 2xx + 结构化码,不存在「200 带 ok:false」这条路径。
|
||||
//
|
||||
// 夹具与假上游在 websearch_testenv_test.go;热重载本体在 websearch_reload_test.go。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// --- ① 面板校验:选一个没配齐的主家当场拒掉 ---------------------------------
|
||||
|
||||
// TestWebSearchSelectionRejectsUnusablePrimary 钉住「写盘之前就拦」:主 provider 选了
|
||||
// 一家没配齐的(缺 key)必须回 400 primary_not_configured,且**盘上一个字节都没变**
|
||||
// (这不是「存下来再说」,那份配置会让后端下次起不来)。
|
||||
func TestWebSearchSelectionRejectsUnusablePrimary(t *testing.T) {
|
||||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
before := env.rawFile(t)
|
||||
|
||||
_, err := env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "primary": search.NameZhipu,
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok || ae.Code != actionCodePrimaryNotConfigured || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("err = %#v, want 400 %s", err, actionCodePrimaryNotConfigured)
|
||||
}
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
|
||||
// 未登记的名字:与条目级保存同一个码同一句口径。
|
||||
_, err = env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "primary": "tavily",
|
||||
})
|
||||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
|
||||
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
|
||||
}
|
||||
// 降级链里的未登记名字同样拒(它永远不会被选中,存下去就是假话)。
|
||||
_, err = env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "fallback": []any{"tavily"},
|
||||
})
|
||||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
|
||||
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
|
||||
}
|
||||
// 形状错(不是数组)⇒ 400,绝不静默当空数组清空降级链。
|
||||
_, err = env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "enabled": true, "fallback": 42,
|
||||
})
|
||||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != pluginmgr.ActionCodeInvalidPayload {
|
||||
t.Fatalf("err = %#v, want %s", err, pluginmgr.ActionCodeInvalidPayload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchSelectionAcceptsConfiguredCloudFallback 钉住「降级链允许放还没配齐的家」:
|
||||
// 未配置的家在运行期会被跳过(不是错误),所以面板存得下去;而主家必须是配齐的。
|
||||
// 同时钉住归一化:去空白、去重、丢空项。
|
||||
func TestWebSearchSelectionAcceptsConfiguredCloudFallback(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"fallback": []any{search.NameZhipu, " bocha ", search.NameZhipu, ""},
|
||||
})
|
||||
if got := env.loadFile(t).Fallback; strings.Join(got, ",") != search.NameZhipu+","+search.NameBocha {
|
||||
t.Fatalf("降级链落盘 = %v, want [zhipu bocha]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReauthEchoesSelection:表单回填必须带上两个文件级字段,否则用户
|
||||
// 「清空降级链」在前端的三态写回里会落成「缺位 = 不改动」。
|
||||
func TestWebSearchReauthEchoesSelection(t *testing.T) {
|
||||
up := newWSFakeSearxng(t, "a")
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"primary": search.NameSearXNG, "fallback": []any{search.NameBocha},
|
||||
})
|
||||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reauth: %v", err)
|
||||
}
|
||||
values, _ := out.(map[string]any)["values"].(map[string]any)
|
||||
if values == nil {
|
||||
t.Fatalf("reauth 结果缺 values: %#v", out)
|
||||
}
|
||||
if values[search.FieldPrimary] != search.NameSearXNG {
|
||||
t.Fatalf("回填的 primary = %v, want %q", values[search.FieldPrimary], search.NameSearXNG)
|
||||
}
|
||||
fb, ok := values[search.FieldFallback].([]string)
|
||||
if !ok || strings.Join(fb, ",") != search.NameBocha {
|
||||
t.Fatalf("回填的 fallback = %#v, want [%s]", values[search.FieldFallback], search.NameBocha)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ② 主 provider 解析口径(三情形 + 清空)---------------------------------
|
||||
|
||||
// TestWebSearchPrimaryResolutionFromEnvOnly:**只配 `.env`**(可变源文件缺失)⇒ 选 `.env`
|
||||
// 那家,来源标 `env`,且播种真的落了盘。
|
||||
func TestWebSearchPrimaryResolutionFromEnvOnly(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||
)
|
||||
wsSurfaceApp(t, cfg, &out)
|
||||
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
|
||||
t.Fatalf("primary = %q, want %q(.env 是唯一来源)", got, search.NameBocha)
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourceEnv {
|
||||
t.Fatalf("configSource = %q, want %q", got, webSearchSourceEnv)
|
||||
}
|
||||
if _, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg)); err != nil || !found {
|
||||
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPrimaryResolutionPanelWins:**配了可变源** ⇒ 选文件里那家(`.env` 说的
|
||||
// 另一家不作数),来源标 panel。
|
||||
func TestWebSearchPrimaryResolutionPanelWins(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
|
||||
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-zhipu",
|
||||
)
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameBocha,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
a := wsSurfaceApp(t, cfg, &out)
|
||||
if !wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("面板里 bocha 已配齐,工具应在注册面里: %v", toolNames(a))
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
|
||||
t.Fatalf("primary = %q, want %q(面板文件是唯一来源,.env 说的 zhipu 不作数)", got, search.NameBocha)
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourcePanel {
|
||||
t.Fatalf("configSource = %q, want %q", got, webSearchSourcePanel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPrimaryResolutionDefaultsToSearxng:**都没配** ⇒ 代码默认 searxng
|
||||
// (本机兜底端点),工具照常在注册面里。
|
||||
func TestWebSearchPrimaryResolutionDefaultsToSearxng(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t)
|
||||
a := wsSurfaceApp(t, cfg, &out)
|
||||
if !wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("都没配时代码默认(searxng 本机兜底)应当生效: %v", toolNames(a))
|
||||
}
|
||||
if got := wsLogField(t, out.String(), "primary"); got != search.NameSearXNG {
|
||||
t.Fatalf("primary = %q, want %q(代码默认值)", got, search.NameSearXNG)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPrimaryResolutionClearedFileMeansOff:文件里把 provider 清空 ⇒ **关闭**
|
||||
// (不是回落 `.env`)——「清空 = 关」是文件形状里写死的语义,面板与 `.env` 不许有两套。
|
||||
func TestWebSearchPrimaryResolutionClearedFileMeansOff(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||
)
|
||||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
// 显式清空主 provider:哨兵语义 = 关掉整把工具。
|
||||
Provider: "off",
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
a := wsSurfaceApp(t, cfg, &out)
|
||||
if wsHasTool(a, webSearchToolName) {
|
||||
t.Fatalf("文件里显式关闭(off)⇒ 工具不得进注册面: %v", toolNames(a))
|
||||
}
|
||||
if out := out.String(); !strings.Contains(out, "web-search tool not registered") {
|
||||
t.Fatalf("关闭必须留下可诊断的 WARN: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ③ 探针失败如实:非 2xx + 结构化码,绝不「200 带 ok:false」----------------
|
||||
|
||||
// TestWebSearchProbeFailuresAreStructuredNon2xx 钉住裁决 #4(点 ④):每一次探针失败都必须是
|
||||
// **结构化非 2xx**。前端行内动作只在失败时显示文案,把「探不通」放进 200 的 data 里
|
||||
// 等于让界面报一句假成功。
|
||||
//
|
||||
// 探针的成功/失败分类由 search.Probe 给出(那里已有单测),这里查的是**动作面**有没有
|
||||
// 把失败原样交出去:码稳定、状态码落在 4xx/5xx、文案不带上游原文。
|
||||
func TestWebSearchProbeFailuresAreStructuredNon2xx(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantCode string
|
||||
want int
|
||||
}{
|
||||
{"凭据被拒", http.StatusUnauthorized, `{"error":"bad key sk-leak"}`, search.ProbeUnauthorized, http.StatusBadGateway},
|
||||
{"被限流", http.StatusTooManyRequests, `{}`, search.ProbeRateLimited, http.StatusBadGateway},
|
||||
{"上游 5xx", http.StatusInternalServerError, `{}`, search.ProbeUpstream, http.StatusBadGateway},
|
||||
{"应答不是预期结构", http.StatusOK, `not json`, search.ProbeBadResponse, http.StatusBadGateway},
|
||||
{"连通但零结果", http.StatusOK, `{"results":[]}`, search.ProbeNoResults, http.StatusBadGateway},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
up := newWSFakeUpstream(t, tc.status, tc.body)
|
||||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
})
|
||||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
|
||||
})
|
||||
if err == nil {
|
||||
// 「200 带 ok:false」正是这条判据要堵的路:要么成功(ok=true 的结果),
|
||||
// 要么结构化失败,没有第三种。
|
||||
if res, ok := out.(search.ProbeResult); ok && !res.OK {
|
||||
t.Fatalf("探针失败却回了成功响应(200 + ok:false):%+v", res)
|
||||
}
|
||||
t.Fatalf("探针失败却回了 200(结果 = %#v)", out)
|
||||
}
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v,期望结构化 ActionError", err)
|
||||
}
|
||||
if ae.Code != tc.wantCode {
|
||||
t.Fatalf("失败码 = %q, want %q", ae.Code, tc.wantCode)
|
||||
}
|
||||
if ae.Status != tc.want {
|
||||
t.Fatalf("失败状态 = %d, want %d(非 2xx 才算如实)", ae.Status, tc.want)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"code": ae.Code, "message": ae.Message})
|
||||
if strings.Contains(string(raw), "sk-leak") {
|
||||
t.Fatalf("失败文案泄漏了上游原文: %s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchProbeNotConfiguredIs400:没配齐是**调用方/配置**的问题(400),
|
||||
// 不是上游不可用(502)—— 前端据此区分「该改配置」还是「该重试」。
|
||||
func TestWebSearchProbeNotConfiguredIs400(t *testing.T) {
|
||||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
_, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": search.NameZhipu}},
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
// 本文件是切片 W3 那几组测试的**夹具**(`newReloadTestEnv` / 假上游 / 日志字段读取):
|
||||
// 单独成一个测试文件,是为了让三个主题文件(热重载、面板选择字段、解析口径与探针)
|
||||
// 各自都在 AGENTS §10 的文件行数之内,且共享的接线只写一份。
|
||||
//
|
||||
// 夹具的关键差别(相对 wire_plugin_actions_test.go 的 actionTestEnv):它带
|
||||
// `a.caps` / `a.dispatcher` —— 热重载换的就是注册表里的条目,没有能力面的夹具
|
||||
// 根本证明不了这件事(那正是 L1/PLG-2 当时如实登记的缺口)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// reloadTestEnv 是一个「组合根真的接好了能力面 + 面板动作面」的夹具。
|
||||
type reloadTestEnv struct {
|
||||
app *app
|
||||
hub *pluginmgr.Panels
|
||||
disp *tool.Dispatcher
|
||||
path string
|
||||
out *bytes.Buffer
|
||||
}
|
||||
|
||||
func newReloadTestEnv(t *testing.T, extra ...string) *reloadTestEnv {
|
||||
t.Helper()
|
||||
cfg := wsLoadConfig(t, extra...)
|
||||
b := bus.New()
|
||||
t.Cleanup(func() { _ = b.Close() })
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||||
t.Cleanup(func() { mgr.Close(context.Background()) })
|
||||
|
||||
var out bytes.Buffer
|
||||
caps := registry.New()
|
||||
disp, err := tool.New(caps)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{
|
||||
cfg: cfg,
|
||||
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
|
||||
bus: b,
|
||||
plugins: mgr,
|
||||
caps: caps,
|
||||
dispatcher: disp,
|
||||
}
|
||||
// 启动期那条路(与 wsSurfaceApp 同源):ioToolOptions → iotools.New → 注册面。
|
||||
set, err := iotools.New(cfg, a.ioToolOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
for _, tl := range set {
|
||||
if rerr := disp.Register(tl); rerr != nil {
|
||||
t.Fatalf("register %s: %v", tl.Name(), rerr)
|
||||
}
|
||||
}
|
||||
wirePanelHub(a)
|
||||
if mgr.Panels() == nil {
|
||||
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
|
||||
}
|
||||
return &reloadTestEnv{
|
||||
app: a, hub: mgr.Panels(), disp: disp,
|
||||
path: runtimeconfig.WebSearchConfigPath(cfg.DataDir), out: &out,
|
||||
}
|
||||
}
|
||||
|
||||
// save 走保存动作(表单形状 `{values:{...}}`),返回结果的**原始 any 与错误**。
|
||||
func (e *reloadTestEnv) save(values map[string]any) (any, error) {
|
||||
return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID,
|
||||
Action: search.ActionSaveProvider,
|
||||
Payload: map[string]any{"values": values},
|
||||
})
|
||||
}
|
||||
|
||||
// mustSave 要求保存成功,并返回响应里的 `hotReload` 块(缺了就失败:它是本片新增的
|
||||
// 「这次保存有没有热重载」的可机读事实)。
|
||||
func (e *reloadTestEnv) mustSave(t *testing.T, values map[string]any) webSearchReloadOutcome {
|
||||
t.Helper()
|
||||
res, err := e.save(values)
|
||||
if err != nil {
|
||||
t.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("保存结果类型 = %T", res)
|
||||
}
|
||||
outcome, ok := m["hotReload"].(webSearchReloadOutcome)
|
||||
if !ok {
|
||||
t.Fatalf("响应里没有 hotReload(热重载没被接线): %#v", m)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
// search 走**分派器**(模型真正调工具的那条路)打一次 web-search,返回线格式结果。
|
||||
func (e *reloadTestEnv) search(t *testing.T, query string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
got, err := e.disp.Invoke(context.Background(), webSearchToolName, map[string]any{"query": query})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("工具返回值类型 = %T", got)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// toolValue 取注册面里那一把 web-search 的**当前**值(不在注册面里就失败)。
|
||||
func (e *reloadTestEnv) toolValue(t *testing.T) any {
|
||||
t.Helper()
|
||||
cap, err := e.app.caps.Get(webSearchToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(e.app))
|
||||
}
|
||||
return cap.Value
|
||||
}
|
||||
|
||||
// rawFile 读可变源的**原始字节**(「字节级没变」是回滚判据里最强的那一种)。
|
||||
func (e *reloadTestEnv) rawFile(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(e.path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.path, err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// loadFile 读盘上的可变源。
|
||||
func (e *reloadTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile {
|
||||
t.Helper()
|
||||
f, found, err := runtimeconfig.LoadWebSearch(e.path)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("LoadWebSearch(%s): found=%v err=%v", e.path, found, err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// wsFakeUpstream 是一个假搜索上游:记下每一次命中,按预设状态码与响应体作答。
|
||||
type wsFakeUpstream struct {
|
||||
url string
|
||||
hits chan *http.Request
|
||||
}
|
||||
|
||||
func newWSFakeUpstream(t *testing.T, status int, body string) *wsFakeUpstream {
|
||||
t.Helper()
|
||||
hits := make(chan *http.Request, 8)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits <- r
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &wsFakeUpstream{url: srv.URL, hits: hits}
|
||||
}
|
||||
|
||||
// newWSFakeSearxng 是一个「答一条结果」的 searxng 假上游(成功路径用)。
|
||||
func newWSFakeSearxng(t *testing.T, title string) *wsFakeUpstream {
|
||||
t.Helper()
|
||||
return newWSFakeUpstream(t, http.StatusOK,
|
||||
`{"results":[{"title":"`+title+`","url":"http://x.example/1","content":"`+title+`"}]}`)
|
||||
}
|
||||
|
||||
// drain 清掉已经排队的命中(用于「重载之后打的是不是新那家」的判定)。
|
||||
func (f *wsFakeUpstream) drain() {
|
||||
for {
|
||||
select {
|
||||
case <-f.hits:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noHit 断言此刻没有新的命中(重载后**不该**再打到旧那家)。
|
||||
func (f *wsFakeUpstream) noHit(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case r := <-f.hits:
|
||||
t.Fatalf("旧上游又收到一个请求(%s)—— 重载没真的换掉工具", r.URL)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// wsLogField 从启动日志里取一个字段值(slog TextHandler 的 `key=value`)。
|
||||
func wsLogField(t *testing.T, out, key string) string {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`(?:^|\s)` + regexp.QuoteMeta(key) + `=(\S+)`)
|
||||
m := re.FindStringSubmatch(out)
|
||||
if m == nil {
|
||||
t.Fatalf("启动日志里找不到 %s=…: %q", key, out)
|
||||
}
|
||||
return strings.Trim(m[1], `"`)
|
||||
}
|
||||
@@ -23,6 +23,11 @@ package main
|
||||
// **启动期报错**,绝不静默回落到兜底。
|
||||
// 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||||
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
||||
//
|
||||
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
|
||||
// `buildWebSearchTool` 按**当前落盘配置**重建这一把工具并替换注册表条目,因此
|
||||
// 「启动期的接线」与「保存后的接线」恒为同一条路径(不是两份语义)。两份调用的差别
|
||||
// 只有日志里的 cause 字段(startup / panel-save),便于排障时区分。
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -32,6 +37,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
@@ -57,6 +63,21 @@ const (
|
||||
webSearchSourcePanel = "panel"
|
||||
)
|
||||
|
||||
// 解析一次 web-search 配置的**触发场景**(只进日志的 cause 字段)。
|
||||
const (
|
||||
// webSearchCauseStartup 是启动期装配(`ioToolOptions`)。
|
||||
webSearchCauseStartup = "startup"
|
||||
// webSearchCausePanelSave 是面板保存后的热重载(`buildWebSearchTool`,见 websearch_reload.go)。
|
||||
webSearchCausePanelSave = "panel-save"
|
||||
)
|
||||
|
||||
// webSearchToolName 是 web-search 工具在能力注册表里的名字。
|
||||
//
|
||||
// 它与 io 包 `func (s *webSearchTool) Name()` 里的字面量同源,而那个名字没有导出常量
|
||||
// (`plugins/tools/io` 不在本片文件面内,不去改它):因此这里用字面量,并由
|
||||
// websearch_reload_test.go 钉住 —— 名字一旦漂移,热重载会立刻找不到工具(测试红)。
|
||||
const webSearchToolName = "web-search"
|
||||
|
||||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||||
func (a *app) ioToolOptions() []iotools.Option {
|
||||
@@ -64,12 +85,41 @@ func (a *app) ioToolOptions() []iotools.Option {
|
||||
iotools.WithKnowledgeRetriever(a.retriever),
|
||||
iotools.WithLogger(a.log),
|
||||
}
|
||||
return append(opts, a.webSearchOptions()...)
|
||||
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
|
||||
}
|
||||
|
||||
// webSearchOptions 把生效配置翻译成 io 选项。
|
||||
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
|
||||
// 重建工具,见 websearch_reload.go)。
|
||||
//
|
||||
// 三种返回值,调用方必须分开处置:
|
||||
// - (tool, nil) ⇒ 正常,工具该在注册面里;
|
||||
// - (nil, nil) ⇒ 当前配置下它**不该在注册面里**(端点/主 provider 显式关闭,或选定
|
||||
// provider 没配齐被 WithoutSearch 摘掉)—— 调用方据此把旧条目摘掉;
|
||||
// - (nil, err) ⇒ 这份配置会让后端**下次启动直接失败**(主 provider 未登记/未配置,
|
||||
// design/27 §3 的 fail-fast)—— 调用方必须如实报错,绝不留下一个起不来的文件。
|
||||
//
|
||||
// 为什么要构造整份 io 工具集再挑出 web-search:io 包的公开构造入口只有
|
||||
// `iotools.New(cfg, opts...)`(整份集合),没有「只造 web-search」的入口,而
|
||||
// `plugins/tools/io` 不在本片文件面内。代价是一次 New(几个工具对象 + 一个 http.Client
|
||||
// 壳;Transport 是共享的 http.DefaultTransport,不新开连接池)—— 面板保存是低频动作,
|
||||
// 付得起,换来的是**重载后的工具与启动期构造的逐字段同源**(不是第二份接线逻辑)。
|
||||
func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
|
||||
set, err := iotools.New(a.cfg, a.webSearchOptions(cause)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range set {
|
||||
if t.Name() == webSearchToolName {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// webSearchOptions 把生效配置翻译成 io 选项。cause 说明**为什么**解析这一次(启动装配 /
|
||||
// 面板保存后的热重载),只进日志:同一个函数被两处调用,日志里必须能分清是哪一次。
|
||||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||||
func (a *app) webSearchOptions() []iotools.Option {
|
||||
func (a *app) webSearchOptions(cause string) []iotools.Option {
|
||||
log := logger.WithSource(a.log, ioLogSource)
|
||||
ws, source := a.effectiveWebSearch(log)
|
||||
|
||||
@@ -77,7 +127,8 @@ func (a *app) webSearchOptions() []iotools.Option {
|
||||
if decision := ws.ResolveProvider(); decision.Disabled {
|
||||
// 配置值**绝不写进日志**(只记键名与原因)。
|
||||
log.Warn("web-search tool not registered",
|
||||
"env", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "source", source,
|
||||
"selectorKey", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "configSource", source,
|
||||
"cause", cause,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
@@ -89,14 +140,17 @@ func (a *app) webSearchOptions() []iotools.Option {
|
||||
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
||||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) {
|
||||
log.Warn("web-search tool not registered",
|
||||
"env", webSearchEndpointEnv, "reason", endpoint.Reason, "source", source,
|
||||
"endpointKey", webSearchEndpointEnv, "reason", endpoint.Reason, "configSource", source,
|
||||
"cause", cause,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
log.Info("web-search providers configured",
|
||||
"env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||||
"env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback, "source", source,
|
||||
// primary 是**生效值**(可能来自面板文件、也可能来自 `.env` 播种)⇒ 它旁边的键名只是
|
||||
// 「这个键在哪份配置里」,而 source 才是「本次的值从哪来」。两者都打出来,不混为一谈。
|
||||
log.Info("web-search providers configured", "cause", cause,
|
||||
"selectorKey", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||||
"fallbackKey", webSearchFallbackEnv, "fallback", ws.Fallback, "configSource", source,
|
||||
"available", availableProviderNames(specs))
|
||||
|
||||
opts := []iotools.Option{
|
||||
@@ -111,7 +165,7 @@ func (a *app) webSearchOptions() []iotools.Option {
|
||||
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
||||
case ws.Timeout < 0:
|
||||
log.Warn("web-search timeout is negative, treated as unset",
|
||||
"env", webSearchTimeoutEnv, "source", source,
|
||||
"key", webSearchTimeoutEnv, "configSource", source, "cause", cause,
|
||||
"effect", "io package default timeout applies")
|
||||
}
|
||||
return opts
|
||||
|
||||
@@ -8,8 +8,10 @@ package main
|
||||
//
|
||||
// 三个文件的分工(都在本包):
|
||||
// - 本文件:登记入口、处理器环境、动作名映射、「声明了却没实现」的装配期核对;
|
||||
// - `websearch_action_data.go`:五个动作的业务数据面(可变源的读写与翻译、载荷解析);
|
||||
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射)。
|
||||
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
|
||||
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
|
||||
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
|
||||
// - `websearch_selection.go`:表单里两个文件级字段(主 provider / 降级链)的读写。
|
||||
//
|
||||
// 三条边界:
|
||||
//
|
||||
@@ -20,8 +22,9 @@ package main
|
||||
// 3. **凭据不出后端**:读侧只回掩码(`MaskedWebSearchFile`),日志只记 provider 名与
|
||||
// 结果码,绝不记 key / 地址。
|
||||
//
|
||||
// 本片不做的事(如实登记,见报告 §未做):不热替换**运行中**的 web-search 工具 ——
|
||||
// 面板保存后工具仍用启动期解析出的 provider 接线,改动在下次重启生效。
|
||||
// 热重载(切片 W3;裁决 2026-10-06 #1):保存动作被 `webSearchActionHandlersWithHotReload`
|
||||
// 包上事务 —— 落盘成功后重建 web-search 工具并替换注册表条目,**下一轮对话即生效**
|
||||
// (design/09 §5.5 的端到端判据「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」)。
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -68,7 +71,7 @@ func wireBuiltinPluginActions(mgr *pluginmgr.Manager, hub *pluginmgr.Panels, a *
|
||||
"pluginId", search.PanelPluginID, "effect", "panel actions will answer 501")
|
||||
return
|
||||
}
|
||||
handlers := webSearchActionHandlers(env)
|
||||
handlers := webSearchActionHandlersWithHotReload(env, a, log)
|
||||
for optionsFrom, handler := range webSearchOptionsHandlers(env) {
|
||||
name := pluginmgr.OptionsActionName(optionsFrom)
|
||||
if name == "" {
|
||||
|
||||
@@ -237,6 +237,44 @@ func (r *Registry) RegisterTool(t sdk.Tool) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
|
||||
// 面板保存后下一轮即用新接线,不必重启)。
|
||||
//
|
||||
// 与「Remove + Register」的两处实质差别,正是这个窄方法存在的理由:
|
||||
// - **原子**:替换在一次写锁内完成,读者中间看不到「这个名字不存在」的空窗
|
||||
// (Remove+Register 之间若有 Invoke 落到这个名字上,会得到 ErrUnknownTool);
|
||||
// - **保留 Policy**:分组/陌生人的可达标记是**运行期可改的配置**(§11 §3),
|
||||
// 不属于工具实现。初始注册时带的标记没有进 persisted 快照,Remove+Register
|
||||
// 会把它悄悄重置成零值(= 退回 owner-only),用户会看到「面板保存后某个群
|
||||
// 忽然不能用了」。这里原样沿用内存里的既有标记。
|
||||
//
|
||||
// 没有同名条目时返回 ErrNotFound(**不**顺手新增:热替换的语义是「换掉现在这把」,
|
||||
// 名字打错时静默插入会掩盖调用方的错);既有条目不是 KindTool 时返回 ErrInvalidArgument。
|
||||
func (r *Registry) ReplaceTool(t sdk.Tool) error {
|
||||
if t == nil || t.Name() == "" {
|
||||
return fmt.Errorf("registry: replace tool: %w", ErrInvalidArgument)
|
||||
}
|
||||
name := t.Name()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cur, ok := r.caps[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("registry: replace %q: %w", name, ErrNotFound)
|
||||
}
|
||||
if cur.Kind != KindTool {
|
||||
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是工具: %w", name, cur.Kind, ErrInvalidArgument)
|
||||
}
|
||||
r.caps[name] = Capability{
|
||||
Name: name,
|
||||
Kind: KindTool,
|
||||
Description: t.Description(),
|
||||
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
|
||||
Policy: cur.Policy,
|
||||
Value: t,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterSkill registers s as a KindSkill capability.
|
||||
func (r *Registry) RegisterSkill(s sdk.Skill) error {
|
||||
if s == nil {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件钉住切片 W3 起的**窄方法** `ReplaceTool`(热替换,design/09 §5.5):
|
||||
// 面板保存后重建出来的同名工具要能就地换掉旧条目,且换完不丢「用户改过的策略」。
|
||||
//
|
||||
// 单独成文件的理由:registry_test.go 在 HEAD 上就没过 gofmt(存量),
|
||||
// 本片不动它(不顺手格式化会产生与主题无关的 diff)。
|
||||
|
||||
// stubTool 是一个名字可指定的最小工具(只用于注册/替换的判定)。
|
||||
type stubTool struct {
|
||||
name string
|
||||
desc string
|
||||
}
|
||||
|
||||
func (s stubTool) Name() string { return s.name }
|
||||
func (s stubTool) Description() string { return s.desc }
|
||||
func (s *stubTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object"}, nil
|
||||
}
|
||||
func (s *stubTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil }
|
||||
|
||||
var _ sdk.Tool = (*stubTool)(nil)
|
||||
|
||||
// TestReplaceToolSwapsValueAndKeepsPolicy 是热替换的主判据:
|
||||
// - Value 换成新工具(调用方随后 Invoke 拿到的就是新的那一把);
|
||||
// - Description 跟着新工具走(它是工具实现的事实);
|
||||
// - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于工具实现;
|
||||
// 丢掉它 = 一次面板保存让某个群忽然不能用了。
|
||||
func TestReplaceToolSwapsValueAndKeepsPolicy(t *testing.T) {
|
||||
r := New()
|
||||
if err := r.RegisterTool(&stubTool{name: "web-search", desc: "old"}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if err := r.SetAllowInGroup("web-search", true); err != nil {
|
||||
t.Fatalf("SetAllowInGroup: %v", err)
|
||||
}
|
||||
|
||||
next := &stubTool{name: "web-search", desc: "new"}
|
||||
if err := r.ReplaceTool(next); err != nil {
|
||||
t.Fatalf("ReplaceTool: %v", err)
|
||||
}
|
||||
cap, err := r.Get("web-search")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if cap.Value != sdk.Tool(next) {
|
||||
t.Fatalf("替换后 Value = %#v, want 新的那一把", cap.Value)
|
||||
}
|
||||
if cap.Description != "new" {
|
||||
t.Fatalf("替换后 Description = %q, want %q(描述属于工具实现,跟着换)", cap.Description, "new")
|
||||
}
|
||||
if !cap.Policy.AllowInGroup {
|
||||
t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于工具构造产出,必须沿用")
|
||||
}
|
||||
if cap.Kind != KindTool {
|
||||
t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindTool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceToolReportsMissingAndInvalid:两种**不该被静默吞掉**的调用错误。
|
||||
func TestReplaceToolReportsMissingAndInvalid(t *testing.T) {
|
||||
r := New()
|
||||
err := r.ReplaceTool(&stubTool{name: "web-search"})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("替换不存在的名字 err = %v, want ErrNotFound(不顺手新增:名字打错时静默插入会掩盖调用方的错)", err)
|
||||
}
|
||||
if err := r.RegisterTool(&stubTool{name: "nope"}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if err := r.ReplaceTool(nil); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceToolRefusesNonToolEntry:同名条目不是工具时拒绝,绝不悄悄改掉它的 Kind
|
||||
// (把一条渠道/MCP 能力换成工具会让「这条能力是什么」变成一件靠时间猜的事)。
|
||||
func TestReplaceToolRefusesNonToolEntry(t *testing.T) {
|
||||
r := New()
|
||||
if err := r.Register(Capability{Name: "web-search", Kind: KindMCP, Value: struct{}{}}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
err := r.ReplaceTool(&stubTool{name: "web-search"})
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("err = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
cap, gerr := r.Get("web-search")
|
||||
if gerr != nil {
|
||||
t.Fatalf("Get: %v", gerr)
|
||||
}
|
||||
if cap.Kind != KindMCP {
|
||||
t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,19 @@ func ActionNames() []string {
|
||||
return []string{ActionListProviders, ActionSaveProvider, ActionTestProvider, ActionReauthProvider}
|
||||
}
|
||||
|
||||
// 表单里两个**文件级**字段的 key(切片 W3;裁决 2026-10-06 #2)。
|
||||
//
|
||||
// 与其余字段的区别:`provider` / `apiKey` / `baseUrl` / `enabled` 描述**一条 provider 条目**,
|
||||
// 而这两个描述**整份配置的选择** —— design/27 §3 的「主后端 + 降级链」(可变源里的文件级
|
||||
// `provider` / `fallback`)。它们必须与组合根动作面读的 key 逐字一致(那里引用这两个常量,
|
||||
// 不另写字面量):声明里写一个 key、处理器读另一个,表现就是「我选了主提供商但它没变」。
|
||||
const (
|
||||
// FieldPrimary 是主 provider 的字段 key。
|
||||
FieldPrimary = "primary"
|
||||
// FieldFallback 是降级链的字段 key(多选,按顺序依次尝试)。
|
||||
FieldFallback = "fallback"
|
||||
)
|
||||
|
||||
// AdminPanels 返回本插件声明的管理页面(design/09 §5.2 示例的 providers 页)。
|
||||
//
|
||||
// 两处相对示例的**明确增补**(示例是形状示范,不是字段清单的穷举;两处都被本片的任务书
|
||||
@@ -55,12 +68,20 @@ func ActionNames() []string {
|
||||
// - 表单多一个 `enabled` 开关:任务书要求每家 provider **独立启用态**;
|
||||
// - 表单的 `baseUrl` 用示例的 `endpoint` 语义(searxng 的端点 = 那家的基地址),
|
||||
// 名字跟可变源的字段名走,免得面板字段名与文件字段名对不上。
|
||||
//
|
||||
// 第三处增补(切片 W3;裁决 2026-10-06 #2):表单末尾两个**文件级**字段
|
||||
// `primary` / `fallback`(主 provider 与降级链)。它们跟同一条 form 走 submit 而不是另开
|
||||
// 一个 form 区块,是因为前端的「带回表单」(reauth)只在面板**恰好一个** form 时转交:
|
||||
// 加第二个 form 会让既有的「重配凭据」整条回填失灵(那是客户端行为,本片不改前端)。
|
||||
// 两个字段的写侧语义见 `cmd/cyrene/websearch_selection.go`。
|
||||
func AdminPanels() []sdk.Panel {
|
||||
return []sdk.Panel{{
|
||||
ID: PanelIDProviders,
|
||||
Title: "搜索提供商",
|
||||
Description: "为 web-search 能力配置多个第三方搜索后端:每条独立凭据与基地址," +
|
||||
"可单独启停并测试连通。改动落 $DATA_DIR/config/plugins/web-search.json,重启后仍然生效。",
|
||||
"可单独启停并测试连通,并指定先用哪家、失败后按什么顺序降级。" +
|
||||
"改动落 $DATA_DIR/config/plugins/web-search.json,**保存后立即生效**" +
|
||||
"(下一轮对话即用新配置,无需重启)。",
|
||||
Group: sdk.GroupPlugins,
|
||||
Order: 20,
|
||||
Icon: "search",
|
||||
@@ -83,6 +104,15 @@ func AdminPanels() []sdk.Panel {
|
||||
{Key: "baseUrl", Label: "接口地址", Type: sdk.FieldText,
|
||||
Placeholder: "https://..."},
|
||||
{Key: "enabled", Label: "启用", Type: sdk.FieldSwitch},
|
||||
// 下面两项是**文件级**的(不是上面这条 provider 的字段):谁先用、
|
||||
// 失败后按什么顺序降级。留空 = 不改动;primary 写成 off / none = 关掉
|
||||
// 整把 web-search(模型看不见它)。
|
||||
{Key: FieldPrimary, Label: "主提供商", Type: sdk.FieldSelect,
|
||||
OptionsFrom: "plugin:providers",
|
||||
Placeholder: "留空 = 不改动;写 off / none = 关掉 web-search"},
|
||||
{Key: FieldFallback, Label: "降级链", Type: sdk.FieldMultiSelect,
|
||||
OptionsFrom: "plugin:providers",
|
||||
Placeholder: "按顺序依次尝试(逗号分隔);留空 = 不改动"},
|
||||
},
|
||||
Submit: &sdk.Submit{Action: ActionSaveProvider},
|
||||
},
|
||||
|
||||
@@ -207,6 +207,83 @@ func TestDeclaredActionNamesMatchDesignExample(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑨(切片 W3;裁决 2026-10-06 #2):表单必须能配「谁先用」——`primary` 与 `fallback`
|
||||
// 两个**文件级**字段就位,且都从 `plugin:providers` 拉候选(否则控件降级成文本框,
|
||||
// 用户得自己记住 provider 名)。
|
||||
func TestAdminPanelsDeclareSelectionFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
form := adminPanelsFormSection(t)
|
||||
byKey := make(map[string]sdk.Field, len(form.Fields))
|
||||
for _, f := range form.Fields {
|
||||
byKey[f.Key] = f
|
||||
}
|
||||
cases := []struct {
|
||||
key string
|
||||
wantType sdk.FieldType
|
||||
wantOptions string
|
||||
}{
|
||||
{FieldPrimary, sdk.FieldSelect, "plugin:providers"},
|
||||
{FieldFallback, sdk.FieldMultiSelect, "plugin:providers"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
f, ok := byKey[tc.key]
|
||||
if !ok {
|
||||
t.Errorf("表单里缺 %q 字段(主 provider / 降级链就没法配了): %+v", tc.key, byKey)
|
||||
continue
|
||||
}
|
||||
if f.Type != tc.wantType {
|
||||
t.Errorf("字段 %q 类型 = %q, want %q", tc.key, f.Type, tc.wantType)
|
||||
}
|
||||
if f.OptionsFrom != tc.wantOptions {
|
||||
t.Errorf("字段 %q 的 optionsFrom = %q, want %q(下拉必须拉得到候选)", tc.key, f.OptionsFrom, tc.wantOptions)
|
||||
}
|
||||
}
|
||||
// 两个 key 不能互相撞,也不能撞上条目级字段:撞了就是「我在改谁」要猜。
|
||||
if FieldPrimary == FieldFallback {
|
||||
t.Fatalf("两个文件级字段的 key 相同(%q)", FieldPrimary)
|
||||
}
|
||||
for _, taken := range []string{"provider", "apiKey", "baseUrl", "enabled"} {
|
||||
if FieldPrimary == taken || FieldFallback == taken {
|
||||
t.Fatalf("文件级字段的 key 撞上了条目级字段 %q", taken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑩(切片 W3):面板**恰好一个 form 区块**这条约束是有原因的,钉在这里免得后人
|
||||
// 顺手拆成两个表单:
|
||||
//
|
||||
// 前端的「带回表单」(reauth)只在恰好一个 form 时转交(`PluginPanel.tsx` 的 singleForm:
|
||||
// 两个表单没有归属,宁可不填也不猜)。第二处增补因此必须挂在既有表单上 —— 拆成两个 form
|
||||
// 会让「重配凭据」的回填整条失灵(那是客户端行为,改它不在本片文件面内)。
|
||||
func TestAdminPanelsKeepASingleFormSection(t *testing.T) {
|
||||
t.Parallel()
|
||||
forms := 0
|
||||
for _, p := range AdminPanels() {
|
||||
for _, s := range p.Sections {
|
||||
if s.Type == sdk.SectionForm {
|
||||
forms++
|
||||
}
|
||||
}
|
||||
}
|
||||
if forms != 1 {
|
||||
t.Fatalf("面板里有 %d 个 form 区块,want 1(多一个会让前端的 reauth 回填失效)", forms)
|
||||
}
|
||||
}
|
||||
|
||||
// adminPanelsFormSection 取唯一那个 form 区块(没有就失败)。
|
||||
func adminPanelsFormSection(t *testing.T) sdk.Section {
|
||||
t.Helper()
|
||||
for _, p := range AdminPanels() {
|
||||
for _, s := range p.Sections {
|
||||
if s.Type == sdk.SectionForm {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("面板里没有 form 区块")
|
||||
return sdk.Section{}
|
||||
}
|
||||
|
||||
// contains 是字符串切片成员判定(测试里只此一处,不值得抽包)。
|
||||
func contains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
|
||||
Reference in new issue
Block a user