Files
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

194 lines
6.9 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 backthink
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 推送出口(D1,design/12 §4/§6):主动消息被镜像到
// 外部推送服务,使主人的移动端即便在没有
// WebSocket 客户端或 IM 渠道在线时也能被触达。按设计投递分两级:
//
// 1. 主人的自托管统一推送服务(主级;PUSH_EXPORTER_URL),
// 2. 个推(Getui)作为自托管服务不可用时的降级。
//
// 第 2 级尚未接入 SDK,因此主级不可用时只记一条明确的
// "Getui fallback not integrated" 备注,并上报未投递,而不
// 假装消息已发出。该钩子是发后不管:它绝不延迟
// 或破坏既有的活跃客户端 / IM 投递。
// defaultExportTimeout 约束单次导出尝试,因此挂死的端点无法永久
// 占住一个 goroutine。
const defaultExportTimeout = 5 * time.Second
// maxExportResponseBytes 限制连接复用前从出口响应体里
// 抽掉多少数据,因此恶意或有缺陷的端点无法把一个
// 无界的响应体流进内存。
const maxExportResponseBytes = 4 << 10
// PushExporter 把一条主动消息上报给外部推送出口。Export
// 必须并发安全且必须尊重 ctx。nil 错误配上
// delivered=false 表示出口无法投递(例如降级
// 级尚未接线);调用方把它当作一次记账的未命中,绝不当作本地
// 推送路径的失败。
type PushExporter interface {
Export(ctx context.Context, msg adapter.UnifiedMessage) (delivered bool, err error)
}
// PushOutletConfig 携带推送出口的接线。Enabled=false 完全关闭
// 该钩子(NewPushOutlet 返回 nil)。URL/Token/Timeout 描述
// 自托管主服务;Getui* 字段保留给延后的
// 降级级,目前尚未消费。
type PushOutletConfig struct {
// Enabled 打开出口钩子。
Enabled bool
// URL 是自托管统一推送服务端点(HTTP POST JSON)。
// 为空则主级保持未配置。
URL string
// Token 以 "Authorization: Bearer <token>" 发送。它绝不写日志。
Token string
// Timeout 约束单次导出尝试。为零用 defaultExportTimeout。
Timeout time.Duration
// Log 是基础日志器。nil 则用丢弃式日志器。
Log *slog.Logger
}
// NewPushOutlet 由 cfg 构建两级推送出口。钩子关闭时它返回 nil,
// 因此推送器直接跳过导出步骤。启用时,
// 主级是自托管 HTTP 端点(仅当
// URL 已设置时存在),降级级是个推,尚未接入。
func NewPushOutlet(cfg PushOutletConfig) PushExporter {
if !cfg.Enabled {
return nil
}
log := logger.WithSource(baseLog(cfg.Log), "services/backthink/push_outlet")
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultExportTimeout
}
var primary PushExporter
if url := strings.TrimSpace(cfg.URL); url != "" {
primary = &httpPushExporter{
url: url,
token: cfg.Token,
client: &http.Client{Timeout: timeout},
}
} else {
log.Warn("push outlet enabled but PUSH_EXPORTER_URL is empty; only the (unwired) Getui fallback remains")
}
log.Info("push outlet configured", "primary", primary != nil, "fallback", "getui (not integrated)")
return &pushOutlet{primary: primary, log: log}
}
// pushOutlet 是两级导出器:先自托管服务,再个推。
// 在个推 SDK 接入前降级级为 nil,因此主级未命中时
// 只记日志并上报未投递,而不是静默丢弃。
type pushOutlet struct {
primary PushExporter
fallback PushExporter
log *slog.Logger
}
// Export 实现 PushExporter。它先试主级,主级
// 未投递时再试降级级。未接降级级时它记录待
// 接入状态并返回 (false, nil):调用方把这当作未命中,
// 绝不当作让主动推送失败的理由。
func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
if o.primary != nil {
delivered, err := o.primary.Export(ctx, msg)
if err == nil && delivered {
return true, nil
}
if err != nil {
o.log.Warn("push outlet primary failed", "error", err)
} else {
o.log.Warn("push outlet primary did not deliver")
}
}
if o.fallback != nil {
return o.fallback.Export(ctx, msg)
}
o.log.Info("push outlet not delivered; getui fallback not integrated",
"hint", "wire the Getui SDK + GETUI_* credentials to enable the fallback tier")
return false, nil
}
// pushExportPayload 是 POST 到自托管推送服务的 JSON 体。它
// 刻意小而稳定:设备寻址归该服务,消息
// 只携带渲染与去重所需的内容。
type pushExportPayload struct {
// Source 标识生产者,好让服务能路由/过滤。
Source string `json:"source"`
// Title 是简短显示标题(角色名)。
Title string `json:"title"`
// Content 是主动消息正文(内部决策标记
// 已由 buildProactiveMessage 剥掉)。
Content string `json:"content"`
// Platform 是出站平台标签(cyrene-backthink 或具体的 IM
// 平台)。
Platform string `json:"platform"`
// Timestamp 是消息时间,Unix 毫秒。
Timestamp int64 `json:"timestamp"`
}
// httpPushExporter 把消息 POST 到自托管推送服务。2xx
// 响应表示已投递;其它任何状态或传输错误都算错误。
// token 只出现在 Authorization 头里,绝不进入日志。
type httpPushExporter struct {
url string
token string
client *http.Client
}
// Export 实现 PushExporter,走 HTTP POST JSON。
func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
body, err := json.Marshal(pushExportPayload{
Source: "cyrene/backthink",
Title: msg.Sender.DisplayName,
Content: msg.Content,
Platform: msg.Platform,
Timestamp: msg.Timestamp.UnixMilli(),
})
if err != nil {
return false, fmt.Errorf("push outlet: marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.url, bytes.NewReader(body))
if err != nil {
return false, fmt.Errorf("push outlet: build request: %w", e.redact(err))
}
req.Header.Set("Content-Type", "application/json")
if e.token != "" {
req.Header.Set("Authorization", "Bearer "+e.token)
}
resp, err := e.client.Do(req)
if err != nil {
return false, fmt.Errorf("push outlet: post: %w", e.redact(err))
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxExportResponseBytes))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false, fmt.Errorf("push outlet: unexpected status %d", resp.StatusCode)
}
return true, nil
}
// redact 从传输错误里移除端点 URL,因此 URL 中内嵌的
// 秘密(例如查询参数里的 token)绝不进入日志行。
func (e *httpPushExporter) redact(err error) error {
if err == nil || e.url == "" {
return err
}
msg := strings.ReplaceAll(err.Error(), e.url, "[push-outlet-url]")
return fmt.Errorf("%s", msg)
}