执行方案阶段 0 · G0-A:组合根 926 行是全仓第一大超限文件,且被 10+ 条裁决争用 (1.4/12.4.1、03.4.3、06.4.2、07.4.4、10.4.5、12.4.2、13.4.3、14.4.1、14.4.3、16.4.2) ⇒ 不先拆,后续全部被迫串行且必然破 500 行红线。 - wire.go(181):入口与共享 —— const 块、app struct、newApp - wire_runtime.go(362):loop/agent/model/memory/capacity/trace(buildPersonas、wireMemory、 wireRuntime、buildCapacity、onCapacity、onToolAudit、defaultPersonaID、defaultInstructions…) - wire_services.go(158):exec/task/iot/session/history/automations/backthink (wireAuxiliary、pushOutletConfig、close、resolveDataDir、resolveSQLitePath) - wire_channels.go(172):渠道/桥(ownerRegistrar、wireChannels、runtimeChannels、reviewFor…) - wire_frontend.go(117):gateway/webui/观测扇出(capacityEmitter、wireFrontend) 纯搬移机械证明:去 package/import/空行后两侧 **811=811 行、排序后 diff 为空、md5 相同**; 顶层声明集合 30 条守恒;唯一新增 = 各文件的 package 行与 import 块(原 import 块按声明归属 重分配给新文件)。未重命名/重排/改注释。 验证:go build/vet 绿;go test ./cmd/... 绿;linux/amd64 + darwin/arm64 交叉编译绿; 五文件 181/362/158/172/117 行均 ≤500;gofmt -l cmd/cyrene/ 无输出。 注:全量 go test 当前有 1 处红 —— internal/contract/enums 的 TestModeTableMatchesFrontendModes 读 frontend/web/src/lib/types.ts 找 MODES,而该文件 正被并行的前端拆分任务搬走;与本提交无关,随后修正该测试的读取路径。
173 lines
7.2 KiB
Go
173 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"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/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
)
|
|
|
|
// ownerRegistrar implements gateway.OwnerRegistrar: it persists a channel account
|
|
// id into the owner whitelist (configs/users/owner.yaml) AND hot-updates the live
|
|
// owner profile the channel bridge holds, so a weixin scan-login confirmation is
|
|
// recognized as the owner immediately — no restart, no manual edit. path and the
|
|
// live profile are captured at wiring time: the boot-loaded *user.OwnerProfile is
|
|
// the same pointer the bridge resolves actors against, so RegisterAccount on it is
|
|
// visible to ResolveActor without touching the bridge.
|
|
type ownerRegistrar struct {
|
|
path string
|
|
owner *user.OwnerProfile
|
|
log *slog.Logger
|
|
}
|
|
|
|
func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error {
|
|
if strings.TrimSpace(accountID) == "" {
|
|
return nil
|
|
}
|
|
if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil {
|
|
return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err)
|
|
}
|
|
// Hot-effect: extend the live profile the bridge already points at. If the
|
|
// profile failed to load at boot (r.owner nil) the persist above still lands
|
|
// on disk and is recognized after a restart; log so the operator knows the
|
|
// hot path was unavailable.
|
|
if r.owner != nil {
|
|
r.owner.RegisterAccount(platform, accountID)
|
|
} else {
|
|
r.log.Warn("owner registrar: no live profile, whitelist persisted to disk only (restart to take effect)",
|
|
"platform", platform, "accountId", accountID)
|
|
}
|
|
r.log.Info("owner whitelist registered", "platform", platform, "accountId", accountID)
|
|
return nil
|
|
}
|
|
|
|
// wireChannels constructs the four IM channel adapters from environment
|
|
// configuration, registers them so the admin API can enumerate them, and wires
|
|
// them into the channel bridge so a configured adapter connects and routes
|
|
// inbound messages through session→actor→loop→reply. Adapters without
|
|
// credentials stay disconnected (logged, never fatal): connecting with an empty
|
|
// config would either fail or, for weixin-ilink, trigger a blocking scan-code
|
|
// login.
|
|
func (a *app) wireChannels() error {
|
|
// The channel config is runtime-editable: channels.json (when present and
|
|
// valid) is authoritative, otherwise the env channel block is the fallback
|
|
// (design/04 §3.1). The factory and the store read the same file so a panel
|
|
// save + hot-reload keeps boot and runtime consistent.
|
|
channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg))
|
|
factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log}
|
|
adapters := buildChannelAdapters(factory)
|
|
for _, ch := range adapters {
|
|
capp := registry.Capability{
|
|
Name: ch.Name(),
|
|
Kind: registry.KindChannel,
|
|
Description: ch.Name(),
|
|
Value: ch,
|
|
}
|
|
if err := a.caps.Register(capp); err != nil {
|
|
a.log.Warn("channel register skipped", "channel", ch.Name(), "error", err)
|
|
}
|
|
}
|
|
|
|
bridge, berr := channelbridge.New(channelbridge.Options{
|
|
Log: logger.WithSource(a.log, "services/channelbridge"),
|
|
Loop: a.loop,
|
|
Sessions: a.sessions,
|
|
Owner: a.owner,
|
|
History: a.history,
|
|
QuietStartHour: a.cfg.Background.QuietStartHour,
|
|
QuietEndHour: a.cfg.Background.QuietEndHour,
|
|
// Optional review model prunes low-value queued messages near the per-adapter
|
|
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict.
|
|
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
|
|
// Light is the design/18 §3 participation judge: a cost-controlled light LLM
|
|
// decision on a triggered group/stranger message (role-name mention / probability
|
|
// / active window). Only an actual trigger feeds the group context.
|
|
Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")),
|
|
TriggerWords: a.defaultTriggerWords(),
|
|
PersonaPrompt: a.defaultInstructions(),
|
|
Engage: channelbridge.DefaultEngageConfig(),
|
|
// EngageFor is left nil (P2a); per-group overrides arrive with P2c.
|
|
// Factory lets the panel save hot-replace a platform's adapters without a
|
|
// restart (design/04 §3.1).
|
|
Factory: factory,
|
|
// ChannelPersona resolves an account's channel-level role from the same
|
|
// config the factory builds from (design/22 §2 方案 b); the bridge caches it
|
|
// per connected adapter and refreshes it on every reload.
|
|
ChannelPersona: factory.Persona,
|
|
})
|
|
if berr != nil {
|
|
return fmt.Errorf("channel bridge: %w", berr)
|
|
}
|
|
for _, ch := range adapters {
|
|
bridge.Add(ch)
|
|
}
|
|
bridge.Start()
|
|
a.channelBridge = bridge
|
|
a.channels = &runtimeChannels{path: channelsPath, fallback: factory.fallback, bridge: bridge, log: a.log}
|
|
a.log.Info("channel adapters registered", "count", len(adapters))
|
|
return nil
|
|
}
|
|
|
|
// runtimeChannels is the gateway-facing view of the runtime-editable channel
|
|
// config. Load re-reads channels.json (file wins → env fallback) and Reload
|
|
// delegates to the bridge's hot-replace, so the panel's save/reload path drives
|
|
// the same source the bridge boots from.
|
|
type runtimeChannels struct {
|
|
path string
|
|
fallback map[string][]config.ChannelAccount
|
|
bridge *channelbridge.Bridge
|
|
log *slog.Logger
|
|
}
|
|
|
|
func (r *runtimeChannels) Path() string { return r.path }
|
|
|
|
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
|
|
accounts, warn := runtimeconfig.LoadChannels(r.path, r.fallback)
|
|
if warn != nil {
|
|
r.log.Warn("channels.json load fell back to env", "error", warn)
|
|
}
|
|
return accounts
|
|
}
|
|
|
|
func (r *runtimeChannels) Reload(platform string) error { return r.bridge.Reload(platform) }
|
|
|
|
// reviewFor builds the channel-bridge review model (design/12 §6 策略四), or nil
|
|
// when no model adapter is available so the gate falls back to FIFO eviction — the
|
|
// reviewer is never invoked with a nil adapter (which would panic on .Chat).
|
|
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
|
|
if llm == nil {
|
|
return nil
|
|
}
|
|
return channelbridge.NewLLMReviewer(llm, log)
|
|
}
|
|
|
|
// engageLightFor builds the design/18 §3 participation judge, or nil when no model
|
|
// adapter is available so the bridge's participation engine fails closed toward
|
|
// silence (a triggered message without a judge is never answered).
|
|
func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge {
|
|
if llm == nil {
|
|
return nil
|
|
}
|
|
return channelbridge.NewLLMLightJudge(llm, log)
|
|
}
|
|
|
|
// defaultTriggerWords returns the default persona's role-name reminder words
|
|
// (design/18 §2) so the channel bridge knows which group messages cause a PROBING.
|
|
func (a *app) defaultTriggerWords() []string {
|
|
if c, ok := a.personas.DefaultCharacter(); ok {
|
|
if p, ok := a.personas.Persona(c.PersonaID); ok {
|
|
return append([]string(nil), p.TriggerWords...)
|
|
}
|
|
}
|
|
return nil
|
|
}
|