feat: RAG 知识库真实数据 + 渠道端到端接线(并行∦wire.go)+ 主会话收口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【渠道端到端·接线(agent-72)】
- 新建 internal/services/channelbridge/:桥接服务(Start 对配置渠道 Connect(10s超时/warn)+SetMessageHandler+ReadMessages;
  入站 UnifiedMessage→sessions.GetOrCreate(ResolveActor 白名单结构性识别)→loop.Run→SendMessage 回执,chat 文本)。
- cmd/cyrene/channels.go:从 CHANNEL_* env 读 4 渠道凭据(telegram/qq_obv11/weixin_ilink/webhook)。
- wire.go:app 加 channelBridge 字段,wireChannels 构造适配器+bridge.Start(),close() 收尾。
- 无真凭据降级:无 CHANNEL_* → configured()false 跳过不 dial;Connect 失败 Warn 不阻启动;
  webhook 仅入站(回执 ErrUnsupported 记 debug)。身份识别绝不靠提示词。收发端到端待凭据验证。

【RAG 知识库(agent-71)】
- internal/runtime/knowledge/:真实数据源(doc=资料/记忆=经历 分离 design/03 §3③)。
  ListDocuments 走 projectindex.ListProjects(+ListChapters)、Search 走 projectindex.Search(标题/描述+章节
  命中行+上下文)+retriever.Retrieve(配 vector 真语义,命中标 semantic:true)。
- gateway/server.go:RagStore 接口+Deps.RAG 可空+3 路由(GET /admin/rag、POST /admin/rag/query、/delete)。
- admin.go:adminListRag/adminSearchRag(空 query 400)/adminDeleteRag(写 501 not_implemented,M10)。
- projectindex/store.go:ListProjects/ListChapters。
- 前端 Rag.tsx:docs 从 GET /admin/rag 拉、检索走 POST /admin/rag/query、删除调真端点(501 显示 Notice),
  上传/分块/嵌入显示 M10 Notice;api.ts/types.ts 加 adminListRag/adminSearchRag/adminDeleteRag。

【主会话收口 wire.go】projStore 提到 app struct+wireMemory 赋值;knowledge.New(...) 注入 gateway.Deps.RAG。

实机验证:admin/rag 200(documents[] 空表非壳)、channels 枚举 4 渠道、health ok。
go build/vet/test 全绿 + 前端 tsc。wire.go 567 行。

