Compare commits

...

2 Commits

Author SHA1 Message Date
91f6d1f32f fix(channels): 日志截断按字符(中文被切出 U+FFFD)+ 面板排版
## 真实缺陷(v2.0.33 上线后在截图里看到)
日志尾显示成:`能力增强不�`
根因:`tostring(cerr):sub(1, 120)` —— **Lua 的 sub 按字节切**,而中文占 3 字节
⇒ 切在字符中间产生**替换字符 U+FFFD**。一句本来完整的诊断("…能力增强不可用,
已退回兜底表")看起来像**数据损坏**,实际只是截断方式错。

生产实测复现:该条 120 字节截断后,末尾正好落在"不"字的第 1 字节上,
python 侧解码得到 `…能力增强不\ufffd`。**修法**:`plugin.utf8Clip(s, maxBytes)`
先按字节切、再回退到最后一个完整 UTF-8 序列边界(最多退 2 字节)。
三处中文文案截断全部改用它。

## 判据(1 条静态 + 1 条行为 + 变异 1/1)
- `TestLogTruncationIsUTF8Safe`(静态):助手存在 / 三处中文截断不再用裸 sub /
  未超长时原样返回 / 回退逻辑处理孤立首字节
- `TestUtf8ClipBehaviour`(**行为**,经 `plugin.__utf8_clip_probe` 走真实函数):
  对 6 个长度断言 `utf8.ValidString(out)` 且不超字节上限。静态断言看不出
  "结果是否真的合法 UTF-8",所以必须有这一条 —— 实测 n=120 从
  `…能力增强不�`(非法)变为 `…能力增强不`(合法)
- 变异:把 utf8Clip 换回裸 sub → 报红

## 排版抛光(截图里可见的三处)
- 头部控件原用 `<h2>` + flex,checkbox 被挤到换行且文字竖排 ⇒ 改 `<b>` +
  独立 flex 行 + `white-space:nowrap`
- 长诊断在窄卡片里被**截断**(而截断的尾巴正是最该看的部分)⇒ `word-break`
- 列表加 `line-height`,可读性

## 兼容性
历史 state 里的 41 条老条目没有 `level` 字段 —— 前端 `e.level || "info"`
已容忍,**不需要数据迁移**(判据亦未假设字段必然存在)。
2026-10-16 17:44:04 +08:00
c1048dcd4e fix(channels): 运行日志加级别/归属/折叠,并从渠道详情移出(C+A)
## 问题(生产实测,非猜测)
用户问「为什么 WebUI 默认显示插件全部日志」。用 CDP 打开真实 UI + 读
`/channels/status` 实测,发现是**三个问题叠加**,而表层症状是"看到的全是噪音":

### ① 归属错误:全局日志渲染在「单个渠道详情」里
`plugin.state.log` 是**单一全局数组**(`logEvent` 写入的条目只有 `{at, msg}`,
**连 platform 字段都没有**),但这个 `<ul id="ch-log">` 的 DOM 位于
`#ch-detail`(点开某渠道才显示)⇒ 用户点开 buddy,看到的却是
trae/minimax/qoder 的日志混在一起,**无从区分**。

### ② 50 条里 48 条(96%)是周期任务噪音
实测分类:`已同步源…` 11×、`模型刷新失败…` 9×、`模型目录已刷新` 9×、
`能力元数据未刷新` 9×、`自动领取…` 9× —— **唯一模式只有 8 种**。
原因:`refresh_models`(2h) + `auto_claim`(2h) + `refresh_creds`(0.5h) 三个
`first_due_now` 任务每次跑写 3~5 条 ⇒ **约 2 小时就把 50 条环形缓冲冲满**,
于是真正的用户动作(如「账号删除」)被挤到看不见。

### ③ 无级别、无去重、无折叠 → 噪音与错误同权重并排

## 修法(用户选 C+A)
**C —— 修正归属**:把「运行日志」从 `#ch-detail` 移出,成为**常驻的独立卡片**
(`#ch-log-card`)。判据断言它与渠道详情是**兄弟节点**(div depth=0),
不是父子 —— 否则"点 buddy 看到 trae"会以另一种形式回来。

**A —— 加级别与过滤**:
- `logEvent(msg, level, platform)`:level ∈ info|task|warn|error。
  定时任务的**常规**结果记 `task`(前端默认折叠),只有异常升为 warn/error。
- **紧邻同文折叠**:与 `log[1]` 完全同文时不新增条目,只 `n=n+1` 并刷新时间。
  ⚠️ 只对**紧邻**条目折叠 —— 否则一条每小时出现的消息会不断"跳"到最前,
  时间线失去意义。前端显示成 `×N`,既保留"重复了多少次"又不刷屏。
- 三个视图开关:级别门槛(全部/警告及以上/仅错误)、显示例行任务、
  按当前选中渠道过滤(不带 platform 的跨平台汇总只在「全部渠道」出现)。
- `refresh_models` 的失败改为**逐平台各记一条**(原先 N 个挤成一条汇总,
  既不属于 trae 也不属于 minimax ⇒ 点进任何单渠道都看不到自己的问题)。

## 判据(3 条 + 变异 2/2)
- `TestChannelsLogEventCarriesLevelAndPlatform`:签名三参 / 条目带两字段 /
  紧邻折叠 / **折叠对象必须是 log[1]**
- `TestChannelsPeriodicTaskLogsAreDowngraded`:逐条咬住**具体调用点**被降级
  (而不是"存在 task 这个词"——那样一处就为真,测不出漏降级的噪音源)
- `TestChannelsLogPanelHasFilters`:日志卡片与渠道详情是**兄弟**(div depth=0)
- 变异:去掉折叠 / 把卡片放回详情内 → 均报红

## 顺带修好的环境事故(重要)
排查中全量测试突然大批失败(`loopback 未就绪(未取得端口)`)。我一度以为是
自己的改动,**`git stash` 后在干净版本上复现 → 预先存在**,继续追到根因:

**`/tmp/bisect.py`(另一个 agent 的临时脚本)遮蔽了 Python 标准库的 `bisect`**
—— 而 `tempfile` → `random` → `bisect`,于是**任何 `import tempfile` 都炸**,
回环接收器(python3)起不来。`sys.path` 含 `/tmp` 就是这个后果。
已移走(`/tmp/_shadowed-stdlib-conflicts/`,不删除以免打断那个 agent),
Python 与全部测试立刻恢复。

⇒ **教训**:临时脚本**绝不能**在 `/tmp` 下用 stdlib 模块名命名;
它会让全机 Python 静默失效,而症状("回环未就绪")与原因毫无关联。
2026-10-16 17:19:46 +08:00
3 changed files with 419 additions and 22 deletions

View File

@ -0,0 +1,190 @@
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("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`,
`logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 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(`</?div`).FindAllString(seg, -1) {
if strings.HasPrefix(m, "</") {
depth--
} else {
depth++
}
}
if depth > 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("✓ 日志面板独立成卡片,三个过滤维度齐备")
}
// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。
//
// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成
//
// `能力增强不<E5BCBA>`
//
// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节
// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。
func TestLogTruncationIsUTF8Safe(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// ① 必须存在按字符截断的助手
if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") {
t.Fatal("必须实现 plugin.utf8Clip(按字符截断)")
}
// ② 中文文案上的截断不得再用裸 sub()
for _, bad := range []string{
`tostring(cres):sub(1, 120)`,
`tostring(cerr):sub(1, 120)`,
`table.concat(failed, "; "):sub(1, 300)`,
} {
if strings.Contains(s, bad) {
t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad)
}
}
// ③ 三处必须已换成 utf8Clip
for _, want := range []string{
"plugin.utf8Clip(cres, 120)",
"plugin.utf8Clip(cerr, 120)",
`plugin.utf8Clip(table.concat(failed, "; "), 300)`,
} {
if !strings.Contains(s, want) {
t.Errorf("缺少 UTF-8 安全截断调用:%s", want)
}
}
// ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短)
if !strings.Contains(s, "if #s <= maxBytes then return s end") {
t.Error("utf8Clip 必须对未超长的输入原样返回")
}
// ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节
if !strings.Contains(s, "elseif b >= 0xC0 then") {
t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)")
}
t.Log("✓ 截断已 UTF-8 安全")
}

View File

@ -0,0 +1,38 @@
package core
import (
"testing"
"unicode/utf8"
)
// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。
func TestUtf8ClipBehaviour(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表"
for _, n := range []int{10, 40, 119, 120, 121, 400} {
out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n})
if err != nil {
t.Fatal(err)
}
got, _ := jsonOf(t, out)["out"].(string)
if !utf8.ValidString(got) {
t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got)
}
if n < len(s) && len(got) > n {
t.Errorf("n=%d 结果超出字节上限: %d", n, len(got))
}
if n >= len(s) && got != s {
t.Errorf("n=%d 未超长时应原样返回", n)
}
t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got))
}
}

View File

@ -64,7 +64,7 @@ plugin.kernel = {
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
@ -2772,9 +2772,70 @@ 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
-- utf8Clip 按**字符**截断(不是字节),并保证结果仍是合法 UTF-8。
--
-- ★ 为什么不能用 `s:sub(1, N)`:Lua 的 sub 按**字节**切,而中文字符占 3 字节
-- ⇒ 切在字符中间就产生**无效 UTF-8**,前端显示成 `能力增强不<E5BCBA>` 这种乱码尾巴。
-- 实测(生产能力元数据那条)就是这样 —— 一句本来完整的诊断被切出替换字符,
-- 看起来像数据损坏,实际是截断方式错。
--
-- 做法:先按字节切,再回退到最后一个完整 UTF-8 序列边界。
-- 只在必要时回退(最多退 2 字节),所以不会显著缩短内容。
plugin.utf8Clip = function(s, maxBytes)
s = tostring(s or "")
if #s <= maxBytes then return s end
local cut = s:sub(1, maxBytes)
-- 回退掉尾部不完整的 UTF-8 续字节(10xxxxxx)与被切半的首字节
for _ = 1, 4 do
local b = cut:byte(-1)
if not b then break end
if b < 0x80 then break -- ASCII,完整
elseif b >= 0xC0 then -- 首字节但没有续字节收尾 ⇒ 整字符不完整
cut = cut:sub(1, -2)
break
else -- 续字节,继续往前退
cut = cut:sub(1, -2)
end
end
return cut
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 +3840,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 +3873,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 +3900,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 +6612,7 @@ function plugin.refresh_models()
end
-- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误)
if cooled > 0 then
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台")
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")
end
-- 顺带刷新模型能力元数据(照参考 opencode-capability)。
-- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。
@ -6558,19 +6620,27 @@ function plugin.refresh_models()
if not cok then
-- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权"
-- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120))
logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 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("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 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 项形如 "<platform>: <err>",拆出平台名做归属
local pid = tostring(f):match("^([%w_%-]+):") or nil
logEvent("模型刷新失败 " .. tostring(f), "warn", pid)
end
end
end
@ -6619,9 +6689,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 .. " 个:" .. plugin.utf8Clip(table.concat(failed, "; "), 300), "warn")
end
end
@ -10738,8 +10808,33 @@ plugin.ui = {
</div>
</div>
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
<!-- 运行日志:**全局**,不是某个渠道的。
★ 为什么必须独立成卡片(而不是留在上面「点开某渠道才显示」的渠道详情里):
日志源 plugin.state.log 是**单一全局数组**(logEvent 写入的条目原先连
platform 字段都没有),把它渲染在渠道详情卡里属于**归属错误** ——
用户点开 buddy,看到的却是 trae/minimax/qoder 的日志混在一起,无从区分。
现在:本卡片常驻;条目带 platform 时按当前选中渠道过滤,不带 platform 的
全局条目(定时任务汇总等)只在「全部渠道」视图显示。 -->
</div>
<div class="card" id="ch-log-card">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<b style="font-size:15px">运行日志</b>
<span class="grow"></span>
<label class="muted" style="font-size:12px;display:inline-flex;align-items:center;gap:5px;white-space:nowrap">
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()" style="margin:0"> 显示例行任务
</label>
<select id="ch-log-level" onchange="chLogRender()"
style="padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);font-size:12px">
<option value="all">全部级别</option>
<option value="warn">警告及以上</option>
<option value="error">仅错误</option>
</select>
</div>
<div class="muted" style="font-size:12px;margin:8px 0" id="ch-log-hint"></div>
<!-- ★ 文本**允许换行**(word-break),否则长诊断在窄卡片里被截断,
而截断的尾巴正是最该看到的部分(如 HTTP 状态/具体原因)。 -->
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto;word-break:break-word;line-height:1.5"></ul>
</div>
</div>
</div>
<script>
@ -10757,15 +10852,76 @@ plugin.ui = {
}
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
let state = { statuses: [], filter: "all", search: "", selected: undefined,
accounts: undefined, models: undefined, login: null, pollTimer: null };
accounts: undefined, models: undefined, login: null, pollTimer: null,
// 日志:原始条目 + 用户的两个视图开关(级别门槛、是否显示例行任务)
log: [], logLevel: "all", logVerbose: false };
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
// ── 运行日志的渲染(级别门槛 + 例行任务折叠 + 按渠道过滤)──
//
// 三个过滤维度,各自解决一个实测问题:
// ① **级别**:`task` 是定时任务的常规结果。实测 50 条里 48 条(96%)都是它,
// 默认**不显示**,否则真正的用户动作被淹没。warn/error 永远显示。
// ② **按渠道**:条目带 `platform` 时只在对应渠道视图显示。这是因为
// 日志本身是全局的(单一数组),若不加区分,点开 buddy 会看到 trae 的事。
// 不带 platform 的条目(跨平台汇总,如"模型目录已刷新(N 个平台)")
// 只在「全部渠道」视图出现 —— 它们本来就不属于任何单一渠道。
// ③ **同文折叠计数**:后端把紧邻同文合并成一条并给 n>1(见 logEvent),
// 这里显示成 `×N`,既保留"重复了多少次"的信息又不刷屏。
function chLogRender() {
const ul = q("#ch-log"); if (!ul) return;
const verbose = q("#ch-log-verbose");
const sel = q("#ch-log-level");
state.logVerbose = verbose ? verbose.checked : state.logVerbose;
state.logLevel = sel ? sel.value : state.logLevel;
const rank = { info: 1, task: 1, warn: 2, error: 3 };
const minRank = state.logLevel === "error" ? 3 : state.logLevel === "warn" ? 2 : 1;
const selPlat = state.selected;
const rows = (state.log || []).filter((e) => {
const lvl = e.level || "info";
// 「显示例行任务」勾上时,task 视同 info 一并显示
const eff = (lvl === "task" && state.logVerbose) ? "info" : lvl;
if ((rank[eff] || 1) < minRank) return false;
// 渠道过滤:选中某渠道时,只显示归属于它、或没有归属的全局条目?
// 不 —— 选中渠道时应**只**显示该渠道的条目,否则"点 buddy 看到 trae"的
// 归属错误会以另一种形式回来。全局条目留给「全部渠道」视图。
if (selPlat) return e.platform === selPlat;
return true;
});
const hidden = (state.log || []).length - rows.length;
const hint = q("#ch-log-hint");
if (hint) {
const parts = [];
parts.push(selPlat ? ("当前渠道:" + esc(selPlat)) : "全部渠道");
if (!state.logVerbose) parts.push("例行任务已折叠");
if (hidden > 0) parts.push("另有 " + hidden + " 条被过滤");
hint.innerHTML = parts.join(" · ");
}
ul.innerHTML = rows.map((e) => {
const lvl = e.level || "info";
const ts = e.at ? new Date(e.at * 1000).toLocaleString() : "";
const badge = lvl === "error" ? '<b style="color:#e5534b">错误</b> '
: lvl === "warn" ? '<b style="color:#d29922">警告</b> '
: lvl === "task" ? '<span class="muted">例行</span> ' : "";
const n = (e.n || 1) > 1 ? ' <span class="muted">×' + e.n + "</span>" : "";
const del = String(e.msg || "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
return '<li style="margin-bottom:3px">' + badge +
'<span class="muted" style="font-size:11px">' + ts + "</span> " + del + n + "</li>";
}).join("") || '<li class="muted">(无匹配日志)</li>';
}
window.chLogRender = chLogRender;
async function loadStatuses() {
const j = await call("channels/provider/status", "POST", {});
state.statuses = j.statuses || [];
renderSummary(); renderGrid();
const sj = await call("channels/status");
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
state.log = sj.log || [];
chLogRender();
const aj = await call("channels/auto_checkin", "POST", {});
q("#ch-auto").checked = aj.autoCheckin !== false;
}
@ -10896,7 +11052,13 @@ plugin.ui = {
// ---- 动作 ----
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
window.chSelect = async (id) => {
state.selected = id;
await renderDetail();
// 切渠道要重渲染日志:日志条目带 platform,视图按当前选中渠道过滤
// (不重渲染的话,点开 buddy 仍显示上一个渠道的日志)。
chLogRender();
};
window.chAcct = async (aid, act) => {
const id = state.selected; if (!id) return;
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
@ -11459,6 +11621,13 @@ function plugin.__resync_sources_probe(args)
return { json = { ok = true, n = n } }
end
-- plugin.__utf8_clip_probe:只给测试用 —— 直接验证 utf8Clip 的语义
-- (截断后仍是合法 UTF-8、未超长时原样返回)。静态断言看不出这些性质。
function plugin.__utf8_clip_probe(args)
args = args or {}
return { json = { out = plugin.utf8Clip(args.s, tonumber(args.n) or 120) } }
end
-- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。
-- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、
-- 不在 exports 里),判据要断言"某件事被记录了"就得有它。