diff --git a/internal/core/channels_log_panel_test.go b/internal/core/channels_log_panel_test.go new file mode 100644 index 0000000..140487d --- /dev/null +++ b/internal/core/channels_log_panel_test.go @@ -0,0 +1,140 @@ +package core + +import ( + "regexp" + "strings" + "testing" +) + +// ★ 插件日志面板:级别 + 归属 + 同文折叠(否则用户动作被 96% 的周期噪音淹没)。 +// +// 真实问题(生产实测):50 条日志里 **48 条(96%)** 是三个定时任务 +// (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now) +// 的重复回音,唯一模式只有 8 种 ⇒ 约 2 小时就把 50 条环形缓冲冲满一轮, +// **真正的用户动作**(如「账号删除」)被挤到看不见。 +// +// 三个结构性修法,每个都在源码里可断言: +// +// ① logEvent 支持 level(task/warn/error),前端默认折叠 task +// ② logEvent 支持 platform,前端按选中渠道过滤(修"点 buddy 看到 trae"的归属错误) +// ③ 同文折叠:紧邻同文不新增条目,只 n=n+1 +func TestChannelsLogEventCarriesLevelAndPlatform(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + + // ① 签名必须收 level 与 platform(三参) + if !regexp.MustCompile(`local function logEvent\(msg, level, platform\)`).MatchString(s) { + t.Error("logEvent 必须接收 (msg, level, platform) —— 否则级别与渠道归属都无从表达") + } + // ② 写入的条目必须带上这两个字段 + for _, field := range []string{"level = level or", "platform = platform"} { + if !strings.Contains(s, field) { + t.Errorf("日志条目必须写入 %s", field) + } + } + // ③ 同文折叠:紧邻同文时只加计数 + if !strings.Contains(s, "top.n = (top.n or 1) + 1") { + t.Error("必须实现紧邻同文折叠(否则周期任务的重复回音会把 50 条缓冲冲满)") + } + // ④ 折叠必须只针对**紧邻**条目 —— 否则一条每小时出现的消息会不断跳到最前, + // 时间线失去意义。断言比较对象是 log[1](栈顶)而不是任意条目。 + if !strings.Contains(s, "local top = log[1]") { + t.Error("折叠必须只比较**紧邻**的上一条(log[1]);对任意条目折叠会让时间线错乱") + } + t.Log("✓ logEvent 具备 level / platform / 紧邻同文折叠") +} + +// ★ 周期任务的**常规**结果必须是 task 级(默认折叠),异常必须是 warn/error。 +// +// 判据咬的是"这些具体调用点被降级了",而不是"存在 task 这个词" —— +// 后者只要有一处就为真,测不出"某个噪音源忘了降级"。 +func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + + // 这些是「每次跑都成功」的常规回音 —— 必须 task 级 + routine := []string{ + `logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")`, + `logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")`, + `logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")`, + } + for _, r := range routine { + if !strings.Contains(s, r) { + t.Errorf("周期任务的常规结果必须降级为 task(否则会淹没用户动作):\n %s", r) + } + } + // 这些是异常 —— 必须 warn(不能被一起折叠掉) + for _, pat := range []string{ + `logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120), "warn")`, + `logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120), "warn")`, + `logEvent("凭据续期失败 " .. #failed .. " 个:`, + } { + if !strings.Contains(s, pat) { + t.Errorf("异常必须保持 warn(不能被当常规噪音折叠):%s", pat) + } + } + // 「已同步源」是周期重同步的常规回音 —— 降级为 task + if !strings.Contains(s, `logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)`) { + t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属") + } + t.Log("✓ 周期噪音已降级,异常保持 warn") +} + +// ★ 前端的三个过滤维度必须都在(级别门槛 / 例行折叠 / 按渠道过滤)。 +func TestChannelsLogPanelHasFilters(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + + // ① 日志卡片必须**独立于渠道详情**(C 的核心:修正归属) + // 判据:日志卡片的 DOM 不是 ch-detail 的子元素。 + // 做法:取 ch-detail 开始到 ch-log-card 之间的片段,要求中间出现过 + // ch-detail 的闭合(即两者是兄弟而非父子)。 + iDetail := strings.Index(s, `id="ch-detail"`) + iLogCard := strings.Index(s, `id="ch-log-card"`) + if iDetail < 0 || iLogCard < 0 { + t.Fatal("找不到 ch-detail 或 ch-log-card(判据需更新)") + } + if iLogCard < iDetail { + t.Fatal("运行日志卡片应在渠道详情**之后**(作为兄弟节点)") + } + // 统计 ch-detail 打开到日志卡片之间未闭合的 div 层数 + seg := s[iDetail:iLogCard] + depth := 0 + for _, m := range regexp.MustCompile(` 0 { + t.Errorf("运行日志卡片仍嵌在渠道详情内部(depth=%d)⇒ 点开某渠道会看到别的渠道的日志(归属错误)", depth) + } + + // ② 三个过滤维度 + for _, id := range []string{`id="ch-log-verbose"`, `id="ch-log-level"`} { + if !strings.Contains(s, id) { + t.Errorf("缺少日志视图控件 %s", id) + } + } + if !strings.Contains(s, "function chLogRender()") { + t.Error("必须实现 chLogRender()(否则过滤逻辑无处落地)") + } + // ③ 按渠道过滤 + 切渠道重渲染 + if !strings.Contains(s, "if (selPlat) return e.platform === selPlat;") { + t.Error("必须按当前选中渠道过滤日志(否则归属错误以另一种形式回来)") + } + if !strings.Contains(s, "chLogRender();\n };\n") { + t.Error("chSelect 切换渠道后必须重渲染日志") + } + t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备") +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 09831da..a117778 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -2772,9 +2772,42 @@ do if sh then sh:close() end end -local function logEvent(msg) - table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg }) - while #plugin.state.log > 50 do table.remove(plugin.state.log) end +-- logEvent 写一条插件事件日志(带级别与渠道归属)。 +-- +-- ★ 为什么需要「级别 + 折叠」,而不是继续往一个裸数组里追加: +-- 实测生产 50 条日志里 **48 条(96%)** 是三个定时任务 +-- (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now) +-- 的重复回音,唯一模式只有 8 种 ⇒ 约 **2 小时**就把 50 条环形缓冲冲满一轮, +-- **真正的用户动作**(如「账号删除」)被挤到看不见 —— 面板沦为 +-- 「最近两小时的定时任务回音」。 +-- +-- 三条措施: +-- ① `level` ∈ info|task|warn|error。定时任务的**常规**结果记 "task" +-- (前端默认折叠),只有异常才 warn/error。 +-- ② **紧邻同文折叠**:与**上一条**完全同文时不新增条目,只 n=n+1 +-- 并刷新时间戳。周期任务的连续回音于是合并成一条(显示 ×9), +-- 而无需给每个调用点判定「这次结果变了没」(那要引入状态比较)。 +-- ⚠️ 只在**紧邻**时折叠:否则一条每小时出现的消息会不断"跳"到最前, +-- 时间线就失去意义。 +-- ③ `platform`:记录归属,前端按当前选中渠道过滤。 +-- ⚠️ 历史 state 里的老条目没有这个字段,前端必须容忍 nil。 +local function logEvent(msg, level, platform) + local log = plugin.state.log + local now = os.time and os.time() or 0 + local top = log[1] + if top and top.msg == msg and (top.level or "info") == (level or "info") then + top.n = (top.n or 1) + 1 + top.at = now + markDirty() + return + end + table.insert(log, 1, { + at = now, + msg = msg, + level = level or "info", + platform = platform, + }) + while #log > 50 do table.remove(log) end markDirty() end @@ -3779,7 +3812,7 @@ local function syncAccountSource(provider, accountId) -- 只在**上一次不是同一条**时记,避免每次同步都刷一条 if not tostring(last):find("撤源(重建)失败", 1, true) then logEvent("撤源(重建)失败 " .. tostring(name) .. ": " - .. tostring(rerr or "(无错误信息)")) + .. tostring(rerr or "(无错误信息)"), "warn", provider) end end local src = { @@ -3812,11 +3845,11 @@ local function syncAccountSource(provider, accountId) -- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会 -- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。 if aerr then - logEvent("接入源失败 " .. name .. ": " .. tostring(aerr)) + logEvent("接入源失败 " .. name .. ": " .. tostring(aerr), "error", provider) elseif r and r.json and r.json.ok == false then - logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error)) + logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error), "error", provider) else - logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") + logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider) end end @@ -3839,7 +3872,8 @@ local function dropAccountSource(provider, accountId) local r, err = __llmsproxy_kernel.call("remove_source", { name = name }) if r == nil or r.ok ~= true then logEvent("撤源失败 " .. tostring(name) .. ": " .. tostring(err or "(无错误信息)") - .. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)") + .. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)", + "error", provider) return false, err end return true @@ -6550,7 +6584,7 @@ function plugin.refresh_models() end -- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误) if cooled > 0 then - logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台") + logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task") end -- 顺带刷新模型能力元数据(照参考 opencode-capability)。 -- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。 @@ -6558,19 +6592,27 @@ function plugin.refresh_models() if not cok then -- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权" -- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。 - logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120)) + logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120), "warn") elseif cres == true then - logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)") + logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task") elseif cerr then - logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120)) + logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120), "warn") end if updated > 0 then - logEvent("模型目录已刷新(" .. updated .. " 个平台)") + logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task") end -- 失败留痕但**不清缓存**(refreshRemoteModels 内部已保证): -- 拉不到时继续用上次的好数据,绝不能把目录清空。 if #failed > 0 then - logEvent("模型刷新失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 200)) + -- ★ 逐平台各记一条(而不是把 N 个挤成一条汇总):这样前端按渠道过滤时, + -- 点开 trae 就只看到 trae 的刷新失败。原先挤成一条汇总是**归属丢失**的 + -- 一个来源 —— 它既不属于 trae 也不属于 minimax,于是只能在「全部渠道」里 + -- 出现,点进任何单渠道都看不到自己的问题。 + for _, f in ipairs(failed) do + -- failed 项形如 ": ",拆出平台名做归属 + local pid = tostring(f):match("^([%w_%-]+):") or nil + logEvent("模型刷新失败 " .. tostring(f), "warn", pid) + end end end @@ -6619,9 +6661,9 @@ function plugin.refresh_creds() end end end - if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end + if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个", "task") end if #failed > 0 then - logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300)) + logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300), "warn") end end @@ -10738,8 +10780,31 @@ plugin.ui = { -

最近日志

- + + +
+

+ 运行日志 + + + +

+
+
    +