Files
Cyrene_v2/cmd/cyrene/websearch_action_data.go
AskaEth c68387a69a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。

- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
  数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
  (设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
  缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
  布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。

⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
2026-10-07 11:10:56 +08:00

437 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 本文件是「搜索提供商」页**五个动作本体**的数据面(切片 L1/PLG-2 的执行面接线):
// 可变源的读写(含失败分类)、表格行 / 下拉候选 / 掩码回填的翻译、以及请求体形状的解析。
//
// 上游事实(`plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` /
// `plugin.reauthProvider` / `plugin.providers`)由组合根登记到插件管理器上,登记入口在
// `wire_plugin_actions.go`,探针的传输层绑定在 `websearch_probe.go`。
//
// 三条边界(与登记面同):只经 runtimeconfig 读写可变源;凭据只出掩码;形状不对就报错。
import (
"context"
"fmt"
"net/http"
"strings"
"time"
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"
)
// --- 四个动作 + 一个候选来源 -------------------------------------------------
// listProviders 是表格区块的数据源:把可变源里的各条配置翻译成面板行
// (provider / state / enabled;**凭据与地址都不进行数据**)。
func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
f, err := e.loadFile()
if err != nil {
return nil, err
}
return map[string]any{"rows": providerRows(f)}, nil
}
// listProviderOptions 给出下拉候选:本期全部已登记的 provider(含尚未配置的家)。
//
// 每项是 `{value,label}`(search.ProviderOption):**`value` 才是落盘的值** —— 它逐字等于
// search.KnownNames(),会被写进 web-search.json 并被注册表按名字取用;`label` 只是显示名
// (中文 + 括注 id),前端拿它渲染下拉与表格,任何写盘路径都不该碰它。
//
// 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增**
// 一家,只列已配置的会让第一次配置无从选起。
func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
return map[string]any{"options": search.ProviderOptions()}, nil
}
// saveProvider 新增 / 修改一条 provider 配置(表单提交,写动作)。
//
// 三态写回(与前端 `schema/values.ts:planSubmit` 逐条同义):
// - apiKey **缺位** ⇒ 保留原值(面板只持有掩码,「留空 = 不改」必须是真的);
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
//
// 同一次提交里还可能带三个**文件级**字段(`primary` / `fallback` / `maxResults`):它们不是
// 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到
// 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法**
// 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为
// 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。
//
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
//
// 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取),
// 那会让「我明明配了 tavily」变成一句假话 —— 所以在这里就拒。
func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
values, err := actionValuesObject(req.Payload)
if err != nil {
return nil, err
}
name, err := requiredProviderName(values)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
cur, err := e.loadFile()
if err != nil {
return nil, err
}
prev, existed := findProviderEntry(cur, name)
entry, err := mergeProviderEntry(prev, values)
if err != nil {
return nil, err
}
entry.Provider = name
next, setPrimary, err := e.mergeSelectionPatch(cur, values)
if err != nil {
return nil, err
}
final := runtimeconfig.UpsertProviderEntry(next, entry)
// 🔴 校验必须在**并条之后**(顺序理由见 websearch_selection.go 的 mergeSelectionPatch):
// 主 provider 配没配齐,取决于**这次刚落进文件的凭据** —— 拿旧文件校验会把
// 「第一次填 key + 同一次提交里选它」整笔回滚,连 key 一起丢。
if setPrimary {
if err := e.checkPrimaryUsable(final, final.Provider); err != nil {
return nil, err
}
}
if err := runtimeconfig.SaveWebSearch(e.path, final); 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)
}
e.log.Info("web-search provider saved from panel",
"path", e.path, "provider", name, "created", !existed, "enabled", entry.IsEnabled())
return map[string]any{
"saved": true,
"provider": name,
"created": !existed,
"enabled": entry.IsEnabled(),
// 文件级取值的**落盘后**样子(没给这几个键时也如实回显当前值):面板按它回显
// 「谁先用」,也让「这次保存有没有动到选择 / 上限」有可机读的答案。
"primary": strings.ToLower(strings.TrimSpace(final.Provider)),
"fallback": selectionNames(final.Fallback),
// maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。
"maxResults": final.MaxResults,
}, nil
}
// testProvider 对一条配置**真发一次最小检索**(行内动作「测试连通」)。
//
// 凭据与地址都从可变源读(面板里刚存下的就是它),不从前端传上来的行数据读
// —— 行数据里没有 key(列表列刻意不带凭据),也不该由客户端指定要探的目标。
//
// 失败一律回**结构化非 2xx**:面板的行内动作只在失败时显示文案(成功分支只说
// 「已完成」),把「探不通」放进 200 的 data 里等于让界面报一句假成功。
func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
name, err := providerNameOf(req.Payload)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
f, err := e.loadFile()
if err != nil {
return nil, err
}
entry, _ := findProviderEntry(f, name)
base := e.effectiveBaseURL(name, entry)
target, err := newProbeTarget(base)
if err != nil {
// 地址不合法 / 为空 ⇒ 这条没配齐(探针自己也会这么判,但这里能给出更准的话)。
return nil, pluginmgr.NewActionError(search.ProbeNotConfigured,
"这条没配齐:接口地址为空或不是 http(s) 绝对地址", http.StatusBadRequest, err)
}
cfg := search.Config{
APIKey: entry.APIKey,
BaseURL: base,
Client: target.client(e.probeTimeout(f)),
MaxBytes: webSearchProbeMaxBytes,
Guard: target.guard,
}
res := search.Probe(ctx, name, cfg, 0)
if !res.OK {
e.log.Warn("web-search panel probe failed",
"provider", name, "code", res.ErrorCode, "latencyMs", res.LatencyMS)
return nil, pluginmgr.NewActionError(res.ErrorCode, res.Error, probeErrorStatus(res.ErrorCode), nil)
}
e.log.Info("web-search panel probe ok", "provider", name, "count", res.Count, "latencyMs", res.LatencyMS)
return res, nil
}
// reauthProvider 把一条配置**带回表单**(行内动作「重配凭据」,design/09 §5.2.1 示例的
// reauth 语义):返回 `{values:{...}}`,前端把它灌进唯一的 form 区块。
//
// 两处口径:
// - apiKey 回**掩码**(读侧无明文,§5.4.2):前端对 secret 字段一律不回填输入框,
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
//
// 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单
// 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 ——
// 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 key」的保存整笔进不去。
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
name, err := providerNameOf(req.Payload)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
f, err := e.loadFile()
if err != nil {
return nil, err
}
entry, exists := findProviderEntry(f, name)
values := map[string]any{
"provider": name,
"baseUrl": entry.BaseURL,
"enabled": !exists || entry.IsEnabled(),
}
// 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端
// 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 ——
// 不回填就看不见盘上的值,改上限也就无从下手。
addSelectionValues(values, f)
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
values["apiKey"] = masked.APIKey
}
return map[string]any{"values": values}, nil
}
// --- 可变源读写(含失败分类) -------------------------------------------------
// loadFile 读可变源。
//
// 文件**缺失或为空** = 「还没配过」(启动期已由 `.env` 播种;极端情况下面板先于启动期
// 被调用)⇒ 返回空文件,不是错误。
// 文件**存在但不合法** ⇒ 结构化失败:绝不把损坏配置当成空配置 —— 那会让一次保存
// 静默盖掉一份人工写坏但可救回的内容(与启动期的处置同向:回落但不覆盖)。
func (e webSearchActionEnv) loadFile() (runtimeconfig.WebSearchFile, error) {
f, found, err := runtimeconfig.LoadWebSearch(e.path)
if err != nil {
e.log.Error("web-search panel config unreadable", "path", e.path, "error", err)
return runtimeconfig.WebSearchFile{}, pluginmgr.NewActionError(actionCodeConfigUnreadable,
"面板配置读不出来(文件损坏或权限不足),本次**没有**改动它;详见服务端日志",
http.StatusInternalServerError, err)
}
if !found {
return runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Providers: []runtimeconfig.ProviderEntry{},
}, nil
}
return f, nil
}
// providerRows 把各条配置翻译成面板表格行(凭据与地址都不进行数据)。
func providerRows(f runtimeconfig.WebSearchFile) []search.ProviderRow {
inputs := make([]search.ProviderInput, 0, len(f.Providers))
for _, p := range f.Providers {
inputs = append(inputs, search.ProviderInput{
Provider: p.Provider,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Enabled: p.IsEnabled(),
})
}
return search.BuildRows(inputs)
}
// findProviderEntry 按名字取一条配置(大小写与首尾空白不敏感,与
// UpsertProviderEntry 的匹配键同一口径)。
func findProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
want := strings.ToLower(strings.TrimSpace(name))
for _, p := range f.Providers {
if strings.EqualFold(strings.TrimSpace(p.Provider), want) {
return p, true
}
}
return runtimeconfig.ProviderEntry{}, false
}
// maskedProviderEntry 取某条配置的**掩码副本**(复用 runtimeconfig 的唯一掩码出口,
// 不在这里另写一份 maskToken)。
func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
return findProviderEntry(runtimeconfig.MaskedWebSearchFile(f), name)
}
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
// **该接入位的代码默认基地址**(`plugins/tools/io/search` 的接入位表,唯一出处)。
//
// 进位链与装配期**同一个函数**(resolveBaseURL):searxng 的默认基地址是空 —— 它的地址
// 为空本身就是「关掉这家」,回落会让「清空地址」变成假话。
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
return resolveBaseURL(name, entry.BaseURL)
}
// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它),
// 其次 `.env`/代码默认值,最后是包内兜底 —— 与工具运行时的裁决顺序一致。
func (e webSearchActionEnv) probeTimeout(f runtimeconfig.WebSearchFile) time.Duration {
if d, err := f.TimeoutDuration(); err == nil && d > 0 {
return d
}
if e.envCfg.Timeout > 0 {
return e.envCfg.Timeout
}
return webSearchProbeTimeout
}
// --- 载荷解析(形状不对就报错,不静默当空) ------------------------------------
// actionObject 取请求体的 JSON 对象。空体(nil)返回 (nil, nil):少数动作不需要参数。
func actionObject(payload any) (map[string]any, error) {
if payload == nil {
return nil, nil
}
obj, ok := payload.(map[string]any)
if !ok {
return nil, invalidPayloadActionError("请求体必须是一个 JSON 对象")
}
return obj, nil
}
// actionValuesObject 取 `{values:{...}}`(表单提交的形状,见前端 formState.useFormSubmit)。
func actionValuesObject(payload any) (map[string]any, error) {
obj, err := actionObject(payload)
if err != nil {
return nil, err
}
if obj == nil {
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
}
values, ok := obj["values"].(map[string]any)
if !ok {
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
}
return values, nil
}
// providerNameOf 从载荷里取 provider 名(三种**显式**来源,不猜字段):
// 行内动作给 `{row:{provider}}`(前端 tableState),也可给 `{values:{provider}}`
// (与表单同一形状),或顶层直接给 `provider`。
func providerNameOf(payload any) (string, error) {
obj, err := actionObject(payload)
if err != nil {
return "", err
}
if obj == nil {
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider)")
}
for _, key := range []string{"row", "values", "provider"} {
switch v := obj[key].(type) {
case string:
if key == "provider" && strings.TrimSpace(v) != "" {
return normalizeProviderName(v), nil
}
case map[string]any:
if name := actionString(v, "provider"); name != "" {
return normalizeProviderName(name), nil
}
}
}
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider,表单给 values.provider)")
}
// requiredProviderName 取表单里的 provider 名(必填)。
func requiredProviderName(values map[string]any) (string, error) {
name := actionString(values, "provider")
if name == "" {
return "", invalidPayloadActionError("provider 必填:请选择 / 填写要配置的提供商名")
}
return normalizeProviderName(name), nil
}
// requireKnownProvider 拒掉本期没登记的名字(存了也永远不会被选中;探它只会得到
// 「未登记」)。错误码与文案与 search 的探针口径一致,前端可按同一个码本地化。
func requireKnownProvider(name string) error {
if search.Known(name) {
return nil
}
return pluginmgr.NewActionError(actionCodeUnknownProvider,
fmt.Sprintf("未登记的搜索提供商 %q(本期接入位:%s)", name, strings.Join(search.KnownNames(), ", ")),
http.StatusBadRequest, nil)
}
// actionString 取一个字符串字段(去首尾空白;非字符串与空串都算「没给」)。
func actionString(m map[string]any, key string) string {
s, ok := m[key].(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// normalizeProviderName 归一 provider 名(小写 + 去空白),与注册表/可变源的匹配键同口径。
func normalizeProviderName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// mergeProviderEntry 把表单补丁合并进一条配置(三态写回,见 saveProvider 的注释)。
// enabled 给了非布尔值时**显式报错**:静默保留会让「我关了它」变成假话。
func mergeProviderEntry(prev runtimeconfig.ProviderEntry, values map[string]any) (runtimeconfig.ProviderEntry, error) {
out := prev
if v, ok := values["apiKey"]; ok {
key, err := actionOptionalString(v, "apiKey")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
out.APIKey = key
}
if v, ok := values["baseUrl"]; ok {
base, err := actionOptionalString(v, "baseUrl")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
out.BaseURL = base
}
on, set, err := actionOptionalBool(values["enabled"], "enabled")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
if set {
out.Enabled = runtimeconfig.EnableFlag(on)
}
return out, nil
}
// actionOptionalString 校验一个可选字符串字段:缺位 ⇒ 空串(调用方按「未给」处理);
// 非字符串 ⇒ 报错(把类型错静默当成空串 = 一次保存悄悄清空了某个字段)。
func actionOptionalString(v any, key string) (string, error) {
if v == nil {
return "", nil
}
s, ok := v.(string)
if !ok {
return "", invalidPayloadActionError(fmt.Sprintf("%s 必须是字符串", key))
}
return strings.TrimSpace(s), nil
}
// actionOptionalBool 解析可选开关:缺位 / nil / 空串 ⇒ 未改动;bool ⇒ 采用;
// 其它类型 ⇒ 报错(理由同 actionOptionalString)。
func actionOptionalBool(v any, key string) (value bool, set bool, err error) {
switch raw := v.(type) {
case nil:
return false, false, nil
case bool:
return raw, true, nil
case string:
if strings.TrimSpace(raw) == "" {
return false, false, nil
}
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
default:
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
}
}
// invalidPayloadActionError 造一个 400 invalid_payload(形状错 = 调用方错,不是上游错)。
func invalidPayloadActionError(message string) error {
return pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, message, http.StatusBadRequest, nil)
}