遗留(记录):① 群聊/陌生人 MemWritable 记忆隔离未接 loop(AGENTS.md 红线,待修) ② webhook InboundHandler
需 gateway 挂载 ③ RAG 语义路径 persona-scoped(project 记忆其他 persona 不浮现)。
This commit is contained in:
Aska committed 2026-09-06 20:09:09 +08:00
1 parent 518bd0a230
commit dbcf3df8a3
13 files changed
+1510 -104

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
package main
// This file builds the four built-in IM channel adapters from environment
// configuration. The config package carries no channel block, so credentials are
// read from CHANNEL_<NAME>_* env vars here and injected into each adapter's
// concrete Config. Adapters whose construction fails are skipped rather than
// aborting boot, matching the "no credentials → warn and continue" contract of
// the channel bridge.
import (
"log/slog"
"os"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// buildChannelAdapters constructs the four channel adapters from environment
// configuration and returns the successfully built subset. Adapter construction
// never dials the network; actual connection happens in the channel bridge.
func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter {
qq := qq_obv11.NewAdapter(qq_obv11.Config{
Mode: envString("CHANNEL_QQ_OBV11_MODE", "server"),
ListenAddr: os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
RemoteURL: os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
AccessToken: os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
SelfID: os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
Logger: log,
})
weixin, werr := weixin_ilink.New(weixin_ilink.Config{
BaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
CDNBaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
BotToken: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"),
BotID: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
OwnerID: os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
}, nil, log)
if werr != nil {
// A blank config defaults to a valid base URL, so this only fires on an
// explicitly invalid one; skip the channel rather than fail startup.
log.Warn("weixin-ilink adapter build skipped", "error", werr)
weixin = nil
}
tg := telegram.New(telegram.Config{
BotToken: os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"),
WebhookURL: os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
}, telegram.WithLogger(log))
// webhook.WithLogger does not tag provenance, so wrap base with the module
// source here to satisfy the logging rule.
wh := webhook.New(
webhook.WithConfig(webhook.Config{
EndpointPath: os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"),
ChannelID: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"),
ChannelName: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"),
Secret: os.Getenv("CHANNEL_WEBHOOK_SECRET"),
SignatureHeader: os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"),
}),
webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")),
)
out := []adapter.PlatformAdapter{qq, tg, wh}
if weixin != nil {
out = append(out, weixin)
}
return out
}
// envString returns the value of key, or fallback when the variable is unset or
// blank.
func envString(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+67 -57
View File
@@ -26,6 +26,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
@@ -41,12 +42,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
@@ -70,28 +68,30 @@ const (
// app holds every subsystem the single Cyrene binary assembles. It is built by
// newApp and torn down by close on shutdown.
type app struct {
cfg *config.Config
log *slog.Logger
db *sql.DB
bus bus.Bus
personas *personareg.Registry
caps *registry.Registry
dispatcher *tool.Dispatcher
memStore *memory.Store
retriever *memory.Retriever
loop *loop.Loop
scheduler *orchestration.Scheduler
tasks *task.Manager
execProv exec.Provider
iot *iot.Manager
voice *voice.Chain
mcpRegs []*mcp.Registry
sessions *session.SessionManager
automation *automations.Service
backthink *backthink.Backthink
vindex vector.Index
owner *user.OwnerProfile
gw *gateway.Server
cfg *config.Config
log *slog.Logger
db *sql.DB
bus bus.Bus
personas *personareg.Registry
caps *registry.Registry
dispatcher *tool.Dispatcher
memStore *memory.Store
retriever *memory.Retriever
projStore *projectindex.Store
loop *loop.Loop
scheduler *orchestration.Scheduler
tasks *task.Manager
execProv exec.Provider
iot *iot.Manager
voice *voice.Chain
mcpRegs []*mcp.Registry
sessions *session.SessionManager
automation *automations.Service
backthink *backthink.Backthink
vindex vector.Index
owner *user.OwnerProfile
gw *gateway.Server
channelBridge *channelbridge.Bridge
}
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
@@ -237,6 +237,7 @@ func (a *app) wireMemory(ctx context.Context) error {
a.log.Warn("project index unavailable", "error", err)
projStore = nil
}
a.projStore = projStore
opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)}
if a.vindex != nil {
@@ -398,34 +399,42 @@ func (a *app) wireAuxiliary() error {
return nil
}
// wireChannels constructs and registers the four IM channel adapters so the
// admin API can enumerate them. They are registered but not connected: real
// platform credentials are supplied per-deployment, and connecting with empty
// config would dial external networks.
// 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 {
channels := []struct {
name string
value any
policy registry.ToolPolicy
}{
{"qq-obv11", qq_obv11.NewAdapter(qq_obv11.Config{Logger: a.log}), registry.ToolPolicy{}},
{"weixin-ilink", mustWeixin(a.log), registry.ToolPolicy{}},
{"telegram", telegram.New(telegram.Config{}, telegram.WithLogger(a.log)), registry.ToolPolicy{}},
{"webhook", webhook.New(webhook.WithLogger(a.log)), registry.ToolPolicy{}},
}
for _, ch := range channels {
adapters := buildChannelAdapters(a.log)
for _, ch := range adapters {
capp := registry.Capability{
Name: ch.name,
Name: ch.Name(),
Kind: registry.KindChannel,
Description: ch.name,
Policy: ch.policy,
Value: ch.value,
Description: ch.Name(),
Value: ch,
}
if err := a.caps.Register(capp); err != nil {
a.log.Warn("channel register skipped", "channel", ch.name, "error", err)
a.log.Warn("channel register skipped", "channel", ch.Name(), "error", err)
}
}
a.log.Info("channel adapters registered", "count", len(channels))
bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop,
Sessions: a.sessions,
Owner: a.owner,
})
if berr != nil {
return fmt.Errorf("channel bridge: %w", berr)
}
for _, ch := range adapters {
bridge.Add(ch)
}
bridge.Start()
a.channelBridge = bridge
a.log.Info("channel adapters registered", "count", len(adapters))
return nil
}
@@ -435,6 +444,13 @@ func (a *app) wireChannels() error {
func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth)
apiKeys := auth.NewAPIKey(a.cfg.Auth)
ragStore := knowledge.New(knowledge.Deps{
Projects: a.projStore,
Retriever: a.retriever,
DefaultPersona: a.defaultPersonaID(),
VectorOn: a.vindex != nil,
Log: a.log,
})
gw, err := gateway.New(a.cfg, gateway.Deps{
Log: logger.WithSource(a.log, "gateway"),
Auth: jwt,
@@ -450,6 +466,7 @@ func (a *app) wireFrontend() error {
Automations: a.automation,
Users: apiKeys,
Backthink: a.backthink,
RAG: ragStore,
Streaming: true,
Frontend: resolveFrontendFS(a.log),
})
@@ -502,6 +519,9 @@ func (a *app) close(ctx context.Context) {
if a.automation != nil {
_ = a.automation.Stop(ctx)
}
if a.channelBridge != nil {
a.channelBridge.Stop(ctx)
}
if a.scheduler != nil {
_ = a.scheduler.CancelAll(ctx)
}
@@ -545,13 +565,3 @@ func resolveSQLitePath(cfg *config.Config, dataDir string) string {
}
return p
}
// mustWeixin builds the weixin-ilink adapter, panicking on an invalid config
// (which should never happen with defaults).
func mustWeixin(log *slog.Logger) *weixin_ilink.Adapter {
adapter, err := weixin_ilink.New(weixin_ilink.Config{}, nil, log)
if err != nil {
panic(fmt.Sprintf("weixin-ilink adapter: %v", err))
}
return adapter
}