feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)

agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)

实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
This commit is contained in:
Aska committed 2026-09-07 05:13:36 +08:00
1 parent 37e98ec584
commit 9c2a37c4ac
32 files changed
+1768 -136

No files matched your search

+112
View File
@@ -8,10 +8,12 @@ package main
// the channel bridge.
import (
"fmt"
"log/slog"
"os"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
@@ -68,9 +70,119 @@ func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter {
if weixin != nil {
out = append(out, weixin)
}
// design/16 §4.6 §5: a platform may carry multiple access configs (multiple
// accounts) via CHANNELS_JSON. Each account becomes its own adapter instance
// (named platform:confID) with independent session/account and its own allow/deny
// black-or-white lists. An empty CHANNELS_JSON adds nothing, so the single-account
// env path above is unchanged.
if accounts, err := config.ParseChannelAccounts(os.Getenv("CHANNELS_JSON")); err == nil {
for platform, accts := range accounts {
for _, acct := range accts {
built, berr := buildAccountAdapter(platform, acct, log)
if berr != nil {
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
continue
}
out = append(out, built)
}
}
} else {
log.Warn("CHANNELS_JSON parse failed, ignoring multi-account config", "error", err)
}
return out
}
// namedAdapter wraps a platform adapter so a multi-account instance gets a unique
// name (platform:confID) and surfaces its allow/deny lists through Config(), which
// the channel bridge reads to enforce the platform's black-or-white lists per
// account (design/16 §4.6 §5-6). Everything else delegates to the inner adapter.
type namedAdapter struct {
adapter.PlatformAdapter
name string
allowGroups []string
denyGroups []string
allowUsers []string
denyUsers []string
}
func (n *namedAdapter) Name() string { return n.name }
func (n *namedAdapter) Config() map[string]any {
cfg := n.PlatformAdapter.Config()
out := make(map[string]any, len(cfg)+4)
for k, v := range cfg {
out[k] = v
}
out["allowGroups"] = n.allowGroups
out["denyGroups"] = n.denyGroups
out["allowUsers"] = n.allowUsers
out["denyUsers"] = n.denyUsers
return out
}
// buildAccountAdapter builds one account's adapter for a platform, decoding the
// account's generic Config map into the platform's typed config and wrapping it so
// the bridge sees a distinct account name and its allow/deny lists.
func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) {
name := platform
if acct.ID != "" {
name = platform + ":" + acct.ID
}
var inner adapter.PlatformAdapter
switch barePlatform(platform) {
case "telegram":
var cfg telegram.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode telegram config: %w", err)
}
inner = telegram.New(cfg, telegram.WithLogger(log))
case "weixin-ilink":
var cfg weixin_ilink.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
}
w, err := weixin_ilink.New(cfg, nil, log)
if err != nil {
return nil, fmt.Errorf("build weixin-ilink: %w", err)
}
inner = w
case "qq-obv11":
var cfg qq_obv11.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode qq-obv11 config: %w", err)
}
inner = qq_obv11.NewAdapter(cfg)
case "webhook":
var cfg webhook.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode webhook config: %w", err)
}
inner = webhook.New(webhook.WithConfig(cfg), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")))
default:
return nil, fmt.Errorf("unknown platform %q", platform)
}
return &namedAdapter{
PlatformAdapter: inner,
name: name,
allowGroups: acct.AllowGroups,
denyGroups: acct.DenyGroups,
allowUsers: acct.AllowUsers,
denyUsers: acct.DenyUsers,
}, nil
}
// barePlatform returns the platform name for a possibly-suffixed "platform:confID".
func barePlatform(name string) string {
for i := 0; i < len(name); i++ {
if name[i] == ':' {
return name[:i]
}
}
return name
}
// envString returns the value of key, or fallback when the variable is unset or
// blank.
func envString(key, fallback string) string {
+7
View File
@@ -15,6 +15,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
"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/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
@@ -553,6 +554,12 @@ func (a *app) wireFrontend() error {
// `message_persisted` broadcast the orchestrator uses, so the Web client mirrors
// IM messages with one, uniform frame (design/16 §4.4).
a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted)
// design/16 §4.6 §6: a message dropped by the platform's allow/deny
// black-or-white lists is recorded in the gateway audit trail so the
// rejection is "audit 可查" even though it never reaches the loop.
a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) {
a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason)
})
}
a.gw = gw
return nil