docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
This commit is contained in:
AskaEth committed 2026-10-03 22:28:25 +08:00
1 parent 91e8d7a029
commit 074ffc4ebf
73 files changed
+2529 -2529

No files matched your search

+108 -108
View File
@@ -17,11 +17,11 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// backthinkListLimit is the number of most-recent think records the admin
// panel asks for when no limit is specified.
// backthinkListLimit 是未指定 limit 时管理面板索取的
// 最近 think 记录条数。
const backthinkListLimit = 50
// PluginSummary is the wire shape of a capability/plugin in the admin list.
// PluginSummary 是管理列表中一个能力/插件的线上结构。
type PluginSummary struct {
Name string `json:"name"`
Kind string `json:"kind"`
@@ -30,14 +30,14 @@ type PluginSummary struct {
AllowInStranger bool `json:"allowInStranger"`
}
// ChannelSummary is the wire shape of a registered channel adapter. Accounts
// carries that platform's configured accounts (masked, no raw tokens) so the
// panel can show and edit the account config; it is omitted when s.channels is
// not wired or the platform has no configured account.
// ChannelSummary 是一个已注册渠道适配器的线上结构。Accounts
// 承载该平台已配置的账号(已脱敏,不含原始 token),使
// 面板能展示并编辑账号配置;当 s.channels 未接线
// 或该平台没有已配置账号时省略。
//
// NeedsRescan/LastError (v0.39) report that the adapter needs the owner to
// re-authorize it (see rescanAware in admin_channels_rescan.go); both are omitted
// unless the adapter really asked for it.
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
// 确实要求时才出现,否则一律省略。
type ChannelSummary struct {
Name string `json:"name"`
Connected bool `json:"connected"`
@@ -46,15 +46,15 @@ type ChannelSummary struct {
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
}
// policyRequest is the POST /api/v1/admin/plugins/policy body.
// policyRequest 是 POST /api/v1/admin/plugins/policy 的请求体。
type policyRequest struct {
Name string `json:"name"`
AllowInGroup bool `json:"allowInGroup"`
AllowInStranger bool `json:"allowInStranger"`
}
// AdminLlmProvider is the wire shape of one LLM provider in the admin config.
// The API key is deliberately absent: it is never returned to the client.
// AdminLlmProvider 是管理配置中一个 LLM provider 的线上结构。
// API key 刻意缺位:它绝不返回给客户端。
type AdminLlmProvider struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -64,20 +64,20 @@ type AdminLlmProvider struct {
Multimodal bool `json:"multimodal"`
Enabled bool `json:"enabled"`
Fallback string `json:"fallback,omitempty"`
// MaxTokens is the group's own token budget (0 = unset; the runtime then
// falls back to LLM_FALLBACK_MAX_TOKENS on the last fallback step, then the
// adapter default).
// MaxTokens 是该分组自身的 token 预算(0 = 未设置;此时运行时
// 在最后一个 fallback 步骤回退到 LLM_FALLBACK_MAX_TOKENS,
// 再回退到适配器默认值)。
MaxTokens int `json:"maxTokens,omitempty"`
}
// AdminLlmConfig is the wire shape of GET /api/v1/admin/llm.
// AdminLlmConfig 是 GET /api/v1/admin/llm 的线上结构。
type AdminLlmConfig struct {
Providers []AdminLlmProvider `json:"providers"`
}
// AdminAutomationRecord is the wire shape of one automation/reminder in the
// admin list. Cron carries the cron expression for the cron kind; lastRun and
// nextRun are omitted when the value is unknown.
// AdminAutomationRecord 是管理列表中一条自动化/提醒的线上结构。
// 对 cron 类型,Cron 承载 cron 表达式;lastRun 与
// nextRun 在值未知时省略。
type AdminAutomationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -88,7 +88,7 @@ type AdminAutomationRecord struct {
NextRun string `json:"nextRun,omitempty"`
}
// AdminUserRecord is the wire shape of one account/API key in the admin list.
// AdminUserRecord 是管理列表中一个账号/API key 的线上结构。
type AdminUserRecord struct {
ID string `json:"id"`
Username string `json:"username"`
@@ -96,8 +96,8 @@ type AdminUserRecord struct {
CreatedAt string `json:"createdAt,omitempty"`
}
// AdminBackthinkItem is the wire shape of one background-think record in the
// admin monitor list.
// AdminBackthinkItem 是管理监控列表中一条后台思考记录的
// 线上结构。
type AdminBackthinkItem struct {
ID string `json:"id"`
Trigger string `json:"trigger"`
@@ -107,17 +107,17 @@ type AdminBackthinkItem struct {
At int64 `json:"at"`
}
// adminListPersonas lists persona summaries (admin read). It is the admin shape:
// the role-level mood plus the `thinking` knobs the panel edits.
// adminListPersonas 列出人格摘要(管理读)。它是管理侧结构:
// 角色级 mood 加上面板可编辑的 `thinking` 开关。
func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectPersonas(true))
}
// adminListSessions lists the persistent chat-history sessions (main + sub) when a
// history store is wired, otherwise falls back to the channel-session view. It is
// the admin counterpart of handleListSessions: the panel shows the real main and
// secondary sessions (design/16 §2) rather than stale channel-session rows, and
// the fallback keeps the pre-design/16 contract for servers built without history.
// adminListSessions 在接线了 history store 时列出持久化聊天历史
// 会话(main + sub),否则回退到渠道会话视图。它是
// handleListSessions 的管理侧对应物:面板展示真实的 main 与
// 次级会话(design/16 §2),而不是陈旧的渠道会话行;
// 回退路径为未接 history 的服务器保留 design/16 之前的契约。
func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) {
if s.history != nil {
s.handleHistorySessions(w, r)
@@ -126,12 +126,12 @@ func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectSessions(r.Context()))
}
// adminListMemories lists memory entries (admin read).
// adminListMemories 列出记忆条目(管理读)。
func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectMemories(r.Context()))
}
// adminListPlugins lists every registered capability (admin read).
// adminListPlugins 列出所有已注册能力(管理读)。
func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
out := make([]PluginSummary, 0)
if s.caps != nil {
@@ -148,9 +148,9 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// adminListChannels lists the registered channel adapters (admin read). When the
// channel config store is wired it also attaches each platform's configured
// accounts, masked so a raw token never reaches the client.
// adminListChannels 列出已注册的渠道适配器(管理读)。当接线了
// 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏,
// 确保原始 token 绝不抵达客户端。
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
accountsByPlatform := s.maskedChannelAccounts()
botByPlatform := s.liveBotIdentity()
@@ -162,26 +162,26 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
continue
}
platform, acctID := splitAdapterName(ch.Name())
// channels.json is the source of truth for which accounts exist. A
// registered adapter surfaces only while its account is still
// configured, so a deleted account disappears from the list. Without a
// config store (tests) every registered adapter is listed unchanged.
// channels.json 是「存在哪些账号」的唯一事实来源。已注册的
// 适配器只有在其账号仍处于配置状态时才浮现,因此已删除的
// 账号会从列表中消失。没有配置存储时(测试),每个已注册
// 适配器原样列出。
if s.channels != nil {
accts, ok := accountsByPlatform[platform]
if !ok || !platformHasAccountID(accts, acctID) {
continue
}
}
// Aggregate by platform: a bare adapter ("kook") and the multi-account
// ones ("kook:fc2048", "kook:conf1") are ONE platform row, so the account
// list is not duplicated into a separate row per in-registry adapter.
// 按平台聚合:裸适配器("kook")与多账号适配器
// ("kook:fc2048"、"kook:conf1")共用一个平台行,
// 因此账号列表不会按注册表中的每个适配器重复成独立行。
if s.hasChannelSummary(out, platform) {
continue
}
sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()}
// Optional rescan capability (weixin-iLink rejected session, v0.39): reported
// only when the adapter really asks for a re-scan, so a healthy adapter
// keeps the fields omitted rather than shipping a fabricated value.
// 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
// 而不是送出一个编造的值。
if ra, ok := asRescanAware(cap.Value); ok {
if required, reason := ra.RescanRequired(); required {
sum.NeedsRescan = true
@@ -194,8 +194,8 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
out = append(out, sum)
}
}
// A platform that has configured accounts but no registered adapter (e.g. the
// account was just saved) still surfaces, so its config is visible.
// 有已配置账号但没有已注册适配器的平台(例如账号刚刚保存)
// 依然会浮现,以便其配置可见。
for platform, accts := range accountsByPlatform {
if len(accts) == 0 || s.hasChannelSummary(out, platform) {
continue
@@ -205,8 +205,8 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// maskedChannelAccounts returns the effective per-platform configured accounts,
// masked (no raw tokens). It is empty when the channel config store is not wired.
// maskedChannelAccounts 返回按平台生效的已配置账号,
// 已脱敏(不含原始 token)。渠道配置存储未接线时为空。
func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
if s.channels == nil {
return map[string][]AdminChannelAccount{}
@@ -229,9 +229,9 @@ func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
return out
}
// platformAdminOps returns the account-level admin operations the platform's
// adapter exposes, so the panel renders only the capabilities the account really
// has. Platforms without a specialized adapter surface return nil (omitted).
// platformAdminOps 返回该平台适配器暴露的账号级管理操作,
// 使面板只渲染账号真正具备的能力。没有专用适配器
// 表面的平台返回 nil(省略)。
func platformAdminOps(platform string) []string {
if channelBasePlatform(platform) == "kook" {
return []string{"friends", "user_chats", "channels", "guilds", "message_test"}
@@ -239,8 +239,8 @@ func platformAdminOps(platform string) []string {
return nil
}
// hasChannelSummary reports whether out already contains a summary for platform
// (so a platform with a registered adapter is not duplicated).
// hasChannelSummary 报告 out 中是否已含该平台的摘要
// (以免有已注册适配器的平台被重复)。
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
for _, c := range out {
if channelBasePlatform(c.Name) == platform {
@@ -250,11 +250,11 @@ func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
return false
}
// platformHasAccountID reports whether the configured accounts of a platform still
// include the account an adapter is bound to. A bare adapter (kook) binds to that
// platform's default account (empty id); a multi-account adapter (kook:conf0)
// binds to exactly that account id. A mismatch means the adapter is stale — its
// account was deleted from channels.json — so it must not surface in the list.
// platformHasAccountID 报告某平台已配置的账号中是否仍包含
// 适配器所绑定的账号。裸适配器(kook)绑定到该平台的
// 默认账号(空 id);多账号适配器(kook:conf0)绑定到
// 恰好那个账号 id。不匹配意味着适配器已陈旧 —— 它的
// 账号已从 channels.json 删除 —— 因此不得出现在列表中。
func platformHasAccountID(accts []AdminChannelAccount, id string) bool {
for _, a := range accts {
if a.ID == id {
@@ -264,22 +264,22 @@ func platformHasAccountID(accts []AdminChannelAccount, id string) bool {
return false
}
// botInfo is the live, connected bot's resolved identity (kook fetches it from
// /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so
// the admin list can show who the bot is without persisting it.
// botInfo 是已连接机器人的实际解析身份(kook 在 Connect 时从
// /api/v3/user/me 获取)。它通过适配器的 Config() 浮现,
// 使管理列表能展示机器人是谁而无需持久化。
type botInfo struct {
id string
username string
nickname string
}
// empty reports whether no part of the bot identity resolved.
// empty 报告机器人身份是否没有任何部分解析成功。
func (b botInfo) empty() bool { return b.id == "" && b.username == "" && b.nickname == "" }
// liveBotIdentity collects the bot identity each registered channel adapter
// reports, keyed by bare platform then account id (a bare adapter name yields an
// empty account id). Adapters that do not expose bot identity (telegram,
// webhook, ...) contribute nothing, so other platforms are unaffected.
// liveBotIdentity 收集每个已注册渠道适配器报告的机器人身份,
// 以裸平台名再按账号 id 为键(裸适配器名得到空账号 id)。
// 不暴露机器人身份的适配器(telegram、webhook……)不贡献
// 任何内容,因此其他平台不受影响。
func (s *Server) liveBotIdentity() map[string]map[string]botInfo {
out := make(map[string]map[string]botInfo)
if s.caps == nil {
@@ -303,9 +303,9 @@ func (s *Server) liveBotIdentity() map[string]map[string]botInfo {
return out
}
// botIdentityOf reads the optional bot-identity fields off an adapter config map.
// The keys are non-sensitive (bot_id / bot_username / bot_nickname) so they are
// never masked by runtimeconfig.Masked.
// botIdentityOf 从适配器配置 map 中读取可选的机器人身份字段。
// 这些键是非敏感的(bot_id / bot_username / bot_nickname),因此
// 绝不会被 runtimeconfig.Masked 脱敏。
func botIdentityOf(cfg map[string]any) botInfo {
if cfg == nil {
return botInfo{}
@@ -317,8 +317,8 @@ func botIdentityOf(cfg map[string]any) botInfo {
return b
}
// splitAdapterName splits a (possibly multi-account) adapter name like
// "kook:conf0" into ("kook", "conf0"); a bare name yields an empty account id.
// splitAdapterName 把形如 "kook:conf0" 的(可能多账号的)适配器名
// 拆成 ("kook", "conf0");裸名得到空账号 id。
func splitAdapterName(name string) (platform, acct string) {
if base, tail, ok := strings.Cut(name, ":"); ok {
return base, tail
@@ -326,10 +326,10 @@ func splitAdapterName(name string) (platform, acct string) {
return name, ""
}
// enrichAccountsWithBot merges the live bot identity into the matching account(s)
// of a platform. An account is matched to its live adapter by id, so a
// multi-account platform shows each bot's own identity. Accounts without a live
// bot (not connected yet) keep their stored fields unchanged.
// enrichAccountsWithBot 把实时机器人身份合并进平台中匹配的账号。
// 账号按 id 与其实时适配器配对,因此多账号平台会展示
// 各自机器人的身份。没有实时机器人的账号(尚未连接)
// 保持其已存字段不变。
func enrichAccountsWithBot(accts []AdminChannelAccount, by map[string]botInfo) []AdminChannelAccount {
if len(by) == 0 {
return accts
@@ -346,7 +346,7 @@ func enrichAccountsWithBot(accts []AdminChannelAccount, by map[string]botInfo) [
return out
}
// adminListIoT lists IoT devices (admin read).
// adminListIoT 列出 IoT 设备(管理读)。
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
out := make([]iotDeviceSummary, 0)
if s.iot != nil {
@@ -363,10 +363,10 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// adminGetLlm returns the LLM provider/model configuration read from the live
// provider stack (models.json, falling back to .env). API keys are never
// returned — the wire shape carries no key field — so the handler leaks nothing
// sensitive.
// adminGetLlm 返回从实时 provider 栈读取的 LLM provider/model
// 配置(models.json,回退到 .env)。API key 绝不返回 ——
// 线上结构中不含 key 字段 —— 因此该 handler
// 不泄露任何敏感信息。
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
groups := s.currentLlmGroups()
providers := make([]AdminLlmProvider, 0, len(groups))
@@ -386,8 +386,8 @@ func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
}
// currentLlmGroups returns the provider groups to display: the live stack's
// groups when one is wired, otherwise the env-synthesized single group.
// currentLlmGroups 返回要展示的 provider 分组:接线了实时栈时
// 用它的分组,否则用由环境变量合成的单一分组。
func (s *Server) currentLlmGroups() []runtimeconfig.Group {
if s.llm != nil {
return s.llm.Groups()
@@ -395,8 +395,8 @@ func (s *Server) currentLlmGroups() []runtimeconfig.Group {
return runtimeconfig.GroupFromEnv(s.cfg.LLM)
}
// providerDisplayName returns a friendly label for a provider group, using the
// vendor name for the two known protocols and the group id otherwise.
// providerDisplayName 返回 provider 分组的友好标签:两种已知协议
// 用厂商名,其余用分组 id。
func providerDisplayName(g runtimeconfig.Group) string {
switch g.Provider {
case runtimeconfig.ProviderOpenAI:
@@ -408,7 +408,7 @@ func providerDisplayName(g runtimeconfig.Group) string {
}
}
// adminListAutomations lists automations and reminders (admin read).
// adminListAutomations 列出自动化与提醒(管理读)。
func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) {
out := make([]AdminAutomationRecord, 0)
if s.automations != nil {
@@ -432,9 +432,9 @@ func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// adminListUsers lists accounts and API keys (admin read). Without a wired
// UserStore it reports the single configured admin account rather than
// fabricating a column of virtual users.
// adminListUsers 列出账号与 API key(管理读)。未接线 UserStore 时
// 它只报告那一个已配置的管理账号,而不是编造
// 一列虚拟用户。
func (s *Server) adminListUsers(w http.ResponseWriter, r *http.Request) {
out := []AdminUserRecord{{
ID: "admin",
@@ -453,7 +453,7 @@ func (s *Server) adminListUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// adminListBackthink lists the most recent background-think records (admin read).
// adminListBackthink 列出最近的后台思考记录(管理读)。
func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
out := make([]AdminBackthinkItem, 0)
if s.backthink == nil {
@@ -479,8 +479,8 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// AdminRagDoc is the wire shape of one knowledge-base document (project-index
// entry) in the admin RAG list.
// AdminRagDoc 是管理 RAG 列表中一个知识库文档(项目索引
// 条目)的线上结构。
type AdminRagDoc struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -489,7 +489,7 @@ type AdminRagDoc struct {
Embedded bool `json:"embedded"`
}
// AdminRagHit is the wire shape of one RAG retrieval hit.
// AdminRagHit 是一条 RAG 检索命中的线上结构。
type AdminRagHit struct {
DocID string `json:"docId"`
DocName string `json:"docName"`
@@ -499,22 +499,22 @@ type AdminRagHit struct {
Semantic bool `json:"semantic"`
}
// AdminRagResponse is the wire shape of GET /api/v1/admin/rag. Hits is present
// only when a query parameter was supplied, so callers distinguish "no search
// requested" from "searched and found nothing".
// AdminRagResponse 是 GET /api/v1/admin/rag 的线上结构。Hits 仅
// 在提供了查询参数时出现,因此调用方能区分「未请求搜索」
// 与「搜索了但没找到」。
type AdminRagResponse struct {
Documents []AdminRagDoc `json:"documents"`
Hits []AdminRagHit `json:"hits,omitempty"`
}
// ragQueryRequest is the POST /api/v1/admin/rag/query body.
// ragQueryRequest 是 POST /api/v1/admin/rag/query 的请求体。
type ragQueryRequest struct {
Query string `json:"query"`
}
// adminListRag lists the knowledge-base documents and, when ?query= is present,
// returns the matching hits too. Without a wired RagStore it degrades to an empty
// list (backward compatible — never a 500).
// adminListRag 列出知识库文档,并在带 ?query= 时
// 一并返回匹配的命中。未接线 RagStore 时它退化为
// 空列表(向后兼容 —— 绝不返回 500)。
func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) {
out := AdminRagResponse{Documents: make([]AdminRagDoc, 0)}
if s.rag == nil {
@@ -540,7 +540,7 @@ func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// adminSearchRag runs a knowledge-base query and returns the matching hits.
// adminSearchRag 执行一次知识库查询并返回匹配的命中。
func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) {
if s.rag == nil {
s.auditClaims(r, "rag.query", "error")
@@ -566,7 +566,7 @@ func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "hits": ragHitsFrom(hits)})
}
// ragDocsFrom maps knowledge docs to their admin wire shape.
// ragDocsFrom 把知识文档映射为其管理侧线上结构。
func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc {
out := make([]AdminRagDoc, 0, len(docs))
for _, d := range docs {
@@ -575,7 +575,7 @@ func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc {
return out
}
// ragHitsFrom maps knowledge hits to their admin wire shape.
// ragHitsFrom 把知识命中映射为其管理侧线上结构。
func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit {
out := make([]AdminRagHit, 0, len(hits))
for _, h := range hits {
@@ -591,8 +591,8 @@ func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit {
return out
}
// adminSetPolicy upserts the reachability markers for a capability. It is the
// audited admin write example (owner-only, enforced by the route registration).
// adminSetPolicy 更新插入某个能力的可达性标记。它是
// 带审计的管理写示例(仅 owner,由路由注册强制)。
func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) {
var req policyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -622,7 +622,7 @@ func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "allowInGroup": req.AllowInGroup, "allowInStranger": req.AllowInStranger})
}
// collectSessions returns a non-nil session slice from the configured store.
// collectSessions 从已配置的存储返回非 nil 的会话切片。
func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
out := make([]SessionSummary, 0)
if s.sessions == nil {
@@ -639,7 +639,7 @@ func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
return out
}
// collectMemories returns a non-nil memory slice from the configured store.
// collectMemories 从已配置的存储返回非 nil 的记忆切片。
func (s *Server) collectMemories(ctx context.Context) []MemorySummary {
out := make([]MemorySummary, 0)
if s.memories == nil {
@@ -656,7 +656,7 @@ func (s *Server) collectMemories(ctx context.Context) []MemorySummary {
return out
}
// iotDeviceSummary is the wire shape of an IoT device.
// iotDeviceSummary 是一个 IoT 设备的线上结构。
type iotDeviceSummary struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -16,25 +16,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// This file covers the admin half of the channel-level role binding (design/22 §2
// 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the
// existing read-modify-write + bridge.Reload path, an unknown role is refused
// instead of binding a role that cannot answer, and the list endpoint reports the
// binding back.
// 本文件覆盖渠道级角色绑定的管理侧一半(design/22 §2
// 方案 b / §4.4):PUT /admin/channels/{platform}/config 通过
// 既有的 read-modify-write + bridge.Reload 路径接受 personaId,
// 未知角色会被拒绝,而不是绑定一个无法应答的角色;
// 列表端点会回报该绑定。
//
// personaId is three-state on the wire, because the panel sends the key only when
// the owner actually touched the role select:
// personaId 在线上是三态的,因为面板仅在主人
// 真正触碰过角色选择时才发送该键:
//
// key absent → keep the stored binding
// explicit "" → clear the binding
// non-empty id → bind that role (unknown id → 400)
// 键缺失 → 保留已存绑定
// 显式 "" → 清除绑定
// 非空 id → 绑定该角色(未知 id → 400)
//
// The absent-key case is the one that silently dropped bindings, so it is asserted
// with a literal JSON body: a map[string]any would still make the assertion pass if
// the DTO had picked up a value from somewhere else.
// 「键缺失」这一情形正是静默丢弃绑定的那个,因此用字面量
// JSON 请求体断言:若 DTO 从别处取到了值,map[string]any
// 仍会让断言通过。
// putChannelConfigRaw posts a literal JSON body so a test can pin the exact wire
// shape (notably: no personaId key at all).
// putChannelConfigRaw 发送字面量 JSON 请求体,使测试能钉死确切的
// 线上结构(尤其是:完全没有 personaId 键)。
func putChannelConfigRaw(t *testing.T, ts *httptest.Server, platform, raw, token string) (*http.Response, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodPut,
@@ -59,7 +59,7 @@ func putChannelConfigRaw(t *testing.T, ts *httptest.Server, platform, raw, token
return resp, b
}
// storedPersonaID reads the binding channels.json holds for one account.
// storedPersonaID 读取 channels.json 为某个账号保存的绑定。
func storedPersonaID(t *testing.T, path, platform, accountID string) string {
t.Helper()
accounts, warn := runtimeconfig.LoadChannels(path, nil)
@@ -75,8 +75,8 @@ func storedPersonaID(t *testing.T, path, platform, accountID string) string {
return ""
}
// listedPersonaID is the personaId GET /admin/channels reports for one account, i.e.
// what the panel reads back to render the role select.
// listedPersonaID 是 GET /admin/channels 为某个账号报告的 personaId,
// 即面板读回用于渲染角色选择的值。
func listedPersonaID(t *testing.T, ts *httptest.Server, token, platform, accountID string) (string, []byte) {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token)
@@ -107,9 +107,9 @@ func listedPersonaID(t *testing.T, ts *httptest.Server, token, platform, account
return "", body
}
// TestAdminSaveChannelConfigPersonaIDBindsAndEchoes binds a channel role and
// asserts it reached channels.json, that the list endpoint echoes it back for the
// panel to render, and that the hot-reload path ran.
// TestAdminSaveChannelConfigPersonaIDBindsAndEchoes 绑定一个渠道角色,
// 断言它到达了 channels.json、列表端点回显它供面板渲染,
// 并且热重载路径被执行。
func TestAdminSaveChannelConfigPersonaIDBindsAndEchoes(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
@@ -136,9 +136,9 @@ func TestAdminSaveChannelConfigPersonaIDBindsAndEchoes(t *testing.T) {
t.Logf("GET /api/v1/admin/channels echoes the binding: %s", listBody)
}
// TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted is the regression for the
// silent data loss: the panel omits personaId unless the owner touched the role
// select, so saving another field must leave the stored binding alone.
// TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted 是静默数据
// 丢失的回归:除非主人触碰过角色选择,面板会省略 personaId,
// 因此保存另一个字段必须不碰已存绑定。
func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
@@ -148,8 +148,8 @@ func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// The wire body carries the new token and no personaId key at all — exactly
// what an untouched role select produces.
// 线上请求体携带新 token,且完全没有 personaId 键 ——
// 正是一个未被触碰的角色选择所产生的形态。
_, body := putChannelConfigRaw(t, ts,
"telegram", `{"id":"conf0","token":"new-tok","groupMsgPerMin":5}`, tok)
@@ -164,16 +164,16 @@ func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) {
t.Fatalf("GET /admin/channels personaId = %q, want yuki (body=%s)", echoed, listBody)
}
// An explicit JSON null is the same as an absent key: the DTO cannot tell them
// apart, and neither means "clear".
// 显式的 JSON null 与键缺失相同:DTO 无法区分二者,
// 二者都不表示「清除」。
putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"newer-tok","personaId":null}`, tok)
if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" {
t.Fatalf("null personaId cleared the binding: personaId = %q, want yuki", got)
}
}
// TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty asserts that clearing
// the binding still works, but only when the panel says so with an explicit "".
// TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty 断言清除
// 绑定仍然可行,但仅在面板以显式 "" 表示时才如此。
func TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
@@ -193,9 +193,9 @@ func TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty(t *testing.T) {
}
}
// TestAdminSaveChannelConfigPersonaIDKeptPerAccount pins the read-modify-write to
// the account being saved: an omitted personaId on one account must inherit that
// account's binding, not a sibling's.
// TestAdminSaveChannelConfigPersonaIDKeptPerAccount 把 read-modify-write
// 钉在正在保存的账号上:某账号省略 personaId 时必须继承该
// 账号自己的绑定,而不是兄弟账号的。
func TestAdminSaveChannelConfigPersonaIDKeptPerAccount(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
@@ -218,9 +218,9 @@ func TestAdminSaveChannelConfigPersonaIDKeptPerAccount(t *testing.T) {
}
}
// TestAdminSaveChannelConfigRejectsUnknownPersonaID keeps the save fail-closed: a
// channel bound to a role the library does not know would silently answer as the
// default role, so it is refused and nothing is written or reloaded.
// TestAdminSaveChannelConfigRejectsUnknownPersonaID 保持保存 fail-closed:
// 绑定到一个库不认识的角色时,渠道会静默地以默认角色应答,
// 因此该绑定被拒绝,不写入也不重载。
func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
@@ -1,12 +1,12 @@
package gateway
// This file adds the per-platform adapter panel endpoints (design/17 §2), owner
// only and audited. They expose a platform's real account data (friends,
// channels, guilds, private chats) via the adapter's REST surface and a single
// controlled "test send" write. Only KOOK is wired; every other platform answers
// an honest 501 rather than fabricated data (a project red line). The bot token
// is never read or logged — the adapter owns the token and surfaces only its
// normalized REST results.
// 本文件新增按平台的适配器面板端点(design/17 §2),仅 owner
// 且带审计。它们通过适配器的 REST 表面暴露某平台的真实账号
// 数据(好友、频道、服务器、私聊),外加一个受控的
// 「测试发送」写操作。目前只接线了 KOOK;其他平台一律
// 返回诚实的 501 而不是编造数据(项目红线)。机器人 token
// 绝不读取或记录 —— 适配器持有 token,只浮现其
// 归一化后的 REST 结果。
import (
"context"
@@ -21,9 +21,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
)
// kookAdmin is the subset of the KOOK adapter's admin REST surface the gateway
// panel endpoints invoke. It is implemented by *kook.Adapter and reached by
// unwrapping the registration wrapper.
// kookAdmin 是网关面板端点所调用的 KOOK 适配器管理 REST 表面的
// 子集。它由 *kook.Adapter 实现,通过拆开注册包装器
// 抵达。
type kookAdmin interface {
ListFriends(ctx context.Context, typ string) (kook.FriendList, error)
HandleFriendRequest(ctx context.Context, id int64, accept bool) error
@@ -37,7 +37,7 @@ type kookAdmin interface {
SendTestMessage(ctx context.Context, targetID, content string) (kook.TestMessageResult, error)
}
// adminKookView is the wire shape of GET /api/v1/admin/channels/{platform}/view.
// adminKookView 是 GET /api/v1/admin/channels/{platform}/view 的线上结构。
type adminKookView struct {
Platform string `json:"platform"`
AccountID string `json:"accountId,omitempty"`
@@ -49,9 +49,9 @@ type adminKookView struct {
Errors map[string]string `json:"errors,omitempty"`
}
// kookAccountFor resolves the *kook.Adapter registered for a platform/account.
// It is nil for a non-kook platform, an unknown account, or a wrapped adapter
// that does not expose the admin surface.
// kookAccountFor 解析为某平台/账号注册的 *kook.Adapter。
// 对非 kook 平台、未知账号,或未暴露管理表面的
// 包装适配器,它返回 nil。
func kookAccountFor(caps CapabilityStore, platform, accountID string) (kookAdmin, bool) {
if caps == nil {
return nil, false
@@ -75,11 +75,11 @@ func kookAccountFor(caps CapabilityStore, platform, accountID string) (kookAdmin
return nil, false
}
// asKookAdmin unwraps a registered channel value to its inner KOOK adapter. The
// registration wraps the adapter in cmd/cyrene's namedAdapter (an unexported
// struct embedding adapter.PlatformAdapter); the embedded field is exported, so
// reflection reads it and type-asserts to the KOOK surface. A direct *kook.Adapter
// value (tests) is also accepted.
// asKookAdmin 把已注册的渠道值拆到其内层 KOOK 适配器。
// 注册时会把适配器包进 cmd/cyrene 的 namedAdapter(一个内嵌
// adapter.PlatformAdapter 的未导出结构体);被内嵌的字段是导出的,
// 因此反射可读取它并类型断言到 KOOK 表面。直接的
// *kook.Adapter 值(测试)也被接受。
func asKookAdmin(v any) (kookAdmin, bool) {
if k, ok := v.(kookAdmin); ok {
return k, true
@@ -104,9 +104,9 @@ func asKookAdmin(v any) (kookAdmin, bool) {
return nil, false
}
// adminChannelPlatformView aggregates a KOOK account's friends, private chats and
// guilds (with the first guild's channels). Each sub-fetch is best-effort; a
// failing one is recorded in errors rather than aborting the whole view.
// adminChannelPlatformView 聚合某 KOOK 账号的好友、私聊与
// 服务器(含首个服务器的频道)。每次子拉取都是尽力而为;
// 失败的那次记入 errors,而不中止整个视图。
func (s *Server) adminChannelPlatformView(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
@@ -136,8 +136,8 @@ func (s *Server) buildKookView(ctx context.Context, k kookAdmin, platform, accou
} else {
view.Guilds = guilds
if len(guilds) > 0 {
// Aggregate the first guild's channels and members so the panel has a
// concrete server to render; deeper servers are a separate fetch.
// 聚合首个服务器的频道与成员,使面板有具体的服务器
// 可渲染;更深的服务器是另一次拉取。
if channels, err := k.ListGuildChannels(ctx, guilds[0].ID, -1); err != nil {
view.Errors["channels"] = err.Error()
} else {
@@ -153,8 +153,8 @@ func (s *Server) buildKookView(ctx context.Context, k kookAdmin, platform, accou
return view
}
// adminChannelPlatformFriends returns a KOOK account's friends/requests/blocked
// list, filtered by the optional ?type= and ?accountId= params.
// adminChannelPlatformFriends 返回某 KOOK 账号的好友/请求/黑名单
// 列表,按可选的 ?type= 与 ?accountId= 参数过滤。
func (s *Server) adminChannelPlatformFriends(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
@@ -173,13 +173,13 @@ func (s *Server) adminChannelPlatformFriends(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, fl)
}
// kookFriendHandleRequest is the POST /admin/channels/{platform}/friend/handle body.
// kookFriendHandleRequest 是 POST /admin/channels/{platform}/friend/handle 的请求体。
type kookFriendHandleRequest struct {
ID int64 `json:"id"`
Accept bool `json:"accept"`
}
// adminChannelFriendHandle accepts or rejects a KOOK friend request (owner write).
// adminChannelFriendHandle 接受或拒绝一个 KOOK 好友请求(owner 写)。
func (s *Server) adminChannelFriendHandle(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
@@ -207,14 +207,14 @@ func (s *Server) adminChannelFriendHandle(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, map[string]any{"handled": true, "id": req.ID, "accept": req.Accept})
}
// kookMessageTestRequest is the POST /admin/channels/{platform}/message/test body.
// kookMessageTestRequest 是 POST /admin/channels/{platform}/message/test 的请求体。
type kookMessageTestRequest struct {
TargetID string `json:"targetId"`
Content string `json:"content"`
}
// adminChannelMessageTest sends one test message to a KOOK target channel (owner
// write), so the panel can confirm the bot can reach the channel.
// adminChannelMessageTest 向 KOOK 目标频道发送一条测试消息(owner
// 写),使面板能确认机器人可以抵达该频道。
func (s *Server) adminChannelMessageTest(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
@@ -248,8 +248,8 @@ func (s *Server) adminChannelMessageTest(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, res)
}
// kookTarget resolves the KOOK adapter for a platform/account, writing an honest
// error and reporting found=false when it cannot.
// kookTarget 解析某平台/账号的 KOOK 适配器;无法解析时
// 写出诚实的错误并报告 found=false。
func (s *Server) kookTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (kookAdmin, bool) {
platform = strings.TrimSpace(platform)
if platform != "kook" {
@@ -1,9 +1,9 @@
package gateway
// Exercises the per-platform KOOK adapter panel endpoints (design/17 §2). The
// adapter is registered through a wrapper that embeds adapter.PlatformAdapter —
// mirroring cmd/cyrene's namedAdapter — so these tests also cover the reflection
// unwrap the gateway uses to reach the concrete *kook.Adapter.
// 演练按平台的 KOOK 适配器面板端点(design/17 §2)。适配器
// 经由一个内嵌 adapter.PlatformAdapter 的包装器注册 —— 仿照
// cmd/cyrene 的 namedAdapter —— 因此这些测试也覆盖网关用来
// 抵达具体 *kook.Adapter 的反射拆包。
import (
"encoding/json"
@@ -23,8 +23,8 @@ func gatewayKookLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// gatewayKookWrap embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter
// does, so the gateway's reflection-based unwrap (asKookAdmin) is exercised.
// gatewayKookWrap 按 cmd/cyrene 的 namedAdapter 的方式内嵌
// adapter.PlatformAdapter,从而演练网关基于反射的拆包(asKookAdmin)。
type gatewayKookWrap struct {
adapter.PlatformAdapter
name string
@@ -32,7 +32,7 @@ type gatewayKookWrap struct {
func (w *gatewayKookWrap) Name() string { return w.name }
// gatewayKookServer answers the KOOK REST surface a panel view touches.
// gatewayKookServer 应答面板视图会触及的 KOOK REST 表面。
func gatewayKookServer(t *testing.T) *httptest.Server {
t.Helper()
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -71,8 +71,8 @@ func gatewayKookServer(t *testing.T) *httptest.Server {
return srv
}
// newKookGateway wires a registry whose kook channel wraps a real adapter pointed
// at an httptest KOOK server, and returns the owner JWT.
// newKookGateway 接线一个注册表,其 kook 渠道包装一个指向 httptest
// KOOK 服务器的真实适配器,并返回 owner JWT。
func newKookGateway(t *testing.T, name string) (string, string) {
t.Helper()
kookSrv := gatewayKookServer(t)
@@ -2,32 +2,32 @@ package gateway
import "reflect"
// This file holds the optional "the adapter needs the owner to re-authorize it"
// capability surfaced by GET /api/v1/admin/channels (v0.39). It lives next to —
// not inside — the channel summary itself because the summary list is one of
// admin.go's larger handlers and the unwrap helper is reusable.
// 本文件承载「适配器需要主人重新授权」这一可选能力,
// 由 GET /api/v1/admin/channels 浮现(v0.39)。它位于渠道摘要
// 本身之旁 —— 而非其内 —— 因为摘要列表是 admin.go 中较大的
// handler 之一,而拆包辅助函数可复用。
// rescanAware is the optional capability a channel adapter exposes when its
// session needs owner re-authorization that a reconnect cannot provide.
// *weixin_ilink.Adapter implements it (RescanRequired): iLink answering
// errcode=-14 means the bot token is dead and the owner must scan a fresh QR. The
// core adapter.PlatformAdapter contract is deliberately NOT extended, so adapters
// that do not need the capability stay untouched.
// rescanAware 是渠道适配器在会话需要主人重新授权、而重连
// 无法提供该授权时所暴露的可选能力。
// *weixin_ilink.Adapter 实现了它(RescanRequired):iLink 应答
// errcode=-14 意味着机器人 token 已失效,主人必须扫新的二维码。
// 核心的 adapter.PlatformAdapter 契约被刻意不扩展,因此不需要
// 该能力的适配器不受任何改动。
//
// A value is only ever reported when the adapter really produced it — a platform
// without the capability keeps the fields omitted rather than shipping a guess.
// 只有在适配器确实产出某值时才会报告它 —— 没有该能力的
// 平台保持字段省略,而不是送出一个猜测。
type rescanAware interface {
// RescanRequired reports whether a new scan-code login is required, and why
// (reason is "" when it is not).
// RescanRequired 报告是否需要一次新的扫码登录,以及原因
// (不需要时 reason 为 "")。
RescanRequired() (required bool, reason string)
}
// asRescanAware reports whether a registered channel value exposes the rescan
// capability. The registration wraps an adapter in cmd/cyrene's namedAdapter — an
// unexported struct embedding adapter.PlatformAdapter — which promotes only the
// interface's methods, so the concrete adapter's extra capability is reachable
// only by unwrapping the embedded field. A direct adapter value (tests,
// single-account registrations) is accepted as-is.
// asRescanAware 报告已注册的渠道值是否暴露 rescan 能力。
// 注册时会把适配器包进 cmd/cyrene 的 namedAdapter —— 一个内嵌
// adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的
// 方法,因此具体适配器多出的能力只能通过拆开被内嵌字段
// 才能抵达。直接的适配器值(测试、单账号注册)
// 按原样接受。
func asRescanAware(v any) (rescanAware, bool) {
if ra, ok := v.(rescanAware); ok {
return ra, true
@@ -40,9 +40,9 @@ func asRescanAware(v any) (rescanAware, bool) {
return ra, ok
}
// unwrapNamedAdapter returns the adapter a registration wrapper embeds in its
// exported PlatformAdapter field. asKookAdmin and asWeixinLoginAdmin perform the
// same unwrap for their own capability subsets and could share this helper.
// unwrapNamedAdapter 返回注册包装器内嵌在其导出 PlatformAdapter
// 字段中的适配器。asKookAdmin 与 asWeixinLoginAdmin 为各自的
// 能力子集做同样的拆包,本可共用此辅助函数。
func unwrapNamedAdapter(v any) (any, bool) {
rv := reflect.ValueOf(v)
if !rv.IsValid() {
@@ -16,9 +16,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// rescanStubAdapter is a channel adapter that also exposes the optional rescan
// capability, exactly as *weixin_ilink.Adapter does once iLink answers
// errcode=-14 (session rejected, re-scan required).
// rescanStubAdapter 是一个渠道适配器,它同时暴露可选的 rescan
// 能力,正如 *weixin_ilink.Adapter 在 iLink 应答 errcode=-14
// (会话被拒,需要重新扫码)之后那样。
type rescanStubAdapter struct {
name string
connected bool
@@ -47,9 +47,9 @@ func (s *rescanStubAdapter) ReadMessages(ctx context.Context, _ func(adapter.Uni
}
func (s *rescanStubAdapter) RescanRequired() (bool, string) { return s.rescan, s.reason }
// namedRescanAdapter mirrors cmd/cyrene's namedAdapter: an unexported wrapper
// embedding adapter.PlatformAdapter, which promotes only the interface's methods.
// The gateway therefore has to unwrap it to reach the adapter's rescan capability.
// namedRescanAdapter 仿照 cmd/cyrene 的 namedAdapter:一个内嵌
// adapter.PlatformAdapter 的未导出包装器,它只提升接口的方法。
// 因此网关必须拆开它才能抵达适配器的 rescan 能力。
type namedRescanAdapter struct{ adapter.PlatformAdapter }
var (
@@ -57,15 +57,15 @@ var (
_ sdk.Channel = namedRescanAdapter{}
)
// TestAdminChannelsReportsNeedsRescan asserts the panel can tell "needs a fresh
// scan" apart from plain "not connected": the rejected adapter's row carries
// needsRescan + the reason, and a healthy adapter omits both rather than shipping
// a fabricated value.
// TestAdminChannelsReportsNeedsRescan 断言面板能把「需要重新扫码」
// 与单纯的「未连接」区分开:被拒适配器的行携带
// needsRescan + 原因,而健康适配器两者都省略,
// 而不是送出一个编造的值。
func TestAdminChannelsReportsNeedsRescan(t *testing.T) {
t.Parallel()
const reason = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)"
caps := capreg.New()
// Registered through the wrapper, the way the composition root registers it.
// 经包装器注册,与组合根注册它的方式一致。
if err := caps.Register(capreg.Capability{
Name: "weixin-ilink",
Kind: capreg.KindChannel,
@@ -112,8 +112,8 @@ func TestAdminChannelsReportsNeedsRescan(t *testing.T) {
t.Errorf("telegram = %+v, want a connected row without rescan fields", tg)
}
// The healthy channel must OMIT the fields (omitempty), not send defaults: the
// panel distinguishes "adapter did not report" from "adapter reported false".
// 健康渠道必须省略这些字段(omitempty),而不是发送默认值:
// 面板要区分「适配器未报告」与「适配器报告了 false」。
var raw []map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &raw); err != nil {
t.Fatalf("decode raw items: %v (body=%s)", err, body)
@@ -129,15 +129,15 @@ func TestAdminChannelsReportsNeedsRescan(t *testing.T) {
t.Error("telegram row carries lastError although there is none")
}
}
// The reason is informational text, never a credential.
// 原因是提示性文本,绝不是凭据。
if strings.Contains(string(body), `"bot_token"`) {
t.Errorf("channel list leaked a bot_token field: %s", body)
}
}
// TestAdminChannelOpenRejectedSessionReturns409 asserts the open endpoint answers
// honestly for a session a reconnect cannot fix: 409 session_rejected with the
// re-scan instruction, never a 200 that would make the panel show "connected".
// TestAdminChannelOpenRejectedSessionReturns409 断言 open 端点对重连
// 修不好的会话诚实作答:409 session_rejected 附带重新扫码
// 指引,绝不返回会让面板显示「已连接」的 200。
func TestAdminChannelOpenRejectedSessionReturns409(t *testing.T) {
t.Parallel()
caps := capreg.New()
@@ -175,10 +175,10 @@ func TestAdminChannelOpenRejectedSessionReturns409(t *testing.T) {
}
}
// TestAdminChannelsRescanFromRealIlinkAdapter closes the loop across the layers:
// a REAL *weixin_ilink.Adapter that iLink answered errcode=-14 for, registered the
// way the composition root registers it (wrapped), must surface needsRescan +
// lastError in the admin channel list.
// TestAdminChannelsRescanFromRealIlinkAdapter 跨层闭环:一个真正被
// iLink 应答 errcode=-14 的 *weixin_ilink.Adapter,按组合根
// 注册它的方式(包装后)注册,必须在管理渠道列表中
// 浮现 needsRescan + lastError。
func TestAdminChannelsRescanFromRealIlinkAdapter(t *testing.T) {
t.Parallel()
ilog := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1,14 +1,14 @@
package gateway
// This file adds the weixin-iLink scan-login admin endpoints (design/08 §4),
// owner only and audited. The panel triggers a QR bind (login-qr), then polls the
// bind status (login-status); on confirmation the handler persists the newly
// obtained bot_token / bot_id into channels.json and hot-reloads the platform so
// the bridge connects the now-configured adapter — no restart required.
// 本文件新增 weixin-iLink 扫码登录管理端点(design/08 §4),
// 仅 owner 且带审计。面板先触发二维码绑定(login-qr),再轮询
// 绑定状态(login-status);确认后 handler 把新获得的
// bot_token / bot_id 持久化进 channels.json 并热重载该平台,
// 使 bridge 连接到这个刚配置好的适配器 —— 无需重启。
//
// The adapter owns the token: it is never logged, and it is returned to the panel
// only masked (**** + trailing 4). Identity stays on the adapter's actor metadata,
// never a prompt.
// token 归适配器所有:绝不记录日志,且返回给面板时
// 只做脱敏(**** + 末 4 位)。身份始终落在适配器的 actor 元数据上,
// 绝不是提示词。
import (
"context"
@@ -28,26 +28,26 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// weixinLoginAdmin is the subset of the weixin-ilink adapter's scan-login surface
// the gateway panel endpoints invoke. It is implemented by *weixin_ilink.Adapter
// and reached by unwrapping the registration wrapper (see asWeixinLoginAdmin).
// weixinLoginAdmin 是网关面板端点所调用的 weixin-ilink 适配器扫码
// 登录表面的子集。它由 *weixin_ilink.Adapter 实现,
// 通过拆开注册包装器抵达(见 asWeixinLoginAdmin)。
type weixinLoginAdmin interface {
StartLogin(ctx context.Context) (weixin_ilink.LoginQR, error)
LoginStatus(ctx context.Context) (status, detail string)
Config() map[string]any
}
// weixinLoginQRResponse is the wire shape of GET /admin/channels/{platform}/login-qr.
// qrUrl is the WeChat QR image URL (qrcode_img_content) and qrCode is the raw scan
// identifier (qrcode); the frontend renders a scannable QR from qrUrl, since the
// liteapp URL is an HTML page that cannot be fetched as an image.
// weixinLoginQRResponse 是 GET /admin/channels/{platform}/login-qr 的线上
// 结构。qrUrl 是微信二维码图片 URL(qrcode_img_content),qrCode 是原始扫码
// 标识(qrcode);前端从 qrUrl 渲染出可扫的二维码,因为
// liteapp URL 是一个 HTML 页面,无法当作图片拉取。
type weixinLoginQRResponse struct {
QrUrl string `json:"qrUrl,omitempty"`
QrCode string `json:"qrCode,omitempty"`
Status string `json:"status"`
}
// weixinLoginStatusResponse is the wire shape of GET /admin/channels/{platform}/login-status.
// weixinLoginStatusResponse 是 GET /admin/channels/{platform}/login-status 的线上结构。
type weixinLoginStatusResponse struct {
Status string `json:"status"`
BotID string `json:"botId,omitempty"`
@@ -55,9 +55,9 @@ type weixinLoginStatusResponse struct {
Err string `json:"err,omitempty"`
}
// adminWeixinLoginQR triggers (or returns the in-progress) QR code for a
// weixin-ilink account. It is the panel's "扫码登录" entry point; it never blocks
// on the user's scan — LoginStatus is polled separately.
// adminWeixinLoginQR 为 weixin-ilink 账号触发(或返回进行中的)二维码。
// 它是面板的「扫码登录」入口;绝不会阻塞于用户的
// 扫码 —— LoginStatus 由另行轮询获得。
func (s *Server) adminWeixinLoginQR(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
@@ -86,9 +86,9 @@ func (s *Server) adminWeixinLoginQR(w http.ResponseWriter, r *http.Request) {
})
}
// adminWeixinLoginStatus reports the scan-login state. On confirmation it also
// persists the freshly obtained token to channels.json and hot-reloads the
// platform, so the bridge connects the now-configured adapter.
// adminWeixinLoginStatus 报告扫码登录状态。确认时它还会
// 把新获得的 token 持久化进 channels.json 并热重载该平台,
// 使 bridge 连接到这个刚配置好的适配器。
func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
@@ -119,15 +119,15 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, resp)
}
// persistWeixinConfirmed writes the login-obtained credentials into the account's
// channels.json config (merging onto the user's curated base_url/cdn fields and
// preserving its allow/deny/limit lists) and hot-reloads the platform so the
// bridge connects the now-configured adapter. When ownerID is non-empty it also
// registers the scanner's bound ilink_user_id into the owner whitelist
// (owner.yaml) so the bridge recognizes it as the owner without a manual edit.
// The whitelist registration is best-effort: a failure is logged but does not
// fail the already-persisted channels.json, since the identity red line never
// blocks a successful bind.
// persistWeixinConfirmed 把登录获得的凭据写入该账号的
// channels.json 配置(合并到用户精心维护的 base_url/cdn 字段之上,
// 并保留其 allow/deny/limit 列表),再热重载该平台,
// 使 bridge 连接到这个刚配置好的适配器。ownerID 非空时它还会
// 把扫码者绑定的 ilink_user_id 登记进主人白名单
// (owner.yaml),使 bridge 无需手工编辑就认出它是主人。
// 白名单登记是尽力而为:失败会记日志,但不会让
// 已持久化的 channels.json 失败,因为身份红线绝不
// 阻塞一次成功的绑定。
func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID, token, botID, ownerID string) error {
if s.channels == nil {
return fmt.Errorf("weixin login: channel config store not injected")
@@ -154,9 +154,9 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID
if ownerID != "" {
cfg["owner_id"] = ownerID
}
// Ensure the binding user (the scanner) lands in the channel's allow list, so
// the bridge recognises it as the owner and replies instead of silencing it as
// a stranger (actor_role=unknown / env=stranger).
// 确保绑定用户(扫码者)落入该渠道的允许列表,
// 使 bridge 认出它是主人并回复,而不是把它当作
// 陌生人(actor_role=unknown / env=stranger)静默掉。
allowUsers := append([]string(nil), base.AllowUsers...)
if ownerID != "" {
found := false
@@ -191,10 +191,10 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID
if err := s.channels.Reload(platform); err != nil {
return err
}
// Stamp the scanner (the bound owner) into the owner whitelist so the bridge
// recognizes it structurally. Best-effort: never fail an already-successful
// bind because a whitelist write hiccupped — the operator is alerted by a log
// line instead.
// 把扫码者(被绑定的主人)盖进主人白名单,使 bridge
// 从结构上认出它。尽力而为:绝不因为白名单写入打嗝
// 就让已经成功的绑定失败 —— 改为用一行日志
// 提醒运维。
if ownerID != "" && s.ownerRegistrar != nil {
if err := s.ownerRegistrar.RegisterChannelOwner(ctx, platform, ownerID); err != nil {
s.log.Error("weixin login owner register", "platform", platform, "err", err)
@@ -203,17 +203,17 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID
return nil
}
// weixinAccountFor resolves the *weixin_ilink.Adapter registered for the
// platform/account. It is nil for an unknown account or a wrapped adapter that
// does not expose the login surface.
// weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。
// 对未知账号,或未暴露登录表面的包装适配器,
// 它返回 nil。
func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) {
if caps == nil {
return nil, false
}
// A weixin adapter may be registered with the bare "weixin-ilink" name (single
// account) rather than "weixin-ilink:<acct>", so first try a composite match, and
// fall back to a bare weixin adapter when the caller names a specific account but
// no composite name carries it (the panel passes the config account id).
// weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号),
// 而非 "weixin-ilink:<acct>",因此先尝试复合匹配,
// 当调用方指定了具体账号、但没有复合名承载它时,回退到
// 裸 weixin 适配器(面板传的就是配置里的账号 id)。
var bare weixinLoginAdmin
var bareFound bool
for _, cap := range caps.List(capreg.KindChannel) {
@@ -242,11 +242,11 @@ func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinL
return nil, false
}
// asWeixinLoginAdmin unwraps a registered channel value to its inner weixin-ilink
// adapter, mirroring asKookAdmin: the registration wraps the adapter in cmd/cyrene's
// namedAdapter (an unexported struct embedding adapter.PlatformAdapter); reflection
// reads the embedded field and type-asserts to the login surface. A direct
// *weixin_ilink.Adapter value (tests) is also accepted.
// asWeixinLoginAdmin 把已注册的渠道值拆到其内层 weixin-ilink
// 适配器,仿照 asKookAdmin:注册时会把适配器包进 cmd/cyrene 的
// namedAdapter(一个内嵌 adapter.PlatformAdapter 的未导出结构体);
// 反射读取被内嵌字段并类型断言到登录表面。直接的
// *weixin_ilink.Adapter 值(测试)也被接受。
func asWeixinLoginAdmin(v any) (weixinLoginAdmin, bool) {
if w, ok := v.(weixinLoginAdmin); ok {
return w, true
@@ -271,8 +271,8 @@ func asWeixinLoginAdmin(v any) (weixinLoginAdmin, bool) {
return nil, false
}
// weixinTarget resolves the weixin-ilink adapter for a platform/account, writing
// an honest error and reporting found=false when it cannot.
// weixinTarget 解析某平台/账号的 weixin-ilink 适配器;无法解析时
// 写出诚实的错误并报告 found=false。
func (s *Server) weixinTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (weixinLoginAdmin, bool) {
platform = strings.TrimSpace(platform)
if channelBasePlatform(platform) != "weixin-ilink" {
@@ -290,8 +290,8 @@ func (s *Server) weixinTarget(r *http.Request, w http.ResponseWriter, what, plat
return wx, true
}
// maskToken redacts a secret to "****" + the trailing 4 characters, matching the
// project's maskToken so a token never lands verbatim in a wire response.
// maskToken 把秘密脱敏成 "****" + 末 4 个字符,与项目的
// maskToken 一致,使 token 绝不原样落在线上响应里。
func maskToken(s string) string {
if len(s) <= 4 {
return "****"
@@ -299,10 +299,10 @@ func maskToken(s string) string {
return "****" + s[len(s)-4:]
}
// qrImageDataURL turns a QR image's raw content into an embeddable data URL. When
// the content is an http(s) URL it fetches and base64-encodes it (best effort); on
// any failure it returns "" so the caller keeps qrUrl. A content already carrying a
// data: prefix is passed through; anything else is treated as raw base64.
// qrImageDataURL 把二维码图片的原始内容转成可内嵌的 data URL。
// 内容是 http(s) URL 时,它拉取并 base64 编码(尽力而为);
// 任何失败都返回 "",使调用方保留 qrUrl。已带
// data: 前缀的内容原样透传;其他一律当作原始 base64。
func qrImageDataURL(ctx context.Context, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -1,9 +1,9 @@
package gateway
// Exercises the weixin-iLink scan-login admin endpoints (design/08 §4). The
// adapter is registered through a wrapper that embeds adapter.PlatformAdapter —
// mirroring cmd/cyrene's namedAdapter — so these tests also cover the reflection
// unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter.
// 演练 weixin-iLink 扫码登录管理端点(design/08 §4)。适配器
// 经由一个内嵌 adapter.PlatformAdapter 的包装器注册 —— 仿照
// 仿照 cmd/cyrene 的 namedAdapter —— 因此这些测试也覆盖网关
// 用来抵达具体 *weixin_ilink.Adapter 的反射拆包。
import (
"context"
@@ -25,8 +25,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// gatewayWeixinWrap embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter
// does, so the gateway's reflection-based unwrap (asWeixinLoginAdmin) is exercised.
// gatewayWeixinWrap 按 cmd/cyrene 的 namedAdapter 的方式内嵌
// adapter.PlatformAdapter,从而演练网关基于反射的拆包(asWeixinLoginAdmin)。
type gatewayWeixinWrap struct {
adapter.PlatformAdapter
name string
@@ -34,7 +34,7 @@ type gatewayWeixinWrap struct {
func (w *gatewayWeixinWrap) Name() string { return w.name }
// fakeWeixinChannels is a minimal ChannelConfigStore for the persist-on-confirm path.
// fakeWeixinChannels 是供「确认即持久化」路径用的最小 ChannelConfigStore。
type fakeWeixinChannels struct {
path string
data map[string][]config.ChannelAccount
@@ -50,8 +50,8 @@ func (f *fakeWeixinChannels) Reload(platform string) error {
return nil
}
// weixinLoginServer answers the scan-login surface: a fixed QR and an immediate
// confirmed status (so the background poll confirms within a poll interval).
// weixinLoginServer 应答扫码登录表面:固定的二维码与立即
// 确认的状态(使后台轮询在一个轮询周期内确认)。
func weixinLoginServer(t *testing.T) *httptest.Server {
t.Helper()
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -75,8 +75,8 @@ func weixinLoginServer(t *testing.T) *httptest.Server {
return srv
}
// newWeixinGateway wires a registry whose weixin channel wraps a real adapter, and
// returns the httptest URL + an owner JWT. channels may be nil (no persist test).
// newWeixinGateway 接线一个注册表,其 weixin 渠道包装真实适配器,
// 并返回 httptest URL + owner JWT。channels 可为 nil(不做持久化测试)。
func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (string, string) {
t.Helper()
wsrv := weixinLoginServer(t)
@@ -101,8 +101,8 @@ func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (s
return ts.URL, tok
}
// newWeixinGatewayPresetToken builds a gateway whose weixin adapter already holds a
// token, so login endpoints report confirmed without any network.
// newWeixinGatewayPresetToken 构建一个网关,其 weixin 适配器已持有
// token,因此登录端点无需任何网络就报告已确认。
func newWeixinGatewayPresetToken(t *testing.T, name string) (string, string) {
t.Helper()
wa, err := weixin_ilink.New(
@@ -122,8 +122,8 @@ func newWeixinGatewayPresetToken(t *testing.T, name string) (string, string) {
return ts.URL, tok
}
// weixinLoginWaitingServer answers the QR but keeps the status "wait" (never
// confirms), so the panel polls a login that is still in progress.
// weixinLoginWaitingServer 应答二维码但状态一直是 "wait"(永不
// 确认),因此面板轮询的是一个仍在进行中的登录。
func weixinLoginWaitingServer(t *testing.T) *httptest.Server {
t.Helper()
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -144,7 +144,7 @@ func weixinLoginWaitingServer(t *testing.T) *httptest.Server {
return srv
}
// newWeixinGatewayWaiting wires a weixin adapter whose status poll never confirms.
// newWeixinGatewayWaiting 接线一个 weixin 适配器,其状态轮询永不确认。
func newWeixinGatewayWaiting(t *testing.T, name string) (string, string) {
t.Helper()
wsrv := weixinLoginWaitingServer(t)
@@ -229,7 +229,7 @@ func TestAdminWeixinLoginStatus_ConfirmPersists(t *testing.T) {
}
base, tok := newWeixinGateway(t, "weixin-ilink:conf0", chs)
// Trigger the QR, then poll until the background login confirms and persists.
// 触发二维码,然后轮询直到后台登录确认并持久化。
if resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok); resp.StatusCode != http.StatusOK {
t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body)
}
@@ -333,12 +333,12 @@ func TestAdminWeixinLogin_NonWeixinNotImplemented(t *testing.T) {
}
}
// ownerRegCall records one RegisterChannelOwner invocation.
// ownerRegCall 记录一次 RegisterChannelOwner 调用。
type ownerRegCall struct {
platform, accountID string
}
// captureOwnerRegistrar records whitelist registrations for the weixin confirm path.
// captureOwnerRegistrar 记录 weixin 确认路径的白名单登记。
type captureOwnerRegistrar struct {
calls []ownerRegCall
}
@@ -348,10 +348,10 @@ func (c *captureOwnerRegistrar) RegisterChannelOwner(_ context.Context, platform
return nil
}
// TestPersistWeixinConfirmed_RegistersOwner verifies that once a scan-login
// confirmation persists the channels.json credentials, the bound owner id is
// handed to the OwnerRegistrar as (weixin-ilink, ilink_user_id), and that an empty
// owner id skips the registration entirely.
// TestPersistWeixinConfirmed_RegistersOwner 验证:一旦扫码登录确认
// 把凭据持久化进 channels.json,被绑定的主人 id 就会以
// (weixin-ilink, ilink_user_id) 交给 OwnerRegistrar,而空
// 主人 id 会完全跳过该登记。
func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
@@ -375,8 +375,8 @@ func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) {
t.Fatalf("registrar calls = %+v, want [weixin-ilink/o-1]", reg.calls)
}
// An empty owner id (a confirmed bind that yields no bound user) must not
// register anything.
// 空的主人 id(一次确认了但没有产出绑定用户的绑定)绝不
// 登记任何内容。
if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-y", "bot-y", ""); err != nil {
t.Fatalf("persistWeixinConfirmed(no owner): %v", err)
}
@@ -385,8 +385,8 @@ func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) {
}
}
// TestNew_WiresOwnerRegistrar verifies the composition point: Deps.OwnerRegistrar
// is threaded onto the Server so the weixin confirm flow has a live registrar.
// TestNew_WiresOwnerRegistrar 验证组合点:Deps.OwnerRegistrar
// 被串到 Server 上,使 weixin 确认流程有活的 registrar。
func TestNew_WiresOwnerRegistrar(t *testing.T) {
t.Parallel()
reg := &captureOwnerRegistrar{}
@@ -8,10 +8,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// TestAdminMissingStoreWritesHonest501 locks the B5 red line for the endpoints
// whose backing store does not exist yet: they must answer 501 not_implemented
// with an explanatory message, never a fabricated success. User creation has no
// user table/login to persist into, and IoT has no scenario/rule model or store.
// TestAdminMissingStoreWritesHonest501 为尚无后端存储的端点锁定 B5
// 红线:它们必须返回 501 not_implemented 并附说明性
// 消息,绝不编造成功。创建用户没有可持久化的用户表/登录,
// 而 IoT 没有场景/规则模型或存储。
func TestAdminMissingStoreWritesHonest501(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
+35 -35
View File
@@ -1,13 +1,13 @@
package gateway
// Admin plugin install/uninstall and the installed-plugin listing. A runtime
// "plugin" in v1 is an external MCP server: install connects it, hot-registers
// its tools into the capability registry (so they are injected into the next
// model request without a restart), persists its config to the gitignored
// plugins.json, and holds the live server for a later refresh/uninstall
// (design/09 插件生态 §1). Installing a compiled Go plugin is impossible in a
// single static binary, so it is not offered. Writes are owner-only and audited;
// an API key is persisted but never returned or logged verbatim.
// 管理侧插件安装/卸载与已安装插件列表。v1 中的运行时
// 「插件」是一个外部 MCP server:安装即连接它、把它的
// 工具热注册进能力注册表(从而无需重启就注入下一次
// 模型请求),把其配置持久化到被 gitignore 的
// plugins.json,并持有活着的 server 以便之后刷新/卸载
// (design/09 插件生态 §1)。在单静态二进制中安装编译好的 Go 插件
// 不可能,因此不提供该方式。写操作仅 owner 且带审计;
// API key 会被持久化,但绝不返回,也绝不原样记日志。
import (
"context"
@@ -24,28 +24,28 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// pluginInstallTimeout bounds the connect+discover handshake the install request
// waits for. The plugin manager applies its own per-server timeout inside it.
// pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手
// 时长。插件管理器在其中施加自己的每 server 超时。
const pluginInstallTimeout = 20 * time.Second
// pluginActionRequest is the POST /api/v1/admin/plugins/uninstall body.
// pluginActionRequest 是 POST /api/v1/admin/plugins/uninstall 的请求体。
type pluginActionRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
}
// pluginInstallRequest is the POST /api/v1/admin/plugins/install body. Config
// carries the MCP server connection fields; the top-level name/kind are kept
// separate so the request reads like the other admin writes.
// pluginInstallRequest 是 POST /api/v1/admin/plugins/install 的请求体。Config
// 承载 MCP server 连接字段;顶层的 name/kind 被单独
// 保留,使该请求读起来与其他管理写一致。
type pluginInstallRequest struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config pluginInstallConfig `json:"config"`
}
// pluginInstallConfig is the MCP server connection block. Endpoint is the URL
// for http/sse; url is accepted as an alias. APIKey/Env are persisted but never
// echoed back.
// pluginInstallConfig 是 MCP server 连接块。Endpoint 是 http/sse
// 的 URL;url 作为别名被接受。APIKey/Env 会被持久化,但绝不
// 回显。
type pluginInstallConfig struct {
Transport string `json:"transport"`
Endpoint string `json:"endpoint"`
@@ -56,8 +56,8 @@ type pluginInstallConfig struct {
Env map[string]string `json:"env"`
}
// AdminInstalledPlugin is the wire shape of one installed plugin in the admin
// list. The API key and any sensitive env values are masked.
// AdminInstalledPlugin 是管理列表中一个已安装插件的线上
// 结构。API key 与任何敏感 env 值都会脱敏。
type AdminInstalledPlugin struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -69,16 +69,16 @@ type AdminInstalledPlugin struct {
APIKey string `json:"apiKey,omitempty"`
Env map[string]string `json:"env,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
// Live reports whether the MCP server is connected and its tools registered.
// Live 报告 MCP server 是否已连接、其工具是否已注册。
Live bool `json:"live"`
// ToolCount is the number of tools the live server registered.
// ToolCount 是活着的 server 已注册的工具数。
ToolCount int `json:"toolCount"`
}
// adminPluginInstall installs (or refreshes) an MCP server plugin: it validates
// the request, connects and discovers the server's tools, then persists the
// config. A connect/discover/register failure returns 502 and persists nothing,
// so a plugin is never half-installed.
// adminPluginInstall 安装(或刷新)一个 MCP server 插件:它校验
// 请求、连接并发现该 server 的工具,然后持久化配置。
// connect/discover/register 失败会返回 502 且不持久化任何东西,
// 因此插件绝不会处于半安装状态。
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
var req pluginInstallRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -145,10 +145,10 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
})
}
// adminPluginUninstall uninstalls a plugin. An installed MCP server is closed and
// its tools removed through the plugin manager, then its config is deleted from
// plugins.json; any other name falls back to removing the capability from the
// registry, preserving the pre-existing behavior.
// adminPluginUninstall 卸载一个插件。已安装的 MCP server 会被关闭、
// 其工具经插件管理器移除,随后其配置从 plugins.json 删除;
// 其他任何名字回退为把该能力从注册表移除,
// 保持既有行为。
func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
var req pluginActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -194,9 +194,9 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true})
}
// adminListInstalledPlugins lists the runtime-installed plugins with their
// masked config and per-server tool count. It is a read endpoint (owner/admin)
// and never returns an API key or a sensitive env value verbatim.
// adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置
// 与每 server 工具数。它是读端点(owner/admin),
// 绝不原样返回 API key 或敏感 env 值。
func (s *Server) adminListInstalledPlugins(w http.ResponseWriter, r *http.Request) {
out := make([]AdminInstalledPlugin, 0)
if s.plugins != nil {
@@ -207,7 +207,7 @@ func (s *Server) adminListInstalledPlugins(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, out)
}
// adminInstalledPluginOf maps an installed plugin to its masked wire shape.
// adminInstalledPluginOf 把已安装插件映射为其脱敏后的线上结构。
func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin {
cfg := runtimeconfig.MaskedPlugin(it.Config)
return AdminInstalledPlugin{
@@ -226,8 +226,8 @@ func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin {
}
}
// firstNonEmpty returns the first non-blank value, so an install body may send
// either endpoint or url for the server address.
// firstNonEmpty 返回首个非空值,因此安装请求体可用 endpoint
// 或 url 任一字段给出 server 地址。
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
@@ -19,9 +19,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// fakeMCPHandler is a minimal Streamable HTTP MCP server for the plugin tests.
// wantAuth, when set, requires the bearer header; tools is mutated atomically so
// a refresh test can change the tool list.
// fakeMCPHandler 是供插件测试用的最小 Streamable HTTP MCP server。
// wantAuth 设置后要求 bearer 头;tools 以原子方式变更,
// 使刷新测试能改变工具列表。
type fakeMCPHandler struct {
wantAuth string
tools []string
@@ -69,8 +69,8 @@ func (f *fakeMCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(resp)
}
// pluginTestEnv is a gateway wired with a real plugin manager over a temp
// plugins.json.
// pluginTestEnv 是一个网关,接线了以临时 plugins.json 为
// 后端的真实插件管理器。
type pluginTestEnv struct {
ts *httptest.Server
caps *capreg.Registry
@@ -108,7 +108,7 @@ func newPluginTestEnv(t *testing.T, mcp *fakeMCPHandler) *pluginTestEnv {
return &pluginTestEnv{ts: ts, caps: caps, path: path, token: tok, mcpURL: mcpURL}
}
// newFakeMCPHandler returns a fake server plus a setter for its tool list.
// newFakeMCPHandler 返回一个假 server 以及其工具列表的 setter。
func newFakeMCPHandler(wantAuth string, tools ...string) *fakeMCPHandler {
return &fakeMCPHandler{wantAuth: wantAuth, tools: tools}
}
@@ -10,9 +10,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
)
// TestAdminRagDeleteRemovesDocument proves the delete write path is real: the
// indexed document (project entry + its chapters) is gone from the list and the
// search, and deleting it again answers 404 rather than a fabricated success.
// TestAdminRagDeleteRemovesDocument 证明删除写路径是真的:已索引
// 的文档(项目条目 + 其章节)从列表与搜索中消失,
// 再次删除它返回 404 而不是编造的成功。
func TestAdminRagDeleteRemovesDocument(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -67,8 +67,8 @@ func TestAdminRagDeleteRemovesDocument(t *testing.T) {
wantErrorShape(t, body2)
}
// TestAdminRagDeleteUnwired501 asserts an unconfigured RAG service still answers
// honestly instead of pretending the document was removed.
// TestAdminRagDeleteUnwired501 断言未配置的 RAG 服务仍然
// 诚实作答,而不是假装文档已被移除。
func TestAdminRagDeleteUnwired501(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
@@ -82,7 +82,7 @@ func TestAdminRagDeleteUnwired501(t *testing.T) {
wantErrorShape(t, body)
}
// TestAdminRagDeleteRequiresOwner asserts the write route stays owner-gated.
// TestAdminRagDeleteRequiresOwner 断言该写路由仍然受 owner 门控。
func TestAdminRagDeleteRequiresOwner(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: newRagStoreForGateway(t)})
+45 -45
View File
@@ -17,13 +17,13 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// System management admin writes (owner-only, audited; design/11 §4 敏感操作代审 +
// design/10 §3 module 14 系统设置). Every handler audits a system.* action and never
// echoes a JWT secret or a database path back to the client.
// 系统管理写操作(仅 owner,带审计;design/11 §4 敏感操作代审 +
// design/10 §3 module 14 系统设置)。每个 handler 都会审计一个 system.* 动作,
// 绝不把 JWT 秘密或数据库路径回显给客户端。
// adminRevokeAllKeys revokes every currently registered API key. It is the
// `system.revoke_keys` admin write; the table is emptied so no prior key can
// authenticate afterward.
// adminRevokeAllKeys 吊销当前已注册的每一个 API key。它是
// `system.revoke_keys` 管理写;该表被清空,因此此前任何 key
// 之后都无法认证。
func (s *Server) adminRevokeAllKeys(w http.ResponseWriter, r *http.Request) {
keys, ok := s.users.(*auth.APIKey)
if !ok {
@@ -36,15 +36,15 @@ func (s *Server) adminRevokeAllKeys(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"revoked": n})
}
// rotateJWTRequest is the POST /api/v1/admin/system/rotate-jwt body. The new
// secret is accepted from the request so the owner can supply a fresh value; it
// is never echoed back or logged.
// rotateJWTRequest 是 POST /api/v1/admin/system/rotate-jwt 的请求体。新
// 秘密从请求中接受,使主人能提供新值;它绝不
// 被回显或被记日志。
type rotateJWTRequest struct {
NewSecret string `json:"newSecret"`
}
// adminRotateJWT replaces the JWT signing secret, invalidating every token issued
// under the old secret. It is the `system.rotate_jwt` admin write.
// adminRotateJWT 替换 JWT 签名秘密,使在旧秘密下签发的所有
// token 失效。它是 `system.rotate_jwt` 管理写。
func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) {
var req rotateJWTRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -71,10 +71,10 @@ func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "rotate_failed", "failed to rotate jwt secret"))
return
}
// Credential rotation must invalidate every long-lived grant too, not only the
// access tokens signed under the old secret. Refresh tokens are opaque and
// secret-independent, so they are deleted wholesale — the minimal, exact
// global invalidation in this architecture (no token_version column needed).
// 凭据轮换也必须让每一个长期授权失效,而不只是旧秘密下
// 签发的访问令牌。refresh token 是不透明的、与秘密无关,
// 因此被整体删除 —— 这是本架构中最小且精确的
// 全局失效手段(无需 token_version 列)。
var refreshRevoked int64
if s.refresh != nil {
n, rerr := s.refresh.RevokeAll(r.Context())
@@ -95,9 +95,9 @@ func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) {
})
}
// adminBackup snapshots the SQLite database into the data directory using
// `VACUUM INTO` (which includes committed WAL content), returning the backup path
// and size. It is the `system.backup` admin write.
// adminBackup 用 `VACUUM INTO` 把 SQLite 数据库快照进数据目录
// (该语句包含已提交的 WAL 内容),返回备份路径
// 与大小。它是 `system.backup` 管理写。
func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(s.dbPath) == "" {
s.auditClaims(r, "system.backup", "error")
@@ -115,17 +115,17 @@ func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"path": path, "size": size})
}
// restoreRequest is the POST /api/v1/admin/system/restore body. Path is the
// backup file to restore, expected to live inside the data directory.
// restoreRequest 是 POST /api/v1/admin/system/restore 的请求体。Path 是
// 要恢复的备份文件,预期位于数据目录内。
type restoreRequest struct {
Path string `json:"path"`
}
// adminRestore validates a backup path (must be inside the data directory — a
// path-traversal guard), snapshots the current database first, then replaces the
// live DB file. Because the running process holds the SQLite file handle open, the
// replace takes effect on restart; it is reported as such. It is the
// `system.restore` admin write.
// adminRestore 校验备份路径(必须位于数据目录内 —— 这是
// 路径穿越防护),先为当前数据库做快照,然后替换
// 活的 DB 文件。由于运行中的进程仍持有 SQLite 文件句柄,
// 替换在重启后生效;响应中如实说明。它是
// `system.restore` 管理写。
func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) {
var req restoreRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -154,8 +154,8 @@ func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "备份文件不存在"))
return
}
// Snapshot the current database before touching it, so an accidental restore
// of the wrong file is recoverable.
// 在动它之前先为当前数据库做快照,这样误恢复
// 错文件时仍可挽回。
curPath, _, err := s.backupDatabase(r.Context())
if err != nil {
s.auditClaims(r, "system.restore", "error")
@@ -177,10 +177,10 @@ func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) {
})
}
// restorePathWithinDataDir resolves src to an absolute path and reports whether it
// lives inside the data directory (the parent of s.dbPath). It is the path-
// traversal guard for the restore endpoint: only paths under the data dir are
// accepted.
// restorePathWithinDataDir 把 src 解析为绝对路径并报告它是否
// 位于数据目录(s.dbPath 的父目录)内。它是恢复端点的
// 路径穿越防护:只接受数据目录之下的
// 路径。
func (s *Server) restorePathWithinDataDir(src string) (string, bool) {
dataDir, err := filepath.Abs(filepath.Dir(s.dbPath))
if err != nil {
@@ -199,11 +199,11 @@ func (s *Server) restorePathWithinDataDir(src string) (string, bool) {
return absSrc, true
}
// backupDatabase snapshots the live SQLite database into
// <dataDir>/backup-<ts>.db via `VACUUM INTO`, which includes every committed WAL
// transaction, and returns the new file's path and size. Opening a second
// connection to the file-backed database is safe: SQLite serializes access, and
// busy_timeout handles a momentarily locked source.
// backupDatabase 经 `VACUUM INTO` 把活的 SQLite 数据库快照到
// <dataDir>/backup-<ts>.db,该语句包含每一个已提交的 WAL
// 事务,并返回新文件的路径与大小。对基于文件的数据库再开
// 一个连接是安全的:SQLite 会串行化访问,而
// busy_timeout 能应对源被短暂锁定。
func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) {
dir := filepath.Dir(s.dbPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -215,8 +215,8 @@ func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) {
return "", 0, fmt.Errorf("open db for backup: %w", err)
}
defer conn.Close()
// VACUUM INTO takes a string literal, not a bound parameter; escape the
// generated filename (safe characters, but escape defensively).
// VACUUM INTO 接受字符串字面量,而不是绑定参数;对生成的
// 文件名做转义(字符本身安全,但防御性地转义)。
query := fmt.Sprintf("VACUUM INTO '%s'", escapeSQLString(dest))
if _, err := conn.ExecContext(ctx, query); err != nil {
return "", 0, fmt.Errorf("vacuum into: %w", err)
@@ -228,11 +228,11 @@ func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) {
return dest, info.Size(), nil
}
// replaceDatabase atomically replaces the live DB file at dest with a copy of src,
// writing to a temp file first so a failure never leaves dest truncated. On Linux
// a running SQLite connection keeps its open handle to the old inode, so the new
// data becomes visible only after the process restarts — which the restore
// endpoint reports.
// replaceDatabase 用 src 的副本原子替换 dest 处的活 DB 文件,
// 先写临时文件,因此失败绝不留下被截断的 dest。在 Linux 上
// 运行中的 SQLite 连接仍持有旧 inode 的句柄,因此新数据
// 只有在进程重启后才可见 —— 恢复端点会如实
// 报告这一点。
func replaceDatabase(src, dest string) error {
in, err := os.Open(src)
if err != nil {
@@ -260,8 +260,8 @@ func replaceDatabase(src, dest string) error {
return nil
}
// escapeSQLString doubles any single quote so a filename can be safely embedded
// in a SQL string literal for VACUUM INTO.
// escapeSQLString 把每个单引号翻倍,使文件名能安全地嵌入
// 供 VACUUM INTO 使用的 SQL 字符串字面量。
func escapeSQLString(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
+23 -23
View File
@@ -14,9 +14,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// newFileDB creates a real SQLite database file at dir/<name>, migrates it, and
// closes the handle, returning the file path. The gateway backup/restore
// endpoints open their own connections, so the handle is not held open here.
// newFileDB 在 dir/<name> 创建真实 SQLite 数据库文件、迁移它、
// 然后关闭句柄,返回文件路径。网关的备份/恢复端点
// 会自己打开连接,因此这里不保持句柄打开。
func newFileDB(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
@@ -35,7 +35,7 @@ func newFileDB(t *testing.T, dir, name string) string {
return path
}
// --- revoke-keys -------------------------------------------------------------
// --- system.revoke_keys(吊销全部密钥) -------------------------------------
func TestAdminRevokeAllKeys(t *testing.T) {
t.Parallel()
@@ -68,7 +68,7 @@ func TestAdminRevokeAllKeys(t *testing.T) {
func TestAdminRevokeAllKeysUnwired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no Users store
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 Users store
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok)
if resp.StatusCode != http.StatusInternalServerError {
@@ -77,13 +77,13 @@ func TestAdminRevokeAllKeysUnwired(t *testing.T) {
wantErrorShape(t, body)
}
// --- rotate-jwt --------------------------------------------------------------
// --- system.rotate_jwt(轮换 JWT) -----------------------------------------
func TestAdminRotateJWT(t *testing.T) {
t.Parallel()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
// The route is owner-only; verify an agent with the current secret passes.
// 该路由仅 owner;验证持有当前秘密的 agent 能通过。
tok := signAny(t, jwt, "acct", auth.RoleOwner)
newSecret := strings.Repeat("Z", 40)
@@ -102,11 +102,11 @@ func TestAdminRotateJWT(t *testing.T) {
if !res.Rotated || res.Note == "" {
t.Fatalf("res = %+v, want rotated with note", res)
}
// The pre-rotation token no longer verifies.
// 轮换前的 token 不再通过校验。
if _, err := jwt.Verify(tok); err == nil {
t.Fatal("pre-rotation token still verifies after RotateSecret")
}
// The new secret is never echoed back in the body, nor is a short one accepted.
// 新秘密绝不在响应体里回显,过短的也不会被接受。
if strings.Contains(string(body), newSecret) {
t.Fatalf("new secret leaked in response body: %s", body)
}
@@ -124,7 +124,7 @@ func TestAdminRotateJWTShortSecret(t *testing.T) {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
// The previous secret stays intact after a rejected rotation.
// 被拒绝的轮换之后,原秘密保持不变。
if _, err := jwt.Verify(tok); err != nil {
t.Fatalf("token invalid after rejected rotation: %v", err)
}
@@ -143,7 +143,7 @@ func TestAdminRotateJWTMissingSecret(t *testing.T) {
}
}
// --- backup ------------------------------------------------------------------
// --- system.backup(备份) --------------------------------------------------
func TestAdminBackupProducesFile(t *testing.T) {
t.Parallel()
@@ -176,7 +176,7 @@ func TestAdminBackupProducesFile(t *testing.T) {
func TestAdminBackupUnwired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 DatabasePath
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok)
if resp.StatusCode != http.StatusInternalServerError {
@@ -185,7 +185,7 @@ func TestAdminBackupUnwired(t *testing.T) {
wantErrorShape(t, body)
}
// --- restore -----------------------------------------------------------------
// --- system.restore(恢复) -------------------------------------------------
func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
t.Parallel()
@@ -193,9 +193,9 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
dbPath := newFileDB(t, dir, "cyrene.db")
backupSrc := newFileDB(t, dir, "backup.db")
// Make the backup source clearly different from the live db by appending a
// marker. Restore only copies bytes and never opens the source, so the marker
// survives into the replaced live file and proves the swap happened.
// 通过追加一个标记,让备份源与活的 db 明显不同。
// 恢复只复制字节、绝不打开源,因此该标记会
// 存活进被替换的活文件,证明替换确实发生过。
srcFile, err := os.OpenFile(backupSrc, os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
t.Fatalf("open backup source: %v", err)
@@ -214,7 +214,7 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// No pre-existing backups yet.
// 此时尚无既有备份。
before, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
map[string]any{"path": backupSrc}, tok)
@@ -232,13 +232,13 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
if !res.Restored || res.Note == "" {
t.Fatalf("res = %+v, want restored with note", res)
}
// The current DB was snapshotted before the replace (a new backup appeared).
// 当前 DB 在替换前已被快照(出现了一个新备份)。
after, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
if len(after) != len(before)+1 {
t.Fatalf("backup count before=%d after=%d, want one more pre-restore snapshot", len(before), len(after))
}
// The live DB was replaced with the backup source's bytes, so it now carries
// the marker and differs from its pre-restore content.
// 活 DB 已被替换为备份源的字节,因此它现在携带
// 该标记,与恢复前的内容不同。
liveAfter, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read live db after: %v", err)
@@ -253,8 +253,8 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
func TestAdminRestoreRejectsTraversal(t *testing.T) {
t.Parallel()
dir := t.TempDir() // data dir
outside := t.TempDir() // a sibling directory, outside the data dir
dir := t.TempDir() // 数据目录
outside := t.TempDir() // 兄弟目录,位于数据目录之外
dbPath := newFileDB(t, dir, "cyrene.db")
outsideBackup := newFileDB(t, outside, "outside.db")
@@ -287,7 +287,7 @@ func TestAdminRestoreRejectsMissingFile(t *testing.T) {
func TestAdminRestoreUnwired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 DatabasePath
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
map[string]any{"path": "x"}, tok)
@@ -1,16 +1,16 @@
package gateway
// Admin channel-configuration writes (owner-only, audited). The panel saves a
// platform's account config (token/address/rate limit/black-or-white lists) to
// channels.json and hot-reloads that platform's adapters — no restart required
// (design/04 §3.1). The GET /admin/channels read path masks every token so a
// secret is never returned to the client; this write path persists the real
// token to the gitignored file and triggers the bridge reload.
// 管理渠道配置写操作(仅 owner,带审计)。面板把某平台的
// 账号配置(token/地址/速率限制/黑或白名单)保存到
// channels.json 并热重载该平台的适配器 —— 无需重启
// (design/04 §3.1)。GET /admin/channels 读路径会对每个 token
// 脱敏,因此秘密绝不返回给客户端;这条写路径把真实
// token 持久化进被 gitignore 的文件并触发 bridge 重载。
//
// The request carries a generic config map (platform-specific keys such as
// bot_token / base_url / remote_url / endpointPath) plus convenience top-level
// fields for the common token/address fields, so the panel can send either the
// config object or the shortcut fields.
// 请求携带一个通用 config map(平台特有键,如
// bot_token / base_url / remote_url / endpointPath),外加针对常见
// token/地址字段的顶层便捷字段,因此面板既可发送
// config 对象,也可发送快捷字段。
import (
"encoding/json"
@@ -22,9 +22,9 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// AdminChannelAccount is the wire shape of one configured channel account in the
// admin list. Config values are masked (no raw token) so nothing sensitive is
// returned to the client.
// AdminChannelAccount 是管理列表中一个已配置渠道账号的线上
// 结构。Config 的值已脱敏(不含原始 token),因此没有敏感
// 内容返回给客户端。
type AdminChannelAccount struct {
ID string `json:"id"`
Config map[string]any `json:"config,omitempty"`
@@ -35,29 +35,29 @@ type AdminChannelAccount struct {
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
MsgPerMin int `json:"msgPerMin,omitempty"`
MaxMessages int `json:"maxMessages,omitempty"`
// PersonaID is the role this account answers as (design/22 §2 方案 b), omitted
// when the account binds none. It is a persona id, not a secret.
// PersonaID 是该账号应答所用的角色(design/22 §2 方案 b),账号
// 未绑定任何角色时省略。它是人格 id,不是秘密。
PersonaID string `json:"personaId,omitempty"`
// BotID/BotUsername/BotNickname are the connected bot's resolved identity
// (kook fetches it from /api/v3/user/me on connect). They come from the live
// adapter, not the persisted channels.json config, and are non-sensitive (the
// bot's public handle/display name), so the panel may show them.
// BotID/BotUsername/BotNickname 是已连接机器人的实际解析身份
// (kook 在 connect 时从 /api/v3/user/me 获取)。它们来自活的
// 适配器,而不是持久化的 channels.json 配置,且非敏感(机器人
// 的公开 handle/显示名),因此面板可以展示它们。
BotID string `json:"botId,omitempty"`
BotUsername string `json:"botUsername,omitempty"`
BotNickname string `json:"botNickname,omitempty"`
// Capabilities lists the account-level admin operations the platform's
// adapter exposes (friends/channels/user-chats/message-test) so the panel
// renders the right per-account cards. It is omitted for platforms without a
// specialized adapter surface.
// Capabilities 列出该平台适配器暴露的账号级管理操作
// (friends/channels/user-chats/message-test),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台
// 会省略它。
Capabilities []string `json:"capabilities,omitempty"`
}
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
// Config holds platform-specific keys (bot_token, base_url, remote_url,
// endpointPath, ...); the top-level convenience fields are merged into it for the
// common token/address fields. AllowGroups/DenyGroups/AllowUsers/DenyUsers are the
// black-or-white lists (design/16 §4.6 §6); the *_PerMin/MaxMessages fields are
// the per-account flow limits (design/12 §6 策略三/四).
// channelConfigRequest 是 PUT /api/v1/admin/channels/{platform}/config 的请求体。
// Config 保存平台特有键(bot_token、base_url、remote_url、
// endpointPath……);顶层便捷字段会为常见 token/地址字段
// 合并进去。AllowGroups/DenyGroups/AllowUsers/DenyUsers 是
// 黑或白名单(design/16 §4.6 §6);*_PerMin/MaxMessages 字段是
// 每账号的流量限制(design/12 §6 策略三/四)。
type channelConfigRequest struct {
ID string `json:"id"`
Token string `json:"token"`
@@ -75,22 +75,22 @@ type channelConfigRequest struct {
GroupMsgPerMin int `json:"groupMsgPerMin"`
MsgPerMin int `json:"msgPerMin"`
MaxMessages int `json:"maxMessages"`
// PersonaID is the role this account answers as (design/22 §2 方案 b). It is a
// pointer because the wire contract is three-state and a plain string cannot
// tell an absent key from an explicit "": absent keeps the stored binding,
// "" clears it, a non-empty id binds it. An unknown id is refused with 400 so a
// save can never bind a role that cannot answer.
// PersonaID 是该账号应答所用的角色(design/22 §2 方案 b)。它是
// 指针,因为线上契约是三态的,而普通 string 无法
// 区分键缺失与显式 "":缺失保留已存绑定,
// "" 清除它,非空 id 绑定它。未知 id 以 400 拒绝,因此
// 保存绝不可能绑定一个无法应答的角色。
PersonaID *string `json:"personaId"`
}
// adminSaveChannelConfig persists one account's config for a platform into
// channels.json (Upsert by id) and hot-reloads that platform's adapters. The
// response carries the channels.json path and the account id so the panel can
// address the saved account (for a later edit/delete).
// adminSaveChannelConfig 把某平台一个账号的配置持久化进
// channels.json(按 id Upsert)并热重载该平台的适配器。
// 响应携带 channels.json 路径与账号 id,使面板能
// 定位已保存的账号(供之后编辑/删除)。
//
// personaId is three-state (see channelPersonaBinding): the panel only sends the
// key when the owner touched the role select, so an absent key must keep the
// stored binding rather than silently unbind the account.
// personaId 是三态的(见 channelPersonaBinding):面板仅在主人
// 触碰过角色选择时才发送该键,因此键缺失必须保留
// 已存绑定,而不是静默解绑该账号。
func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
if platform == "" {
@@ -108,11 +108,11 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
// A channel-level role must exist in the persona library, or the account would
// be bound to a role that cannot answer (design/22 §2 方案 b). Only a value the
// panel actually sent is validated; an absent key (nil) inherits the stored
// binding, which was validated when it was written. An explicit "" is always
// accepted: it means "clear the channel binding".
// 渠道级角色必须存在于人格库中,否则账号会被绑定到
// 一个无法应答的角色(design/22 §2 方案 b)。只校验面板确实
// 发送的值;键缺失(nil)继承已存绑定,该绑定在
// 写入时就已校验。显式的 "" 一律接受:
// 它表示「清除渠道绑定」。
if req.PersonaID != nil {
if !s.requireKnownPersona(w, strings.TrimSpace(*req.PersonaID)) {
s.auditClaims(r, "channel.save", "error")
@@ -120,10 +120,10 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
}
}
cfg := channelConfigFromRequest(req)
// weixin-ilink may be saved with an empty config: the panel creates the account
// first, then the user scans the QR (get_bot_qrcode) which fills bot_token/bot_id/
// owner_id via login-status (design/08 §4). Requiring a token here would block that
// flow, so exempt it from the "at least one field" gate.
// weixin-ilink 可以带空配置保存:面板先创建账号,
// 随后用户扫码(get_bot_qrcode),它经 login-status 填入
// bot_token/bot_id/owner_id(design/08 §4)。此处要求 token 会阻塞该
// 流程,因此把它从「至少一个字段」的门禁中豁免。
if len(cfg) == 0 && platform != "weixin-ilink" {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "至少提供 token/baseUrl/address 等配置字段"))
@@ -163,8 +163,8 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
})
}
// adminDeleteChannelAccount removes one account (by the id query param) from a
// platform in channels.json and hot-reloads that platform so the adapter stops.
// adminDeleteChannelAccount 按 id 查询参数从某平台在 channels.json
// 中移除一个账号,并热重载该平台使适配器停止。
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
id := strings.TrimSpace(r.URL.Query().Get("id"))
@@ -206,10 +206,10 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques
})
}
// channelConfigFromRequest merges the request's convenience fields into the
// platform-specific config map, preferring keys the panel already set explicitly.
// It never injects a key the panel left out (an empty token is left absent so the
// adapter's zero value, not an empty-string token, is used).
// channelConfigFromRequest 把请求的便捷字段合并进平台特有 config
// map,优先采用面板已显式设置的键。它绝不注入面板
// 未给出的键(空 token 保持缺位,这样用的是
// 适配器的零值,而不是空字符串 token)。
func channelConfigFromRequest(req channelConfigRequest) map[string]any {
cfg := make(map[string]any, len(req.Config)+8)
for k, v := range req.Config {
@@ -253,17 +253,17 @@ func channelConfigFromRequest(req channelConfigRequest) map[string]any {
return cfg
}
// channelPersonaBinding resolves the three-state personaId contract of
// PUT /admin/channels/{platform}/config:
// channelPersonaBinding 解析 PUT /admin/channels/{platform}/config 的
// 三态 personaId 契约:
//
// - the key is absent (nil) → keep the account's stored binding;
// - the key is an explicit "" → clear the binding (an explicit unbind);
// - the key is a non-empty id → bind that role.
// - 键缺失(nil) → 保留该账号已存的绑定;
// - 键是显式 "" → 清除绑定(显式解绑);
// - 键是非空 id → 绑定该角色。
//
// The panel omits the key unless the owner touched the role select, so resolving
// an absent key from the stored account is what keeps a plain config edit from
// silently dropping a binding. A save that creates a new account (no stored
// account to inherit from) resolves to "no binding".
// 除非主人触碰过角色选择,面板会省略该键,因此从已存
// 账号解析缺失键,正是让一次普通的配置编辑不会
// 静默丢弃绑定的原因。创建新账号的保存(没有已存
// 账号可继承)解析为「无绑定」。
func channelPersonaBinding(sent *string, stored config.ChannelAccount) string {
if sent == nil {
return strings.TrimSpace(stored.PersonaID)
@@ -271,10 +271,10 @@ func channelPersonaBinding(sent *string, stored config.ChannelAccount) string {
return strings.TrimSpace(*sent)
}
// existingChannelAccount returns the stored account a save would replace (same
// platform, same id) so the write can inherit the fields the panel did not send.
// An empty id means the save creates a new account, so there is nothing to
// inherit.
// existingChannelAccount 返回一次保存将要替换的已存账号(同
// 平台、同 id),使写操作能继承面板未发送的字段。
// 空 id 意味着该保存创建新账号,因此没有
// 可继承的内容。
func existingChannelAccount(list []config.ChannelAccount, id string) config.ChannelAccount {
id = strings.TrimSpace(id)
if id == "" {
@@ -288,9 +288,9 @@ func existingChannelAccount(list []config.ChannelAccount, id string) config.Chan
return config.ChannelAccount{}
}
// savedAccountID returns the account id that a save produced — the caller-supplied
// one when present, otherwise the id runtimeconfig.Upsert generated (which it
// appends last for a brand-new account).
// savedAccountID 返回一次保存产出的账号 id —— 调用方提供时
// 用它的,否则用 runtimeconfig.Upsert 生成的 id(对全新账号
// 它会追加在末尾)。
func savedAccountID(list []config.ChannelAccount, supplied string) string {
if supplied != "" {
return supplied
@@ -301,7 +301,7 @@ func savedAccountID(list []config.ChannelAccount, supplied string) string {
return list[len(list)-1].ID
}
// adminChannelAccountOf maps a (masked) account to its admin wire shape.
// adminChannelAccountOf 把一个(已脱敏的)账号映射为其管理侧线上结构。
func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
return AdminChannelAccount{
ID: acct.ID,
@@ -317,9 +317,9 @@ func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
}
}
// channelBasePlatform returns the bare platform of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so config lookup groups every
// account of a platform together.
// channelBasePlatform 返回形如 "telegram:conf0" → "telegram" 的
// (可能多账号的)适配器名的裸平台,使配置查找把某平台的
// 所有账号归到一组。
func channelBasePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
@@ -327,9 +327,9 @@ func channelBasePlatform(name string) string {
return name
}
// configuredAccount reports whether an account carries a meaningful config (a
// panel-created id or a real token/address), so the admin list skips the blank
// placeholder accounts the env fallback synthesizes for unconfigured platforms.
// configuredAccount 报告某账号是否携带有效配置(面板创建的
// id 或真实 token/地址),使管理列表跳过环境变量回退
// 为未配置平台合成的空白占位账号。
func configuredAccount(a config.ChannelAccount) bool {
if strings.TrimSpace(a.ID) != "" {
return true
@@ -16,8 +16,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
// real temp channels.json path, recording Reload calls.
// stubChannelConfigStore 是一个 ChannelConfigStore,后端是内存 map 与
// 真实临时 channels.json 路径,并记录 Reload 调用。
type stubChannelConfigStore struct {
path string
accounts map[string][]config.ChannelAccount
@@ -37,9 +37,9 @@ func (s *stubChannelConfigStore) Reload(platform string) error {
return s.reloadErr
}
// stubNamedChannel is an sdk.Channel with a caller-chosen adapter name, so a test
// can register a multi-account adapter (kook:3fc8) distinct from its bare-platorm
// sibling and observe which entries survive a config change.
// stubNamedChannel 是一个 sdk.Channel,适配器名由调用方选定,
// 使测试能注册一个多账号适配器(kook:3fc8),与它的裸平台
// 兄弟区分开,并观察配置变更后哪些条目存活。
type stubNamedChannel struct {
name string
}
@@ -80,7 +80,7 @@ func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
t.Fatalf("save res = %+v", res)
}
// The account landed in channels.json with the real token and the black-list.
// 该账号带着真实 token 与黑名单落到了 channels.json。
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load channels warn = %v", warn)
@@ -96,12 +96,12 @@ func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
t.Fatalf("groupMsgPerMin = %d, want 3", kook[0].GroupMsgPerMin)
}
// Reload was triggered for the saved platform.
// 已为保存的平台触发了 Reload。
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// GET /admin/channels reflects the account and never leaks the raw token.
// GET /admin/channels 反映了该账号,且绝不泄露原始 token。
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if gResp.StatusCode != http.StatusOK {
t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody)
@@ -124,7 +124,7 @@ func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) {
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("blank config status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// No reload should have been triggered.
// 不应触发任何重载。
if len(store.reloaded) != 0 {
t.Fatalf("reloaded = %v, want none", store.reloaded)
}
@@ -132,7 +132,7 @@ func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) {
func TestAdminSaveChannelConfigUnwired500(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no channel config store
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无渠道配置存储
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
map[string]any{"token": "x"}, tok)
@@ -157,7 +157,7 @@ func TestAdminDeleteChannelConfig(t *testing.T) {
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// The file is rewritten without the account; the platform key is dropped.
// 文件被重写且不含该账号;平台键被删除。
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn == nil {
t.Fatalf("expected a warn after deleting the last account (empty map is invalid)")
@@ -167,9 +167,9 @@ func TestAdminDeleteChannelConfig(t *testing.T) {
}
}
// adminChannelsList is a small read helper that issues GET /admin/channels and
// decodes the data array, returning the raw body too so a caller can assert that a
// secret never leaked. It fails the test on any transport/parse error.
// adminChannelsList 是一个小的读辅助函数:发起 GET /admin/channels,
// 解码 data 数组,同时返回原始响应体,使调用方能断言
// 秘密从未泄露。任何传输/解析错误都会让测试失败。
func adminChannelsList(t *testing.T, ts *httptest.Server, token string) ([]ChannelSummary, []byte) {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token)
@@ -183,10 +183,10 @@ func adminChannelsList(t *testing.T, ts *httptest.Server, token string) ([]Chann
return items, body
}
// TestAdminDeleteChannelAccountRemovesFromList is the regression for the
// stale-adapter bug: deleting an account removes it from channels.json, but the
// adapter stays registered in caps. The list must still drop it (channels.json is
// the source of truth), so a deleted account never reappears on refresh.
// TestAdminDeleteChannelAccountRemovesFromList 是陈旧适配器 bug 的
// 回归:删除账号会把它从 channels.json 移除,但适配器
// 仍注册在 caps 中。列表必须仍然丢弃它(channels.json 是
// 唯一事实来源),因此已删除账号绝不在刷新时复现。
func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) {
t.Parallel()
caps := capreg.New()
@@ -202,7 +202,7 @@ func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Before the delete the account shows up (aggregated into a single "kook" row).
// 删除前该账号会出现(聚合进单个 "kook" 行)。
items, _ := adminChannelsList(t, ts, tok)
if len(items) != 1 || items[0].Name != "kook" || len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "3fc8" {
t.Fatalf("before delete items = %+v, want one kook row with account 3fc8", items)
@@ -216,16 +216,16 @@ func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// The stale adapter remains in caps (the bridge reload does not touch the
// registry), yet the deleted account must not reappear in the list.
// 陈旧适配器仍留在 caps 中(bridge 重载不触碰注册表),
// 但已删除的账号不得在列表中复现。
if items, _ := adminChannelsList(t, ts, tok); len(items) != 0 {
t.Fatalf("after delete items = %+v, want none (deleted account must vanish)", items)
}
}
// TestAdminDeleteChannelAccountKeepsSibling verifies that deleting one account of a
// multi-account platform drops only that account: the sibling adapter and its
// account remain, and the deleted one disappears.
// TestAdminDeleteChannelAccountKeepsSibling 验证删除多账号平台的
// 一个账号只丢弃该账号:兄弟适配器及其
// 账号保留,被删除的那个消失。
func TestAdminDeleteChannelAccountKeepsSibling(t *testing.T) {
t.Parallel()
caps := capreg.New()
@@ -260,10 +260,10 @@ func TestAdminDeleteChannelAccountKeepsSibling(t *testing.T) {
}
}
// TestAdminSaveChannelConfigListMirrorsChannelsJSON verifies the save path: after
// persisting an account the list reflects it, while a stale caps adapter for a
// different account id (here kook:oldid, never in channels.json) is dropped. The
// account's token is never leaked.
// TestAdminSaveChannelConfigListMirrorsChannelsJSON 验证保存路径:
// 持久化账号后列表会反映它,而针对不同账号 id(这里是
// kook:oldid,从未出现在 channels.json)的陈旧 caps 适配器被丢弃。该
// 账号的 token 从不泄露。
func TestAdminSaveChannelConfigListMirrorsChannelsJSON(t *testing.T) {
t.Parallel()
caps := capreg.New()
@@ -11,19 +11,19 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
)
// IoT device-control and automation-rule admin writes (owner-only, audited).
// Device control performs a real control on the owning provider; the others
// answer honestly when no scenario/rule store is wired.
// IoT 设备控制与自动化规则管理写操作(仅 owner,带审计)。
// 设备控制会在所属 provider 上执行真实控制;其余在没有
// 接线场景/规则存储时诚实作答。
// iotControlRequest is the POST /api/v1/admin/iot/{id}/control body.
// iotControlRequest 是 POST /api/v1/admin/iot/{id}/control 的请求体。
type iotControlRequest struct {
Action string `json:"action"`
Params map[string]any `json:"params"`
}
// adminIoTControl sends a control instruction to a device through the IoT
// manager, which applies it on the owning provider and publishes a device_update
// event. A real action only when the deployment has a device provider.
// adminIoTControl 经 IoT 管理器向设备发送控制指令,管理器
// 在所属 provider 上施加它并发布 device_update
// 事件。只有部署了设备 provider 时才是真实动作。
func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req iotControlRequest
@@ -56,10 +56,10 @@ func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, iotDeviceSummary{ID: dev.ID, Name: dev.Name, Type: dev.Type, State: dev.State, Online: dev.Online})
}
// adminIoTScenario answers honestly: the IoT subsystem owns device
// list/control/status only (the Provider interface has no scenario model or
// store), so there is nothing to persist a scenario into. It reports the missing
// store rather than fabricating one.
// adminIoTScenario 诚实作答:IoT 子系统只管设备
// 列表/控制/状态(Provider 接口没有场景模型或
// 存储),因此没有可持久化场景的地方。它报告缺失的
// 存储,而不是编造一个。
func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "iot.scenario", "error")
writeError(w, &apperr.AppError{
@@ -69,9 +69,9 @@ func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) {
})
}
// adminIoTRule answers honestly: no IoT automation-rule model or store exists
// (the generic automations service runs cron/event rules, not device-state
// conditions with device actions), so a rule cannot be persisted or executed.
// adminIoTRule 诚实作答:不存在 IoT 自动化规则模型或存储
// (通用 automations 服务运行 cron/event 规则,而不是带设备动作的
// 设备状态条件),因此规则无法被持久化或执行。
func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "iot.rule", "error")
writeError(w, &apperr.AppError{
@@ -81,7 +81,7 @@ func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) {
})
}
// mapIotError maps iot sentinel errors to HTTP errors.
// mapIotError 把 iot 哨兵错误映射为 HTTP 错误。
func mapIotError(err error) *apperr.AppError {
switch {
case errors.Is(err, iot.ErrDeviceNotFound):
@@ -97,15 +97,15 @@ func mapIotError(err error) *apperr.AppError {
}
}
// automationCreateRequest is the POST /api/v1/admin/automations body.
// automationCreateRequest 是 POST /api/v1/admin/automations 的请求体。
type automationCreateRequest struct {
Name string `json:"name"`
Kind string `json:"kind"` // cron | event
Expression string `json:"expression"`
}
// adminCreateAutomation registers a real cron/event rule whose action records its
// fire. Invalid cron expressions or malformed rules are answered honestly.
// adminCreateAutomation 注册一条真实的 cron/event 规则,其动作会记录
// 触发。无效的 cron 表达式或畸形的规则会被诚实作答。
func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) {
var req automationCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -141,7 +141,7 @@ func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "name": req.Name, "kind": req.Kind})
}
// adminRunAutomation manually triggers a registered rule's action once.
// adminRunAutomation 手动触发一次已注册规则的动作。
func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
auto, ok := s.automations.(*automations.Service)
@@ -164,7 +164,7 @@ func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"id": id, "output": out})
}
// adminDeleteAutomation removes a registered rule (cron or event) by id.
// adminDeleteAutomation 按 id 移除一条已注册规则(cron 或 event)。
func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
auto, ok := s.automations.(*automations.Service)
@@ -21,27 +21,27 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// LLM-provider and channel-lifecycle admin writes (owner-only, audited). The LLM
// test performs a real model call; provider "add" records a process-lifetime
// entry. Channel open/close drive the real adapter lifecycle; the identity test
// is the structural actor × whitelist rule, never prompt text.
// LLM provider 与渠道生命周期的管理写操作(仅 owner,带审计)。LLM
// 测试会发起真实模型调用;provider「add」记录一条
// 进程生命周期内的条目。渠道开/关驱动真实适配器生命周期;身份
// 测试走结构化的 actor × 白名单规则,绝不是提示词文本。
const (
// ownerProfilePath is the owner whitelist loaded for the structural identity
// test. It mirrors the path wire.go uses for owner recognition.
// ownerProfilePath 是为结构化身份测试加载的主人白名单。
// 它与 wire.go 用于主人识别的路径一致。
ownerProfilePath = "configs/users/owner.yaml"
// llmTestTimeout bounds a single LLM probe request.
// llmTestTimeout 限定单次 LLM 探测请求。
llmTestTimeout = 30 * time.Second
// llmProbePrompt is the minimal prompt used to verify a model responds.
// llmProbePrompt 是用于验证模型能应答的最小提示词。
llmProbePrompt = "Reply with the single word: OK"
)
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
// provider group. Every field is a pointer so the handler can tell "absent"
// (preserve the stored value) from "explicitly zero" (overwrite): a partial edit
// from the panel must never wipe a field it did not touch. Name is a client-side
// display label (not persisted — the models.json schema carries id/provider/
// baseUrl/apiKey/model/fallback/multimodal/maxTokens).
// llmProviderRequest 是 POST /api/v1/admin/llm 的请求体,用于添加/更新
// provider 分组。每个字段都是指针,使 handler 能区分「缺失」
// (保留已存值)与「显式零值」(覆盖):来自面板的局部
// 编辑绝不清除它未触碰的字段。Name 是客户端侧
// 显示标签(不持久化 —— models.json 结构含 id/provider/
// baseUrl/apiKey/model/fallback/multimodal/maxTokens)。
type llmProviderRequest struct {
ID *string `json:"id"`
Name *string `json:"name"`
@@ -51,16 +51,16 @@ type llmProviderRequest struct {
Model *string `json:"model"`
Multimodal *bool `json:"multimodal"`
Fallback *string `json:"fallback"`
// MaxTokens is the group's own token budget. Absent preserves the stored
// budget (0 = unset for a new group). Negative is rejected.
// MaxTokens 是该分组自身的 token 预算。缺失则保留已存
// 预算(新分组 0 = 未设置)。负值被拒绝。
MaxTokens *int `json:"maxTokens"`
}
// adminAddLlmProvider saves a provider group to models.json (read-modify-write,
// promoting it to the active group) and hot-reloads the running LLM stack so the
// new provider/model takes effect without a restart (design/04 §3.1). A field the
// client omits keeps its stored value, so a partial edit never resets a budget or
// key. The API key is persisted to the gitignored file but never returned/logged.
// adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write,
// 并将其提升为活动分组),再热重载运行中的 LLM 栈,使
// 新的 provider/model 无需重启即生效(design/04 §3.1)。客户端省略的
// 字段保持其已存值,因此局部编辑绝不复位预算或
// key。API key 被持久化进被 gitignore 的文件,但绝不返回/记日志。
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
var req llmProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -83,8 +83,8 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
return
}
// Promote the saved group to the active (index 0) slot, then persist and
// hot-reload so Current() reflects the new provider/model immediately.
// 把已保存的分组提升到活动(下标 0)槽位,然后持久化并
// 热重载,使 Current() 立即反映新的 provider/model。
groups := runtimeconfig.Activate(s.llm.Groups(), group)
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
s.auditClaims(r, "llm.addProvider", "error")
@@ -117,9 +117,9 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
})
}
// mergeLlmProvider applies a partial provider patch over the stored groups: a
// field present in the request wins, an absent field keeps the stored value (or
// its default for a new group). It returns a validated group.
// mergeLlmProvider 把局部 provider 补丁施加到已存分组之上:
// 请求中出现的字段胜出,缺失字段保留已存值(新分组
// 则用其默认值)。它返回经过校验的分组。
func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) {
id := ""
if req.ID != nil {
@@ -166,7 +166,7 @@ func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (run
return g, nil
}
// llmTestRequest is the POST /api/v1/admin/llm/test body.
// llmTestRequest 是 POST /api/v1/admin/llm/test 的请求体。
type llmTestRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
@@ -175,8 +175,8 @@ type llmTestRequest struct {
Prompt string `json:"prompt"`
}
// adminTestLlm performs a real completion against the supplied provider/model
// endpoint and reports the result — a genuine round-trip, not a placeholder.
// adminTestLlm 针对提供的 provider/model 端点执行一次真实补全
// 并报告结果 —— 真实的往返,而不是占位。
func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
var req llmTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -217,12 +217,12 @@ func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens})
}
// channelActionRequest is the POST /api/v1/admin/channels/open|close body.
// channelActionRequest 是 POST /api/v1/admin/channels/open|close 的请求体。
type channelActionRequest struct {
Name string `json:"name"`
}
// adminChannelOpen connects a registered channel adapter (real lifecycle call).
// adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.open")
if !ok {
@@ -236,17 +236,17 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
}
if err := ch.Connect(r.Context()); err != nil {
s.auditClaims(r, "channel.open", "error")
// A state a plain reconnect cannot fix must not read as a generic failure:
// the weixin-iLink bot token was rejected by iLink (errcode=-14), so the
// owner needs a fresh scan-code login. Answer 409 with that instruction
// instead of pretending the channel opened (the panel marks it connected).
// 单纯重连修不好的状态不得读作通用失败:
// weixin-iLink 机器人 token 被 iLink 拒绝(errcode=-14),因此
// 主人需要一次新的扫码登录。用该指引返回 409,
// 而不是假装渠道已打开(面板会把它标记为已连接)。
if errors.Is(err, weixin_ilink.ErrSessionRejected) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "session_rejected",
"该渠道会话已被 iLink 拒绝(errcode=-14):请用「扫码登录」重新绑定后再连接"))
return
}
// weixin-iLink with no token yet must not hang nor read as a credential
// error: the panel routes the owner to the out-of-band 扫码登录 flow.
// 尚无 token 的 weixin-iLink 既不能挂起,也不能读作凭据
// 错误:面板会把主人导向带外的扫码登录流程。
if errors.Is(err, weixin_ilink.ErrNeedsLogin) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接"))
return
@@ -259,7 +259,7 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
}
// adminChannelClose disconnects a registered channel adapter (real lifecycle call).
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.close")
if !ok {
@@ -281,17 +281,17 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
}
// channelTestRequest is the POST /api/v1/admin/channels/test body: a channel
// account id plus the conversation shape, resolved by the structural actor rule.
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道
// 账号 id 加上对话形态,由结构化 actor 规则解析。
type channelTestRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
IsGroup bool `json:"isGroup"`
}
// adminChannelTest runs the structural identity rule (actor metadata × owner
// whitelist), never prompt text. It loads the owner whitelist from disk so a
// mismatch degrades to the conservative default (member/stranger).
// adminChannelTest 运行结构化身份规则(actor 元数据 × 主人
// 白名单),绝不是提示词文本。它从磁盘加载主人白名单,
// 因此不匹配时退化为保守默认(member/stranger)。
func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
var req channelTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -327,8 +327,8 @@ func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
})
}
// channelName decodes the {name} body for open/close routes, writing a 400 on
// failure. It reports uniqueness of the decode via ok.
// channelName 为 open/close 路由解码 {name} 请求体,失败时写出 400。
// 它通过 ok 报告解码的唯一性。
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) {
var req channelActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -343,11 +343,11 @@ func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string)
return req.Name, true
}
// findChannel returns the registered channel adapter named name, if any. An exact
// adapter name ("kook:conf0") wins; a bare platform name ("kook") that is not an
// exact name still resolves to that platform's first registered account, so the
// panel/CLI "connect kook" reaches the token-bearing account even when it is named
// "kook:conf0" (the empty-token bare placeholder is never registered).
// findChannel 返回名为 name 的已注册渠道适配器(若有)。精确的
// 适配器名("kook:conf0")胜出;非精确名的裸平台名("kook")
// 仍会解析到该平台首个已注册账号,因此面板/CLI 的
// "connect kook" 能抵达携带 token 的账号,即使它名为
// "kook:conf0"(空 token 的裸占位符从不注册)。
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
if caps == nil {
return nil, false
@@ -371,8 +371,8 @@ func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
return nil, false
}
// loadOwnerProfile loads the owner whitelist from disk, returning nil on any
// failure so the identity test degrades to the conservative deny.
// loadOwnerProfile 从磁盘加载主人白名单,任何失败都返回
// nil,因此身份测试退化为保守的拒绝。
func loadOwnerProfile() *user.OwnerProfile {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
@@ -381,7 +381,7 @@ func loadOwnerProfile() *user.OwnerProfile {
return p
}
// describeIdentity renders the structural verdict for the identity test.
// describeIdentity 为身份测试渲染结构化判定结果。
func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
switch {
case a.IsGroupChat():
@@ -11,22 +11,22 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// Admin memory + knowledge-base write endpoints (owner-only, audited). The memory
// add writes a real item into the memory store (owner-private, safe), and the RAG
// index imports a document into the project index so it becomes searchable. Both
// degrade to a clear 501 when their backing store is not wired.
// 管理侧记忆 + 知识库写端点(仅 owner,带审计)。添加记忆
// 会把真实条目写入记忆存储(owner 私有,安全),而 RAG 索引
// 把文档导入项目索引使其可被检索。二者在各自
// 后端存储未接线时都退化为明确的 501。
// memoryAddRequest is the POST /api/v1/admin/memories body.
// memoryAddRequest 是 POST /api/v1/admin/memories 的请求体。
type memoryAddRequest struct {
PersonaID string `json:"personaId"`
Content string `json:"content"`
Scope string `json:"scope"`
}
// adminAddMemory writes a single memory item for the owner. It is the manual
// "添加记忆" path: the item is scoped to the persona (default), so it behaves like
// the "记住 X" path the loop already writes. Scope may be omitted (default
// persona) or one of user|persona|project; an invalid non-empty scope is rejected.
// adminAddMemory 为主人写入单条记忆。它是手动的
// 「添加记忆」路径:条目默认作用于 persona,因此行为与
// loop 已在写的「记住 X」路径一致。Scope 可省略(默认
// persona),或为 user|persona|project 之一;非法非空 scope 会被拒绝。
func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) {
var req memoryAddRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -70,7 +70,7 @@ func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"id": stored.ID, "scope": string(stored.Scope), "text": stored.Content})
}
// isValidMemoryScope reports whether raw is one of the defined memory scopes.
// isValidMemoryScope 报告 raw 是否是已定义的记忆作用域之一。
func isValidMemoryScope(raw string) bool {
switch memory.Scope(strings.TrimSpace(raw)) {
case memory.ScopeUser, memory.ScopePersona, memory.ScopeProject:
@@ -80,16 +80,16 @@ func isValidMemoryScope(raw string) bool {
}
}
// ragIndexRequest is the POST /api/v1/admin/rag body.
// ragIndexRequest 是 POST /api/v1/admin/rag 的请求体。
type ragIndexRequest struct {
Name string `json:"name"`
Desc string `json:"desc"`
Content string `json:"content"`
}
// adminIndexRag imports one knowledge document: it hands the name/description and
// content to the knowledge store, which chunks it and indexes it into the project
// index so the document becomes keyword/searchable. It is the admin "导入文档" path.
// adminIndexRag 导入一份知识文档:它把 name/description 与
// content 交给知识存储,后者切分它并索引进项目
// 索引,使文档可被关键词检索。它是管理侧的「导入文档」路径。
func (s *Server) adminIndexRag(w http.ResponseWriter, r *http.Request) {
var req ragIndexRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -127,15 +127,15 @@ func (s *Server) adminIndexRag(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"name": name})
}
// ragDeleteRequest is the POST /api/v1/admin/rag/delete body.
// ragDeleteRequest 是 POST /api/v1/admin/rag/delete 的请求体。
type ragDeleteRequest struct {
ID string `json:"id"`
}
// adminDeleteRag removes a knowledge document by id: the store deletes the
// project-index entry and cascades its chapters. A missing document is answered
// with 404, a store that cannot delete with a clear 501, and any other failure
// with 500 — never a fabricated success. The route is owner-gated and audited.
// adminDeleteRag 按 id 移除一份知识文档:存储删除
// 项目索引条目并级联其章节。文档缺失时返回 404,
// 存储无法删除时返回明确的 501,其他失败
// 返回 500 —— 绝不编造成功。该路由受 owner 门控且带审计。
func (s *Server) adminDeleteRag(w http.ResponseWriter, r *http.Request) {
if s.rag == nil {
s.auditClaims(r, "rag.delete", "error")
@@ -9,11 +9,11 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
)
// TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML pins the admin persona
// write path for the optional `thinking` block: the hand-written DTO maps into
// the loader struct, yaml.Marshal emits the documented keys, and the reload of
// that YAML yields the same values — i.e. the field really round-trips instead of
// being dropped by an un-whitelisted DTO.
// TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML 钉住管理侧人格
// 写路径对可选 `thinking` 块的处理:手写 DTO 映射进
// loader 结构体,yaml.Marshal 输出文档化的键,重载
// 该 YAML 得到相同的值 —— 即该字段真的往返成功,
// 而不是被未列入白名单的 DTO 丢弃。
func TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML(t *testing.T) {
t.Parallel()
@@ -42,9 +42,9 @@ func TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML(t *testing.T) {
}
}
// TestPersonaUpsertRequest_ThinkingInvalidRejected asserts an invalid thinking
// block is rejected through the same validation the loader uses, so the admin API
// cannot persist a mode the runtime would refuse to load.
// TestPersonaUpsertRequest_ThinkingInvalidRejected 断言非法的 thinking
// 块会经由 loader 所用的同一套校验被拒绝,因此管理 API
// 无法持久化运行时拒绝加载的模式。
func TestPersonaUpsertRequest_ThinkingInvalidRejected(t *testing.T) {
t.Parallel()
@@ -60,9 +60,9 @@ func TestPersonaUpsertRequest_ThinkingInvalidRejected(t *testing.T) {
}
}
// TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey asserts a request without a
// thinking block produces none in the YAML, so no existing persona file gains a
// noise section.
// TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey 断言不带 thinking
// 块的请求在 YAML 中不产出它,因此既有人格文件不会多出
// 一段噪声。
func TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey(t *testing.T) {
t.Parallel()
@@ -75,9 +75,9 @@ func TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey(t *testing.T) {
}
}
// TestApplyThinkingEdit_PreservesOnOmission is the non-destructive update rule: a
// client that omits `thinking` keeps the stored block, an explicit block replaces
// it, and an all-default block clears it.
// TestApplyThinkingEdit_PreservesOnOmission 是非破坏性更新规则:
// 省略 `thinking` 的客户端保留已存块,显式给出块则替换
// 它,全默认值的块清除它。
func TestApplyThinkingEdit_PreservesOnOmission(t *testing.T) {
t.Parallel()
@@ -16,14 +16,14 @@ import (
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// persona-to-identity and API-key admin writes (owner-only, audited). Persona
// writes register into the persona registry (real runtime effect) and persist a
// YAML file so they survive a restart; API keys are minted/revoked in the
// auth.APIKey store.
// 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格
// 写操作注册进人格注册表(真实的运行时效果),并持久化
// 一个 YAML 文件使其在重启后存活;API key 在
// auth.APIKey 存储中铸造/吊销。
const personaDir = "configs/personas"
// personaUpsertRequest is the POST/PUT /api/v1/admin/personas body.
// personaUpsertRequest 是 POST/PUT /api/v1/admin/personas 的请求体。
type personaUpsertRequest struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -38,14 +38,14 @@ type personaUpsertRequest struct {
Modes []string `json:"modes"`
Mode string `json:"mode"`
MemoryNS string `json:"memoryNamespace"`
// Thinking is the optional reasoning/presentation block (design/01 §2.1). It
// is persisted into the persona YAML as-is; omitting it on an update keeps the
// stored block (see adminUpdatePersona).
// Thinking 是可选的推理/呈现块(design/01 §2.1)。它
// 原样持久化进人格 YAML;更新时省略它会保留
// 已存块(见 adminUpdatePersona)。
Thinking *personaThinkingRequest `json:"thinking,omitempty"`
}
// personaThinkingRequest is the wire form of the persona `thinking` section. The
// pointers/naming mirror the YAML so a panel can round-trip it verbatim.
// personaThinkingRequest 是人格 `thinking` 段的线上形式。
// 其指针/命名与 YAML 一致,使面板能原样往返。
type personaThinkingRequest struct {
BudgetTokens int `json:"budgetTokens,omitempty"`
Effort string `json:"effort,omitempty"`
@@ -53,8 +53,8 @@ type personaThinkingRequest struct {
Mode string `json:"mode,omitempty"`
}
// adminCreatePersona registers a new persona in the registry (real runtime
// effect) and persists it to the persona YAML library so it survives a restart.
// adminCreatePersona 在注册表中注册一个新人格(真实的运行时
// 效果),并持久化进人格 YAML 库使其在重启后存活。
func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
var req personaUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -103,8 +103,8 @@ func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, s.personaSummaryOf(p, characterFromRequest(req)))
}
// adminUpdatePersona edits an existing persona, persisting the YAML and
// replacing the in-memory persona/character.
// adminUpdatePersona 编辑既有人格,持久化 YAML 并
// 替换内存中的人格/角色。
func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req personaUpsertRequest
@@ -135,10 +135,10 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err))
return
}
// A client that does not know the optional `thinking` block (an older panel, a
// script) omits it; keep the stored one instead of silently wiping a
// hand-edited config. Sending the object replaces it, and sending it with all
// fields default clears it.
// 不认识可选 `thinking` 块的客户端(旧面板、脚本)会省略它;
// 应保留已存的那个,而不是静默清掉手工编辑的
// 配置。发送该对象则替换它,全字段为默认值
// 时清除它。
p.Thinking = applyThinkingEdit(req.Thinking, existingPersona(reg, id))
if err := savePersonaYAML(p); err != nil {
s.auditClaims(r, "persona.update", "error")
@@ -160,11 +160,11 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.personaSummaryOf(p, characterFromRequest(req)))
}
// adminCreateUser answers honestly: there is no persistent user table and no
// user-authenticated login to back one (the deployment is a single admin
// account from .env plus runtime API keys; design/11 is single-user first), so
// user creation would be a row nobody could log in with. It reports the missing
// store instead of fabricating a user.
// adminCreateUser 诚实作答:既没有持久化用户表,也没有
// 可供支撑的用户认证登录(部署形态是来自 .env 的单个管理
// 账号加运行时 API key;design/11 是单用户优先),因此
// 创建用户会是一行没人能登录的记录。它报告缺失的
// 存储,而不是编造一个用户。
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "user.create", "error")
writeError(w, &apperr.AppError{
@@ -174,14 +174,14 @@ func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
})
}
// apiKeyIssueRequest is the POST /api/v1/admin/users/apikey body.
// apiKeyIssueRequest 是 POST /api/v1/admin/users/apikey 的请求体。
type apiKeyIssueRequest struct {
Role string `json:"role"`
}
// adminIssueApiKey mints a real API key bound to a role and returns the plaintext
// once (the only time it is visible). The id in the response is the masked form
// the admin list shows, so it can be used to revoke the key later.
// adminIssueApiKey 铸造一个绑定到某角色的真实 API key,并返回一次
// 明文(唯一可见的一次)。响应中的 id 是管理列表所展示的
// 脱敏形式,因此之后可用它吊销该 key。
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
var req apiKeyIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -210,8 +210,8 @@ func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)})
}
// adminRevokeApiKey revokes a key by its masked id (the identifier the admin list
// returns). It reports not-found when no key carries that mask.
// adminRevokeApiKey 按脱敏 id(管理列表返回的标识)吊销一个
// key。没有 key 携带该掩码时报告 not-found。
func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
keys, ok := s.users.(*auth.APIKey)
@@ -229,8 +229,8 @@ func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
}
// personaFromRequest builds a loader.Persona from the upsert request, filling a
// derived system prompt/characterRef when omitted so a minimal create validates.
// personaFromRequest 从 upsert 请求构建 loader.Persona,在省略时
// 填入派生的 system prompt/characterRef,使最小创建也能通过校验。
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
systemPrompt := strings.TrimSpace(req.SystemPrompt)
if systemPrompt == "" {
@@ -255,8 +255,8 @@ func personaFromRequest(req personaUpsertRequest) *loader.Persona {
}
}
// thinkingFromRequest maps the wire `thinking` block onto the persona field; a
// missing block yields the zero value, which yaml.Marshal omits entirely.
// thinkingFromRequest 把线上 `thinking` 块映射到人格字段;
// 块缺失得到零值,yaml.Marshal 会完全省略它。
func thinkingFromRequest(req *personaThinkingRequest) loader.ThinkingParams {
if req == nil {
return loader.ThinkingParams{}
@@ -269,7 +269,7 @@ func thinkingFromRequest(req *personaThinkingRequest) loader.ThinkingParams {
}
}
// existingPersona returns the stored persona for id, or nil when it is absent.
// existingPersona 返回 id 对应的已存人格,不存在时为 nil。
func existingPersona(reg *personareg.Registry, id string) *loader.Persona {
if p, ok := reg.Persona(id); ok {
return p
@@ -277,10 +277,10 @@ func existingPersona(reg *personareg.Registry, id string) *loader.Persona {
return nil
}
// applyThinkingEdit returns the `thinking` block an update should persist: the
// request's own block when it carries one, otherwise the stored persona's block —
// so a client that does not know the optional field can never wipe it. An update
// that sends the object replaces the block outright (all-default fields clear it).
// applyThinkingEdit 返回更新应持久化的 `thinking` 块:请求自带
// 块时用请求的,否则用已存人格的块 ——
// 因此不认识该可选字段的客户端绝不可能清掉它。发送
// 该对象的更新会直接替换整块(全默认字段则清除它)。
func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) loader.ThinkingParams {
if req != nil {
return thinkingFromRequest(req)
@@ -291,7 +291,7 @@ func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) lo
return loader.ThinkingParams{}
}
// characterFromRequest builds the registry.Character bound to the persona.
// characterFromRequest 构建绑定到该人格的 registry.Character。
func characterFromRequest(req personaUpsertRequest) *personareg.Character {
modes := append([]string(nil), req.Modes...)
if len(modes) == 0 && req.Mode != "" {
@@ -300,10 +300,10 @@ func characterFromRequest(req personaUpsertRequest) *personareg.Character {
return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes}
}
// personaSummaryOf maps a persona+character to its wire summary. It is the same
// shape the admin list returns — the role-level mood plus the `thinking` block —
// so a write response and a subsequent read describe the persona identically
// (a client can trust either for backfill).
// personaSummaryOf 把人格+角色映射为其线上摘要。它与
// 管理列表返回的形状相同 —— 角色级 mood 加上 `thinking` 块 ——
// 因此写响应与随后的读对人格的描述完全一致
// (客户端可信任任一方用于回填)。
func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
return PersonaSummary{
ID: p.ID,
@@ -317,9 +317,9 @@ func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) Pe
}
}
// savePersonaYAML persists p to the persona library as <id>.yaml so it survives
// a restart (wire.go loads this directory). The id is sanitized so user input
// cannot traverse out of the library directory.
// savePersonaYAML 把 p 以 <id>.yaml 持久化进人格库,使其在
// 重启后存活(wire.go 加载该目录)。id 会被净化,使用户
// 输入无法穿越出库目录。
func savePersonaYAML(p *loader.Persona) error {
data, err := yaml.Marshal(p)
if err != nil {
@@ -335,8 +335,8 @@ func savePersonaYAML(p *loader.Persona) error {
return nil
}
// sanitizeID keeps only URL/path-safe characters so a persona id cannot escape
// the persona directory.
// sanitizeID 只保留 URL/路径安全字符,使人格 id 不能
// 逃出人格目录。
func sanitizeID(id string) string {
var b strings.Builder
for _, r := range id {
@@ -11,18 +11,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// Memory retrieval and session export admin writes (owner-only, audited). Memory
// search runs a real retrieval; session export returns the session metadata. The
// plugin install/uninstall writes live in admin_plugins.go.
// 记忆检索与会话导出的管理写操作(仅 owner,带审计)。记忆
// 搜索执行真实检索;会话导出返回会话元数据。
// 插件安装/卸载写操作位于 admin_plugins.go。
// memorySearchRequest is the POST /api/v1/admin/memories/search body.
// memorySearchRequest 是 POST /api/v1/admin/memories/search 的请求体。
type memorySearchRequest struct {
Query string `json:"query"`
PersonaID string `json:"personaId"`
TopK int `json:"topK"`
}
// memorySearchHit is one admin memory retrieval hit.
// memorySearchHit 是一条管理侧记忆检索命中。
type memorySearchHit struct {
ID string `json:"id"`
Scope string `json:"scope"`
@@ -30,10 +30,10 @@ type memorySearchHit struct {
Score float64 `json:"score"`
}
// adminMemorySearch runs a real retrieval over the memory store. The retrieval
// ranks by keyword relevance × decay; the semantic/vector path requires the
// composition root to inject the vector index, so this answers on the keyword
// path and reports semantic=false honestly.
// adminMemorySearch 在记忆存储上执行真实检索。检索按
// 关键词相关度 × 衰减排序;语义/向量路径需要
// 组合根注入向量索引,因此这里走关键词路径
// 并诚实报告 semantic=false。
func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) {
var req memorySearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -53,10 +53,10 @@ func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) {
}
personaID := strings.TrimSpace(req.PersonaID)
if personaID == "" {
// Fall back to the library's default role rather than a hardcoded id, so the
// admin search follows a renamed/replaced default (design/22 §4.5). No default
// registered means there is nothing to search: answer honestly instead of
// querying an id that cannot match.
// 回退到库的默认角色,而不是硬编码 id,使
// 管理搜索跟随被改名/替换的默认角色(design/22 §4.5)。没有注册
// 默认角色意味着无可搜索:诚实作答,而不是
// 查询一个不可能匹配的 id。
personaID = s.defaultPersonaID()
if personaID == "" {
s.auditClaims(r, "memory.search", "error")
@@ -83,9 +83,9 @@ func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "personaId": personaID, "semantic": false, "hits": hits})
}
// adminExportSession exports a session as JSON. Without a conversation-message
// store the export carries the session identity metadata that exists today; a
// full transcript export is deferred to M10.
// adminExportSession 把一个会话导出为 JSON。没有对话消息
// 存储时,导出只携带当下存在的会话身份元数据;
// 完整转录导出推迟到 M10。
func (s *Server) adminExportSession(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
sess := s.findSession(r.Context(), id)
@@ -98,7 +98,7 @@ func (s *Server) adminExportSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"session": summaryOfSession(*sess)})
}
// findSession returns the session whose SessionID equals id, if known.
// findSession 返回 SessionID 等于 id 的会话(若已知)。
func (s *Server) findSession(ctx context.Context, id string) *session.Session {
if s.sessions == nil {
return nil
+24 -24
View File
@@ -25,9 +25,9 @@ func gwTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newMemoryStoreForGateway opens an isolated in-memory SQLite DB and builds a
// real memory.Store over it so the owner write path (which type-asserts to
// *memory.Store) exercises the real Add.
// newMemoryStoreForGateway 打开隔离的内存 SQLite DB,并在其上构建
// 真实的 memory.Store,使 owner 写路径(它类型断言到
// *memory.Store)演练真实的 Add。
func newMemoryStoreForGateway(t *testing.T) *memory.Store {
t.Helper()
ctx := context.Background()
@@ -97,7 +97,7 @@ func TestAdminAddMemoryWritesAndLists(t *testing.T) {
t.Fatalf("added = %+v, want persona/她喜欢喝冰美式", added)
}
// The freshly added item is now listed.
// 刚添加的条目现在已被列出。
listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok)
if listResp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody)
@@ -117,13 +117,13 @@ func TestAdminAddMemoryRejectsBadInput(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Blank content.
// 空白内容。
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
map[string]any{"personaId": "cyrene", "content": " "}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("blank content status = %d, want 400", resp.StatusCode)
}
// Invalid scope.
// 非法 scope。
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
map[string]any{"personaId": "cyrene", "content": "x", "scope": "bogus"}, tok)
if resp2.StatusCode != http.StatusBadRequest {
@@ -133,7 +133,7 @@ func TestAdminAddMemoryRejectsBadInput(t *testing.T) {
func TestAdminAddMemoryUnwired501(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no memory store
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无记忆存储
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
map[string]any{"content": "x"}, tok)
@@ -155,7 +155,7 @@ func TestAdminIndexRagImportsAndLists(t *testing.T) {
t.Fatalf("index status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
// The document now shows up in the list (imported + chunked).
// 该文档现在出现在列表中(已导入 + 已切分)。
listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
if listResp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody)
@@ -168,7 +168,7 @@ func TestAdminIndexRagImportsAndLists(t *testing.T) {
t.Fatalf("documents = %+v, want one 内部知识库.md with 2 chunks", res.Documents)
}
// It is immediately queryable (keyword/line path).
// 它立即可查询(关键词/行路径)。
qResp, qBody := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query",
map[string]any{"query": "评审流程"}, tok)
if qResp.StatusCode != http.StatusOK {
@@ -214,9 +214,9 @@ func TestAdminIndexRagUnwired501(t *testing.T) {
}
}
// newLlmRuntimeForGateway builds a hot-swappable RuntimeLLM pointing at a fresh
// temp models.json, seeded by the env config. It is the real stack the admin
// write endpoint persists to and reloads.
// newLlmRuntimeForGateway 构建一个可热切换的 RuntimeLLM,指向全新的
// 临时 models.json,由环境配置播种。它就是管理写端点
// 持久化并重载的真实栈。
func newLlmRuntimeForGateway(t *testing.T, modelsPath string, env config.LLMConfig) *model.RuntimeLLM {
t.Helper()
return model.NewRuntimeLLM(modelsPath, env, gwTestLogger())
@@ -247,7 +247,7 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) {
t.Fatalf("add res = %+v", res)
}
// models.json now holds the saved group (promoted to index 0 = active).
// models.json 现在持有已保存的分组(提升到下标 0 = 活动)。
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
if warn != nil {
t.Fatalf("load models warn = %v", warn)
@@ -256,12 +256,12 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) {
t.Fatalf("file groups = %+v, want deepseek first with sk-ds and maxTokens 1234", groups)
}
// The running stack reflects the new active provider/model (hot reload).
// 运行中的栈反映了新的活动 provider/model(热重载)。
if runtime.Provider() != "openai" || runtime.Model() != "deepseek-flash" {
t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-flash", runtime.Provider(), runtime.Model())
}
// GET /admin/llm reflects the saved group and never leaks any API key.
// GET /admin/llm 反映已保存的分组,且绝不泄露任何 API key。
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok)
if gResp.StatusCode != http.StatusOK {
t.Fatalf("get status = %d, want 200", gResp.StatusCode)
@@ -286,7 +286,7 @@ func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Saving an anthropic group makes it the active provider/model.
// 保存 anthropic 分组会使其成为活动的 provider/model。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"provider": "anthropic", "baseUrl": "https://api.anthropic.com", "apiKey": "sk-ant", "model": "claude-sonnet-4"}, tok)
if resp.StatusCode != http.StatusCreated {
@@ -295,7 +295,7 @@ func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
if runtime.Provider() != "anthropic" || runtime.Model() != "claude-sonnet-4" {
t.Fatalf("runtime Provider=%q Model=%q, want anthropic/claude-sonnet-4", runtime.Provider(), runtime.Model())
}
// The saved group is active (index 0) and anthropic.
// 已保存的分组是活动的(下标 0)且为 anthropic。
if runtime.Group().Provider != "anthropic" {
t.Fatalf("active group = %+v, want anthropic", runtime.Group())
}
@@ -309,16 +309,16 @@ func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Seed a fully-specified group.
// 播种一个完整指定的分组。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-flash", "multimodal": true, "fallback": "deepseek-v4-flash", "maxTokens": 1234}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("seed status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
// Partial edit: only the model is given. Every omitted field must keep its
// stored value — in particular maxTokens (a full replace reset it to 0) and
// apiKey (a full replace wiped it).
// 局部编辑:只给出 model。每个省略的字段都必须保持其
// 已存值 —— 尤其是 maxTokens(整体替换会把它重置为 0)与
// apiKey(整体替换会抹掉它)。
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"id": "deepseek", "model": "deepseek-v4-pro"}, tok)
if resp2.StatusCode != http.StatusCreated {
@@ -352,13 +352,13 @@ func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Missing model.
// 缺少 model。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"baseUrl": "https://x"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("missing model status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// Invalid provider.
// 非法 provider。
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"provider": "bogus", "baseUrl": "https://x", "model": "m"}, tok)
if resp2.StatusCode != http.StatusBadRequest {
@@ -368,7 +368,7 @@ func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
func TestAdminAddLlmProviderUnwired501(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no LLM stack wired
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 未接线 LLM 栈
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"baseUrl": "https://x", "model": "m"}, tok)
+23 -23
View File
@@ -10,23 +10,23 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// maxAudioRequestBytes caps the JSON body a /api/v1/audio/* request may carry so
// a huge base64 audio payload cannot exhaust memory.
// maxAudioRequestBytes 限定 /api/v1/audio/* 请求可携带的 JSON 体大小,
// 使巨大的 base64 音频载荷无法耗尽内存。
const maxAudioRequestBytes = 32 << 20
// transcribeRequest is the POST /api/v1/audio/transcribe body. Audio is a base64
// (or data: URI) blob; Format is its encoding ("wav"/"mp3"/"ogg").
// transcribeRequest 是 POST /api/v1/audio/transcribe 的请求体。Audio 是 base64
// (或 data: URI)数据块;Format 是其编码("wav"/"mp3"/"ogg")。
type transcribeRequest struct {
Audio string `json:"audio"`
Format string `json:"format"`
DeviceID string `json:"deviceId,omitempty"`
}
// speechRequest is the POST /api/v1/audio/speech body. Voice/format are accepted
// for forward compatibility; synthesis currently follows the server's
// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT config. Instruction is an optional
// per-request emotion/tone override for a cloned voice (声音复刻); a system voice
// (longxiaochun_v3) ignores it and never receives a free-form instruction.
// speechRequest 是 POST /api/v1/audio/speech 的请求体。Voice/format 为前向
// 兼容而被接受;合成目前遵循服务端的
// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT 配置。Instruction 是可选的
// 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色
// (longxiaochun_v3)忽略它,绝不接收自由形式的指令。
type speechRequest struct {
Text string `json:"text"`
Voice string `json:"voice,omitempty"`
@@ -34,9 +34,9 @@ type speechRequest struct {
Instruction string `json:"instruction,omitempty"`
}
// handleTranscribe runs STT on the uploaded audio and returns the recognized
// text. It is the REST source for native clients, bypassing the 4KB WebSocket
// frame limit that would truncate a base64 audio blob.
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket
// 帧上限(该上限会截断 base64 音频块)。
func (s *Server) handleTranscribe(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req transcribeRequest
@@ -76,8 +76,8 @@ func (s *Server) handleTranscribe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// handleSpeech synthesizes speech for the given text and writes the raw audio
// bytes (audio/<format>) back — a binary response, not a JSON envelope.
// handleSpeech 为给定文本合成语音并写回原始音频
// 字节(audio/<format>)—— 二进制响应,不是 JSON 信封。
func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req speechRequest
@@ -120,18 +120,18 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(audi.Data)
}
// voiceCloneRequest is the POST /api/v1/audio/voice-clone body. URL is the public
// reference audio URL; prefix is the optional voice-name prefix (≤10 alnum chars).
// Neither is logged, and the URL is not persisted (owner + audit only, red line).
// voiceCloneRequest 是 POST /api/v1/audio/voice-clone 的请求体。URL 是公开的
// 参考音频 URL;prefix 是可选音色名前缀(≤10 个字母数字字符)。
// 二者都不记日志,URL 也不持久化(仅 owner + 审计,红线)。
type voiceCloneRequest struct {
URL string `json:"url"`
Prefix string `json:"prefix,omitempty"`
}
// handleVoiceClone creates a custom TTS voice from a public reference audio URL
// via the Bailian voice-enrollment API (声音复刻 create_voice) and returns the new
// voice_id. Owner-only + audited; the reference URL is never written to the audit
// trail or the log (red line).
// handleVoiceClone 经百炼声音注册 API(声音复刻 create_voice)从公开
// 参考音频 URL 创建自定义 TTS 音色,并返回新的
// voice_id。仅 owner + 带审计;参考 URL 绝不写入审计
// 记录或日志(红线)。
func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req voiceCloneRequest
@@ -153,7 +153,7 @@ func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) {
voiceID, err := s.voice.CloneVoice(r.Context(), req.URL, req.Prefix)
if err != nil {
s.auditClaims(r, "audio.voice-clone", "error")
// Do not log the cause: a provider error body may echo the reference URL.
// 不要记录原因:provider 的错误体可能回显参考 URL。
s.log.Warn("voice clone failed")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_clone_failed", "voice clone failed"))
return
@@ -162,7 +162,7 @@ func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"voiceId": voiceID})
}
// audioMime maps an audio format to its Content-Type for the speech response.
// audioMime 把音频格式映射为其用于语音响应的 Content-Type。
func audioMime(format string) string {
switch strings.ToLower(format) {
case "mp3", "mpeg":
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// audioStubVoice is a VoiceService returning fixed STT/TTS results for the
// REST /api/v1/audio/* handler tests.
// audioStubVoice 是一个 VoiceService,为 REST /api/v1/audio/* handler
// 测试返回固定的 STT/TTS 结果。
type audioStubVoice struct {
sttText string
sttErr error
+14 -14
View File
@@ -8,10 +8,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// auditClaims records an admin write with the caller identity read from the
// request context (populated by auth.Authorize). It is the who/what/when/result
// audit trail required for admin operations; the "主代理代审" user-facing note is
// the frontend's job, not the gateway's.
// auditClaims 记录一次管理写操作,调用方身份从请求上下文读取
// (由 auth.Authorize 填充)。它是管理操作所需的
// 谁/什么/何时/结果审计链;「主代理代审」这一面向用户的提示是
// 前端的职责,不是网关的。
func (s *Server) auditClaims(r *http.Request, what, result string) {
who := "anonymous"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
@@ -20,20 +20,20 @@ func (s *Server) auditClaims(r *http.Request, what, result string) {
s.audit(r.Context(), who, what, result)
}
// AuditEvent records an external (non-HTTP) audit event with an explicit caller
// identity, so programmatic sinks outside the request path (for example the
// channel bridge's blacklist rejection hook, design/16 §4.6 §6) can append to the
// same who/what/when/result trail. who is a free-form actor label ("channel" or
// "bridge:telegram"); what is the operation and result its outcome. It is safe for
// concurrent use.
// AuditEvent 记录一次带显式调用方身份的外部(非 HTTP)审计事件,
// 使请求路径之外的程序化写入方(例如渠道 bridge 的
// 黑名单拒绝钩子,design/16 §4.6 §6)能追加到同一
// 谁/什么/何时/结果链。who 是自由形式的 actor 标签("channel" 或
// "bridge:telegram");what 是操作,result 是其结果。它对
// 并发使用安全。
func (s *Server) AuditEvent(who, what, result string) {
s.audit(context.Background(), who, what, result)
}
// audit emits one structured audit record on the gateway logger and persists it to
// the configured AuditStore (falling back to an in-memory store when none is
// injected). A persist failure is logged but never blocks the admin write, so a
// broken audit store cannot take down the audit trail's primary job.
// audit 在网关 logger 上发出一条结构化审计记录,并把它持久化到
// 已配置的 AuditStore(未注入时回退到内存存储)。
// 持久化失败会记日志,但绝不阻塞管理写,因此
// 坏掉的审计存储无法拖垮审计链的首要职责。
func (s *Server) audit(ctx context.Context, who, what, result string) {
s.log.Info("admin audit",
"who", who,
+25 -25
View File
@@ -12,17 +12,17 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// defaultRefreshTTL is the refresh-token lifetime used when cfg.Auth.RefreshTTL
// is unset (an undecoded config in tests), matching the AUTH_REFRESH_TTL default.
// defaultRefreshTTL 是 cfg.Auth.RefreshTTL 未设置时(测试中未解码的
// 配置)使用的 refresh token 生命周期,与 AUTH_REFRESH_TTL 默认值一致。
const defaultRefreshTTL = 720 * time.Hour
// refreshRequest is the POST /api/v1/auth/refresh body.
// refreshRequest 是 POST /api/v1/auth/refresh 的请求体。
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshResponse is the refresh success payload: a fresh access token plus the
// rotated refresh token (omitted when rotation is disabled by config).
// refreshResponse 是刷新成功的载荷:新的访问令牌加上
// 轮换后的 refresh token(配置禁用轮换时省略)。
type refreshResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
@@ -30,13 +30,13 @@ type refreshResponse struct {
RefreshToken string `json:"refreshToken,omitempty"`
}
// logoutRequest is the POST /api/v1/auth/logout body.
// logoutRequest 是 POST /api/v1/auth/logout 的请求体。
type logoutRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshTTL returns the configured refresh-token lifetime, falling back to
// defaultRefreshTTL when the config was not decoded (zero value).
// refreshTTL 返回已配置的 refresh token 生命周期,配置未解码
// (零值)时回退到 defaultRefreshTTL。
func (s *Server) refreshTTL() time.Duration {
if s.cfg != nil && s.cfg.Auth.RefreshTTL > 0 {
return s.cfg.Auth.RefreshTTL
@@ -44,16 +44,16 @@ func (s *Server) refreshTTL() time.Duration {
return defaultRefreshTTL
}
// refreshRotate reports whether single-use rotation is enabled. Rotation is the
// default; only an explicit AUTH_REFRESH_ROTATE_DISABLED=true opts out.
// refreshRotate 报告是否启用一次性轮换。轮换是
// 默认;只有显式 AUTH_REFRESH_ROTATE_DISABLED=true 才选择退出。
func (s *Server) refreshRotate() bool {
return s.cfg == nil || !s.cfg.Auth.RefreshRotateDisabled
}
// handleRefresh exchanges a valid opaque refresh token for a new access token,
// rotating the refresh token by default. It is public (the refresh token is the
// credential) and returns 401 invalid_grant for an unknown, revoked, or expired
// token. A successful rotation is audited; the token itself is never logged.
// handleRefresh 用有效的非透明 refresh token 换取新的访问令牌,
// 默认轮换 refresh token。它是公开的(refresh token 就是
// 凭据),对未知、已吊销或已过期的 token 返回 401 invalid_grant。
// 成功的轮换会被审计;token 本身绝不记日志。
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -92,18 +92,18 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
})
}
// handleLogout revokes the refresh token in the body. It is public and
// idempotent: an unknown or already revoked token yields {"revoked": false} with
// 200 rather than an error, so a client can always clear its local state.
// handleLogout 吊销请求体中的 refresh token。它公开且
// 幂等:未知或已吊销的 token 返回 {"revoked": false} 与
// 200 而不是错误,因此客户端总能清理其本地状态。
//
// The body may carry either a refreshToken (revokes that one grant) or nothing,
// in which case the Authorization Bearer access token identifies the account and
// every refresh grant for it is revoked (logout on all devices). A request with
// neither is a 400.
// 请求体可以携带 refreshToken(只吊销那一份授权),也可以不携带,
// 此时 Authorization Bearer 访问令牌标识账号,该账号的
// 每一份 refresh 授权都被吊销(全设备登出)。两者都
// 没有的请求是 400。
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
var req logoutRequest
// An empty body is valid: it means "no refreshToken", and the account is
// identified by the bearer access token below.
// 空请求体是合法的:它意味着「无 refreshToken」,账号由
// 下面的 bearer 访问令牌标识。
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
@@ -141,8 +141,8 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"revoked": n > 0, "revokedCount": n})
}
// bearerFromRequest extracts the Bearer credential from the Authorization
// header, returning "" when the header is absent or malformed.
// bearerFromRequest 从 Authorization 头提取 Bearer 凭据,
// 头缺失或畸形时返回 ""。
func bearerFromRequest(r *http.Request) string {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
+10 -10
View File
@@ -17,9 +17,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// newRefreshEnv builds a gateway backed by a real JWT service and a real
// SQLite-backed refresh store, returning the store and DB so a test can assert
// what was persisted. The DB is isolated in memory per test.
// newRefreshEnv 构建一个网关,后端是真实 JWT 服务与真实的
// SQLite refresh 存储,并返回该存储与 DB,使测试能断言
// 持久化了什么。DB 每个测试都隔离在内存中。
func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) {
t.Helper()
ctx := context.Background()
@@ -52,7 +52,7 @@ func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) {
return &testEnv{srv: srv, ts: ts, auth: jwt, bus: b}, refresh, sqlDB
}
// decodeData unmarshals the {data: ...} envelope payload into v.
// decodeData 把 {data: ...} 信封载荷反序列化进 v。
func decodeData(t *testing.T, body []byte, v any) {
t.Helper()
env := decodeBody(t, body)
@@ -65,7 +65,7 @@ func decodeData(t *testing.T, body []byte, v any) {
}
}
// loginRefresh performs an admin login and returns the access and refresh tokens.
// loginRefresh 执行一次管理登录并返回访问令牌与 refresh token。
func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) {
t.Helper()
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login",
@@ -89,7 +89,7 @@ func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) {
return data.Token, data.RefreshToken
}
// postRefresh calls /auth/refresh with token and returns the status and body.
// postRefresh 用 token 调用 /auth/refresh 并返回状态与响应体。
func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte) {
t.Helper()
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/refresh",
@@ -97,7 +97,7 @@ func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte)
return resp.StatusCode, body
}
// wantInvalidGrant asserts a 401 with the machine-readable invalid_grant code.
// wantInvalidGrant 断言 401 与机器可读的 invalid_grant 码。
func wantInvalidGrant(t *testing.T, status int, body []byte) {
t.Helper()
if status != http.StatusUnauthorized {
@@ -165,11 +165,11 @@ func TestRefreshRotatesAccessAndRefreshTokens(t *testing.T) {
t.Fatalf("refreshed claims = %+v, want admin/owner", claims)
}
// The presented token is single-use: replay must fail.
// 出示的 token 是一次性的:重放必须失败。
status, body = postRefresh(t, env.ts, oldRefresh)
wantInvalidGrant(t, status, body)
// The rotated token works.
// 轮换后的 token 有效。
if status, body = postRefresh(t, env.ts, data.RefreshToken); status != http.StatusOK {
t.Fatalf("rotate replacement status = %d, want 200: %s", status, body)
}
@@ -229,7 +229,7 @@ func TestLogoutRevokesRefreshToken(t *testing.T) {
t.Fatalf("refresh after logout status = %d, want 401: %s", status, body)
}
// Logout is idempotent: a second call reports revoked=false, not an error.
// 登出是幂等的:第二次调用报告 revoked=false,而不是错误。
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout",
map[string]string{"refreshToken": refreshToken}, "")
if resp.StatusCode != http.StatusOK {
+121 -121
View File
@@ -24,225 +24,225 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// This file declares the dependency contracts the gateway consumes. Every one is
// a narrow interface satisfied by a concrete backend the composition root injects
// (see Deps in deps.go), so the gateway never reaches for a concrete
// implementation and tests can substitute a stub.
// 本文件声明网关所消费的依赖契约。每一个都是窄接口,
// 由组合根注入的具体后端满足(见 deps.go 中的
// Deps),因此网关绝不直接取用具体实现,
// 测试也能替换成桩。
// Authenticator abstracts the credential + RBAC service. *auth.JWTService is the
// production implementation; it is an interface so tests substitute a mock.
// Authenticator 抽象凭据 + RBAC 服务。*auth.JWTService 是
// 生产实现;它是接口,因此测试可替换为 mock。
type Authenticator interface {
// Sign issues a token for sub with the given role and lifetime.
// Sign 为 sub 签发一个带给定角色与生命周期的 token。
Sign(sub string, role auth.Role, ttl time.Duration) (string, error)
// Verify validates a token and returns its claims.
// Verify 校验一个 token 并返回其 claims。
Verify(token string) (*auth.Claims, error)
// Authorize returns an HTTP middleware enforcing at least one required role.
// Authorize 返回一个 HTTP 中间件,强制至少一个所需角色。
Authorize(required ...auth.Role) func(http.Handler) http.Handler
}
// CapabilityStore abstracts the global capability registry used to enumerate
// plugins and channels in the admin API. *capability.Registry is the production
// implementation.
// CapabilityStore 抽象管理 API 中用于枚举插件与渠道的全局
// 能力注册表。*capability.Registry 是生产
// 实现。
type CapabilityStore interface {
// List returns every capability whose Kind matches, or all when kind is
// empty.
// List 返回 Kind 匹配的每一个能力,kind 为空时
// 返回全部。
List(kind capreg.Kind) []capreg.Capability
// SetPolicy replaces the reachability markers for the named capability.
// SetPolicy 替换指定能力的可达性标记。
SetPolicy(name string, p capreg.ToolPolicy) error
}
// Chatter runs a single agent turn. *loop.Loop is the production implementation.
// Chatter 运行单次 agent 回合。*loop.Loop 是生产实现。
type Chatter interface {
// Run drives one turn for sessionID and returns the final answer.
// Run 为 sessionID 驱动一个回合并返回最终答案。
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// SessionStore lists known channel sessions. *session.SessionManager is the
// production implementation.
// SessionStore 列出已知的渠道会话。*session.SessionManager 是
// 生产实现。
type SessionStore interface {
List(ctx context.Context) ([]session.Session, error)
}
// PersonaStore lists registered characters (each bound one-to-one to a persona).
// *persona/registry.Registry is the production implementation. The gateway maps
// each Character to its wire summary; the interface deliberately returns the
// domain type so the wire shape stays out of the store contract.
// PersonaStore 列出已注册角色(每个与一个人格一一绑定)。
// *persona/registry.Registry 是生产实现。网关把
// 每个 Character 映射为其线上摘要;该接口刻意返回
// 领域类型,使线上结构不进入存储契约。
type PersonaStore interface {
List() []personareg.Character
// Persona returns the persona with the given id (to read its visual assets).
// The production registry implements it; the wire summary reads Avatar /
// ChatBackground from it so each role's visuals stay role-scoped.
// Persona 返回给定 id 的人格(用于读取其视觉资产)。
// 生产注册表实现了它;线上摘要从它读取 Avatar /
// ChatBackground,使每个角色的视觉保持角色作用域。
Persona(id string) (*loader.Persona, bool)
}
// PersonaMoodStore is the *optional* capability of a persona store that also owns
// the per-persona emotion state (design/01 §2: emotion follows the persona). The
// production *personareg.Registry implements it; the gateway reads a role's mood
// through an assertion on this narrow interface so a store that only lists
// personas (a test double, a future read-only catalog) keeps satisfying
// PersonaStore without owning an emotion machine. When a store does not implement
// it, a role's mood simply reads as "" rather than being invented.
// PersonaMoodStore 是人格存储的*可选*能力,该存储同时持有
// 每人格的情绪状态(design/01 §2:情绪跟随人格)。
// 生产实现 *personareg.Registry 实现了它;网关通过对这个窄
// 接口做断言来读取某角色的 mood,因此只列出人格的存储
// (测试替身、未来的只读目录)无需持有情绪机
// 就能继续满足 PersonaStore。当存储未实现它时,
// 某角色的 mood 只读作 "",而不是被凭空造出。
type PersonaMoodStore interface {
Emotions() *emotion.Manager
}
// MemoryStore lists memory entries. *runtime/memory.Store is the production
// implementation. The gateway maps each MemoryItem to its wire summary.
// MemoryStore 列出记忆条目。*runtime/memory.Store 是生产
// 实现。网关把每个 MemoryItem 映射为其线上摘要。
type MemoryStore interface {
List(ctx context.Context) ([]memory.MemoryItem, error)
}
// HistoryStore is the persistent chat-history store (design/16). *runtime/history.Store
// is the production implementation. When nil the session/message endpoints degrade: list
// sessions falls back to the channel-session view, and the create/messages/delete
// endpoints answer with a clear 501.
// HistoryStore 是持久化聊天历史存储(design/16)。*runtime/history.Store
// 是生产实现。为 nil 时会话/消息端点退化:列出
// 会话回退到渠道会话视图,而创建/取消息/删除
// 端点返回明确的 501。
type HistoryStore interface {
Append(ctx context.Context, msg history.Message) error
// AppendFull persists msg like Append but returns the complete stored message
// (with the assigned id/timestamp) so the orchestrator can broadcast it for
// cross-end realtime sync (design/16 §4.2).
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息
// (含分配的 id/时间戳),使编排器能广播它以进行
// 跨端实时同步(design/16 §4.2)。
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
// ListAfter returns up to limit messages strictly newer than after in
// oldest-first order, for forward incremental catch-up after a reconnect
// (the "before" cursor can only walk backwards). platform filters as in List.
// ListAfter 按最旧优先返回至多 limit 条严格新于 after 的消息,
// 用于重连后的前向增量追赶("before" 游标只能
// 向后走)。platform 的过滤同 List。
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]history.Message, error)
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error)
ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error)
DeleteSession(ctx context.Context, sessionID string) error
// Search returns session-scoped content matches (newest first) for the
// ?q= message-search path. An empty query yields an empty slice.
// Search 为 ?q= 消息搜索路径返回会话作用域的内容匹配
// (最新优先)。空查询得到空切片。
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]history.Message, error)
// UpdateMessage edits a single message's content and returns the updated
// message. An unknown id returns history.ErrMessageNotFound.
// UpdateMessage 编辑单条消息的内容并返回更新后的
// 消息。未知 id 返回 history.ErrMessageNotFound。
UpdateMessage(ctx context.Context, id, content string) (history.Message, error)
// DeleteMessage removes a single message. An unknown id returns
// history.ErrMessageNotFound.
// DeleteMessage 移除单条消息。未知 id 返回
// history.ErrMessageNotFound。
DeleteMessage(ctx context.Context, id string) error
// RenameSession sets a session's display title (main or sub). An unknown id
// returns history.ErrSessionNotFound.
// RenameSession 设置会话的显示标题(main 或 sub)。未知 id
// 返回 history.ErrSessionNotFound。
RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned pins/unpins a session (main or sub). An unknown id returns
// history.ErrSessionNotFound.
// SetPinned 置顶/取消置顶一个会话(main 或 sub)。未知 id 返回
// history.ErrSessionNotFound。
SetPinned(ctx context.Context, sessionID string, pinned bool) error
NewSubSession(ctx context.Context, ownerID string) (string, error)
// MarkSessionRead advances a session's read cursor (D4) to before and returns
// the resulting cursor as a Unix millisecond timestamp; a zero before means
// "now". The cursor only moves forward. An unknown id returns
// history.ErrSessionNotFound.
// MarkSessionRead 把会话的读游标(D4)推进到 before 并返回
// 结果游标作为 Unix 毫秒时间戳;before 为零意味着
// 「现在」。游标只向前移动。未知 id 返回
// history.ErrSessionNotFound。
MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error)
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
}
// IoTStore lists IoT devices. *iot.Manager is the production implementation.
// IoTStore 列出 IoT 设备。*iot.Manager 是生产实现。
type IoTStore interface {
List(ctx context.Context) ([]iot.Device, error)
}
// SubagentAuditor audits a sub-agent's conversation context for the subagent_ask
// WS route. *orchestration.Scheduler is the production implementation.
// SubagentAuditor 为 subagent_ask WS 路由审计子代理的对话
// 上下文。*orchestration.Scheduler 是生产实现。
type SubagentAuditor interface {
AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error)
}
// TaskOutputStore queries a task's output for the task_output_query WS route.
// *task.Manager is the production implementation.
// TaskOutputStore 为 task_output_query WS 路由查询任务输出。
// *task.Manager 是生产实现。
type TaskOutputStore interface {
TaskOutput(ctx context.Context, id string) (task.OutputEvent, error)
}
// AutomationStore enumerates the automations and reminders for the admin list.
// *automations.Service is the production implementation. The gateway maps each
// record to its wire shape.
// AutomationStore 为管理列表枚举自动化与提醒。
// *automations.Service 是生产实现。网关把每条
// 记录映射为其线上结构。
type AutomationStore interface {
List() []automations.AutomationRecord
}
// UserStore enumerates the accounts and API keys visible to the admin panel.
// The production implementation reads the configured admin account plus the
// auth.APIKey table. The gateway maps each summary to its wire shape.
// UserStore 枚举管理面板可见的账号与 API key。
// 生产实现读取已配置的管理账号加上
// auth.APIKey 表。网关把每个摘要映射为其线上结构。
type UserStore interface {
List() []auth.UserSummary
}
// RefreshGrantStore manages opaque refresh tokens (auth.RefreshStore is the
// production implementation). It is the credential behind the refreshToken
// returned by login and the POST /auth/refresh, /auth/logout endpoints. Only
// hashes are persisted; the plaintext token crosses this interface exactly once
// per issue/rotate. Optional: when nil login omits refreshToken and the refresh
// and logout endpoints report a clear not-configured error.
// RefreshGrantStore 管理非透明 refresh token(auth.RefreshStore 是
// 生产实现)。它是登录与 POST /auth/refresh、/auth/logout 端点
// 所返回 refreshToken 背后的凭据。只持久化哈希;
// 明文 token 每次签发/轮换恰好穿过该接口一次。
// 可选:为 nil 时登录省略 refreshToken,
// refresh 与 logout 端点返回明确的 not-configured 错误。
type RefreshGrantStore interface {
// Issue mints a refresh token for userID/role and returns its plaintext
// (visible once) plus its expiry.
// Issue 为 userID/role 铸造一个 refresh token,返回其明文
// (仅可见一次)及其过期时间。
Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error)
// Rotate validates plaintext and, when opts.Rotate is set, revokes it and
// returns a replacement. It returns auth.ErrInvalidGrant for an unknown,
// revoked, or expired token.
// Rotate 校验 plaintext,并在 opts.Rotate 设置时吊销它并
// 返回替代品。对未知、已吊销或已过期的 token 返回
// auth.ErrInvalidGrant 错误。
Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error)
// Revoke marks the token matching plaintext revoked, returning whether a
// live token was found. It is idempotent.
// Revoke 把匹配 plaintext 的 token 标记为已吊销,并返回是否
// 找到了存活的 token。它是幂等的。
Revoke(ctx context.Context, plaintext string) (bool, error)
// RevokeAll deletes every refresh token (global invalidation on secret
// rotation) and returns how many were removed.
// RevokeAll 删除每一个 refresh token(秘密轮换时的全局
// 失效)并返回移除了多少个。
RevokeAll(ctx context.Context) (int64, error)
// RevokeByUser deletes every refresh token for userID (logout by access
// token, where the individual grant is unknown) and returns how many were
// removed.
// RevokeByUser 删除 userID 的每一个 refresh token(按访问
// 令牌登出,此时不知道具体是哪一份授权)并返回
// 移除了多少个。
RevokeByUser(ctx context.Context, userID string) (int64, error)
}
// BackthinkStore enumerates the persisted background-think records for the
// M9/M10 monitor panel. *backthink.Backthink is the production implementation.
// BackthinkStore 为 M9/M10 监控面板枚举持久化的后台思考
// 记录。*backthink.Backthink 是生产实现。
type BackthinkStore interface {
List(ctx context.Context, limit int) ([]backthink.Record, error)
}
// ChannelConfigStore is the runtime-editable channel-adapter configuration
// (channels.json, design/04 §3.1). It is implemented by the composition root's
// channel store: Load returns the effective per-platform accounts (file → env
// fallback), Path reports the channels.json path, and Reload hot-replaces a
// platform's running adapters after a panel save (Bridge.Reload). Load returns
// account configs with real tokens so the owner write path can persist them; the
// admin read path masks them before returning to the client.
// ChannelConfigStore 是运行时可编辑的渠道适配器配置
// (channels.json,design/04 §3.1)。它由组合根的渠道存储
// 实现:Load 返回按平台生效的账号(文件 → 环境变量
// 回退),Path 报告 channels.json 路径,Reload 在面板保存后
// 热替换某平台运行中的适配器(Bridge.Reload)。Load 返回
// 带真实 token 的账号配置,使 owner 写路径能持久化它们;
// 管理读路径在返回客户端前对它们脱敏。
type ChannelConfigStore interface {
Path() string
// Load returns the effective per-platform accounts (file → env fallback),
// including the secret tokens needed to persist them.
// Load 返回按平台生效的账号(文件 → 环境变量回退),
// 含持久化它们所需的秘密 token。
Load() map[string][]config.ChannelAccount
// Reload rebuilds and hot-replaces the running adapters for platform.
// Reload 重建并热替换该平台运行中的适配器。
Reload(platform string) error
}
// OwnerRegistrar registers a channel account id into the owner whitelist
// (configs/users/owner.yaml). It is the structural identity red line: a scan-login
// confirmation stamps the scanner's bound ilink_user_id into the whitelist so the
// channel bridge recognizes it as the owner via actor metadata, never a prompt.
// The composition root implements it (cmd/cyrene's ownerRegistrar) to persist the
// whitelist and hot-update the live owner profile. Registration is best-effort:
// a failure is logged by the caller but does not fail the already-applied
// channels.json persist.
// OwnerRegistrar 把一个渠道账号 id 登记进主人白名单
// (configs/users/owner.yaml)。它是结构化的身份红线:扫码登录
// 确认会把扫码者绑定的 ilink_user_id 盖进白名单,使
// 渠道 bridge 经 actor 元数据认出它是主人,绝不是提示词。
// 组合根实现了它(cmd/cyrene 的 ownerRegistrar)以持久化
// 白名单并热更新活的 owner profile。登记是尽力而为:
// 失败由调用方记日志,但不会让已经施加的
// channels.json 持久化失败。
type OwnerRegistrar interface {
RegisterChannelOwner(ctx context.Context, platform, accountID string) error
}
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
// *runtime/knowledge.Store is the production implementation: it lists/searches
// the project index, imports a document (chunk + index) and deletes one
// (project entry + cascaded chapters). An implementation that cannot write
// reports a clear error (knowledge.ErrWriteUnsupported) rather than a fabricated
// success; the handlers map a missing document to 404.
// RagStore 支撑管理侧知识库 RAG 模块(design/10 §3.12 module 8)。
// *runtime/knowledge.Store 是生产实现:它列出/搜索
// 项目索引、导入文档(切分 + 索引)、删除文档
// (项目条目 + 级联章节)。不能写入的实现
// 会报告明确的错误(knowledge.ErrWriteUnsupported),而不是编造
// 成功;handler 把文档缺失映射为 404。
type RagStore interface {
// ListDocuments enumerates the knowledge-base documents (project-index
// entries).
// ListDocuments 枚举知识库文档(项目索引
// 条目)。
ListDocuments(ctx context.Context) ([]knowledge.Doc, error)
// Search answers a knowledge-base query (line-level hierarchy + optional
// semantic/vector ranking).
// Search 应答一次知识库查询(行级层级 + 可选
// 语义/向量排序)。
Search(ctx context.Context, query string) ([]knowledge.Hit, error)
// IndexDocument uploads/chunks/embeds a document into the project index.
// IndexDocument 把一份文档上传/切分/嵌入进项目索引。
IndexDocument(ctx context.Context, doc knowledge.Doc) error
// DeleteDocument removes a document (and its chapters) by id, returning
// knowledge.ErrDocumentNotFound when no such document exists.
// DeleteDocument 按 id 移除一份文档(及其章节),在
// 不存在该文档时返回 knowledge.ErrDocumentNotFound。
DeleteDocument(ctx context.Context, id string) error
}
+67 -67
View File
@@ -10,99 +10,99 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Deps carries the gateway's injected dependencies. Every field is nil-tolerant
// except Auth, which is required (authentication backs login and every RBAC
// decision). Registry/Loop are satisfied by the concrete *capability.Registry and
// *loop.Loop; Sessions/Personas/Memories are satisfied by the concrete session
// manager, persona registry and memory store, and the handlers return [] only
// when a dependency is left nil.
// Deps 承载网关被注入的依赖。除 Auth 外每个字段都容忍
// nil,Auth 是必需的(认证支撑登录与每一个 RBAC
// 决策)。Registry/Loop 由具体的 *capability.Registry 与
// *loop.Loop 满足;Sessions/Personas/Memories 由具体的会话
// 管理器、人格注册表与记忆存储满足,只有当依赖留为
// nil 时 handler 才返回 []。
type Deps struct {
// Log is the base logger. Nil uses a discard logger.
// Log 是基础 logger。为 nil 时使用丢弃型 logger。
Log *slog.Logger
// Auth is the JWT/RBAC service. Required.
// Auth 是 JWT/RBAC 服务。必需。
Auth Authenticator
// Bus is the in-process event bus the WS hub subscribes to. Optional.
// Bus 是 WS hub 所订阅的进程内事件总线。可选。
Bus bus.Bus
// Registry enumerates capabilities/plugins/channels. Optional.
// Registry 枚举能力/插件/渠道。可选。
Registry CapabilityStore
// Loop runs chat turns. Optional (chat returns an error when unset).
// Loop 运行聊天回合。可选(未设置时 chat 返回错误)。
Loop Chatter
// Session lists channel sessions. Optional.
// Session 列出渠道会话。可选。
Session SessionStore
// Personas lists personas. Optional.
// Personas 列出人格。可选。
Personas PersonaStore
// Memories lists memory entries. Optional.
// Memories 列出记忆条目。可选。
Memories MemoryStore
// History is the persistent chat-history store (design/16). Optional; when nil the
// session/message endpoints degrade (list falls back to channel sessions, others 501).
// History 是持久化聊天历史存储(design/16)。可选;为 nil 时
// 会话/消息端点退化(列出回退到渠道会话,其余 501)。
History HistoryStore
// IoT lists IoT devices. Optional.
// IoT 列出 IoT 设备。可选。
IoT IoTStore
// Scheduler audits sub-agent context for the subagent_ask WS route.
// Optional (subagent_ask returns a clear error when unset).
// Scheduler 为 subagent_ask WS 路由审计子代理上下文。
// 可选(未设置时 subagent_ask 返回明确的错误)。
Scheduler SubagentAuditor
// Tasks queries task output for the task_output_query WS route.
// Optional (task_output_query returns a clear error when unset).
// Tasks 为 task_output_query WS 路由查询任务输出。
// 可选(未设置时 task_output_query 返回明确的错误)。
Tasks TaskOutputStore
// Automations lists automations and reminders. Optional.
// Automations 列出自动化与提醒。可选。
Automations AutomationStore
// Users lists accounts and API keys. Optional (falls back to the configured
// single admin account).
// Users 列出账号与 API key。可选(回退到已配置的
// 单个管理账号)。
Users UserStore
// Refresh is the opaque refresh-token store. Optional: when nil login omits
// refreshToken and /auth/refresh + /auth/logout answer not-configured.
// Refresh 是非透明 refresh token 存储。可选:为 nil 时登录省略
// refreshToken,/auth/refresh + /auth/logout 返回 not-configured。
Refresh RefreshGrantStore
// Backthink lists background-think records. Optional.
// Backthink 列出后台思考记录。可选。
Backthink BackthinkStore
// RAG backs the admin knowledge-base RAG module. Optional; list/search
// return empty when unset, and write endpoints report a clear 501.
// RAG 支撑管理侧知识库 RAG 模块。可选;未设置时列出/搜索
// 返回空,写端点报告明确的 501。
RAG RagStore
// LLM is the hot-swappable provider stack (models.json, design/04 §3.1).
// Optional: when nil the admin /llm endpoints fall back to the env config so
// pre-existing gateway tests keep working.
// LLM 是可热切换的 provider 栈(models.json,design/04 §3.1)。
// 可选:为 nil 时管理 /llm 端点回退到环境配置,使
// 既有的网关测试继续可用。
LLM *model.RuntimeLLM
// Channels is the runtime-editable channel-adapter config (channels.json).
// Optional: when nil the admin /channels/config endpoints return a clear
// not-configured error, matching how the LLM stack behaves when unset.
// Channels 是运行时可编辑的渠道适配器配置(channels.json)。
// 可选:为 nil 时管理 /channels/config 端点返回明确的
// not-configured 错误,与 LLM 栈未设置时的行为一致。
Channels ChannelConfigStore
// Plugins is the runtime-installable external tool provider manager
// (plugins.json + live MCP servers). Optional: when nil install answers a
// clear not-implemented error and uninstall falls back to a plain capability
// removal, so pre-existing gateway tests keep working.
// Plugins 是运行时可安装的外部工具 provider 管理器
// (plugins.json + 活的 MCP server)。可选:为 nil 时安装返回
// 明确的 not-implemented 错误,卸载回退为普通的能力
// 移除,使既有的网关测试继续可用。
Plugins *pluginmgr.Manager
// OwnerRegistrar stamps a scan-login's bound owner id into the owner
// whitelist (configs/users/owner.yaml) and hot-updates the live profile.
// Optional: when nil the weixin confirm flow still persists channels.json but
// skips the whitelist registration (best-effort by design).
// OwnerRegistrar 把扫码登录绑定的主人 id 盖进主人
// 白名单(configs/users/owner.yaml)并热更新活的 profile。
// 可选:为 nil 时 weixin 确认流程仍会持久化 channels.json,但
// 跳过白名单登记(按设计即为尽力而为)。
OwnerRegistrar OwnerRegistrar
// AuditStore persists the admin audit trail (observability → audit). When nil
// the gateway degrades to an in-memory store so the endpoint still returns
// data; the composition root injects NewSQLiteAuditStore over a.db for real
// persistence. Optional.
// AuditStore 持久化管理审计链(observability → audit)。为 nil 时
// 网关退化为内存存储,使端点仍能返回
// 数据;组合根注入基于 a.db 的 NewSQLiteAuditStore 以获得真实
// 持久化。可选。
AuditStore AuditStore
// DatabasePath is the resolved path of the SQLite database file the
// composition root actually opened (the value derived by resolveSQLitePath,
// which may differ from cfg.DB.SQLitePath when the data dir default applies).
// It backs the admin backup/restore endpoints. Optional; when empty those
// endpoints answer honestly with a clear not-configured error.
// DatabasePath 是组合根实际打开的 SQLite 数据库文件解析后
// 路径(由 resolveSQLitePath 推导的值,当数据目录默认值
// 生效时可能与 cfg.DB.SQLitePath 不同)。它支撑
// 管理备份/恢复端点。可选;为空时那些端点诚实作答,
// 返回明确的 not-configured 错误。
DatabasePath string
// DataDir is the resolved data directory (the value resolveDataDir returns,
// which may differ from cfg.DataDir when the OS default applies). It backs
// the image-upload store (<DataDir>/uploads) and its static route. Optional;
// when empty the upload handlers fall back to cfg.DataDir.
// DataDir 是解析后的数据目录(resolveDataDir 返回的值,当操作系统
// 默认值生效时可能与 cfg.DataDir 不同)。它支撑
// 图片上传存储(<DataDir>/uploads)及其静态路由。可选;
// 为空时上传 handler 回退到 cfg.DataDir。
DataDir string
// Streaming marks the injected Loop as streaming-capable (its OnStream is
// wired to publish ws.stream_chunk). When set the hub skips its own single
// final stream_chunk emission, since deltas already stream through OnStream.
// Streaming 标记注入的 Loop 具备流式能力(其 OnStream
// 已接线为发布 ws.stream_chunk)。设置后 hub 会跳过它自己的
// 单次最终 stream_chunk 发送,因为增量已通过 OnStream 流出。
Streaming bool
// Frontend is the SPA filesystem served for all non-API, non-WS routes.
// When nil the gateway serves no static assets (API and WS only). The
// embedded dist (internal/webui) or an external dist directory is used.
// Frontend 是为所有非 API、非 WS 路由提供的 SPA 文件系统。
// 为 nil 时网关不提供任何静态资产(只有 API 与 WS)。
// 使用内嵌的 dist(internal/webui)或外部 dist 目录。
Frontend fs.FS
// Webhooks are externally-mounted inbound HTTP handlers (endpoint path →
// handler), typically from channel adapters that receive webhook callbacks
// (e.g. webhook). Each is mounted on the ServeMux without gateway auth; the
// handler itself enforces its own signature/authorization (webhook HMAC).
// Optional.
// Webhooks 是外部挂载的入站 HTTP handler(端点路径 →
// handler),通常来自接收 webhook 回调的渠道适配器
// (例如 webhook)。每个都挂到 ServeMux 上且不经网关认证;
// handler 自身强制其签名/授权(webhook HMAC)。
// 可选。
Webhooks map[string]http.Handler
}
@@ -7,7 +7,7 @@ import (
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
)
// testChannel is a bare sdk.Channel used to exercise findChannel resolution.
// testChannel 是一个裸 sdk.Channel,用于演练 findChannel 的解析。
type testChannel struct{ name string }
func (t testChannel) Name() string { return t.name }
@@ -18,7 +18,7 @@ func (t testChannel) Disconnect(context.Context) error { return nil }
func (t testChannel) IsConnected() bool { return false }
func (t testChannel) HealthCheck(context.Context) error { return nil }
// TestFindChannelExactMatch prefers an exact adapter name over a bare-name match.
// TestFindChannelExactMatch 优先精确适配器名而非裸名匹配。
func TestFindChannelExactMatch(t *testing.T) {
reg := capreg.New()
_ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}})
@@ -33,10 +33,10 @@ func TestFindChannelExactMatch(t *testing.T) {
}
}
// TestFindChannelBarePlatformResolvesToTokenAccount verifies a bare platform name
// ("kook") that is not an exact adapter name resolves to that platform's first
// registered account (the token-bearing one, since the empty-token bare account
// is never registered).
// TestFindChannelBarePlatformResolvesToTokenAccount 验证非精确适配器名的
// 裸平台名("kook")会解析到该平台首个已注册账号
// (即携带 token 的那个,因为空 token 的裸账号
// 从不注册)。
func TestFindChannelBarePlatformResolvesToTokenAccount(t *testing.T) {
reg := capreg.New()
_ = reg.Register(capreg.Capability{Name: "kook:3fc8", Kind: capreg.KindChannel, Value: testChannel{name: "kook:3fc8"}})
@@ -46,7 +46,7 @@ func TestFindChannelBarePlatformResolvesToTokenAccount(t *testing.T) {
}
}
// TestFindChannelUnknownOrBlank rejects a name that matches no channel.
// TestFindChannelUnknownOrBlank 拒绝不匹配任何渠道的名字。
func TestFindChannelUnknownOrBlank(t *testing.T) {
reg := capreg.New()
_ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}})
@@ -56,7 +56,7 @@ func TestFindChannelUnknownOrBlank(t *testing.T) {
if _, ok := findChannel(reg, ""); ok {
t.Fatal("blank name should not be found")
}
// Skipped: non-channel capabilities must not match.
// 已跳过:非渠道能力不得匹配。
_ = reg.Register(capreg.Capability{Name: "some-tool", Kind: capreg.KindTool, Value: testChannel{name: "some-tool"}})
if _, ok := findChannel(reg, "some-tool"); ok {
t.Fatal("non-channel capability must not match findChannel")
@@ -14,15 +14,15 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// These tests cover the only leg of the design/19 flow chain that had no test:
// the producer's bus event → hub relay → outbound WS frame. The loop side is
// pinned by internal/runtime/loop/flow_test.go; this pins the gateway relay AND
// the wire field names the frontend's parseGraph reads, so a rename on either side
// fails here instead of silently blanking the 全链路流程 page.
// 这些测试覆盖 design/19 流程链中此前唯一没有测试的一段:
// 生产者的 bus 事件 → hub 中继 → 下行 WS 帧。loop 一侧由
// internal/runtime/loop/flow_test.go 钉住;这里钉住网关中继以及
// 前端 parseGraph 所读的线上字段名,因此任一侧改名都会
// 在这里失败,而不是静默让全链路流程页面变空。
// TestFlowGraphUpdateRelayedToWS publishes a real trace.RunGraph snapshot on the
// producer subject (ws.graph_update) and asserts the client receives a
// `graph_update` frame carrying the snapshot unchanged.
// TestFlowGraphUpdateRelayedToWS 在生产主题(ws.graph_update)上
// 发布真实的 trace.RunGraph 快照,并断言客户端收到一个
// 原样携带该快照的 `graph_update` 帧。
func TestFlowGraphUpdateRelayedToWS(t *testing.T) {
env := newEnv(t, nil, false)
tok := env.token(t, auth.RoleOwner)
@@ -35,8 +35,8 @@ func TestFlowGraphUpdateRelayedToWS(t *testing.T) {
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
// Round-trip a mode_switch first so the connection is registered before the
// publish (same guard as TestWSUpgradeAndBroadcast).
// 先往返一次 mode_switch,使连接在发布前已注册
// (与 TestWSUpgradeAndBroadcast 相同的保护)。
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
t.Fatalf("write mode_switch: %v", err)
}
@@ -61,7 +61,7 @@ func TestFlowGraphUpdateRelayedToWS(t *testing.T) {
t.Fatalf("frame type = %q, want graph_update (frame=%s)", frame.Type, raw)
}
// The relayed payload must be the snapshot verbatim — same JSON, not re-shaped.
// 中继的载荷必须是快照原文 —— 同样的 JSON,未被重塑。
want, err := json.Marshal(snap)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
@@ -71,10 +71,10 @@ func TestFlowGraphUpdateRelayedToWS(t *testing.T) {
}
}
// TestFlowGraphUpdateWireFields pins the JSON key names on every level of the
// snapshot against the fields the frontend reader (parseGraph in lib/wsParse.ts)
// requires. It is deliberately an exact key-set comparison: adding, renaming or
// dropping a field the frontend reads must fail here.
// TestFlowGraphUpdateWireFields 把快照每一层的 JSON 键名钉到
// 前端读取器(lib/wsParse.ts 中的 parseGraph)所需的字段上。
// 它刻意做精确键集比较:新增、改名或丢弃
// 前端所读的字段必须在这里失败。
func TestFlowGraphUpdateWireFields(t *testing.T) {
raw, err := json.Marshal(sampleFlowSnapshot())
if err != nil {
@@ -93,7 +93,7 @@ func TestFlowGraphUpdateWireFields(t *testing.T) {
assertGraphMeta(t, raw)
}
// assertGraphKeys pins the top-level FlowGraph keys read by parseGraph/Flow.tsx.
// assertGraphKeys 钉住 parseGraph/Flow.tsx 所读的 FlowGraph 顶层键。
func assertGraphKeys(t *testing.T, graph map[string]json.RawMessage) {
t.Helper()
want := []string{"id", "nodes", "edges", "sessionId", "startedAt", "endedAt"}
@@ -102,9 +102,9 @@ func assertGraphKeys(t *testing.T, graph map[string]json.RawMessage) {
}
}
// assertNodeKeys checks every emitted node key is one the frontend reads, that each
// node carries the always-read id/label, and that the node holding props+log has
// the complete set (which pins the omitempty tags too).
// assertNodeKeys 检查每个发出的节点键都是前端会读的、每个
// 节点都携带始终读取的 id/label,以及持有 props+log 的节点具备
// 完整的键集(这也钉住了 omitempty 标签)。
func assertNodeKeys(t *testing.T, nodes []map[string]json.RawMessage) {
t.Helper()
want := []string{"id", "label", "kind", "status", "startedAt", "endedAt", "props", "log"}
@@ -131,7 +131,7 @@ func assertNodeKeys(t *testing.T, nodes []map[string]json.RawMessage) {
}
}
// assertEdgeKeys pins the FlowEdge shape (parseGraph reads from/to).
// assertEdgeKeys 钉住 FlowEdge 形状(parseGraph 读取 from/to)。
func assertEdgeKeys(t *testing.T, edges []map[string]json.RawMessage) {
t.Helper()
if len(edges) == 0 {
@@ -144,7 +144,7 @@ func assertEdgeKeys(t *testing.T, edges []map[string]json.RawMessage) {
}
}
// assertLogKeys pins a node's log entry shape (parseGraph reads time/type/props).
// assertLogKeys 钉住节点的 log 条目形状(parseGraph 读取 time/type/props)。
func assertLogKeys(t *testing.T, nodes []map[string]json.RawMessage) {
t.Helper()
var logged []map[string]json.RawMessage
@@ -167,8 +167,8 @@ func assertLogKeys(t *testing.T, nodes []map[string]json.RawMessage) {
}
}
// assertGraphMeta pins the envelope values the frontend renders (graph id/session
// pair) and that timestamps arrive as RFC3339Nano (what Date.parse expects).
// assertGraphMeta 钉住前端渲染的信封值(graph id/session
// 对),以及时间戳以 RFC3339Nano 抵达(Date.parse 所期望的格式)。
func assertGraphMeta(t *testing.T, raw []byte) {
t.Helper()
var meta struct {
@@ -187,9 +187,9 @@ func assertGraphMeta(t *testing.T, raw []byte) {
}
}
// sampleFlowSnapshot builds a real producer-side snapshot (trace.RunGraph, the same
// type the loop's FlowPublisher publishes) whose every optional wire field is
// populated, so the key-set assertions above are meaningful.
// sampleFlowSnapshot 构建真实的生产者侧快照(trace.RunGraph,与
// loop 的 FlowPublisher 发布的类型相同),其每个可选线上字段
// 都被填充,使上面的键集断言有意义。
func sampleFlowSnapshot() *trace.FlowGraph {
base := time.Date(2026, 10, 3, 21, 0, 0, 0, time.UTC)
g := trace.NewRunGraph("web")
@@ -222,7 +222,7 @@ func sampleFlowSnapshot() *trace.FlowGraph {
return g.Snapshot()
}
// decodeObjects decodes a raw JSON array of objects, failing on a malformed shape.
// decodeObjects 解码原始 JSON 对象数组,形状畸形时失败。
func decodeObjects(t *testing.T, raw json.RawMessage, what string) []map[string]json.RawMessage {
t.Helper()
var out []map[string]json.RawMessage
@@ -235,7 +235,7 @@ func decodeObjects(t *testing.T, raw json.RawMessage, what string) []map[string]
return out
}
// readRawWS reads one text frame and returns its raw bytes.
// readRawWS 读取一个文本帧并返回其原始字节。
func readRawWS(t *testing.T, conn *websocket.Conn) []byte {
t.Helper()
_, msg, err := conn.ReadMessage()
@@ -245,7 +245,7 @@ func readRawWS(t *testing.T, conn *websocket.Conn) []byte {
return msg
}
// sortedKeys returns the object's JSON keys in sorted order.
// sortedKeys 按排序后的顺序返回该对象的 JSON 键。
func sortedKeys(obj map[string]json.RawMessage) []string {
out := make([]string, 0, len(obj))
for k := range obj {
@@ -255,7 +255,7 @@ func sortedKeys(obj map[string]json.RawMessage) []string {
return out
}
// equalKeys reports whether got has exactly the members of want (both sorted).
// equalKeys 报告 got 是否恰好拥有 want 的全部成员(二者均已排序)。
func equalKeys(got []string, want []string) bool {
want = append([]string(nil), want...)
sort.Strings(want)
@@ -270,7 +270,7 @@ func equalKeys(got []string, want []string) bool {
return true
}
// containsKey reports whether the (sorted) key list holds k.
// containsKey 报告(已排序的)键列表中是否含有 k。
func containsKey(keys []string, k string) bool {
for _, key := range keys {
if key == k {
+19 -19
View File
@@ -23,7 +23,7 @@ import (
const testSecret = "0123456789abcdef0123456789abcdef"
// stubRunner is a Chatter whose Run returns a fixed result.
// stubRunner 是一个 Chatter,其 Run 返回固定结果。
type stubRunner struct {
res *loop.Result
err error
@@ -33,7 +33,7 @@ func (s *stubRunner) Run(ctx context.Context, sessionID string, messages []model
return s.res, s.err
}
// stubTool is a minimal sdk.Tool for capability-registry tests.
// stubTool 是供能力注册表测试用的最小 sdk.Tool。
type stubTool struct{}
func (stubTool) Name() string { return "echo" }
@@ -41,7 +41,7 @@ func (stubTool) Description() string { return "ec
func (stubTool) Parameters() (map[string]any, error) { return nil, nil }
func (stubTool) Execute(ctx context.Context, args any) (any, error) { return args, nil }
// testEnv bundles everything a test needs.
// testEnv 打包了测试所需的一切。
type testEnv struct {
srv *Server
ts *httptest.Server
@@ -50,8 +50,8 @@ type testEnv struct {
caps *capreg.Registry
}
// newEnv builds a Server backed by a real JWT service and in-process bus, plus a
// httptest server. runner may be nil (chat returns 500).
// newEnv 构建一个 Server,后端是真实 JWT 服务与进程内 bus,
// 外加一个 httptest 服务器。runner 可为 nil(chat 返回 500)。
func newEnv(t *testing.T, runner Chatter, withCaps bool) *testEnv {
t.Helper()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
@@ -87,7 +87,7 @@ func (e *testEnv) token(t *testing.T, role auth.Role) string {
return tok
}
// doReq performs an HTTP request and returns the response plus the raw body.
// doReq 执行一次 HTTP 请求并返回响应与原始响应体。
func doReq(t *testing.T, method, url string, body any, token string) (*http.Response, []byte) {
t.Helper()
var rd io.Reader
@@ -118,7 +118,7 @@ func doReq(t *testing.T, method, url string, body any, token string) (*http.Resp
return resp, bodyBytes
}
// decodeBody decodes a response body into a generic map.
// decodeBody 把响应体解码进一个通用 map。
func decodeBody(t *testing.T, b []byte) map[string]json.RawMessage {
t.Helper()
var m map[string]json.RawMessage
@@ -128,7 +128,7 @@ func decodeBody(t *testing.T, b []byte) map[string]json.RawMessage {
return m
}
// numOrZero returns the numeric value of a JSON number in the data envelope.
// numOrZero 返回 data 信封中某个 JSON 数字的数值。
func wantErrorShape(t *testing.T, b []byte) {
t.Helper()
env := decodeBody(t, b)
@@ -291,9 +291,9 @@ func TestRBACForbiddenAndUnauthorized(t *testing.T) {
{name: "chat no token", url: "/api/v1/chat", token: "", want: http.StatusUnauthorized},
{name: "chat viewer", url: "/api/v1/chat", token: viewer, want: http.StatusForbidden},
{name: "admin no token", url: "/api/v1/admin/plugins", token: "", want: http.StatusUnauthorized},
// A viewer reads the monitoring surface (/admin/plugins) but not the
// restricted reads (/admin/memories) — gateway/rbac_viewer_test.go
// enumerates both sets route by route.
// viewer 可以读监控表面(/admin/plugins),但不能读
// 受限读(/admin/memories)—— gateway/rbac_viewer_test.go
// 逐路由枚举了这两个集合。
{name: "admin plugin viewer ok", url: "/api/v1/admin/plugins", token: viewer, want: http.StatusOK},
{name: "admin memories viewer forbidden", url: "/api/v1/admin/memories", token: viewer, want: http.StatusForbidden},
{name: "admin owner ok", url: "/api/v1/admin/plugins", token: owner, want: http.StatusOK},
@@ -330,13 +330,13 @@ func TestAdminSetPolicyAndAudit(t *testing.T) {
t.Fatalf("policy data = %s, want allowInGroup true", data)
}
// Unknown capability → 404.
// 未知能力 → 404。
resp2, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "nope"}, owner)
if resp2.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp2.StatusCode)
}
// Viewer is forbidden from the write.
// viewer 被禁止执行该写操作。
viewer := env.token(t, auth.RoleViewer)
resp3, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, viewer)
if resp3.StatusCode != http.StatusForbidden {
@@ -370,8 +370,8 @@ func TestWSUpgradeAndBroadcast(t *testing.T) {
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
// Round-trip a mode_switch first: the relay re-broadcasts it, which
// guarantees the connection is registered before we trigger a broadcast.
// 先往返一次 mode_switch:中继会重新广播它,这
// 保证连接在我们触发广播前已注册。
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
t.Fatalf("write mode_switch: %v", err)
}
@@ -379,7 +379,7 @@ func TestWSUpgradeAndBroadcast(t *testing.T) {
t.Fatalf("first frame type = %q, want mode_switch (frame=%+v)", got.Type, got)
}
// Publish an outbound event; the relay must forward it to the client.
// 发布一个下行事件;中继必须把它转发给客户端。
if err := env.bus.Publish(context.Background(), "ws.thinking", map[string]any{"text": "hmm"}); err != nil {
t.Fatalf("publish: %v", err)
}
@@ -390,7 +390,7 @@ func TestWSUpgradeAndBroadcast(t *testing.T) {
func TestWsEventSessionIDField(t *testing.T) {
t.Parallel()
// When set, sessionId is marshalled so the client can filter by session.
// 设置时 sessionId 会被序列化,使客户端能按会话过滤。
set, err := json.Marshal(wsEvent{Type: "stream_end", SessionID: "web", Data: map[string]any{"stopReason": "stop"}})
if err != nil {
t.Fatalf("marshal set: %v", err)
@@ -398,7 +398,7 @@ func TestWsEventSessionIDField(t *testing.T) {
if !strings.Contains(string(set), `"sessionId":"web"`) {
t.Fatalf("set frame = %s, want sessionId web", set)
}
// When empty, omitempty drops it so existing clients/tests see no change.
// 为空时 omitempty 会丢弃它,因此既有客户端/测试看不到变化。
unset, err := json.Marshal(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": "stop"}})
if err != nil {
t.Fatalf("marshal unset: %v", err)
@@ -415,7 +415,7 @@ func TestWsEventSessionIDField(t *testing.T) {
}
}
// readWS reads one text frame and decodes it into a wsEvent.
// readWS 读取一个文本帧并解码进 wsEvent。
func readWS(t *testing.T, conn *websocket.Conn) wsEvent {
t.Helper()
_, msg, err := conn.ReadMessage()
+80 -80
View File
@@ -13,46 +13,46 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// historySessionSummary is the wire shape of one persistent session (main or sub) in the
// GET /api/v1/sessions response.
// historySessionSummary 是 GET /api/v1/sessions 响应中一个持久化会话
// (main 或 sub)的线上结构。
type historySessionSummary struct {
ID string `json:"id"`
Type string `json:"type"` // "main" | "sub"
Title string `json:"title,omitempty"`
CreatedAt int64 `json:"createdAt"`
// Pinned floats the session to the top of the list (D5). It is order metadata
// only and is always present so a client can render/sort without a second call.
// Pinned 把会话浮到列表顶部(D5)。它只是排序元数据,
// 且始终存在,使客户端无需第二次调用就能渲染/排序。
Pinned bool `json:"pinned"`
// LastMessage is a display-only preview of the session's newest message, so the
// client can render a conversation row (e.g. the Android session list) without a
// per-session request. It is omitted for a session with no messages. Content is
// already truncated by the store.
// LastMessage 是会话最新消息的纯展示预览,使客户端
// 无需逐会话请求就能渲染对话行(例如 Android 会话列表)。
// 没有消息的会话会省略它。内容
// 已由存储截断。
LastMessage *historyLastMessage `json:"lastMessage,omitempty"`
// UnreadCount is the number of messages newer than the session's read cursor
// (D4), derived in the same list query. It is always returned (0 when nothing
// is unread) so a client can render/clear the badge without a second call. The
// cursor is session-scoped and single-owner: reading on any one end clears the
// badge for every end of the account.
// UnreadCount 是比会话读游标(D4)更新的消息数,
// 在同一次列表查询中推导。它始终返回(无未读时为 0),
// 使客户端无需第二次调用就能渲染/清除角标。该
// 游标是会话作用域且单归属:在任一端已读会为
// 该账号的每一端清除角标。
UnreadCount int `json:"unreadCount"`
// PersonaID is the role this session belongs to (design/22 §2/§4.5). It is the
// single source of truth for which role answers in the conversation, and the
// client uses it to render the role's name/avatar for the active session.
// Omitted when empty, and empty means "inherit the global default role", so an
// old client that ignores the field keeps showing the default role's visuals.
// PersonaID 是该会话所属的角色(design/22 §2/§4.5)。它是
// 「对话中由哪个角色应答」的唯一事实来源,客户端
// 用它为活动会话渲染角色的名字/头像。
// 为空时省略,而空意味着「继承全局默认角色」,因此
// 忽略该字段的旧客户端仍展示默认角色的视觉。
PersonaID string `json:"personaId,omitempty"`
}
// historyLastMessage is the wire shape of the last-message preview in a session summary.
// historyLastMessage 是会话摘要中最后消息预览的线上结构。
type historyLastMessage struct {
Role string `json:"role"` // "user" | "assistant"
Content string `json:"content"`
CreatedAt int64 `json:"createdAt"`
}
// historyMessage is the wire shape of one persisted chat message in the
// GET /api/v1/sessions/{id}/messages response. Mode is carried so the frontend renders
// the bubble without guessing (design/16 §5.1); thinking is stored and returned to the
// owner/admin (the messages route is owner/admin-only, so it never reaches a viewer).
// historyMessage 是 GET /api/v1/sessions/{id}/messages 响应中一条持久化聊天
// 消息的线上结构。Mode 被携带,使前端无需猜测就渲染
// 气泡(design/16 §5.1);thinking 会被存储并返回给
// owner/admin(消息路由仅 owner/admin,因此绝不触达 viewer)。
type historyMessage struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
@@ -64,25 +64,25 @@ type historyMessage struct {
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
// SenderName is the human-readable sender display name of a third-party
// message (group/stranger, design/16 §4.6). It is blank for the owner's
// private chat on the main session, so empty means "labeled as the owner".
// SenderName 是第三方消息(群聊/陌生人,design/16 §4.6)
// 给人看的发送者显示名。对主会话中主人的私聊它为
// 空,因此空意味着「标记为主人」。
SenderName string `json:"senderName,omitempty"`
// AdapterID is the adapter (platform + account) registration name of the IM
// source that produced the message (e.g. "kook:3bef..."). It is blank for
// Web-originated messages.
// AdapterID 是产出该消息的 IM 来源适配器(平台 + 账号)
// 注册名(例如 "kook:3bef...")。对 Web 来源的消息
// 为空。
AdapterID string `json:"adapterId,omitempty"`
// PersonaID is the role that SPOKE this message (design/22 §7-5). A session may
// switch roles while keeping its context, so the authoring role is stored and
// returned per message and the client renders the bubble with that role's
// name/avatar. Omitted when empty, and empty means "the global default role" —
// the pre-multi-character rows read exactly as before.
// PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以
// 在保留上下文的同时切换角色,因此作者角色按消息
// 存储并返回,客户端用该角色的名字/头像渲染
// 气泡。为空时省略,而空意味着「全局默认角色」——
// 多角色之前的行读起来与以往完全一致。
PersonaID string `json:"personaId,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions returns the main session first, then every sub session (oldest
// first). It is the chat-history session list for the frontend (design/16 §2.2).
// handleHistorySessions 先返回主会话,再返回每个子会话(最旧
// 优先)。它是前端的聊天历史会话列表(design/16 §2.2)。
func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
meta, err := s.history.ListSessions(r.Context())
if err != nil {
@@ -106,8 +106,8 @@ func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// toHistoryLastMessage maps a stored last-message preview to its wire shape, returning
// nil for a session that has no messages (so the field is omitted from the JSON).
// toHistoryLastMessage 把已存的最后消息预览映射为其线上结构,
// 对没有消息的会话返回 nil(因此该字段从 JSON 中省略)。
func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
if m == nil {
return nil
@@ -115,21 +115,21 @@ func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
}
// createSubSessionRequest is the POST /api/v1/sessions body. The body itself is
// optional (a client that just wants a plain session sends none), and personaId is
// the optional role the new session is bound to (design/22 §4.5). It is the
// "seed the new session's role" half of the design's precedence: an explicit role
// here becomes the session-level binding (the single source of truth), while
// omitting it leaves persona_id = '' so the session inherits the global default.
// createSubSessionRequest 是 POST /api/v1/sessions 的请求体。请求体本身
// 可选(只想要普通会话的客户端不发送任何内容),personaId 是
// 新会话可选绑定的角色(design/22 §4.5)。它是设计的
// 优先级中「为新会话播种角色」的那一半:此处显式给出的角色
// 成为会话级绑定(唯一事实来源),而省略它则
// 保持 persona_id = '',使会话继承全局默认。
type createSubSessionRequest struct {
PersonaID string `json:"personaId"`
}
// handleCreateSubSession creates a new sub session and returns its sessionId. It is a
// user-side write, so it is owner/admin-gated like /api/v1/chat. An optional
// {"personaId":"..."} body binds the new session to that role; an unknown id is
// rejected with 400 rather than creating a session that would silently answer as
// the default role.
// handleCreateSubSession 创建一个新的子会话并返回其 sessionId。它是
// 用户侧写操作,因此与 /api/v1/chat 一样受 owner/admin 门控。可选的
// {"personaId":"..."} 请求体把新会话绑定到该角色;未知 id 会
// 以 400 拒绝,而不是创建一个会静默以
// 默认角色应答的会话。
func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -165,22 +165,22 @@ func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, resp)
}
// handleListMessages returns a page of messages for a session with cursor pagination.
// It is the hot-paging history endpoint (design/16 §2.2, §5.1). Two mutually exclusive
// cursors are supported:
// - before (millisecond createdAt): newest-first page of messages strictly older than
// before — the default, used to page backwards through history;
// - after (millisecond createdAt): oldest-first page of messages strictly newer than
// after — forward incremental catch-up, so a client that reconnected can pull
// everything it missed from the newest createdAt it already has, without the
// one-page limit of a backward walk.
// handleListMessages 用游标分页返回某会话的一页消息。
// 它是热分页历史端点(design/16 §2.2、§5.1)。支持两个互斥的
// 游标:
// - before(毫秒 createdAt):返回严格早于 before 的消息,最新优先
// —— 默认方向,用于向后翻历史;
// - after(毫秒 createdAt):返回严格新于 after 的消息,最旧优先
// —— 前向增量追赶,使已重连的客户端能拉取
// 从其已有的最新 createdAt 起错过的全部内容,而不受
// 向后翻的单页上限限制。
//
// limit defaults to 50 (clamped to 200) for both directions. platform filters the page
// by source platform in either direction. An optional q switches the page to a
// session-scoped content search (newest first, still honoring before/limit); q is
// mutually exclusive with after, and an empty q is ignored so plain paging is
// unchanged. Supplying both before and after (both > 0) answers 400, as does a
// non-numeric/negative after or an invalid limit/before.
// limit 两个方向都默认 50(上限 200)。platform 在任一方向按
// 来源平台过滤该页。可选的 q 把该页切换为
// 会话作用域的内容搜索(最新优先,仍遵守 before/limit);q 与
// after 互斥,空 q 被忽略,因此普通分页
// 不变。同时给出 before 与 after(二者都 > 0)返回 400,
// after 非数字/为负或 limit/before 非法时亦然。
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -248,9 +248,9 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// parseAfterCursor reads the non-negative "after" query param (a millisecond createdAt
// cursor). An absent/empty param yields (0, true) — no forward bound. A non-numeric or
// negative value yields (0, false) so the caller answers 400.
// parseAfterCursor 读取非负的 "after" 查询参数(毫秒 createdAt
// 游标)。参数缺失/为空得到 (0, true) —— 无前向边界。非数字或
// 负值得到 (0, false),使调用方返回 400。
func parseAfterCursor(r *http.Request) (int64, bool) {
raw := r.URL.Query().Get("after")
if raw == "" {
@@ -263,10 +263,10 @@ func parseAfterCursor(r *http.Request) (int64, bool) {
return v, true
}
// handleChannelPlatformLog returns up to limit messages for an IM platform across its
// isolated (group/stranger) sessions, newest first, for the admin adapter log page
// (design/16 §4.6 §4). It excludes the fixed main web session, so only the platform's
// non-owner message stream is surfaced; the owner-private mirror stays out of this view.
// handleChannelPlatformLog 为某个 IM 平台跨其隔离(群聊/陌生人)
// 会话返回至多 limit 条消息,最新优先,供管理适配器日志页
// (design/16 §4.6 §4)。它排除固定的主 Web 会话,因此只浮现该平台的
// 非主人消息流;owner 私有镜像不进入该视图。
func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -309,13 +309,13 @@ func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, out)
}
// handleChannelMessages returns up to limit messages for the admin adapter log page,
// newest first, across one of three granularities (design/16 §4.6 §4): a single
// adapter config (?adapter=kook:3bef...), a whole platform (?platform=kook), or the
// full IM adapter overview (neither, so adapter_id != ''). before/limit reuse the
// standard cursor pagination. The owner-private mirror on the main web session is
// NOT excluded here because it carries the adapter_id; each granularity filters on
// adapter_id / platform directly.
// handleChannelMessages 为管理适配器日志页返回至多 limit 条消息,
// 最新优先,覆盖三种粒度之一(design/16 §4.6 §4):单个
// 适配器配置(?adapter=kook:3bef...)、整个平台(?platform=kook),或
// 全部 IM 适配器总览(二者都不给,即 adapter_id != '')。before/limit 复用
// 标准游标分页。主 Web 会话上的 owner 私有镜像在此
// 不被排除,因为它携带 adapter_id;每种粒度直接按
// adapter_id / platform 过滤。
func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -387,7 +387,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// toHistoryMessage maps a stored history.Message to its wire shape.
// toHistoryMessage 把已存的 history.Message 映射为其线上结构。
func toHistoryMessage(m history.Message) historyMessage {
return historyMessage{
ID: m.ID,
@@ -407,5 +407,5 @@ func toHistoryMessage(m history.Message) historyMessage {
}
}
// historyDefaultLimit is the page size used when the client omits the limit query param.
// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。
const historyDefaultLimit = 50
+16 -16
View File
@@ -9,9 +9,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// TestHistoryListMessagesAfterCursor covers the forward cursor (A5): after returns the
// strictly-newer messages oldest-first, before keeps its existing newest-first behavior,
// and the two cursors are mutually exclusive.
// TestHistoryListMessagesAfterCursor 覆盖前向游标(A5):after 返回
// 严格更新的消息且最旧优先,before 保持既有的最新优先行为,
// 两个游标互斥。
func TestHistoryListMessagesAfterCursor(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -24,7 +24,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) {
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/messages"
// after=2000 → strictly newer, oldest first.
// after=2000 → 严格更新,最旧优先。
resp, body := doReq(t, http.MethodGet, base+"?after=2000", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("after status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -35,7 +35,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) {
t.Fatalf("after=2000 = %+v, want ascending [3000 4000]", forward)
}
// after at the newest message → empty page, still 200.
// after 设为最新消息 → 空页,仍为 200。
resp, body = doReq(t, http.MethodGet, base+"?after=4000", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("after newest status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -46,7 +46,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) {
t.Fatalf("after=4000 = %+v, want empty", empty)
}
// before behavior is unchanged: newest first, strictly older.
// before 行为不变:最新优先,严格更旧。
resp, body = doReq(t, http.MethodGet, base+"?before=3000&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("before status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -57,7 +57,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) {
t.Fatalf("before=3000 = %+v, want descending [2000 1000]", backward)
}
// after=0 is not a forward bound → falls back to the default newest-first page.
// after=0 不是前向边界 → 回退到默认的最新优先页。
resp, body = doReq(t, http.MethodGet, base+"?after=0&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("after=0 status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -69,8 +69,8 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) {
}
}
// TestHistoryListMessagesAfterRejectsBadInput covers the 400s: both cursors together,
// and a negative or non-numeric after.
// TestHistoryListMessagesAfterRejectsBadInput 覆盖那些 400:两个游标同时
// 给出,以及 after 为负或非数字。
func TestHistoryListMessagesAfterRejectsBadInput(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
@@ -78,9 +78,9 @@ func TestHistoryListMessagesAfterRejectsBadInput(t *testing.T) {
base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/messages"
for _, q := range []string{
"?after=1000&before=3000", // both cursors
"?after=-1", // negative
"?after=abc", // non-numeric
"?after=1000&before=3000", // 两个游标都给
"?after=-1", // 负数
"?after=abc", // 非数字
} {
resp, body := doReq(t, http.MethodGet, base+q, nil, tok)
if resp.StatusCode != http.StatusBadRequest {
@@ -89,8 +89,8 @@ func TestHistoryListMessagesAfterRejectsBadInput(t *testing.T) {
}
}
// TestHistoryListMessagesAfterPlatformFilter asserts the platform filter composes with
// the forward cursor.
// TestHistoryListMessagesAfterPlatformFilter 断言平台过滤与前向游标
// 可以组合。
func TestHistoryListMessagesAfterPlatformFilter(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -119,8 +119,8 @@ func TestHistoryListMessagesAfterPlatformFilter(t *testing.T) {
}
}
// TestHistoryListMessagesAfterLimitClamp asserts the forward page is capped at 200 like
// the backward one, and that advancing the returned cursor pages forward without a gap.
// TestHistoryListMessagesAfterLimitClamp 断言前向页与后向一样上限 200,
// 且推进返回的游标能无缝隙地向前翻页。
func TestHistoryListMessagesAfterLimitClamp(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -16,13 +16,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// testLogger returns a logger that writes nowhere, keeping test output clean.
// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newHistoryEnv builds a Server wired with a real in-memory history store, returning the
// httptest server, the JWT service and the underlying history store.
// newHistoryEnv 构建一个接线了真实内存 history 存储的 Server,返回
// httptest 服务器、JWT 服务与底层 history 存储。
func newHistoryEnv(t *testing.T) (*Server, *httptest.Server, *auth.JWTService, *history.Store) {
t.Helper()
ctx := context.Background()
@@ -39,7 +39,7 @@ func newHistoryEnv(t *testing.T) (*Server, *httptest.Server, *auth.JWTService, *
return srv, ts, jwt, st
}
// decodeDataSlice decodes the {data:[...]} envelope into the given slice target.
// decodeDataSlice 把 {data:[...]} 信封解码进给定的切片目标。
func decodeDataSlice(t *testing.T, body []byte, out any) {
t.Helper()
if err := json.Unmarshal(decodeBody(t, body)["data"], out); err != nil {
@@ -52,7 +52,7 @@ func TestHistoryCreateAndListSessions(t *testing.T) {
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Fresh store always lists the main session.
// 全新存储总会列出主会话。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -63,7 +63,7 @@ func TestHistoryCreateAndListSessions(t *testing.T) {
t.Fatalf("initial sessions = %+v, want [main web]", initial)
}
// Create a sub session.
// 创建一个子会话。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
@@ -78,7 +78,7 @@ func TestHistoryCreateAndListSessions(t *testing.T) {
t.Fatalf("sessionId = %q, want prefix web:", created.SessionID)
}
// Now main first, then the sub.
// 现在 main 在前,sub 在后。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -97,7 +97,7 @@ func TestHistoryListMessagesPagination(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
// Seed the main session with messages at ascending ms timestamps.
// 用毫秒时间戳递增的消息播种主会话。
for _, ts := range []int64{1000, 2000, 3000, 4000} {
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: ts}); err != nil {
t.Fatalf("seed %d: %v", ts, err)
@@ -105,7 +105,7 @@ func TestHistoryListMessagesPagination(t *testing.T) {
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Newest page of 2.
// 最新优先的一页,2 条。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("first page status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -116,7 +116,7 @@ func TestHistoryListMessagesPagination(t *testing.T) {
t.Fatalf("first page = %+v, want [4000 3000]", page)
}
// Cursor before the page's oldest (3000) → older page [2000 1000].
// 游标设在该页最旧(3000)之前 → 更旧的一页 [2000 1000]。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?before=3000&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("older page status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -127,7 +127,7 @@ func TestHistoryListMessagesPagination(t *testing.T) {
t.Fatalf("older page = %+v, want [2000 1000]", older)
}
// Invalid limit/before → 400.
// 非法 limit/before → 400。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid limit status = %d, want 400 (body=%s)", resp.StatusCode, body)
@@ -146,7 +146,7 @@ func TestHistoryListMessagesPlatformFilter(t *testing.T) {
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// No platform → the full main session (both lanes).
// 不带 platform → 完整的主会话(两条通道)。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -157,7 +157,7 @@ func TestHistoryListMessagesPlatformFilter(t *testing.T) {
t.Fatalf("all = %d, want 2", len(all))
}
// ?platform=telegram → only the telegram lane, and the DTO carries platform.
// ?platform=telegram → 只有 telegram 通道,且 DTO 携带 platform。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10&platform=telegram", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -169,10 +169,10 @@ func TestHistoryListMessagesPlatformFilter(t *testing.T) {
}
}
// handleChannelMessagesGranularity exercises the admin adapter log endpoint at each
// granularity: a single adapter config (?adapter=), a whole platform (?platform=),
// and the full IM adapter overview (neither). It asserts adapterId is carried on the
// DTO so the panel can label the source adapter config.
// handleChannelMessagesGranularity 在每种粒度上演练管理适配器日志
// 端点:单个适配器配置(?adapter=)、整个平台(?platform=)、
// 以及全部 IM 适配器总览(二者都不给)。它断言 DTO 上携带
// adapterId,使面板能标注来源适配器配置。
func TestHistoryChannelMessagesGranularity(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -189,8 +189,8 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) {
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// ?adapter=kook:3bef → only that adapter config, both the owner mirror and the
// group message, newest first, and the DTO carries adapterId.
// ?adapter=kook:3bef → 只有该适配器配置,包含 owner 镜像与
// 群消息,最新优先,且 DTO 携带 adapterId。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?adapter=kook:3bef", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("adapter status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -207,8 +207,8 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) {
t.Fatalf("adapter[1] = %+v, want kook-owner on main session", byAdapter[1])
}
// ?platform=kook → the platform lane across isolated sessions (owner-private
// mirror excluded, matching handleChannelPlatformLog semantics).
// ?platform=kook → 跨隔离会话的平台通道(owner 私有
// 镜像被排除,与 handleChannelPlatformLog 语义一致)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?platform=kook", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -219,7 +219,7 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) {
t.Fatalf("platform page = %+v, want [kook-group]", byPlatform)
}
// No filter → the full IM adapter overview (adapter_id != ''), web excluded.
// 无过滤 → 全部 IM 适配器总览(adapter_id != ''),web 被排除。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -233,7 +233,7 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) {
t.Fatalf("all order = %+v, want [tg-group kook-group kook-owner]", all)
}
// Invalid before → 400.
// 非法 before → 400。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?before=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid before status = %d, want 400 (body=%s)", resp.StatusCode, body)
@@ -246,7 +246,7 @@ func TestHistoryDeleteSubSession(t *testing.T) {
ctx := context.Background()
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Create a sub and seed a message.
// 创建一个子会话并播种一条消息。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
@@ -261,12 +261,12 @@ func TestHistoryDeleteSubSession(t *testing.T) {
t.Fatalf("seed sub: %v", err)
}
// Delete it.
// 删除它。
resp, _ = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+created.SessionID, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200", resp.StatusCode)
}
// It is gone from the list.
// 它已从列表中消失。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200", resp.StatusCode)
@@ -277,7 +277,7 @@ func TestHistoryDeleteSubSession(t *testing.T) {
t.Fatalf("sessions after delete = %+v, want [main]", sessions)
}
// Main session is immutable → 403.
// 主会话不可变 → 403。
resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+history.MainSessionID, nil, tok)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("delete main status = %d, want 403 (body=%s)", resp.StatusCode, body)
@@ -285,14 +285,14 @@ func TestHistoryDeleteSubSession(t *testing.T) {
wantErrorShape(t, body)
}
// TestHistoryRenameSession covers PATCH /api/v1/sessions/{id}: renaming a sub and
// the fixed main session, the input bounds, RBAC and an unknown id.
// TestHistoryRenameSession 覆盖 PATCH /api/v1/sessions/{id}:重命名子会话
// 与固定的主会话、输入边界、RBAC 以及未知 id。
func TestHistoryRenameSession(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Create a sub session to rename.
// 创建一个待重命名的子会话。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
@@ -304,7 +304,7 @@ func TestHistoryRenameSession(t *testing.T) {
t.Fatalf("decode create data: %v", err)
}
// Rename it and assert the response envelope plus the persisted list.
// 重命名它并断言响应信封与持久化后的列表。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": " 我的会话 "}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rename status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -338,7 +338,7 @@ func TestHistoryRenameSession(t *testing.T) {
t.Fatalf("renamed sub %s missing from %+v", created.SessionID, sessions)
}
// The fixed main session may be renamed even though it cannot be deleted.
// 固定的主会话即使不能删除也可以被重命名。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+history.MainSessionID, map[string]any{"title": "主控台"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rename main status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -354,7 +354,7 @@ func TestHistoryRenameSession(t *testing.T) {
t.Fatalf("main rename data = %+v, want id web title 主控台", mainRenamed)
}
// Empty / whitespace-only / over-long titles are 400.
// 空 / 仅空白 / 过长的标题都是 400。
for _, bad := range []string{"", " ", strings.Repeat("字", maxSessionTitleRunes+1)} {
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": bad}, tok)
if resp.StatusCode != http.StatusBadRequest {
@@ -363,20 +363,20 @@ func TestHistoryRenameSession(t *testing.T) {
wantErrorShape(t, body)
}
// An unknown session id is 404.
// 未知会话 id 是 404。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"title": "x"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown rename status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// A title of exactly the 80-rune bound is accepted.
// 恰好为 80 个 rune 边界的标题被接受。
exact := strings.Repeat("字", maxSessionTitleRunes)
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": exact}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("80-rune rename status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// A viewer cannot rename (owner/admin write).
// viewer 不能重命名(owner/admin 写)。
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "nope"}, viewer)
if resp.StatusCode != http.StatusForbidden {
@@ -384,17 +384,17 @@ func TestHistoryRenameSession(t *testing.T) {
}
}
// TestPersistUserMessageNamesSession exercises the Web persistence path end to end:
// persistUserMessages (the orchestrator hook the HTTP/WS boundary calls) writes the
// first user message through the real store, which names the session. A later user
// message leaves the title alone.
// TestPersistUserMessageNamesSession 端到端演练 Web 持久化路径:
// persistUserMessages(HTTP/WS 边界所调用的编排器钩子)经真实
// 存储写入首条用户消息,从而命名该会话。之后的用户
// 消息不再改标题。
func TestPersistUserMessageNamesSession(t *testing.T) {
t.Parallel()
_, _, _, st := newHistoryEnv(t)
o := &Orchestrator{history: st, log: testLogger()}
ctx := context.Background()
// A sub session starts nameless; the first user message names it.
// 子会话起始无名;首条用户消息为它命名。
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
@@ -416,7 +416,7 @@ func TestPersistUserMessageNamesSession(t *testing.T) {
t.Fatalf("sub title = %q, want 帮我规划一次旅行", gotSub)
}
// The main session is named from its first Web user message too.
// 主会话同样由它的首条 Web 用户消息命名。
o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, " 今天\n天气如何 ")}, "web:test", "chat", "cyrene")
metas, err = st.ListSessions(ctx)
if err != nil {
@@ -438,7 +438,7 @@ func TestHistoryNotWired(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Messages/create/delete answer a clear 501 when no history store is wired.
// 未接线 history 存储时,取消息/创建/删除返回明确的 501。
for _, tc := range []struct {
method, url string
want int
@@ -463,7 +463,7 @@ func TestHistoryRBAC(t *testing.T) {
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
owner := signAny(t, jwt, "acct", auth.RoleOwner)
// Viewer can list session metadata (readAuth) but not read/write history content.
// viewer 可以列出会话元数据(readAuth),但不能读写历史内容。
resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, viewer)
if resp.StatusCode != http.StatusOK {
t.Fatalf("viewer list sessions status = %d, want 200", resp.StatusCode)
@@ -476,7 +476,7 @@ func TestHistoryRBAC(t *testing.T) {
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer create status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
// Owner can read messages.
// owner 可以读取消息。
resp, _ = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/web/messages?limit=2", nil, owner)
if resp.StatusCode != http.StatusOK {
t.Fatalf("owner messages status = %d, want 200", resp.StatusCode)
@@ -9,10 +9,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// TestHistorySessionsLastMessage asserts the GET /api/v1/sessions wire contract for the
// last-message preview (B3): every session carries lastMessage {role,content,createdAt}
// with the newest message's whitespace-collapsed content, and a message-less session
// omits the field.
// TestHistorySessionsLastMessage 断言 GET /api/v1/sessions 中最后消息预览的
// 线上契约(B3):每个会话都携带 lastMessage {role,content,createdAt},
// 其 content 是折叠空白后的最新消息内容,而没有消息的会话
// 省略该字段。
func TestHistorySessionsLastMessage(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -33,8 +33,8 @@ func TestHistorySessionsLastMessage(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// Decode into a JSON-tagged shape so the wire contract (key names) is asserted,
// not just the in-package struct.
// 解码进带 JSON 标签的形状,因此断言的是线上契约(键名),
// 而不只是包内结构体。
var sessions []struct {
ID string `json:"id"`
LastMessage *struct {
@@ -51,7 +51,7 @@ func TestHistorySessionsLastMessage(t *testing.T) {
if main == nil || main.Role != history.RoleAssistant || main.Content != "在的 请说" || main.CreatedAt != 2000 {
t.Fatalf("main lastMessage = %+v, want assistant/在的 请说/2000", main)
}
// The empty sub session omits lastMessage entirely.
// 空的子会话完全省略 lastMessage。
if sessions[1].ID != sub {
t.Fatalf("sessions[1].id = %q, want %q", sessions[1].ID, sub)
}
@@ -11,8 +11,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// seedFullMessage appends m through the real store and returns the stored message
// (with its generated id), so HTTP tests can address a specific message.
// seedFullMessage 经真实存储追加 m 并返回已存消息
// (含其生成的 id),使 HTTP 测试能定位到具体某条消息。
func seedFullMessage(t *testing.T, st *history.Store, m history.Message) history.Message {
t.Helper()
got, err := st.AppendFull(context.Background(), m)
@@ -22,8 +22,8 @@ func seedFullMessage(t *testing.T, st *history.Store, m history.Message) history
return got
}
// TestHistorySearchMessagesHTTP covers D2: session-scoped ?q= search hits, paging,
// the empty-q fallback, input validation and isolation from another session.
// TestHistorySearchMessagesHTTP 覆盖 D2:会话作用域的 ?q= 搜索命中、分页、
// 空 q 回退、输入校验以及与其他会话的隔离。
func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -39,7 +39,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// A matching message in another (sub) session must never surface in the main search.
// 另一个(子)会话中的匹配消息绝不得出现在主会话搜索中。
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
@@ -49,7 +49,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) {
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Newest-first page of 2 matching hits.
// 最新优先的一页,2 条匹配命中。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("search status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -63,7 +63,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Fatalf("search leaked session %q", page[0].SessionID)
}
// before cursor pages to the older hits.
// before 游标翻到更旧的命中。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&before=3000&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("search older status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -74,7 +74,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Fatalf("search older = %+v, want [2000 1000]", older)
}
// The sub session's search sees only its own hit.
// 子会话的搜索只看到自己的命中。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages?q=天气", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sub search status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -85,7 +85,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Fatalf("sub search = %+v, want only the sub hit", subHits)
}
// An empty q falls back to plain paging (all messages).
// 空 q 回退到普通分页(全部消息)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=&limit=10", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty q status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -96,7 +96,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Fatalf("empty q = %d messages, want 5", len(all))
}
// q is mutually exclusive with after, and invalid limit still 400.
// q 与 after 互斥,且非法 limit 仍是 400。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&after=1000", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("q+after status = %d, want 400 (body=%s)", resp.StatusCode, body)
@@ -107,8 +107,8 @@ func TestHistorySearchMessagesHTTP(t *testing.T) {
}
}
// TestHistoryPatchAndDeleteMessageHTTP covers D3: editing and deleting one message,
// the input bounds, 404 and RBAC.
// TestHistoryPatchAndDeleteMessageHTTP 覆盖 D3:编辑与删除单条消息、
// 输入边界、404 与 RBAC。
func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -116,7 +116,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/messages/" + msg.ID
// Edit the content; role/timestamp/session are preserved and echoed back.
// 编辑 content;role/时间戳/会话保持不变并回显。
resp, body := doReq(t, http.MethodPatch, base, map[string]any{"content": "改后的正文"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -129,7 +129,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
t.Fatalf("updated = %+v, want content edited with role/ts preserved", updated)
}
// The edit is persisted.
// 该编辑已被持久化。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -140,7 +140,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
t.Fatalf("listed = %+v, want the edited content", listed)
}
// Invalid edits: empty/whitespace, missing field, over-long, unknown id.
// 非法编辑:空/仅空白、字段缺失、过长、未知 id。
for _, tc := range []struct {
name string
body any
@@ -162,7 +162,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
t.Fatalf("patch unknown status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// Delete the message, then deleting again is 404.
// 删除该消息,再次删除则是 404。
resp, body = doReq(t, http.MethodDelete, base, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -181,7 +181,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
t.Fatalf("delete again status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// A viewer cannot edit or delete (owner/admin write).
// viewer 不能编辑或删除(owner/admin 写)。
other := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: 2000})
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
for _, method := range []string{http.MethodPatch, http.MethodDelete} {
@@ -192,8 +192,8 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
}
}
// TestHistoryMessagesNotWiredHTTP asserts the D2/D3 endpoints answer a clear 501 when
// no HistoryStore is injected.
// TestHistoryMessagesNotWiredHTTP 断言未注入 HistoryStore 时 D2/D3 端点
// 返回明确的 501。
func TestHistoryMessagesNotWiredHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
@@ -215,8 +215,8 @@ func TestHistoryMessagesNotWiredHTTP(t *testing.T) {
}
}
// TestHistoryPatchSessionPinnedHTTP covers D5: pin/unpin changes GET /sessions order,
// the response echoes only the changed field, and the A4 title PATCH still works.
// TestHistoryPatchSessionPinnedHTTP 覆盖 D5:置顶/取消置顶改变 GET /sessions 顺序,
// 响应只回显被改动的字段,且 A4 的标题 PATCH 仍然可用。
func TestHistoryPatchSessionPinnedHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
@@ -265,8 +265,8 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) {
assertOrder([]string{history.MainSessionID, first, second})
// Pin the newest sub: it floats above the main session, and the response echoes
// only the pinned field.
// 置顶最新的子会话:它会浮到主会话之上,且响应只回显
// 被置顶的字段。
resp, body := doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("pin status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -284,7 +284,7 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) {
}
assertOrder([]string{second, history.MainSessionID, first})
// GET /sessions reports the pin flag.
// GET /sessions 报告置顶标志。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -295,7 +295,7 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) {
t.Fatalf("sessions[0] = %+v, want pinned sub %s", sessions[0], second)
}
// A title PATCH still works and keeps its {id,title} response shape.
// 标题 PATCH 仍然可用,并保持其 {id,title} 响应形状。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"title": " 置顶会话 "}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("title patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -311,14 +311,14 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) {
t.Fatalf("title patch = %+v, want 置顶会话", renamed)
}
// Unpin restores the baseline order.
// 取消置顶恢复基线顺序。
resp, _ = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": false}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unpin status = %d, want 200", resp.StatusCode)
}
assertOrder([]string{history.MainSessionID, first, second})
// Neither field → 400; unknown id → 404; viewer → 403.
// 两个字段都不给 → 400;未知 id → 404;viewer → 403。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body)
@@ -17,15 +17,15 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file covers the gateway half of design/22 (多角色并存 + 按会话选角): the REST
// contract for binding a session to a role, and the resolver that turns a
// sessionID into the loop environment the turn runs under. The chain asserted end to
// 本文件覆盖 design/22(多角色并存 + 按会话选角)的网关侧一半:把
// 会话绑定到角色的 REST 契约,以及把 sessionID 转成该回合
// 所运行的 loop 环境的解析器。端到端断言的链条是:
// end is: sessionID → chat_sessions.persona_id → turnEnvResolver → loop.SessionEnv →
// per-role agent (system prompt), so a regression at any hop shows up as the wrong
// role answering.
// 按角色的 agent(system prompt),因此任一环节回归都会表现为
// 错误的角色在应答。
// personaAdapter records every request the loop built, so a test can assert which
// role's system prompt actually reached the model.
// personaAdapter 记录 loop 构建的每一个请求,使测试能断言
// 究竟是哪个角色的 system prompt 抵达了模型。
type personaAdapter struct {
reqs []model.Request
}
@@ -41,8 +41,8 @@ func (p *personaAdapter) ChatStream(ctx context.Context, req model.Request, _ fu
return p.Chat(ctx, req, model.Options{})
}
// personaTestRegistry registers two roles (one default) so the gateway can validate
// personaId values and resolve the default role.
// personaTestRegistry 注册两个角色(其中一个是默认),使网关能校验
// personaId 值并解析默认角色。
func personaTestRegistry(t *testing.T) *personareg.Registry {
t.Helper()
reg := personareg.New()
@@ -63,7 +63,7 @@ func personaTestRegistry(t *testing.T) *personareg.Registry {
return reg
}
// personaTestStore opens an in-memory chat-history store.
// personaTestStore 打开一个内存聊天历史存储。
func personaTestStore(t *testing.T) *history.Store {
t.Helper()
ctx := context.Background()
@@ -79,8 +79,8 @@ func personaTestStore(t *testing.T) *history.Store {
return st
}
// gatewayAgentFor builds the per-role agent the composition root would build: the
// same adapter, a role-specific instruction string.
// gatewayAgentFor 构建组合根会构建的按角色 agent:同一个
// 适配器,角色专属的指令字符串。
func gatewayAgentFor(t *testing.T, instructions string, ad model.Adapter) *agent.Agent {
t.Helper()
ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil))
@@ -90,12 +90,12 @@ func gatewayAgentFor(t *testing.T, instructions string, ad model.Adapter) *agent
return ag
}
// TestChatTurnAnswersAsTheBoundRole is design/22 acceptance 3 seen from the process
// boundary: a sub session created and bound to role Y answers as Y (the first system
// message is Y's prompt and the persisted messages carry Y). Its role is fixed at
// creation (§8-1), so the PATCH that used to unbind it is refused and the session
// keeps answering as Y; the only way to get the default role is a session created
// without a binding.
// TestChatTurnAnswersAsTheBoundRole 是从进程边界看 design/22 验收 3:
// 一个创建并绑定到角色 Y 的子会话以 Y 应答(首条 system
// 消息是 Y 的提示词,且持久化的消息携带 Y)。其角色在
// 创建时即固定(§8-1),因此此前用于解绑的 PATCH 被拒绝,会话
// 继续以 Y 应答;要得到默认角色,唯一的办法是创建
// 一个不带绑定的会话。
func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -128,8 +128,8 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
t.Fatalf("bound session prompt = %q, want role Y's prompt", got)
}
// The orchestrator files both the user turn and the reply under the turn's role,
// which is what makes the stored transcript readable role-by-role (design/22 §7-5).
// 编排器把用户回合与回复都归到该回合的角色名下,
// 这正是让已存转录能按角色阅读的原因(design/22 §7-5)。
msgs, err := st.List(ctx, sub, 0, 10, "")
if err != nil {
t.Fatalf("list messages: %v", err)
@@ -143,8 +143,8 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
}
}
// Unbinding is refused (design/22 §8-1): a session's role is decided when it is
// created, so this PATCH answers 400 and the very next turn still answers as Y.
// 解绑被拒绝(design/22 §8-1):会话的角色在创建时
// 决定,因此该 PATCH 返回 400,紧接着的下一个回合仍以 Y 应答。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+sub, map[string]any{"personaId": ""}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unbind status = %d, want 400 (a session's role is immutable; body=%s)", resp.StatusCode, body)
@@ -157,8 +157,8 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
t.Fatalf("prompt after the refused unbind = %q, want role Y's prompt", got)
}
// The default role is reached by a session created WITHOUT a binding — the only
// way to change role now.
// 默认角色要通过创建时不带绑定的会话才能抵达 —— 这是
// 目前改变角色的唯一方式。
unbound, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("NewSubSession: %v", err)
@@ -172,7 +172,7 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
}
}
// firstSystemText returns the text of the first system message of req.
// firstSystemText 返回 req 中首条 system 消息的文本。
func firstSystemText(req model.Request) string {
for _, m := range req.Messages {
if m.Role != model.RoleSystem {
@@ -187,12 +187,12 @@ func firstSystemText(req model.Request) string {
return ""
}
// TestSessionPersonaHTTPContract covers the REST contract of design/22 §4.5 as
// amended by §8-1: create with a personaId binds it, the session list reports it, an
// unknown personaId on create is rejected (400) instead of binding a role that cannot
// answer, a PATCH with neither title nor pinned is a 400, and PATCH REFUSES the
// personaId key for every value — a session's role is fixed at creation, so there is
// no supported rebind and the refusal can never be mistaken for a silent no-op.
// TestSessionPersonaHTTPContract 覆盖经 §8-1 修订后的 design/22 §4.5 的
// REST 契约:带 personaId 创建即绑定它、会话列表报告它、
// 创建时的未知 personaId 被拒绝(400)而不是绑定一个无法
// 应答的角色、既无 title 也无 pinned 的 PATCH 是 400,且 PATCH 对
// personaId 键的任何取值一律拒绝 —— 会话角色在创建时固定,
// 因此没有受支持的重绑,该拒绝绝不会被误认为静默无操作。
func TestSessionPersonaHTTPContract(t *testing.T) {
t.Parallel()
st := personaTestStore(t)
@@ -215,7 +215,7 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
t.Fatalf("create echoed personaId = %q, want yuki", created.PersonaID)
}
// The session list surfaces the binding (the client renders the active role from it).
// 会话列表浮现该绑定(客户端据此渲染活动角色)。
var sessions []historySessionSummary
_, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
decodeDataSlice(t, body, &sessions)
@@ -229,23 +229,23 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
t.Fatalf("listed persona_id = %q, want yuki (sessions=%+v)", found, sessions)
}
// An unknown role on create is refused rather than silently ignored.
// 创建时的未知角色被拒绝,而不是被静默忽略。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", map[string]any{"personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("create with unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// A body with none of the supported fields stays a 400.
// 不含任何受支持字段的请求体仍是 400。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// PATCH refuses the personaId key for EVERY value (design/22 §8-1): the role is
// fixed when the session is created, so a rebind — to another role, to the bound
// role, to "", or to null — is not a supported operation. Each refusal must name
// personaId (so a client can tell it from "no field given") and must leave the
// stored binding exactly as it was.
// PATCH 对 personaId 键的任何取值都拒绝(design/22 §8-1):角色在
// 会话创建时固定,因此重绑 —— 绑到另一个角色、绑到已绑
// 角色、绑到 ""、或绑到 null —— 都不是受支持的操作。每次拒绝都必须点明
// personaId(使客户端能与「未给出字段」区分),并且必须让
// 已存绑定保持原样。
for _, bad := range []any{"xilian", "yuki", "nobody", "", " ", nil} {
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": bad}, tok)
if resp.StatusCode != http.StatusBadRequest {
@@ -259,8 +259,8 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
}
}
// The refusal beats a legal field in the same body: a client cannot smuggle a
// rebind alongside a rename, and the rename must not land either.
// 该拒绝压过同一请求体中的合法字段:客户端不能借改名
// 夹带重绑,而改名也绝不能生效。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "偷偷改名", "personaId": "xilian"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("rename+personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
@@ -278,12 +278,12 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
}
}
// The personaId check runs BEFORE the session lookup: an unknown session carrying
// the key answers 400 (immutable role), not 404 — while a title-only patch for the
// very same unknown id is still the store's 404, which is what proves the 400 above
// comes from the persona rule rather than from a missing session. An empty body is
// the third corner of the same table: 400 for a missing field, again without ever
// looking the session up.
// personaId 检查在会话查找之前执行:携带该键的未知会话
// 返回 400(角色不可变),而不是 404 —— 而针对同一个未知 id
// 的纯标题补丁仍是存储的 404,这正是证明上面的 400
// 来自人格规则而非会话缺失的依据。空请求体是
// 同一张表的第三个角:字段缺失返回 400,同样
// 完全不查找会话。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"personaId": "xilian"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown session + personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
@@ -297,7 +297,7 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
t.Fatalf("unknown session + title status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// title/pinned are untouched by the refusal.
// title/pinned 不受该拒绝影响。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "新名字"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("title-only patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -307,8 +307,8 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
}
}
// personaBindingOf reads a session's bound role straight from the store, so the test
// asserts the persisted state rather than only the response echo.
// personaBindingOf 直接从存储读取会话的绑定角色,因此测试
// 断言的是持久化状态,而不只是响应回显。
func personaBindingOf(t *testing.T, st *history.Store, sessionID string) string {
t.Helper()
personaID, err := st.SessionPersona(context.Background(), sessionID)
@@ -318,10 +318,10 @@ func personaBindingOf(t *testing.T, st *history.Store, sessionID string) string
return personaID
}
// TestTurnEnvResolver covers the resolver itself (design/22 §4.5): the session's
// binding wins, an unknown/unbound session yields "" (so the loop falls back to the
// global default), and the identity/memory fields stay the owner-full ones — the
// role never changes what a turn is allowed to do.
// TestTurnEnvResolver 覆盖解析器本身(design/22 §4.5):会话的
// 绑定胜出,未知/未绑定的会话得到 ""(使 loop 回退到
// 全局默认),且身份/记忆字段保持 owner 全量 ——
// 角色绝不改变一个回合被允许做什么。
func TestTurnEnvResolver(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -352,7 +352,7 @@ func TestTurnEnvResolver(t *testing.T) {
t.Fatalf("unknown session env persona = %q, want '' (inherit the default)", got)
}
// A gateway without a history store cannot resolve a binding and must not fail.
// 没有 history 存储的网关无法解析绑定,也绝不能失败。
bare := turnEnvResolver{log: testLogger()}
if got := bare.owner(ctx, "web").PersonaID; got != "" {
t.Fatalf("no-store env persona = %q, want ''", got)
@@ -362,9 +362,9 @@ func TestTurnEnvResolver(t *testing.T) {
}
}
// TestDefaultPersonaIDFromRegistry locks the admin fallback design/22 §4.5 asks for:
// the default role is read from the library's default character instead of a
// hardcoded "cyrene", and an unwired registry reports none.
// TestDefaultPersonaIDFromRegistry 锁定 design/22 §4.5 要求的管理回退:
// 默认角色从库的默认角色读取,而不是硬编码的
// "cyrene",且未接线的注册表报告无默认。
func TestDefaultPersonaIDFromRegistry(t *testing.T) {
t.Parallel()
srv, _, _ := buildEnv(t, cfgDefault(), Deps{Personas: personaTestRegistry(t)})
+57 -57
View File
@@ -12,47 +12,47 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// This file owns the session/message write endpoints added for the Android
// collaboration batch ③a: PATCH /sessions/{id} (A4 rename + D5 pin), PATCH
// /messages/{id} (D3 edit) and DELETE /messages/{id} (D3 delete). All are owner/admin
// history writes; the reads and DTOs live in history.go. The D3 edit/delete paths also
// broadcast their outcome over the WS hub (message_edited / message_deleted, see
// ws_hub.go) so the account's other browsers stay in sync without a manual re-read.
// 本文件拥有为 Android 协作批次 ③a 新增的会话/消息写端点:
// PATCH /sessions/{id}(A4 改名 + D5 置顶)、PATCH
// /messages/{id}(D3 编辑)与 DELETE /messages/{id}(D3 删除)。它们都是 owner/admin
// 历史写;读与 DTO 位于 history.go。D3 的编辑/删除路径还会
// 经 WS hub 广播其结果(message_edited / message_deleted,见
// ws_hub.go),使该账号的其他浏览器无需手动重读即保持同步。
// patchSessionRequest is the PATCH /api/v1/sessions/{id} body. Both fields are
// pointers so an absent field is distinguishable from an explicit zero value:
// title renames the session (A4), pinned sets/unets the pin (D5), and at least one
// must be present.
// patchSessionRequest 是 PATCH /api/v1/sessions/{id} 的请求体。两个字段都是
// 指针,因此字段缺失与显式零值可区分:title 重命名该
// 会话(A4),pinned 设置/取消置顶(D5),且至少一个
// 必须出现。
//
// personaId is deliberately NOT a settable field: a session's role is fixed when the
// session is created (design/22 §8-1), so the key is rejected outright instead of
// being ignored. It stays in the struct as the raw JSON so the handler can tell
// "the key is absent" from "the key is present with any value" — `"yuki"`, `""` and
// `null` are all refused, and only a body without the key is accepted.
// personaId 刻意不是可设置字段:会话角色在会话创建时
// 固定(design/22 §8-1),因此该键会被直接拒绝,而不是
// 被忽略。它以原始 JSON 留在结构体中,使 handler 能区分
// 「键缺失」与「键存在且为任意值」—— `"yuki"`、`""` 与
// `null` 一律被拒,只有不含该键的请求体才被接受。
type patchSessionRequest struct {
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
PersonaID json.RawMessage `json:"personaId"`
}
// maxSessionTitleRunes bounds a client-supplied session title so a display row never
// overflows. It is a display-only bound: identity and permissions never derive from
// the title (project red line).
// maxSessionTitleRunes 限定客户端提供的会话标题,使显示行绝不
// 溢出。它只是显示边界:身份与权限绝不派生自
// 标题(项目红线)。
const maxSessionTitleRunes = 80
// handlePatchSession updates a persistent session's display metadata — its title
// (A4) and its pinned flag (D5). Both are metadata: unlike DELETE they are allowed
// on the fixed main session. A request carrying neither field answers 400, as do an
// empty/whitespace or over-long title and an unknown session id (404/500 as usual).
// handlePatchSession 更新持久化会话的显示元数据 —— 其标题
// (A4)与置顶标志(D5)。二者都是元数据:与 DELETE 不同,它们
// 允许作用于固定的主会话。两个字段都不携带的请求返回 400,
// 空/仅空白或过长的标题亦然,未知会话 id(照常 404/500)。
//
// A body carrying the personaId key answers 400 for EVERY value, and that check runs
// before the session is looked up: a session's role is fixed at creation (design/22
// §8-1), so switching the role of an existing conversation — including clearing it —
// is not a supported operation; create a new session instead. Refusing explicitly
// (rather than ignoring the key) is what keeps a client from believing a rebind
// landed. It is owner/admin-gated like the other history writes. The response echoes
// only the fields that were changed, so a title-only PATCH keeps its pre-D5
// {id,title} shape.
// 携带 personaId 键的请求体对任何取值都返回 400,且该检查
// 在会话被查找之前执行:会话角色在创建时固定(design/22
// §8-1),因此切换既有对话的角色 —— 包括清除它 ——
// 不是受支持的操作;请改为新建会话。显式拒绝
// (而不是忽略该键)正是让客户端不会误以为重绑
// 生效的原因。它与其他历史写一样受 owner/admin 门控。响应只
// 回显被改动的字段,因此纯标题 PATCH 保持其 D5 之前的
// {id,title} 形状。
func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -104,8 +104,8 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// writeSessionPatchError maps a RenameSession/SetPinned failure to its response:
// ErrSessionNotFound → 404, anything else → 500 (logged under op).
// writeSessionPatchError 把 RenameSession/SetPinned 的失败映射为其响应:
// ErrSessionNotFound → 404,其余 → 500(在 op 下记日志)。
func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op string) {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
@@ -115,21 +115,21 @@ func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to update session"))
}
// patchMessageRequest is the PATCH /api/v1/messages/{id} body. Content is a pointer so
// an absent field is rejected rather than treated as an empty edit.
// patchMessageRequest 是 PATCH /api/v1/messages/{id} 的请求体。Content 是指针,
// 因此字段缺失会被拒绝,而不是被当作空编辑。
type patchMessageRequest struct {
Content *string `json:"content"`
}
// maxMessageContentRunes bounds an edited message body. It is a storage/display bound
// on the edit path only; inbound chat messages are not subject to it.
// maxMessageContentRunes 限定被编辑的消息体。它只是编辑路径上的
// 存储/显示边界;入站聊天消息不受其约束。
const maxMessageContentRunes = 4000
// handlePatchMessage edits a single message's content (Android collaboration batch ③a,
// D3). Only content changes — role/timestamp/platform stay as stored — and the response
// returns the updated message so the client can upsert it by id without a re-read. An
// empty/whitespace or over-long content answers 400, an unknown id answers 404, and a
// missing HistoryStore answers 501. It is owner/admin-gated.
// handlePatchMessage 编辑单条消息的内容(Android 协作批次 ③a,
// D3)。只有 content 会变 —— role/时间戳/platform 保持已存值 —— 响应
// 返回更新后的消息,使客户端能按 id upsert 而无需重读。
// 空/仅空白或过长的 content 返回 400,未知 id 返回 404,
// HistoryStore 缺失返回 501。它受 owner/admin 门控。
func (s *Server) handlePatchMessage(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -168,20 +168,20 @@ func (s *Server) handlePatchMessage(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "message_failed", "failed to update message"))
return
}
// Broadcast the edit so every other browser of the account re-renders the same
// bubble without a refresh (the message_persisted realtime-sync contract, D3 edit
// path). Thinking is stripped from the frame to match the persisted/replay
// contract; the REST reply keeps it because the messages routes are owner/admin-only.
// 广播该编辑,使该账号的其他每个浏览器无需刷新就重渲染同一个
// 气泡(message_persisted 实时同步契约,D3 编辑
// 路径)。thinking 会从帧中剥掉,以匹配持久化/重放
// 契约;REST 回复保留它,因为消息路由仅 owner/admin。
edited := toHistoryMessage(msg)
edited.Thinking = ""
s.hub.broadcastMessageEdited(edited)
writeJSON(w, http.StatusOK, toHistoryMessage(msg))
}
// handleDeleteMessage removes a single message (D3). An unknown id answers 404, a
// missing HistoryStore 501. It is owner/admin-gated. A successful delete is
// broadcast as a `message_deleted` frame so every browser of the account drops the
// row without a refresh; the owning session is read before the row disappears.
// handleDeleteMessage 移除单条消息(D3)。未知 id 返回 404,
// HistoryStore 缺失返回 501。它受 owner/admin 门控。成功的删除会
// 以 `message_deleted` 帧广播,使该账号的每个浏览器无需刷新就丢掉
// 该行;所属会话在该行消失前被读取。
func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -206,18 +206,18 @@ func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// messageLookup is the optional narrower view of HistoryStore used by DELETE to learn
// a message's owning session before it is removed (afterwards there is nothing left to
// ask). *history.Store implements it; a store that does not simply yields an unscoped
// frame, which the client resolves by the globally unique message id.
// messageLookup 是 HistoryStore 可选的更窄视图,DELETE 用它
// 在移除前得知消息所属的会话(之后就没有可问的了)。
// *history.Store 实现了它;未实现的存储只是产出一个无作用域
// 的帧,客户端凭全局唯一的消息 id 解析它。
type messageLookup interface {
GetMessage(ctx context.Context, id string) (history.Message, error)
}
// messageSession returns the session id owning message id, or "" when the store cannot
// answer (no lookup surface, unknown id, or a read error). A blank session is safe for
// the broadcast: the message id is globally unique, so a client that falls back to an
// id-only filter can only ever touch that message's own conversation.
// messageSession 返回拥有 message id 的会话 id,存储无法作答时
// 返回 ""(无查找表面、未知 id 或读错误)。空白会话
// 对广播是安全的:消息 id 全局唯一,因此回退到
// 纯 id 过滤的客户端只可能触及该消息自己的对话。
func (s *Server) messageSession(ctx context.Context, id string) string {
lookup, ok := s.history.(messageLookup)
if !ok {
+17 -17
View File
@@ -6,17 +6,17 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
)
// This file owns the read-only client metadata surface: GET /api/v1/meta/enums,
// which publishes the authoritative platform/mood/mode tables (Android
// collaboration B4, design/07 §6). Clients (Web, Android, an IM bridge) read the
// tables instead of hardcoding their own id → label mapping, so a source badge or
// a mood label cannot drift per client. The tables themselves live in
// internal/contract/enums; this handler only serves them, so there is one source
// of truth rather than a copy per consumer.
// 本文件拥有只读的客户端元数据表面:GET /api/v1/meta/enums,
// 它发布权威的 platform/mood/mode 表(Android
// 协作 B4,design/07 §6)。客户端(Web、Android、IM bridge)读这些
// 表,而不是各自硬编码 id → 标签映射,因此来源徽标或
// 情绪标签不会逐客户端漂移。这些表本身位于
// internal/contract/enums;本 handler 只负责提供它们,因此只有
// 一个事实来源,而不是每个消费方一份副本。
// metaEnums is the GET /api/v1/meta/enums payload. `unknown` carries the
// machine-readable fallback policy (enums.UnknownFallback) so a client reads the
// tables and the rule for an unlisted value from the same response.
// metaEnums 是 GET /api/v1/meta/enums 的载荷。`unknown` 携带
// 机器可读的回退策略(enums.UnknownFallback),使客户端从同一
// 响应中读到表以及对未列出取值的规则。
type metaEnums struct {
Platforms []enums.Item `json:"platforms"`
Moods []enums.Item `json:"moods"`
@@ -24,14 +24,14 @@ type metaEnums struct {
Unknown string `json:"unknown"`
}
// handleListEnums serves the shared client enum tables. It is a pure projection
// of the Go-side authority (no store, no error path) and contains nothing
// sensitive, so it is gated like GET /api/v1/sessions: any authenticated role may
// read it.
// handleListEnums 提供共享的客户端枚举表。它是 Go 侧权威的
// 纯投影(无存储、无错误路径),不含任何
// 敏感内容,因此像 GET /api/v1/sessions 一样门控:任何已认证角色都可以
// 读取它。
//
// Adding a value to a table is a non-breaking change for existing clients, which
// must display an unlisted value raw (see enums.UnknownFallback) rather than
// dropping it or erroring.
// 向表中新增取值对既有客户端是非破坏性变更,客户端
// 必须原样展示未列出的取值(见 enums.UnknownFallback),而不是
// 丢弃它或报错。
func (s *Server) handleListEnums(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, metaEnums{
Platforms: enums.Platforms(),
+16 -16
View File
@@ -9,21 +9,21 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// These tests cover the shared client enum endpoint (Android collaboration B4,
// design/07 §6): the wire shape of GET /api/v1/meta/enums, the values it
// publishes, and the fact that it is readable by any authenticated role. They
// assert the JSON contract with literals rather than by comparing against
// internal/contract/enums, so the client-facing shape is pinned independently of
// the Go table (the table's own drift tests live in that package).
// 这些测试覆盖共享的客户端枚举端点(Android 协作 B4,
// design/07 §6):GET /api/v1/meta/enums 的线上结构、它发布的
// 取值,以及任何已认证角色都可读这一点。它们
// 用字面量断言 JSON 契约,而不是与
// internal/contract/enums 对比,因此面向客户端的形状独立于
// Go 表被钉住(表自身的漂移测试在该包内)。
// enumEntry is the wire shape of one published enum item.
// enumEntry 是一个已发布枚举项的线上结构。
type enumEntry struct {
ID string `json:"id"`
Label string `json:"label"`
}
// metaEnumsWire decodes GET /api/v1/meta/enums and asserts the envelope and the
// per-item shape, returning the ids of each table for value assertions.
// metaEnumsWire 解码 GET /api/v1/meta/enums 并断言信封与
// 逐项形状,返回每个表的 id 供取值断言。
func metaEnumsWire(t *testing.T, ts *httptest.Server, tok string) (platforms, moods, modes []string, unknown string) {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/meta/enums", nil, tok)
@@ -67,9 +67,9 @@ func entryIDs(items []enumEntry) []string {
return out
}
// TestMetaEnumsShape pins the published contract: the three tables name the real
// platform/mood/mode values and the response carries the unknown-value fallback
// policy.
// TestMetaEnumsShape 钉住已发布的契约:三张表列出真实的
// platform/mood/mode 取值,且响应携带未知取值回退
// 策略。
func TestMetaEnumsShape(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
@@ -94,9 +94,9 @@ func TestMetaEnumsShape(t *testing.T) {
}
}
// TestMetaEnumsAnyAuthenticatedRole asserts the endpoint is a read-only,
// non-sensitive table: every role may read it, and anonymous callers get 401
// rather than the data.
// TestMetaEnumsAnyAuthenticatedRole 断言该端点是只读、非敏感的
// 表:每个角色都可读它,匿名调用方得到 401
// 而不是数据。
func TestMetaEnumsAnyAuthenticatedRole(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
@@ -117,7 +117,7 @@ func TestMetaEnumsAnyAuthenticatedRole(t *testing.T) {
wantErrorShape(t, body)
}
// sameStringSet reports whether got and want hold the same values, ignoring order.
// sameStringSet 报告 got 与 want 是否持有相同取值(忽略顺序)。
func sameStringSet(got, want []string) bool {
if len(got) != len(want) {
return false
+14 -14
View File
@@ -10,9 +10,9 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// cors sets the Access-Control-* headers for origins in the configured allow
// list and short-circuits OPTIONS preflight requests. An empty origin (same-origin
// or non-browser client) is passed through untouched.
// cors 为已配置允许列表中的来源设置 Access-Control-* 头,并
// 短路 OPTIONS 预检请求。空来源(同源或
// 非浏览器客户端)原样放行。
func (s *Server) cors(next http.Handler) http.Handler {
allowed, allowAll := s.allowedOrigins()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -36,8 +36,8 @@ func (s *Server) cors(next http.Handler) http.Handler {
})
}
// allowedOrigins resolves the configured origin list into a lookup set plus an
// allow-all flag for the "*" wildcard.
// allowedOrigins 把已配置的来源列表解析成查找集合,外加针对
// "*" 通配符的允许全部标志。
func (s *Server) allowedOrigins() (map[string]bool, bool) {
list := s.cfg.AllowedOrigins
if len(list) == 0 {
@@ -55,8 +55,8 @@ func (s *Server) allowedOrigins() (map[string]bool, bool) {
return out, allowAll
}
// recover converts a panic in a downstream handler into a 500 {error:...}
// response instead of crashing the process, and logs the panic.
// recover 把下游 handler 中的 panic 转成 500 {error:...}
// 响应,而不是让进程崩溃,并记录该 panic。
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
@@ -69,7 +69,7 @@ func (s *Server) recover(next http.Handler) http.Handler {
})
}
// logging records one structured line per request (method, path, status, dur).
// logging 为每个请求记录一行结构化日志(method、path、status、dur)。
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
@@ -86,21 +86,21 @@ func (s *Server) logging(next http.Handler) http.Handler {
})
}
// statusRecorder captures the written status so the logging middleware can report
// it. It forwards the optional Hijacker/Flusher interfaces so the WebSocket
// upgrader (which requires http.Hijacker) and streaming handlers still work.
// statusRecorder 捕获写入的状态,使 logging 中间件能报告
// 它。它转发可选的 Hijacker/Flusher 接口,因此 WebSocket
// 升级器(要求 http.Hijacker)与流式 handler 仍能工作。
type statusRecorder struct {
http.ResponseWriter
status int
}
// WriteHeader records the status before delegating.
// WriteHeader 在委托之前记录状态。
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// Hijack forwards to the underlying writer when it supports connection hijacking.
// Hijack 在底层 writer 支持连接劫持时转发给它。
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := r.ResponseWriter.(http.Hijacker); ok {
return hj.Hijack()
@@ -108,7 +108,7 @@ func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, fmt.Errorf("gateway: hijack unsupported by %T", r.ResponseWriter)
}
// Flush forwards to the underlying writer when it supports flushing.
// Flush 在底层 writer 支持 flush 时转发给它。
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
+71 -71
View File
@@ -14,54 +14,54 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// Notification feed limits (design/10 §3 #16). Compile-time constants so no bare
// magic number appears in the run path (project rule §10).
// 通知 feed 的限制(design/10 §3 #16)。编译期常量,因此运行路径中
// 不出现裸魔法数字(项目规范 §10)。
const (
defaultNotificationLimit = 50
maxNotificationLimit = 200
// notificationFetchFactor widens the per-source read. The merged page must be
// the newest N of the union, which the per-source newest-N always contains,
// but with a `before` cursor one source can dominate the newest window and
// starve the other — the factor pays for that.
// notificationFetchFactor 加宽每来源的读取。合并页必须是并集的
// 最新 N 条,而每来源的最新 N 条总是包含它,
// 但在带 `before` 游标时某一来源可能占据最新窗口并
// 饿死另一来源 —— 该系数就是为此付出的代价。
notificationFetchFactor = 4
// notificationBodyRunes caps a backthink summary in the feed; the full text
// stays available on GET /api/v1/admin/backthink.
// notificationBodyRunes 限定 feed 中后台思考摘要的长度;全文
// 仍可在 GET /api/v1/admin/backthink 获取。
notificationBodyRunes = 200
)
// The unified feed's families, used both as the item `type` and as the `?type=`
// filter. They are derived from the sources the composition root already has
// (design/10 §3 #16: 先聚合现有源为统一 feed,避免新建重模型).
// 统一 feed 的族,既用作条目的 `type`,也用作 `?type=`
// 过滤器。它们派生自组合根已有的来源
// (design/10 §3 #16:先聚合现有源为统一 feed,避免新建重模型)。
const (
// NotificationTypeSystem is a system/security operation: backup & restore,
// JWT rotation, API-key revocation, plugin install/uninstall, tool policy.
// NotificationTypeSystem 是系统/安全操作:备份与恢复、
// JWT 轮换、API key 吊销、插件安装/卸载、工具策略。
NotificationTypeSystem = "system"
// NotificationTypeChannel is a channel lifecycle or connection event
// (channel.open / close / save / reject / login).
// NotificationTypeChannel 是渠道生命周期或连接事件
// (channel.open / close / save / reject / login)。
NotificationTypeChannel = "channel"
// NotificationTypeBackthink is one proactive push the background thinker
// decided to send.
// NotificationTypeBackthink 是后台思考器决定发送的一次
// 主动推送。
NotificationTypeBackthink = "backthink"
// NotificationTypeAudit is the remaining admin audit trail: personas, memory,
// users, RAG, sessions, uploads, audio.
// NotificationTypeAudit 是剩余的管理审计链:人格、记忆、
// 用户、RAG、会话、上传、音频。
NotificationTypeAudit = "audit"
)
// Provenance labels carried on an item's `source` field.
// 条目 `source` 字段上携带的来源标签。
const (
notificationSourceAudit = "audit"
notificationSourceBackthink = "backthink"
// notificationIDPrefixAudit / ...Backthink namespace the source's own record id
// so the merged feed has stable, collision-free, client-dedupable ids.
// notificationIDPrefixAudit / ...Backthink 为来源自身的记录 id 加命名空间,
// 使合并 feed 的 id 稳定、无冲突、客户端可去重。
notificationIDPrefixAudit = "audit:"
notificationIDPrefixBackthink = "backthink:"
)
// AdminNotification is one item of the unified notification feed (design/10 §3
// #16). It is a read-only projection of an existing record — audit rows and
// backthink records — never a new persisted model: `id` is "<source>:<原生 id>",
// `title`/`body` are derived from the source record, and `createdAt` is epoch
// milliseconds.
// AdminNotification 是统一通知 feed 的一个条目(design/10 §3
// #16)。它是既有记录 —— 审计行与后台思考记录 —— 的只读
// 投影,绝不是新的持久化模型:`id` 是 "<source>:<原生 id>",
// `title`/`body` 派生自来源记录,`createdAt` 是 epoch
// 毫秒。
type AdminNotification struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -71,13 +71,13 @@ type AdminNotification struct {
CreatedAt int64 `json:"createdAt"`
}
// adminListNotifications returns the unified notification feed (owner/admin
// read): the admin audit trail and the background thinker's proactive pushes,
// merged newest-first and filterable by family and by a `before` cursor.
// adminListNotifications 返回统一通知 feed(owner/admin
// 读):管理审计链与后台思考器的主动推送,
// 最新优先合并,可按族与 `before` 游标过滤。
//
// A source that is not injected contributes no items — the feed reports what
// actually exists instead of fabricating a family — so a deployment without the
// backthink service simply has no `backthink` items.
// 未注入的来源不贡献任何条目 —— feed 报告确实存在的
// 内容,而不是编造一个族 —— 因此没有后台思考服务的
// 部署就是没有 `backthink` 条目。
func (s *Server) adminListNotifications(w http.ResponseWriter, r *http.Request) {
limit := parseNotificationLimit(r)
typ := strings.TrimSpace(r.URL.Query().Get("type"))
@@ -112,9 +112,9 @@ func (s *Server) adminListNotifications(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out)
}
// notificationItems reads both sources and returns their merge, newest first. A
// source failure is returned (never swallowed into a silently shorter feed), so
// the endpoint answers a clear error instead of pretending there was no event.
// notificationItems 读取两个来源并返回它们的合并结果,最新优先。
// 来源失败会被返回(绝不被吞进静默变短的 feed),因此
// 端点返回明确的错误,而不是假装没有事件。
func (s *Server) notificationItems(ctx context.Context, fetch int) ([]AdminNotification, error) {
auditItems, err := s.notificationsFromAudit(ctx, fetch)
if err != nil {
@@ -129,17 +129,17 @@ func (s *Server) notificationItems(ctx context.Context, fetch int) ([]AdminNotif
if items[i].CreatedAt != items[j].CreatedAt {
return items[i].CreatedAt > items[j].CreatedAt
}
// Descending id keeps the order deterministic for items sharing a
// millisecond (the cursor can split one millisecond across pages).
// id 降序为共享同一毫秒的条目保持确定性顺序
// (游标可能把一毫秒拆到两页)。
return items[i].ID > items[j].ID
})
return items, nil
}
// notificationsFromAudit projects the admin audit trail (audit_logs, persisted
// by NewSQLiteAuditStore) into feed items. The fallback in-memory store holds
// only records really appended in this process, so an unwired store yields an
// empty family rather than fabricated entries.
// notificationsFromAudit 把管理审计链(audit_logs,由
// NewSQLiteAuditStore 持久化)投影为 feed 条目。回退的内存存储
// 只保留本进程真正追加过的记录,因此未接线的存储得到
// 空族,而不是编造条目。
func (s *Server) notificationsFromAudit(ctx context.Context, fetch int) ([]AdminNotification, error) {
if s.obs == nil || s.obs.audit == nil {
return nil, nil
@@ -162,10 +162,10 @@ func (s *Server) notificationsFromAudit(ctx context.Context, fetch int) ([]Admin
return out, nil
}
// notificationsFromBackthink projects the background thinker's records into feed
// items. Only records that decided to push are notifications (a plain thinking
// record is not an event the owner needs to be told about); each carries the
// delivery state so the feed never claims an undelivered push was sent.
// notificationsFromBackthink 把后台思考器的记录投影为 feed
// 条目。只有决定推送的记录才是通知(普通思考
// 记录不是需要告知主人的事件);每条都携带
// 投递状态,因此 feed 绝不声称未投递的推送已发出。
func (s *Server) notificationsFromBackthink(ctx context.Context, fetch int) ([]AdminNotification, error) {
if s.backthink == nil {
return nil, nil
@@ -191,9 +191,9 @@ func (s *Server) notificationsFromBackthink(ctx context.Context, fetch int) ([]A
return out, nil
}
// notificationTypeOfAudit classifies an audit operation into a feed family: the
// channel lifecycle is `channel`, and the system/plugin/policy operations are
// `system`; everything else stays `audit`.
// notificationTypeOfAudit 把审计操作归类到 feed 族:
// 渠道生命周期是 `channel`,系统/插件/策略操作是
// `system`;其余保持 `audit`。
func notificationTypeOfAudit(what string) string {
switch {
case strings.HasPrefix(what, "channel."):
@@ -207,8 +207,8 @@ func notificationTypeOfAudit(what string) string {
}
}
// notificationAuditBody derives an audit item's body from its who/what/result
// trail, e.g. "owner:owner · ok".
// notificationAuditBody 从审计条目的 who/what/result
// 链推导其 body,例如 "owner:owner · ok"。
func notificationAuditBody(rec AuditRecord) string {
parts := make([]string, 0, 2)
if who := strings.TrimSpace(rec.Who); who != "" {
@@ -220,9 +220,9 @@ func notificationAuditBody(rec AuditRecord) string {
return strings.Join(parts, " · ")
}
// notificationAuditAt parses an audit record's RFC3339 timestamp into epoch
// milliseconds. An unparseable timestamp yields 0: the item still appears (oldest
// first) instead of being dropped or stamped with a made-up time.
// notificationAuditAt 把审计记录的 RFC3339 时间戳解析为 epoch
// 毫秒。无法解析的时间戳得到 0:条目仍然出现(最旧
// 优先),而不是被丢弃或被盖上一个编造的时间。
func notificationAuditAt(when string) int64 {
ts, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(when))
if err != nil {
@@ -231,9 +231,9 @@ func notificationAuditAt(when string) int64 {
return ts.UnixMilli()
}
// backthinkNotificationTitle labels one proactive push with its trigger and
// delivery state, so the feed distinguishes "sent" from "decided but not
// delivered" (route none / no online client / push failure).
// backthinkNotificationTitle 用触发原因与投递状态标注一条主动
// 推送,使 feed 区分「已发送」与「已决定但未
// 投递」(route none / 无在线客户端 / 推送失败)。
func backthinkNotificationTitle(rec backthink.Record) string {
state := "未送达"
if rec.PushResult.Pushed {
@@ -245,8 +245,8 @@ func backthinkNotificationTitle(rec backthink.Record) string {
return "主动推送(" + state + ")"
}
// clipRunes truncates s to at most max runes, appending an ellipsis when it was
// cut. It never splits a multi-byte rune.
// clipRunes 把 s 截断到至多 max 个 rune,被截断时追加省略号。
// 它绝不切开多字节 rune。
func clipRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
@@ -255,9 +255,9 @@ func clipRunes(s string, max int) string {
return string(runes[:max]) + "…"
}
// parseNotificationLimit parses the ?limit= query param, clamping to
// [1, maxNotificationLimit] and selecting defaultNotificationLimit when absent or
// invalid.
// parseNotificationLimit 解析 ?limit= 查询参数,夹取到
// [1, maxNotificationLimit],缺失或非法时选用
// defaultNotificationLimit 作为默认值。
func parseNotificationLimit(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 {
@@ -269,11 +269,11 @@ func parseNotificationLimit(r *http.Request) int {
return n
}
// parseNotificationBefore parses the ?before= epoch-millisecond cursor. The
// cursor is inclusive (items with createdAt <= before are returned): the sources
// resolve to milliseconds, so a page boundary can fall inside one millisecond,
// and the client deduplicates by id. An absent or malformed cursor means "from
// the newest item".
// parseNotificationBefore 解析 ?before= epoch 毫秒游标。该
// 游标是包含式的(返回 createdAt <= before 的条目):来源
// 解析到毫秒,因此页边界可能落在一毫秒之内,
// 而客户端按 id 去重。游标缺失或畸形意味着「从
// 最新条目开始」。
func parseNotificationBefore(r *http.Request) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get("before"))
if raw == "" {
@@ -286,8 +286,8 @@ func parseNotificationBefore(r *http.Request) (int64, bool) {
return ms, true
}
// notificationFetchLimit widens a page request into the per-source read window,
// capped at maxObsLimit (the audit store's own ceiling) and never below the page.
// notificationFetchLimit 把页请求加宽为每来源读取窗口,
// 上限为 maxObsLimit(审计存储自身的上限),且绝不低于该页。
func notificationFetchLimit(limit int) int {
fetch := limit * notificationFetchFactor
if fetch > maxObsLimit {
@@ -299,8 +299,8 @@ func notificationFetchLimit(limit int) int {
return fetch
}
// validNotificationType reports whether t names a feed family. An unknown family
// is rejected with 400 rather than answered with a silently empty feed.
// validNotificationType 报告 t 是否是一个 feed 族名。未知族
// 以 400 拒绝,而不是以静默为空的 feed 作答。
func validNotificationType(t string) bool {
switch t {
case NotificationTypeSystem, NotificationTypeChannel, NotificationTypeBackthink, NotificationTypeAudit:
+37 -37
View File
@@ -14,9 +14,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// stubAuditFeed is an AuditStore over scripted records (already newest first),
// so the feed tests control millisecond timestamps exactly — the in-memory
// fallback store only keeps second precision.
// stubAuditFeed 是一个基于脚本化记录的 AuditStore(已按最新优先),
// 使 feed 测试能精确控制毫秒时间戳 —— 内存
// 回退存储只保留秒级精度。
type stubAuditFeed struct {
records []AuditRecord
err error
@@ -34,7 +34,7 @@ func (s *stubAuditFeed) List(_ context.Context, limit int) ([]AuditRecord, error
return append([]AuditRecord(nil), s.records[:limit]...), nil
}
// stubBackthinkFeed is a BackthinkStore over scripted records (newest first).
// stubBackthinkFeed 是一个基于脚本化记录的 BackthinkStore(最新优先)。
type stubBackthinkFeed struct {
records []backthink.Record
err error
@@ -50,15 +50,15 @@ func (s *stubBackthinkFeed) List(_ context.Context, limit int) ([]backthink.Reco
return append([]backthink.Record(nil), s.records[:limit]...), nil
}
// notificationMS renders epoch milliseconds as the RFC3339 timestamp an audit
// record carries.
// notificationMS 把 epoch 毫秒渲染成审计
// 记录所携带的 RFC3339 时间戳。
func notificationMS(ms int64) string {
return time.UnixMilli(ms).UTC().Format(time.RFC3339Nano)
}
// notificationFeedFixture scripts both sources: channel + system + audit rows in
// the audit trail and one delivered / one undelivered / one non-push backthink
// record.
// notificationFeedFixture 脚本化两个来源:审计链中的
// channel + system + audit 行,以及一条已投递 / 一条未投递 / 一条非推送的
// 后台思考记录。
func notificationFeedFixture() (*stubAuditFeed, *stubBackthinkFeed) {
audit := &stubAuditFeed{records: []AuditRecord{
{ID: 5, Who: "owner:owner", What: "channel.open", When: notificationMS(3000), Result: "ok"},
@@ -76,7 +76,7 @@ func notificationFeedFixture() (*stubAuditFeed, *stubBackthinkFeed) {
return audit, bt
}
// listNotifications calls the endpoint and decodes the feed.
// listNotifications 调用该端点并解码 feed。
func listNotifications(t *testing.T, ts *httptest.Server, tok, query string) (int, []AdminNotification, []byte) {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/notifications"+query, nil, tok)
@@ -90,10 +90,10 @@ func listNotifications(t *testing.T, ts *httptest.Server, tok, query string) (in
return resp.StatusCode, out, body
}
// TestAdminListNotifications_MergesAuditAndBackthink proves the feed is the two
// existing sources merged newest-first with source-namespaced ids, the audit
// family classification, and that a backthink record which decided not to push is
// not an event.
// TestAdminListNotifications_MergesAuditAndBackthink 证明 feed 是两个
// 既有来源按最新优先合并、id 带来源命名空间、审计
// 族分类正确,以及决定不推送的后台思考记录
// 不是事件。
func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
@@ -134,8 +134,8 @@ func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) {
t.Fatalf("backthink title = %q, want the delivery state", got.Title)
}
}
// The undelivered push is older and its conclusion is long: both the body cap
// and the "未送达" label must show.
// 未投递的推送更旧且其结论很长:body 上限
// 与「未送达」标签都必须体现。
for _, it := range items {
if it.ID == "backthink:6" {
if !strings.Contains(it.Title, "未送达") {
@@ -146,7 +146,7 @@ func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) {
}
}
}
// A thinking record that decided not to push never becomes a notification.
// 决定不推送的思考记录绝不成为通知。
for _, it := range items {
if it.ID == "backthink:7" {
t.Fatalf("non-push backthink record leaked into the feed: %+v", it)
@@ -154,9 +154,9 @@ func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) {
}
}
// TestAdminListNotifications_TypeFilter proves `?type=` selects one family and
// that a family without any source data answers an empty feed (never a fabricated
// row).
// TestAdminListNotifications_TypeFilter 证明 `?type=` 选中一个族,
// 且没有任何来源数据的族返回空 feed(绝不编造
// 一行)。
func TestAdminListNotifications_TypeFilter(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
@@ -187,7 +187,7 @@ func TestAdminListNotifications_TypeFilter(t *testing.T) {
}
}
// An empty source (no backthink service injected) yields an empty family.
// 空来源(未注入后台思考服务)得到空族。
_, auditOnly, jwtOnly := buildEnv(t, nil, Deps{AuditStore: audit})
tokAudit := signAny(t, jwtOnly, "acct", auth.RoleOwner)
status, items, body := listNotifications(t, auditOnly, tokAudit, "?type=backthink")
@@ -196,9 +196,9 @@ func TestAdminListNotifications_TypeFilter(t *testing.T) {
}
}
// TestAdminListNotifications_BeforeCursorPagination proves the `before` cursor:
// pages walk older, the cursor item itself is included (the sources resolve to
// milliseconds and the client dedupes by id), and `limit` caps a page.
// TestAdminListNotifications_BeforeCursorPagination 证明 `before` 游标:
// 翻页向更旧走,游标条目本身被包含(来源解析到
// 毫秒,客户端按 id 去重),且 `limit` 限定一页。
func TestAdminListNotifications_BeforeCursorPagination(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
@@ -229,16 +229,16 @@ func TestAdminListNotifications_BeforeCursorPagination(t *testing.T) {
t.Fatalf("page3 = %+v, want [backthink:8 audit:3]", page3)
}
// A cursor older than every item ends the walk with an empty page.
// 比所有条目都旧的游标以空页结束翻页。
status, tail, body := listNotifications(t, ts, tok, "?before=1")
if status != http.StatusOK || len(tail) != 0 {
t.Fatalf("tail = %+v / status %d, want empty / 200 (body=%s)", tail, status, body)
}
}
// TestAdminListNotifications_NoSourcesNoFabrication proves the honest empty feed:
// with neither source injected the endpoint returns an empty array, not invented
// notices.
// TestAdminListNotifications_NoSourcesNoFabrication 证明诚实的空 feed:
// 两个来源都未注入时端点返回空数组,而不是编造
// 通知。
func TestAdminListNotifications_NoSourcesNoFabrication(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, nil, Deps{})
@@ -256,9 +256,9 @@ func TestAdminListNotifications_NoSourcesNoFabrication(t *testing.T) {
}
}
// TestAdminListNotifications_Errors proves the endpoint fails loudly: an unknown
// family is a 400 and a failing source is a 500 (never a silently shortened
// feed). A viewer reads the feed (read-only monitoring tier).
// TestAdminListNotifications_Errors 证明该端点响亮地失败:未知
// 族是 400,来源失败是 500(绝不是静默变短的
// feed)。viewer 可以读该 feed(只读监控层)。
func TestAdminListNotifications_Errors(t *testing.T) {
t.Parallel()
audit, bt := notificationFeedFixture()
@@ -283,10 +283,10 @@ func TestAdminListNotifications_Errors(t *testing.T) {
}
}
// TestNotificationAuditAt_ParsesBothStoreFormats locks the timestamp contract with
// the two real audit stores: SQLiteAuditStore writes RFC3339Nano, the in-memory
// fallback writes RFC3339, and an unparseable value degrades to 0 (oldest) rather
// than being dropped or faked.
// TestNotificationAuditAt_ParsesBothStoreFormats 用两个真实审计存储锁定
// 时间戳契约:SQLiteAuditStore 写 RFC3339Nano,内存
// 回退写 RFC3339,无法解析的值退化到 0(最旧),而不是
// 被丢弃或伪造。
func TestNotificationAuditAt_ParsesBothStoreFormats(t *testing.T) {
t.Parallel()
base := time.Date(2026, 9, 30, 10, 0, 0, 123456000, time.UTC)
@@ -307,8 +307,8 @@ func TestNotificationAuditAt_ParsesBothStoreFormats(t *testing.T) {
}
}
// TestNotificationQueryParsing locks the query knobs: the limit defaults to 50 and
// clamps at 200, and an absent or malformed `before` means "from newest".
// TestNotificationQueryParsing 锁定查询旋钮:limit 默认 50,
// 上限 200,且 `before` 缺失或畸形意味着「从最新开始」。
func TestNotificationQueryParsing(t *testing.T) {
t.Parallel()
limitCases := []struct {
+25 -25
View File
@@ -11,29 +11,29 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Default list limits shared by the trace/audit endpoints. Compile-time constants
// so no magic number appears in the run path (project rule §10).
// trace/audit 端点共享的默认列表上限。编译期常量,
// 因此运行路径中不出现魔法数字(项目规范 §10)。
const (
defaultObsLimit = 50
maxObsLimit = 500
)
// observability bundles the gateway's live admin instrumentation: the request
// metrics counter, the trace span ring buffer, the audit store and the
// process-wide log ring buffer. It is built in New and is safe for concurrent
// use.
// observability 打包网关的实时管理仪表:请求
// 指标计数器、trace span 环形缓冲、审计存储与
// 进程级日志环形缓冲。它在 New 中构建,且对并发
// 使用安全。
type observability struct {
metrics *MetricsCollector
traceBuf *RingBuffer
audit AuditStore
// logBuf is the logger's log ring buffer behind the admin log-tail endpoint.
// The composition root injects it via SetLogBuffer once the gateway exists;
// until then the endpoint answers 501 rather than an empty-but-fake tail.
// logBuf 是管理日志尾部端点背后的 logger 日志环形缓冲。
// 组合根在网关存在后经 SetLogBuffer 注入它;在那之前
// 该端点返回 501,而不是空但虚假的尾部。
logBuf atomic.Pointer[logger.RingBuffer]
}
// newObservability builds the instrumentation, falling back to an in-memory audit
// store when no store is injected.
// newObservability 构建仪表,未注入存储时回退到内存审计
// 存储。
func newObservability(deps Deps) *observability {
obs := &observability{
metrics: newMetricsCollector(),
@@ -47,10 +47,10 @@ func newObservability(deps Deps) *observability {
return obs
}
// ObserveSpan feeds a completed trace span into the gateway's ring buffer and the
// LLM/subagent metric counters. It is the gateway's trace injection point: the
// composition root wires the loop tracer here (e.g. through TraceSink) so the
// admin panel can query spans without the gateway touching wire.go.
// ObserveSpan 把一个已完成的 trace span 送入网关的环形缓冲与
// LLM/子代理指标计数器。它是网关的 trace 注入点:
// 组合根把 loop tracer 接到这里(例如通过 TraceSink),使
// 管理面板能查询 span 而网关无需触碰 wire.go。
func (s *Server) ObserveSpan(rec trace.SpanRecord) {
if s.obs == nil {
return
@@ -59,10 +59,10 @@ func (s *Server) ObserveSpan(rec trace.SpanRecord) {
s.obs.metrics.Observe(rec)
}
// TraceSink returns a trace.Writer that appends completed spans to the gateway's
// trace ring buffer. Combine it with the existing JSONRecorder in the composition
// root (e.g. trace.New(MultiTraceWriter{...})) so spans both hit stderr and the
// admin panel.
// TraceSink 返回一个 trace.Writer,把已完成的 span 追加进网关的
// trace 环形缓冲。在组合根把它与既有的 JSONRecorder 结合
// (例如 trace.New(MultiTraceWriter{...})),使 span 既落 stderr 又进
// 管理面板。
func (s *Server) TraceSink() trace.Writer {
if s.obs == nil {
return nil
@@ -70,7 +70,7 @@ func (s *Server) TraceSink() trace.Writer {
return traceSink{s.obs.traceBuf}
}
// recordMetric counts one request for the observability panel.
// recordMetric 为可观测面板计一次请求。
func (s *Server) recordMetric(method, path string, status int, durMs int64) {
if s.obs == nil {
return
@@ -78,7 +78,7 @@ func (s *Server) recordMetric(method, path string, status int, durMs int64) {
s.obs.metrics.Record(method, path, status, durMs)
}
// adminObservabilityMetrics returns the current gateway metrics (admin read).
// adminObservabilityMetrics 返回当前网关指标(管理读)。
func (s *Server) adminObservabilityMetrics(w http.ResponseWriter, r *http.Request) {
if s.obs == nil {
writeJSON(w, http.StatusOK, AdminMetrics{
@@ -89,7 +89,7 @@ func (s *Server) adminObservabilityMetrics(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, s.obs.metrics.Snapshot(time.Now()))
}
// adminObservabilityTrace returns the most recent traced spans (admin read).
// adminObservabilityTrace 返回最近的已追踪 span(管理读)。
func (s *Server) adminObservabilityTrace(w http.ResponseWriter, r *http.Request) {
if s.obs == nil {
writeJSON(w, http.StatusOK, []AdminTraceSpan{})
@@ -103,7 +103,7 @@ func (s *Server) adminObservabilityTrace(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out)
}
// adminObservabilityAudit returns the most recent audit entries (admin read).
// adminObservabilityAudit 返回最近的审计条目(管理读)。
func (s *Server) adminObservabilityAudit(w http.ResponseWriter, r *http.Request) {
if s.obs == nil || s.obs.audit == nil {
writeJSON(w, http.StatusOK, []AuditRecord{})
@@ -118,8 +118,8 @@ func (s *Server) adminObservabilityAudit(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, records)
}
// parseLimit parses the ?limit= query param, clamping to [1, maxObsLimit], and
// selects defaultObsLimit when absent or invalid.
// parseLimit 解析 ?limit= 查询参数,夹取到 [1, maxObsLimit],
// 缺失或非法时选用 defaultObsLimit。
func parseLimit(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 {
@@ -9,7 +9,7 @@ import (
"time"
)
// AuditRecord is one persisted admin audit entry (who/what/when/result).
// AuditRecord 是一条持久化的管理审计条目(who/what/when/result)。
type AuditRecord struct {
ID int64 `json:"id"`
Who string `json:"who"`
@@ -18,33 +18,33 @@ type AuditRecord struct {
Result string `json:"result"`
}
// AuditStore persists the admin audit trail. Implementations must be safe for
// concurrent use. *SQLiteAuditStore is the production implementation (wired by the
// composition root over a.db); when none is injected the gateway falls back to an
// in-memory store so the endpoint always returns data.
// AuditStore 持久化管理审计链。实现必须对
// 并发使用安全。*SQLiteAuditStore 是生产实现(由
// 组合根接到 a.db 上);未注入任何实现时网关回退到
// 内存存储,因此端点总能返回数据。
type AuditStore interface {
// Append persists one audit entry at ts.
// Append 在 ts 持久化一条审计条目。
Append(ctx context.Context, who, what, result string, ts time.Time) error
// List returns at most limit most-recent entries, newest first. A non-positive
// limit selects the default.
// List 返回至多 limit 条最近条目,最新优先。非正的
// limit 选用默认值。
List(ctx context.Context, limit int) ([]AuditRecord, error)
}
// memoryAuditStore is the nil-injection fallback: it holds the audit trail in
// process memory so the admin endpoint works before the composition root injects a
// SQLite store. It is concurrency-safe.
// memoryAuditStore 是未注入时的回退:它把审计链放在
// 进程内存中,使管理端点在组合根注入 SQLite 存储
// 之前也能工作。它对并发安全。
type memoryAuditStore struct {
mu sync.Mutex
records []AuditRecord
seq int64
}
// newMemoryAuditStore builds an empty in-memory audit store.
// newMemoryAuditStore 构建一个空的内存审计存储。
func newMemoryAuditStore() *memoryAuditStore {
return &memoryAuditStore{records: make([]AuditRecord, 0, 64)}
}
// Append stores one entry with a monotonically increasing id.
// Append 用单调递增的 id 存一条条目。
func (m *memoryAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error {
if err := ctx.Err(); err != nil {
return err
@@ -58,7 +58,7 @@ func (m *memoryAuditStore) Append(ctx context.Context, who, what, result string,
return nil
}
// List returns the most recent entries, newest first.
// List 返回最近的条目,最新优先。
func (m *memoryAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) {
if err := ctx.Err(); err != nil {
return nil, err
@@ -84,9 +84,9 @@ func (m *memoryAuditStore) List(ctx context.Context, limit int) ([]AuditRecord,
return out, nil
}
// audit DDL for a self-contained SQLite store (no migration bookkeeping), the
// same pattern channel/session and backthink use. recorded_at is the UTC RFC3339
// wall-clock of the audited action.
// 自包含 SQLite 存储的审计 DDL(无需迁移记账),与
// channel/session 与 backthink 所用模式相同。recorded_at 是被审计
// 动作的 UTC RFC3339 墙钟时间。
const (
createAuditSQL = `
CREATE TABLE IF NOT EXISTS audit_logs (
@@ -102,16 +102,16 @@ CREATE TABLE IF NOT EXISTS audit_logs (
selectAuditSQL = `SELECT id, who, what, result, recorded_at FROM audit_logs ORDER BY id DESC LIMIT ?`
)
// SQLiteAuditStore persists the admin audit trail in SQLite. It is safe for
// concurrent use by virtue of the underlying *sql.DB.
// SQLiteAuditStore 把管理审计链持久化在 SQLite 中。凭借底层的
// *sql.DB,它对并发使用安全。
type SQLiteAuditStore struct {
db *sql.DB
log *slog.Logger
}
// NewSQLiteAuditStore builds a SQLite-backed audit store over db, guaranteeing the
// audit_logs table and index exist. It returns an error when db is nil or the DDL
// fails. The composition root wires this over the primary *sql.DB (a.db).
// NewSQLiteAuditStore 在 db 上构建 SQLite 审计存储,保证
// audit_logs 表与索引存在。db 为 nil 或 DDL 失败时
// 返回错误。组合根把它接到主 *sql.DB(a.db)上。
func NewSQLiteAuditStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*SQLiteAuditStore, error) {
if db == nil {
return nil, fmt.Errorf("gateway: audit store: nil database")
@@ -125,7 +125,7 @@ func NewSQLiteAuditStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*SQ
return &SQLiteAuditStore{db: db, log: sourcedLog(log)}, nil
}
// Append persists one audit entry at ts.
// Append 在 ts 持久化一条审计条目。
func (s *SQLiteAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error {
if _, err := s.db.ExecContext(ctx, insertAuditSQL, who, what, result, ts.UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("gateway: insert audit: %w", err)
@@ -133,7 +133,7 @@ func (s *SQLiteAuditStore) Append(ctx context.Context, who, what, result string,
return nil
}
// List returns the most recent entries, newest first.
// List 返回最近的条目,最新优先。
func (s *SQLiteAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) {
if limit <= 0 {
limit = defaultObsLimit
+20 -20
View File
@@ -11,16 +11,16 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Log-tail endpoint tuning. Compile-time constants (project rule §10).
// 日志尾部端点的调参。编译期常量(项目规范 §10)。
const (
defaultLogLimit = 200
maxLogLimit = 1000
)
// AdminLogEntry is the wire shape of one entry in
// GET /api/v1/admin/observability/logs: {time,level,source,msg}. `time` is
// RFC3339 UTC (matching trace/audit); `level` ∈ debug|info|warn|error; `source`
// is the record's provenance tag, empty when the record carried none.
// AdminLogEntry 是 GET /api/v1/admin/observability/logs 中一个条目的
// 线上结构:{time,level,source,msg}。`time` 是
// RFC3339 UTC(与 trace/audit 一致);`level` ∈ debug|info|warn|error;`source`
// 是记录的来源标签,记录未携带时为空。
type AdminLogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
@@ -28,10 +28,10 @@ type AdminLogEntry struct {
Msg string `json:"msg"`
}
// SetLogBuffer injects the process-wide log ring buffer behind
// GET /api/v1/admin/observability/logs. The composition root calls it once the
// gateway exists (the logger is built before the gateway); until then the
// endpoint answers 501. It is safe to call concurrently.
// SetLogBuffer 注入 GET /api/v1/admin/observability/logs 背后的
// 进程级日志环形缓冲。组合根在网关存在后调用它
// (logger 先于网关构建);在那之前该
// 端点返回 501。并发调用是安全的。
func (s *Server) SetLogBuffer(buf *logger.RingBuffer) {
if s == nil || s.obs == nil {
return
@@ -39,7 +39,7 @@ func (s *Server) SetLogBuffer(buf *logger.RingBuffer) {
s.obs.logBuf.Store(buf)
}
// logBuffer returns the injected log ring buffer, or nil when none was injected.
// logBuffer 返回已注入的日志环形缓冲,未注入时为 nil。
func (s *Server) logBuffer() *logger.RingBuffer {
if s == nil || s.obs == nil {
return nil
@@ -47,10 +47,10 @@ func (s *Server) logBuffer() *logger.RingBuffer {
return s.obs.logBuf.Load()
}
// adminObservabilityLogs returns the most recent backend log records, newest
// first, optionally filtered to a minimum level (admin read). It is the backend
// behind the panel's log tail: the records come from the logger's own ring
// buffer and nothing is re-rendered or fabricated.
// adminObservabilityLogs 返回最近的后端日志记录,最新
// 优先,可选按最低级别过滤(管理读)。它是面板日志
// 尾部背后的后端:记录来自 logger 自身的环形
// 缓冲,没有任何东西被重新渲染或编造。
func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request) {
minLevel, filtered, ok := parseLogLevel(r.URL.Query().Get("level"))
if !ok {
@@ -85,7 +85,7 @@ func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out)
}
// adminLogOf maps a captured log entry to the camelCase admin wire shape.
// adminLogOf 把捕获的日志条目映射为驼峰式管理线上结构。
func adminLogOf(e logger.Entry) AdminLogEntry {
return AdminLogEntry{
Time: e.Time.UTC().Format(time.RFC3339),
@@ -95,9 +95,9 @@ func adminLogOf(e logger.Entry) AdminLogEntry {
}
}
// parseLogLevel resolves the ?level= filter. An empty value disables filtering
// (filtered=false); debug/info/warn/error select that level and above; anything
// else is rejected with ok=false so the handler answers 400.
// parseLogLevel 解析 ?level= 过滤器。空值关闭过滤
// (filtered=false);debug/info/warn/error 选中该级别及以上;其他任何
// 取值都以 ok=false 拒绝,使 handler 返回 400。
func parseLogLevel(raw string) (min slog.Level, filtered, ok bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
@@ -115,8 +115,8 @@ func parseLogLevel(raw string) (min slog.Level, filtered, ok bool) {
}
}
// parseLogLimit parses the ?limit= query param, clamping to [1, maxLogLimit] and
// selecting defaultLogLimit when absent or invalid.
// parseLogLimit 解析 ?limit= 查询参数,夹取到 [1, maxLogLimit] 并在
// 缺失或非法时选用 defaultLogLimit。
func parseLogLimit(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 {
@@ -12,13 +12,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// logEnvelope decodes the {data:[...]} envelope of the log-tail endpoint.
// logEnvelope 解码日志尾部端点的 {data:[...]} 信封。
type logEnvelope struct {
Data []AdminLogEntry `json:"data"`
}
// logsServer builds a gateway with an injected log ring buffer already fed by a
// real logger, so the endpoint is exercised against the production capture path.
// logsServer 构建一个网关,注入了已由真实 logger 喂入的日志
// 环形缓冲,因此该端点是针对生产捕获路径演练的。
func logsServer(t *testing.T) (*Server, string, string) {
t.Helper()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
@@ -74,7 +74,7 @@ func TestAdminObservabilityLogs_LevelFilter(t *testing.T) {
{"info", []string{"error", "warn", "info"}},
{"warn", []string{"error", "warn"}},
{"error", []string{"error"}},
{"ERROR", []string{"error"}}, // case-insensitive
{"ERROR", []string{"error"}}, // 大小写不敏感
{"", []string{"error", "warn", "info"}},
}
for _, tc := range cases {
@@ -133,7 +133,7 @@ func TestAdminObservabilityLogs_LimitClamped(t *testing.T) {
t.Fatalf("limit=2 data = %+v, want the two newest", env.Data)
}
// An invalid/absent limit falls back to the default (all 3 here < 200).
// 非法/缺失的 limit 回退到默认值(这里全部 3 条 < 200)。
resp, body = doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=abc", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("bad limit status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -201,10 +201,10 @@ func TestAdminObservabilityLogs_EmptyBufferIs200Empty(t *testing.T) {
}
}
// TestAdminObservabilityLogs_ViewerReadsTail locks the viewer gate: the log tail
// is part of the read-only monitoring surface the viewer role exists for, so a
// viewer reads it exactly like an owner (the payload is log records, not
// credentials).
// TestAdminObservabilityLogs_ViewerReadsTail 锁定 viewer 门:日志尾部
// 属于 viewer 角色存在的那个只读监控表面,因此
// viewer 读它就像 owner 一样(载荷是日志记录,不是
// 凭据)。
func TestAdminObservabilityLogs_ViewerReadsTail(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
@@ -8,17 +8,17 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Metrics collector tuning. Compile-time constants.
// 指标收集器调参。编译期常量。
const (
// metricsWindow bounds how long request timestamps are retained for QPS.
// metricsWindow 限定请求时间戳为 QPS 保留多久。
metricsWindow = 2 * time.Second
// metricsQPSSpan is the sliding window over which QPS is measured.
// metricsQPSSpan 是测量 QPS 的滑动窗口。
metricsQPSSpan = time.Second
)
// AdminMetrics is the wire shape of GET /api/v1/admin/observability/metrics.
// LLM/Token cost is deliberately absent: the gateway does not track token usage or
// spend, and a fabricated number would violate the "不伪造指标" red line.
// AdminMetrics 是 GET /api/v1/admin/observability/metrics 的线上结构。
// LLM/Token 成本刻意缺位:网关不跟踪 token 用量或
// 花费,编造一个数字会违反「不伪造指标」红线。
type AdminMetrics struct {
TotalRequests int64 `json:"totalRequests"`
QPS float64 `json:"qps"`
@@ -31,8 +31,8 @@ type AdminMetrics struct {
SubagentEvents int64 `json:"subagentEvents"`
}
// MetricsCollector accumulates request metrics and derived counters for the admin
// observability panel. It is concurrency-safe.
// MetricsCollector 为管理可观测面板累积请求指标与派生
// 计数器。它对并发安全。
type MetricsCollector struct {
mu sync.Mutex
total int64
@@ -46,7 +46,7 @@ type MetricsCollector struct {
subagent int64
}
// newMetricsCollector builds an empty metrics collector.
// newMetricsCollector 构建一个空指标收集器。
func newMetricsCollector() *MetricsCollector {
return &MetricsCollector{
perMethod: make(map[string]int64),
@@ -55,7 +55,7 @@ func newMetricsCollector() *MetricsCollector {
}
}
// Record counts one request with the given method, path, status and duration.
// Record 用给定的 method、path、status 与 duration 计一次请求。
func (m *MetricsCollector) Record(method, path string, status int, durMs int64) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -72,8 +72,8 @@ func (m *MetricsCollector) Record(method, path string, status int, durMs int64)
m.prune(now)
}
// Observe increments the LLM/subagent counters when a span of that kind is
// recorded. It is called by ObserveSpan so the counters reflect real work.
// Observe 在记录到该类 span 时递增 LLM/子代理计数器。
// 它由 ObserveSpan 调用,使计数器反映真实工作。
func (m *MetricsCollector) Observe(rec trace.SpanRecord) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -85,8 +85,8 @@ func (m *MetricsCollector) Observe(rec trace.SpanRecord) {
}
}
// prune drops request times older than metricsWindow so recent stays small. The
// caller must hold m.mu.
// prune 丢弃早于 metricsWindow 的请求时间,使 recent 保持小。
// 调用方必须持有 m.mu。
func (m *MetricsCollector) prune(now time.Time) {
min := now.Add(-metricsWindow)
i := 0
@@ -98,15 +98,15 @@ func (m *MetricsCollector) prune(now time.Time) {
}
}
// Snapshot returns a point-in-time copy of the metrics. qps counts the requests
// started within metricsQPSSpan of now.
// Snapshot 返回指标的时间点副本。qps 统计在 now 的
// metricsQPSSpan 之内开始的请求。
//
// Every map in the result is a fresh copy, never the collector's own: the caller
// serializes the value AFTER this lock is released (the admin handler JSON-encodes
// it), so returning the live maps would let a concurrent Record mutate a map while
// the encoder iterates it — the Go runtime answers that with an unrecoverable
// fatal "concurrent map iteration and map write" that takes the whole process
// down (the 2026-10-03 21:00:28 crash on GET /api/v1/admin/observability/metrics).
// 结果中的每个 map 都是全新副本,绝不是收集器自己的:调用方
// 在本锁释放之后才序列化该值(管理 handler 对它做 JSON
// 编码),因此返回活 map 会让并发的 Record 在编码器
// 遍历它时改动该 map —— Go 运行时对此的回答是不可恢复的
// 致命错误 "concurrent map iteration and map write",它会让整个进程
// 倒下(2026-10-03 21:00:28 在 GET /api/v1/admin/observability/metrics 上的崩溃)。
func (m *MetricsCollector) Snapshot(now time.Time) AdminMetrics {
m.mu.Lock()
defer m.mu.Unlock()
@@ -133,9 +133,9 @@ func (m *MetricsCollector) Snapshot(now time.Time) AdminMetrics {
}
}
// copyCounts returns a detached copy of a string-keyed counter map, so the
// snapshot can be encoded after the collector's lock is released. The caller must
// hold m.mu while copying (the copy iterates the live map).
// copyCounts 返回字符串键计数器 map 的脱离副本,使
// 快照能在收集器锁释放后编码。调用方在复制时
// 必须持有 m.mu(复制会遍历活 map)。
func copyCounts(src map[string]int64) map[string]int64 {
out := make(map[string]int64, len(src))
for k, v := range src {
@@ -144,8 +144,8 @@ func copyCounts(src map[string]int64) map[string]int64 {
return out
}
// copyStatusCounts renders the int-keyed status counter as the string-keyed wire
// map the AdminMetrics JSON shape declares. The caller must hold m.mu while copying.
// copyStatusCounts 把 int 键的状态计数器渲染为 AdminMetrics JSON
// 形状所声明的字符串键线上 map。调用方在复制时必须持有 m.mu。
func copyStatusCounts(src map[int]int64) map[string]int64 {
out := make(map[string]int64, len(src))
for k, v := range src {
@@ -12,17 +12,17 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// These tests pin the contract that broke in production (2026-10-03 21:00:28):
// MetricsCollector.Snapshot must hand back DETACHED counter maps, because the
// admin handler JSON-encodes the snapshot after the collector's lock is released.
// Returning the collector's own maps let a concurrent Record write a map while the
// encoder iterated it → unrecoverable
// "fatal error: concurrent map iteration and map write" → the whole backend died,
// and with it every admin page (the reported「全链路流程功能无效」).
// 这些测试钉住生产中坏掉的那个契约(2026-10-03 21:00:28):
// MetricsCollector.Snapshot 必须交回脱离的计数器 map,因为
// 管理 handler 在收集器锁释放后对快照做 JSON 编码。
// 返回收集器自己的 map 会让并发的 Record 在编码器遍历它时
// 写该 map → 不可恢复的
// "fatal error: concurrent map iteration and map write" → 整个后端死亡,
// 随之所有管理页面也死(上报的「全链路流程功能无效」)。
// TestMetricsSnapshotDetachesMaps is the deterministic regression: a value read out
// of a snapshot must not change when the collector is counted afterwards, and
// writing into a snapshot must not reach the collector.
// TestMetricsSnapshotDetachesMaps 是确定性的回归:从快照读出的
// 值不得在之后对收集器计数时改变,且写入
// 快照不得触达收集器。
func TestMetricsSnapshotDetachesMaps(t *testing.T) {
t.Parallel()
m := newMetricsCollector()
@@ -36,7 +36,7 @@ func TestMetricsSnapshotDetachesMaps(t *testing.T) {
t.Fatalf("PerMethod[GET] = %d, want 1", got)
}
// A count recorded after the snapshot must not appear in the older snapshot.
// 快照之后记录的计数不得出现在较旧的快照中。
m.Record(http.MethodPost, "/api/v1/b", http.StatusTeapot, 1)
if _, ok := snap.PerPath["/api/v1/b"]; ok {
t.Fatalf("snapshot shares the collector's perPath map: later path leaked into it (%v)", snap.PerPath)
@@ -52,8 +52,8 @@ func TestMetricsSnapshotDetachesMaps(t *testing.T) {
assertCollectorNotMutatedBySnapshot(t, m)
}
// assertSnapshotWriteIsolation writes into the snapshot's maps and checks the
// collector is untouched afterwards.
// assertSnapshotWriteIsolation 写入快照的 map 并检查
// 之后收集器未被触碰。
func assertSnapshotWriteIsolation(t *testing.T, m *MetricsCollector, snap AdminMetrics) {
t.Helper()
snap.PerPath["/api/v1/evil"] = 999
@@ -78,8 +78,8 @@ func assertSnapshotWriteIsolation(t *testing.T, m *MetricsCollector, snap AdminM
}
}
// assertCollectorNotMutatedBySnapshot re-reads a counter the collector recorded
// before the isolation writes to prove no counter value was disturbed.
// assertCollectorNotMutatedBySnapshot 重新读取收集器在隔离写入之前
// 记录的一个计数器,以证明没有任何计数值被扰动。
func assertCollectorNotMutatedBySnapshot(t *testing.T, m *MetricsCollector) {
t.Helper()
final := m.Snapshot(time.Now())
@@ -91,11 +91,11 @@ func assertCollectorNotMutatedBySnapshot(t *testing.T, m *MetricsCollector) {
}
}
// TestMetricsSnapshotEncodedConcurrently runs the exact interleaving that crashed
// production: one goroutine counts requests (the logging middleware does this on
// every request) while another encodes snapshots (the admin handler does this
// outside the lock). Under -race a shared live map is reported as a data race; on
// an unpatched build it can also abort the process outright.
// TestMetricsSnapshotEncodedConcurrently 运行让生产崩溃的那个确切
// 交错:一个 goroutine 计请求(logging 中间件对每个请求都
// 这么做),另一个编码快照(管理 handler 在锁外
// 这么做)。在 -race 下共享活 map 会被报告为数据竞争;在
// 未打补丁的构建上它还可能直接把进程搞崩。
func TestMetricsSnapshotEncodedConcurrently(t *testing.T) {
t.Parallel()
m := newMetricsCollector()
@@ -127,18 +127,18 @@ func TestMetricsSnapshotEncodedConcurrently(t *testing.T) {
wg.Wait()
}
// TestAdminMetricsEndpointConcurrentPoll drives the real HTTP path — the admin
// panel polls every module in parallel, so one request can be encoding the metrics
// JSON while another request's logging middleware records into the collector. This
// is the production crash reproduced end-to-end through the middleware + handler.
// TestAdminMetricsEndpointConcurrentPoll 驱动真实 HTTP 路径 —— 管理
// 面板并行轮询每个模块,因此一个请求可能在编码指标
// JSON,而另一个请求的 logging 中间件正在往收集器里记录。这
// 是生产崩溃经中间件 + handler 端到端复现。
func TestAdminMetricsEndpointConcurrentPoll(t *testing.T) {
env := newEnv(t, nil, false)
tok := env.token(t, auth.RoleOwner)
metricsURL := env.ts.URL + "/api/v1/admin/observability/metrics"
healthURL := env.ts.URL + "/api/v1/health"
// Both jobs run concurrently: the panel's metrics reads race the load requests
// that keep recording into the collector.
// 两个任务并发运行:面板的指标读与持续往收集器
// 记录的负载请求相互竞争。
for _, err := range runConcurrent(
func() error { return driveRequests(healthURL, "", 4, 25) },
func() error { return driveRequests(metricsURL, tok, 6, 25) },
@@ -169,7 +169,7 @@ func TestAdminMetricsEndpointConcurrentPoll(t *testing.T) {
}
}
// runConcurrent runs every job in its own goroutine and returns the non-nil errors.
// runConcurrent 在各自 goroutine 中运行每个任务并返回非 nil 错误。
func runConcurrent(jobs ...func() error) []error {
var wg sync.WaitGroup
errs := make([]error, len(jobs))
@@ -184,8 +184,8 @@ func runConcurrent(jobs ...func() error) []error {
return errs
}
// driveRequests issues n GETs of url, workers of them in parallel, using token as
// a bearer credential when non-empty. It returns the first failure (status or I/O).
// driveRequests 对 url 发起 n 次 GET,其中 workers 个并行,token 非空
// 时作为 bearer 凭据。它返回首个失败(状态或 I/O)。
func driveRequests(url, token string, workers, n int) error {
var wg sync.WaitGroup
fail := make(chan error, workers)
@@ -7,12 +7,12 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// defaultTraceBufCap is how many completed spans the ring buffer holds before the
// oldest is evicted. Compile-time constant.
// defaultTraceBufCap 是环形缓冲在逐出最旧 span 之前持有的已
// 完成 span 数。编译期常量。
const defaultTraceBufCap = 512
// AdminTraceSpan is the camelCase wire shape of one traced span. It decouples the
// frontend from the trace package's snake_case SpanRecord.
// AdminTraceSpan 是一个已追踪 span 的驼峰式线上结构。它把
// 前端与 trace 包蛇形命名的 SpanRecord 解耦。
type AdminTraceSpan struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
@@ -26,9 +26,9 @@ type AdminTraceSpan struct {
Events []trace.Event `json:"events,omitempty"`
}
// RingBuffer is a concurrency-safe bounded buffer of completed trace spans. The
// oldest span is evicted when the buffer is full, so the admin panel always sees
// the most recent work without unbounded growth.
// RingBuffer 是已完成的 trace span 的并发安全有界缓冲。缓冲
// 满时逐出最旧 span,因此管理面板总能看到
// 最近的工作而不会无界增长。
type RingBuffer struct {
mu sync.RWMutex
buf []trace.SpanRecord
@@ -37,7 +37,7 @@ type RingBuffer struct {
cap int
}
// newRingBuffer builds a ring buffer holding up to cap spans (default when cap<=0).
// newRingBuffer 构建至多容纳 cap 个 span 的环形缓冲(cap<=0 时用默认值)。
func newRingBuffer(cap int) *RingBuffer {
if cap <= 0 {
cap = defaultTraceBufCap
@@ -45,7 +45,7 @@ func newRingBuffer(cap int) *RingBuffer {
return &RingBuffer{buf: make([]trace.SpanRecord, cap), cap: cap}
}
// Append stores rec, evicting the oldest when the buffer is full.
// Append 存储 rec,缓冲满时逐出最旧的。
func (r *RingBuffer) Append(rec trace.SpanRecord) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -56,8 +56,8 @@ func (r *RingBuffer) Append(rec trace.SpanRecord) {
}
}
// Snapshot returns up to n most-recent spans, newest first. A non-positive n
// selects defaultObsLimit; n is clamped to maxObsLimit.
// Snapshot 返回至多 n 条最近 span,最新优先。非正的 n
// 选用 defaultObsLimit;n 被夹取到 maxObsLimit。
func (r *RingBuffer) Snapshot(n int) []trace.SpanRecord {
if n <= 0 {
n = defaultObsLimit
@@ -82,11 +82,11 @@ func (r *RingBuffer) Snapshot(n int) []trace.SpanRecord {
return out
}
// traceSink adapts a *RingBuffer to trace.Writer so a completed span is appended
// to the gateway's trace buffer.
// traceSink 把 *RingBuffer 适配为 trace.Writer,使已完成的 span 被
// 追加进网关的 trace 缓冲。
type traceSink struct{ b *RingBuffer }
// WriteSpan appends rec to the ring buffer.
// WriteSpan 把 rec 追加进环形缓冲。
func (s traceSink) WriteSpan(rec trace.SpanRecord) error {
if s.b == nil {
return nil
@@ -95,16 +95,16 @@ func (s traceSink) WriteSpan(rec trace.SpanRecord) error {
return nil
}
// MultiTraceWriter fans a completed span out to several trace.Writers, so a tracer
// can record to both the gateway's ring buffer and a JSONL file in one pass. A nil
// Writer is skipped. It is the composition-root helper the main session uses to
// route the loop tracer's output into the gateway without dropping stderr.
// MultiTraceWriter 把已完成的 span 扇出到多个 trace.Writer,使一个
// tracer 能一趟同时记录到网关的环形缓冲与一个 JSONL 文件。nil 的
// Writer 被跳过。它是主会话用来把 loop tracer 的输出引入
// 网关而不丢掉 stderr 的组合根辅助件。
type MultiTraceWriter struct {
// Writers are the sinks a span is written to. A nil Writer is skipped.
// Writers 是 span 被写入的汇聚点。nil 的 Writer 被跳过。
Writers []trace.Writer
}
// WriteSpan writes rec to every configured Writer.
// WriteSpan 把 rec 写入每一个已配置的 Writer。
func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error {
for _, w := range m.Writers {
if w == nil {
@@ -117,7 +117,7 @@ func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error {
return nil
}
// adminSpanOf maps a trace.SpanRecord to the camelCase admin wire shape.
// adminSpanOf 把 trace.SpanRecord 映射为驼峰式管理线上结构。
func adminSpanOf(rec trace.SpanRecord) AdminTraceSpan {
sp := AdminTraceSpan{
ID: rec.ID,
+59 -59
View File
@@ -11,82 +11,82 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// errPersonaBindingUnsupported is returned by newSubSession when a caller asks for
// a role-bound session but the injected history store cannot bind roles. The
// handler maps it to 501 rather than pretending the binding landed.
// errPersonaBindingUnsupported 由 newSubSession 在调用方要求一个
// 绑定角色的会话、但注入的 history 存储无法绑定角色时返回。
// handler 把它映射为 501,而不是假装绑定已生效。
var errPersonaBindingUnsupported = errors.New("gateway: history store does not support session persona binding")
// This file owns the gateway's turn-environment resolution (DevDocs/design/22
// §2/§4.5): which role answers, expressed as the loop.SessionEnv the turn runs
// under.
// 本文件拥有网关的回合环境解析(DevDocs/design/22
// §2/§4.5):由哪个角色应答,表达为该回合运行的
// loop.SessionEnv 环境。
//
// Precedence (design/22 §2, top down):
// 1. request-level override — NOT implemented in this batch (batch 1 exposes the
// session-level binding; a per-request override is additive and unpersisted);
// 2. session-level binding — chat_sessions.persona_id, resolved here;
// 3. channel-level mapping — batch 2 (channels.json / channel_sessions);
// 4. global default — expressed as PersonaID == "", which the loop resolves to its
// configured default role (Options.PersonaID). That is what makes every
// pre-existing session behave exactly as before.
// 优先级(design/22 §2,自上而下):
// 1. 请求级覆盖 —— 本批次不实现(批次 1 暴露的是
// 会话级绑定;按请求覆盖是加性且不持久化的);
// 2. 会话级绑定 —— chat_sessions.persona_id,在此解析;
// 3. 渠道级映射 —— 批次 2(channels.json / channel_sessions);
// 4. 全局默认 —— 表达为 PersonaID == "",由 loop 解析到其
// 已配置的默认角色(Options.PersonaID)。这正是让每个
// 既有会话的行为与以往完全一致的原因。
//
// The env's OTHER fields are untouched: ActorEnv and MemWritable still come from
// loop.OwnerEnv() for these owner-facing Web/voice paths. That is the identity red
// line — who may do what is decided by the channel actor metadata (here: a JWT
// owner), never by the role being played. Persona selection only changes the
// prompt, the memory namespace, the emotion machine and the thinking budget.
// env 的其他字段不受影响:对这些面向 owner 的 Web/语音路径,
// ActorEnv 与 MemWritable 仍来自 loop.OwnerEnv()。这就是身份红线
// —— 谁可以做什么由 channel 的 actor 元数据决定(此处是一个 JWT
// owner),绝不是由所扮演的角色决定。人格选择只改变
// 提示词、记忆命名空间、情绪机与思考预算。
// sessionPersonaReader is the optional narrower view of HistoryStore used to read a
// session's bound role. *history.Store implements it; a store that does not (a test
// fake, or a store predating design/22) simply yields "" — the global default — so
// the gateway keeps working.
// sessionPersonaReader 是 HistoryStore 可选的更窄视图,用于读取
// 会话的绑定角色。*history.Store 实现了它;未实现的存储(测试
// 替身,或早于 design/22 的存储)只是得到 "" —— 全局默认 —— 因此
// 网关继续工作。
type sessionPersonaReader interface {
SessionPersona(ctx context.Context, sessionID string) (string, error)
}
// sessionPersonaWriter is the optional narrower view of HistoryStore for writing a
// session's role binding. A session's role is decided at creation (design/22 §8-1),
// so no request path rebinds one any more — creation goes through subSessionCreator —
// and the gateway no longer type-asserts this interface. It stays next to the reader
// and the creator so the store's role-binding surface is documented in one place:
// *history.Store still implements SetSessionPersona (design/22 §4.1) and the
// admin/owner write path that would use it is a separate endpoint, not a PATCH.
// sessionPersonaWriter 是 HistoryStore 可选的更窄视图,用于写入
// 会话的角色绑定。会话角色在创建时决定(design/22 §8-1),
// 因此不再有任何请求路径重绑 —— 创建走 subSessionCreator ——
// 而网关也不再对该接口做类型断言。它留在读取器
// 与创建器旁边,使存储的角色绑定表面集中记录在一处:
// *history.Store 仍实现 SetSessionPersona(design/22 §4.1),而会用它的
// 管理/owner 写路径是独立端点,不是 PATCH。
type sessionPersonaWriter interface {
SetSessionPersona(ctx context.Context, sessionID, personaID string) error
}
// subSessionCreator is the optional narrower view of HistoryStore used to create a
// session already bound to a role (POST /sessions {personaId}, design/22 §4.5).
// *history.Store implements it; a store without it can only create an unbound
// session, so a request that explicitly asks for a role is refused with 501.
// subSessionCreator 是 HistoryStore 可选的更窄视图,用于创建一个
// 已绑定角色的会话(POST /sessions {personaId},design/22 §4.5)。
// *history.Store 实现了它;没有它的存储只能创建未绑定的
// 会话,因此显式要求角色的请求以 501 拒绝。
type subSessionCreator interface {
NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error)
}
// turnEnvResolver builds the loop session environment for one turn. It holds the
// two read-only dependencies the resolution needs, so both the HTTP/WS handlers
// and the hub resolve roles through the same code path (design/22 §4.5: one
// resolver, not a hardcoded loop.OwnerEnv() per entry point).
// turnEnvResolver 为一个回合构建 loop 会话环境。它持有
// 解析所需的两个只读依赖,因此 HTTP/WS handler
// 与 hub 都经同一条代码路径解析角色(design/22 §4.5:一个
// 解析器,而不是每个入口各硬编码一份 loop.OwnerEnv())。
type turnEnvResolver struct {
history HistoryStore
personas PersonaStore
log *slog.Logger
}
// owner returns the per-turn environment for one owner-facing turn of sessionID:
// the full-access owner env (identity unchanged) plus the session's bound role.
// A blank role means "inherit the global default"; the loop performs that final
// fallback, so the gateway never has to know the default role's id.
// owner 返回 sessionID 一个面向 owner 回合的每回合环境:
// 全访问 owner env(身份不变)加上该会话绑定的角色。
// 空白角色意味着「继承全局默认」;loop 执行那最后一次
// 回退,因此网关永远不必知道默认角色的 id。
func (t turnEnvResolver) owner(ctx context.Context, sessionID string) loop.SessionEnv {
env := loop.OwnerEnv()
env.PersonaID = t.sessionPersona(ctx, sessionID)
return env
}
// sessionPersona returns the role bound to sessionID, or "" when the session
// inherits the global default. An unknown session, a store without the reader, or
// a read error all degrade to "" (the default) rather than failing the turn: a
// chat must never be blocked by a metadata lookup, and the fallback is the
// pre-multi-character behavior.
// sessionPersona 返回绑定到 sessionID 的角色,会话继承全局
// 默认时返回 ""。未知会话、没有读取器的存储、或
// 读错误都退化为 ""(默认),而不是让该回合失败:
// 聊天绝不能被一次元数据查找阻塞,而该回退正是
// 多角色之前的行为。
func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) string {
if t.history == nil || sessionID == "" {
return ""
@@ -105,10 +105,10 @@ func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) s
return personaID
}
// defaultPersonaID returns the persona id of the library's default character, or
// "" when no persona store is wired or no character is marked default. It is the
// gateway-side view of owner.yaml:defaultPersona used where a role id is needed
// without a session (the admin memory search fallback, design/22 §4.5).
// defaultPersonaID 返回库中默认角色的人格 id,未接线人格
// 存储或没有角色被标记为默认时返回 ""。它是
// owner.yaml:defaultPersona 的网关侧视图,用于在没有会话时
// 需要角色 id 的场合(管理记忆搜索回退,design/22 §4.5)。
func (s *Server) defaultPersonaID() string {
if s.personas == nil {
return ""
@@ -121,10 +121,10 @@ func (s *Server) defaultPersonaID() string {
return ""
}
// requireKnownPersona rejects a personaId the registry does not know, answering
// 400 and returning false. An empty id is always allowed (it means "inherit the
// global default"), and a gateway without a persona store cannot validate, so the
// binding is accepted there and simply resolves as an unknown role later.
// requireKnownPersona 拒绝注册表不认识的 personaId,返回
// 400 并返回 false。空 id 一律允许(它意味着「继承
// 全局默认」),而没有 persona 存储的网关无法校验,因此
// 那里接受该绑定,之后只是解析为未知角色。
func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bool {
if personaID == "" || s.personas == nil {
return true
@@ -136,10 +136,10 @@ func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bo
return true
}
// newSubSession creates a sub session bound to personaID, using the role-aware
// creator when the store provides one. A store without it can only create an
// unbound session, so an explicit role request is refused (501) instead of
// silently dropping the binding.
// newSubSession 创建一个绑定到 personaID 的子会话,存储提供
// 角色感知创建器时使用它。没有它的存储只能创建
// 未绑定会话,因此显式的角色请求被拒绝(501),而不是
// 静默丢弃该绑定。
func (s *Server) newSubSession(ctx context.Context, ownerID, personaID string) (string, error) {
creator, ok := s.history.(subSessionCreator)
if !ok {
+22 -22
View File
@@ -12,9 +12,9 @@ import (
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// moodRegistry builds a real persona registry (the production PersonaStore, which
// also owns each role's emotion machine) from the given personas, registering the
// matching character too.
// moodRegistry 从给定人格构建真实人格注册表(生产
// PersonaStore,它同时持有每个角色的情绪机),并一并注册
// 匹配的角色。
func moodRegistry(t *testing.T, personas ...*loader.Persona) *personareg.Registry {
t.Helper()
reg := personareg.New()
@@ -29,8 +29,8 @@ func moodRegistry(t *testing.T, personas ...*loader.Persona) *personareg.Registr
return reg
}
// personaList fetches a persona list endpoint and returns each row as a raw map,
// so the test can tell a missing key from a present null/false.
// personaList 拉取人格列表端点并把每行作为原始 map 返回,
// 使测试能区分键缺失与存在且为 null/false。
func personaList(t *testing.T, ts *httptest.Server, jwt *auth.JWTService, path string) []map[string]any {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, mustSign(t, jwt))
@@ -44,17 +44,17 @@ func personaList(t *testing.T, ts *httptest.Server, jwt *auth.JWTService, path s
return rows
}
// mustSign mints an owner token for the read endpoints.
// mustSign 为这些读端点铸造一个 owner token。
func mustSign(t *testing.T, jwt *auth.JWTService) string {
t.Helper()
return signAny(t, jwt, "acct", auth.RoleOwner)
}
// TestAdminListPersonasThinkingKeepShowToUserTriState pins the read-side contract
// of the panel's `thinking` block: an unconfigured persona carries NO `thinking`
// key (nil, not an all-zero object), an explicitly false `showToUser` stays
// `false`, and an unset one stays `null` — never flattened into false, which is
// the distinction the write side keeps with *bool.
// TestAdminListPersonasThinkingKeepShowToUserTriState 钉住面板 `thinking`
// 块的读侧契约:未配置的人格不携带 `thinking`
// 键(nil,而不是全零对象),显式为 false 的 `showToUser` 保持
// `false`,未设置的保持 `null` —— 绝不被压平成 false,这正是
// 写侧用 *bool 保持的区分。
func TestAdminListPersonasThinkingKeepShowToUserTriState(t *testing.T) {
t.Parallel()
@@ -118,9 +118,9 @@ func TestAdminListPersonasThinkingKeepShowToUserTriState(t *testing.T) {
}
}
// TestUserListPersonasReportsRoleMoodOnly pins the user-side shape: the role-level
// mood rides along (the frontend seeds its badge from it), while the admin-only
// thinking knobs stay out of the user contract.
// TestUserListPersonasReportsRoleMoodOnly 钉住用户侧形状:角色级
// mood 一并带上(前端从它给角标播种),而仅管理的
// thinking 开关留在用户契约之外。
func TestUserListPersonasReportsRoleMoodOnly(t *testing.T) {
t.Parallel()
@@ -150,10 +150,10 @@ func TestUserListPersonasReportsRoleMoodOnly(t *testing.T) {
}
}
// TestPersonaMoodFollowsTheRole is the "mood is role-level, not per-session" rule:
// it reads the live emotion machine, so a transition on one role's machine shows
// up for that role and leaves every other role untouched, with no session input
// anywhere in the path.
// TestPersonaMoodFollowsTheRole 是「mood 是角色级、不是每会话」规则:
// 它读取活的情绪机,因此一个角色机器上的转移会为该角色
// 体现出来,且不触碰其他任何角色,路径中
// 任何地方都没有会话输入。
func TestPersonaMoodFollowsTheRole(t *testing.T) {
t.Parallel()
@@ -175,7 +175,7 @@ func TestPersonaMoodFollowsTheRole(t *testing.T) {
if x, y := readMoods(); x != "calm" || y != "calm" {
t.Fatalf("initial moods = (%q, %q), want (calm, calm)", x, y)
}
// Move only 昔涟's machine (the machine a settled turn's observer would move).
// 只移动昔涟的机器(一个已结算回合的观察者会移动的机器)。
if err := reg.Emotions().Transition("xilian", emotion.Happy); err != nil {
t.Fatalf("Transition: %v", err)
}
@@ -184,9 +184,9 @@ func TestPersonaMoodFollowsTheRole(t *testing.T) {
}
}
// TestPersonaMoodEmptyWithoutEmotionStore is the honest degrade: a persona store
// that does not own emotion (it only lists personas) reports an empty mood rather
// than inventing one.
// TestPersonaMoodEmptyWithoutEmotionStore 是诚实的退化:不持有情绪
// 的人格存储(它只列出人格)报告空 mood,而不是
// 编造一个。
func TestPersonaMoodEmptyWithoutEmotionStore(t *testing.T) {
t.Parallel()
+17 -17
View File
@@ -10,14 +10,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// wsProactiveMessage is the WS event type for an autonomous proactive push
// (design/12 §3): a message Cyrene decided to send without a recent user input.
// wsProactiveMessage 是自主主动推送的 WS 事件类型
// (design/12 §3):Cyrene 在没有近期用户输入时决定发送的消息。
const wsProactiveMessage = "proactive_message"
// proactivePayload is the sanitized wire shape of a proactive message. It carries
// only the fields a client needs to render a bubble (content, platform, channel,
// sender label); it deliberately omits RawData / media bytes / tokens so a
// proactive push can never leak a credential or payload into the frontend.
// proactivePayload 是主动消息消毒后的线上结构。它只
// 携带客户端渲染气泡所需的字段(content、platform、channel、
// 发送者标签);它刻意省略 RawData / 媒体字节 / token,使
// 主动推送绝不可能把凭据或载荷泄露进前端。
type proactivePayload struct {
Platform string `json:"platform,omitempty"`
ChannelID string `json:"channelId,omitempty"`
@@ -29,12 +29,12 @@ type proactivePayload struct {
MessageID string `json:"messageId,omitempty"`
}
// SendProactive delivers msg to every live owner/admin connection and returns how
// many accepted it. A zero count means no owner/admin client is online, so the
// caller falls back to the IM bridge. Only owner/admin connections are targeted
// (red line: proactive is owner-only); a viewer or unauthenticated connection is
// never reached. It never blocks: a full send buffer drops the frame for that
// slow client rather than stalling the backthink push.
// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回
// 有多少个接受了它。计数为零意味着没有 owner/admin 客户端在线,
// 因此调用方回退到 IM bridge。只有 owner/admin 连接会被作为
// 目标(红线:主动推送仅 owner);viewer 或未认证连接
// 绝不被触达。它绝不阻塞:发送缓冲满时对该
// 慢客户端丢弃该帧,而不是卡住后台思考推送。
func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
if err := ctx.Err(); err != nil {
return 0, err
@@ -59,13 +59,13 @@ func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (in
return n, nil
}
// Server.SendProactive satisfies the backthink ActiveClient seam by delegating to
// the hub, so the composition root can hand the gateway itself to the sink setter.
// Server.SendProactive 通过委托给 hub 满足后台思考的 ActiveClient
// 接缝,因此组合根能把网关自身交给汇聚点设置器。
func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
return s.hub.SendProactive(ctx, msg)
}
// proactivePayloadFrom projects a UnifiedMessage into the sanitized wire payload.
// proactivePayloadFrom 把 UnifiedMessage 投影为消毒后的线上载荷。
func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload {
return proactivePayload{
Platform: msg.Platform,
@@ -79,8 +79,8 @@ func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload {
}
}
// ownerAdminClaims reports whether claims authorize a proactive push (owner or
// admin). Nil claims (unauth / bare test connections) are never targeted.
// ownerAdminClaims 报告 claims 是否授权一次主动推送(owner 或
// admin)。nil claims(未认证 / 裸测试连接)绝不被作为目标。
func ownerAdminClaims(claims *auth.Claims) bool {
if claims == nil {
return false
+10 -10
View File
@@ -8,9 +8,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// TestHub_SendProactive_OwnerAndAdmin verifies a proactive push reaches exactly
// the owner/admin connections and returns the delivered count; a viewer or
// unauthenticated (nil-claims) connection never receives it.
// TestHub_SendProactive_OwnerAndAdmin 验证主动推送恰好抵达
// owner/admin 连接并返回投递计数;viewer 或未认证
// (nil claims)连接绝不收到它。
func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
@@ -20,7 +20,7 @@ func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) {
admin.claims = &auth.Claims{Role: auth.RoleAdmin}
viewer := newFakeConn(hb, 8)
viewer.claims = &auth.Claims{Role: auth.RoleViewer}
bare := newFakeConn(hb, 8) // claims nil: unauthenticated
bare := newFakeConn(hb, 8) // claims 为 nil:未认证
for _, c := range []*wsConn{owner, admin, viewer, bare} {
hb.register(c)
}
@@ -69,8 +69,8 @@ func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) {
}
}
// TestHub_SendProactive_NoOnlineOwner verifies a zero count (no error) when no
// owner/admin connection is live, so the caller falls back to the IM bridge.
// TestHub_SendProactive_NoOnlineOwner 验证没有存活 owner/admin 连接时
// 计数为零(无错误),使调用方回退到 IM bridge。
func TestHub_SendProactive_NoOnlineOwner(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
@@ -87,8 +87,8 @@ func TestHub_SendProactive_NoOnlineOwner(t *testing.T) {
}
}
// TestHub_SendProactive_CancelledContext verifies a cancelled context denies the
// push (fail closed) with a wrapped error.
// TestHub_SendProactive_CancelledContext 验证被取消的 context 拒绝该
// 推送(fail closed)并返回被 wrap 的错误。
func TestHub_SendProactive_CancelledContext(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
@@ -100,8 +100,8 @@ func TestHub_SendProactive_CancelledContext(t *testing.T) {
}
}
// TestServer_SendProactive_DelegatesToHub verifies Server.SendProactive mirrors the
// hub so the composition root can hand the gateway itself to the sink setter.
// TestServer_SendProactive_DelegatesToHub 验证 Server.SendProactive 镜像
// hub,使组合根能把网关自身交给汇聚点设置器。
func TestServer_SendProactive_DelegatesToHub(t *testing.T) {
t.Parallel()
env := newEnv(t, nil, false)
+38 -38
View File
@@ -8,19 +8,19 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// rbacRoute is one admin route plus the method needed to reach it. The name is
// the subtest label, so a failure names the route without reading the path.
// rbacRoute 是一条管理路由加上抵达它所需的方法。name 是
// 子测试标签,因此失败时不必读路径就能指明是哪条路由。
type rbacRoute struct {
name string
method string
path string
}
// viewerReadRoutes is the read-only monitoring tier: the exact admin route set
// wired to viewerAuth in gateway/routes.go (owner/admin/viewer). It must stay in
// lockstep with registerAdminRoutes — adding a route to viewerAuth without
// adding it here leaves it untested, and moving one out of viewerAuth makes the
// matching subtest fail (see the batch's mutation-verification records).
// viewerReadRoutes 是只读监控层:gateway/routes.go 中接到 viewerAuth
// 的确切管理路由集合(owner/admin/viewer)。它必须与
// registerAdminRoutes 保持同步 —— 把一条路由加进 viewerAuth 而不
// 加进这里会让它未被测试,而把一条移出 viewerAuth 会让对应
// 子测试失败(见本批次的变异验证记录)。
var viewerReadRoutes = []rbacRoute{
{"personas", http.MethodGet, "/api/v1/admin/personas"},
{"sessions", http.MethodGet, "/api/v1/admin/sessions"},
@@ -36,10 +36,10 @@ var viewerReadRoutes = []rbacRoute{
{"notifications", http.MethodGet, "/api/v1/admin/notifications"},
}
// ownerAdminReadRoutes is the admin read tier that stays owner/admin: private
// content, other accounts' data and the audit trail. A viewer must get 403; an
// admin must pass the gate (the handler's own status is out of scope here, so
// only "not 401/403" is asserted for admin).
// ownerAdminReadRoutes 是仍保持 owner/admin 的管理读层:私有
// 内容、其他账号的数据与审计链。viewer 必须得到 403;
// admin 必须通过该门(handler 自身的状态在此不在范围内,因此
// 对 admin 只断言「不是 401/403」)。
var ownerAdminReadRoutes = []rbacRoute{
{"memories", http.MethodGet, "/api/v1/admin/memories"},
{"channels/messages", http.MethodGet, "/api/v1/admin/channels/messages"},
@@ -52,10 +52,10 @@ var ownerAdminReadRoutes = []rbacRoute{
{"rag/query", http.MethodPost, "/api/v1/admin/rag/query"},
}
// ownerOnlyRoutes is every route reserved for the owner, including the
// credential-acquiring channel endpoints (login-qr/login-status) and the
// state-mutating writes. An admin must be rejected too, so the list doubles as
// the owner>admin boundary test.
// ownerOnlyRoutes 是保留给 owner 的每一条路由,包括
// 获取凭据的渠道端点(login-qr/login-status)与
// 改变状态的写操作。admin 也必须被拒绝,因此该列表同时用作
// owner>admin 边界测试。
var ownerOnlyRoutes = []rbacRoute{
{"channels/{platform}/login-qr", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-qr"},
{"channels/{platform}/login-status", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-status"},
@@ -94,9 +94,9 @@ var ownerOnlyRoutes = []rbacRoute{
{"system/restore", http.MethodPost, "/api/v1/admin/system/restore"},
}
// newRBACEnv builds the gateway with a real JWT service, a capability registry
// and an injected log ring buffer, so every viewer-readable route can answer
// 200 (the log tail would otherwise be an honest 501).
// newRBACEnv 用真实 JWT 服务、能力注册表
// 与注入的日志环形缓冲构建网关,使每条 viewer 可读路由都能返回
// 200(否则日志尾部会是诚实的 501)。
func newRBACEnv(t *testing.T) *testEnv {
t.Helper()
env := newEnv(t, nil, true)
@@ -104,10 +104,10 @@ func newRBACEnv(t *testing.T) *testEnv {
return env
}
// rbacBody returns a request body valid enough that the RBAC gate — not a
// decoder error — decides the status. A failed decode still answers 4xx, which
// never turns a 403 into a false pass, but a valid body keeps the assertions
// honest about what each route expects.
// rbacBody 返回一个足够合法的请求体,使决定状态的是 RBAC 门 ——
// 而不是解码错误。解码失败仍返回 4xx,这绝不会把
// 403 变成假通过,但合法请求体让断言对每条路由
// 的预期保持诚实。
func rbacBody(rt rbacRoute) any {
switch rt.name {
case "rag/query":
@@ -117,9 +117,9 @@ func rbacBody(rt rbacRoute) any {
}
}
// TestAdminRBAC_ViewerReadsMonitoringSurface locks the viewer tier: each of the
// twelve read-only monitoring routes answers 200 for viewer, admin and owner
// alike (owner>admin>viewer all satisfy viewerAuth).
// TestAdminRBAC_ViewerReadsMonitoringSurface 锁定 viewer 层:十二条只读
// 监控路由对 viewer、admin 与 owner 都返回 200
// (owner>admin>viewer 都满足 viewerAuth)。
func TestAdminRBAC_ViewerReadsMonitoringSurface(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
@@ -140,9 +140,9 @@ func TestAdminRBAC_ViewerReadsMonitoringSurface(t *testing.T) {
}
}
// TestAdminRBAC_AnonymousIsUnauthorized proves every admin route (both tiers)
// fails closed with 401 — not 403 and never a silent success — when no bearer
// token is presented.
// TestAdminRBAC_AnonymousIsUnauthorized 证明未出示 bearer token 时每条
// 管理路由(两层)都以 401 失败关闭 —— 不是 403,也绝不是
// 静默成功。
func TestAdminRBAC_AnonymousIsUnauthorized(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
@@ -162,10 +162,10 @@ func TestAdminRBAC_AnonymousIsUnauthorized(t *testing.T) {
}
}
// TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes locks the exclusion list: a
// viewer is 403 on every admin read that carries private content, other
// accounts' data or audit records, and on every owner-only route (writes plus
// the credential-acquiring channel endpoints).
// TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes 锁定排除列表:viewer 在
// 每一条携带私有内容、其他账号数据或审计记录的
// 管理读上都是 403,在每一条仅 owner 路由(写操作加上
// 获取凭据的渠道端点)上亦然。
func TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
@@ -185,10 +185,10 @@ func TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes(t *testing.T) {
}
}
// TestAdminRBAC_AdminPassesReadGateButNotOwnerGate separates the two upper
// tiers: an admin passes the gate on the owner/admin read routes (its handler
// may still answer 404/501 — only the gate's verdict is asserted) but is 403 on
// every owner-only route, proving admin never leaks into owner.
// TestAdminRBAC_AdminPassesReadGateButNotOwnerGate 区分上面两层:admin
// 在 owner/admin 读路由上通过该门(其 handler 仍可能返回
// 404/501 —— 只断言门的判定)但在每一条仅 owner 路由上
// 是 403,证明 admin 绝不越入 owner。
func TestAdminRBAC_AdminPassesReadGateButNotOwnerGate(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
@@ -215,8 +215,8 @@ func TestAdminRBAC_AdminPassesReadGateButNotOwnerGate(t *testing.T) {
}
}
// TestAdminRBAC_ViewerForbiddenOnWrites pins the two write routes named in the
// wiring brief so a future gate change cannot quietly hand a viewer a mutation.
// TestAdminRBAC_ViewerForbiddenOnWrites 钉住接线说明中点名的两条写
// 路由,使今后的门的改动无法悄悄把变更能力交给 viewer。
func TestAdminRBAC_ViewerForbiddenOnWrites(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
+85 -85
View File
@@ -14,10 +14,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// tokenTTL is how long an issued JWT remains valid.
// tokenTTL 是已签发 JWT 的有效时长。
const tokenTTL = 24 * time.Hour
// PersonaSummary is the wire shape of a persona in REST/admin list responses.
// PersonaSummary 是 REST/管理列表响应中一个人格的线上结构。
type PersonaSummary struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -26,53 +26,53 @@ type PersonaSummary struct {
Mode string `json:"mode"`
MemoryNS string `json:"memoryNamespace"`
Avatar string `json:"avatar,omitempty"`
// ChatBackground holds per-orientation wallpaper; a role's own, never another's.
// ChatBackground 持有分方向的壁纸;是本角色自己的,绝不是别人的。
ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"`
// Mood is the role's current emotion state (design/01 §2: emotion follows the
// role, never a session). "" means the role has no tracked emotion — an honest
// empty, never a fabricated default. Clients seed their mood badge from it so a
// refresh no longer flashes an empty mood.
// Mood 是该角色的当前情绪状态(design/01 §2:情绪跟随角色,
// 绝不是会话)。"" 意味着该角色没有受跟踪的情绪 —— 诚实的
// 空值,绝不是编造的默认。客户端从它给 mood 角标播种,因此
// 刷新不再闪出空 mood。
Mood string `json:"mood"`
// Thinking carries the persona's optional reasoning/presentation block
// (design/01 §2.1). It is nil — and so absent from the JSON — when the persona
// configures none, mirroring the write side's "an omitted block keeps the
// stored one". Admin list only: these are model-call knobs, not user-UI state
// (the user list takes Mood and leaves the knobs to the panel).
// Thinking 携带人格可选的推理/呈现块
// (design/01 §2.1)。人格未配置时它是 nil —— 因而从 JSON 中缺席 ——
// 与写侧「省略的块保留
// 已存块」。仅管理列表:这些是模型调用开关,不是用户 UI 状态
// (用户列表取 Mood,把开关留给面板)。
Thinking *PersonaThinkingSummary `json:"thinking,omitempty"`
}
// PersonaThinkingSummary mirrors a persona's `thinking` block on the wire
// (design/01 §2.1; provider mapping in design/04 §3.2).
// PersonaThinkingSummary 在线上镜像人格的 `thinking` 块
// (design/01 §2.1;provider 映射见 design/04 §3.2)。
type PersonaThinkingSummary struct {
// BudgetTokens is the reasoning budget (Anthropic thinking.budget_tokens).
// BudgetTokens 是推理预算(Anthropic thinking.budget_tokens)。
BudgetTokens int `json:"budgetTokens"`
// Effort is the provider-specific reasoning strength (reasoning_effort). The
// model layer validates it against the active provider's whitelist and WARNs
// when it is dropped — this side just reports what is stored.
// Effort 是 provider 特有的推理强度(reasoning_effort)。
// 模型层会按活动 provider 的白名单校验它,并在它被丢弃时
// 打 WARN —— 这一侧只报告已存的内容。
Effort string `json:"effort"`
// ShowToUser is TRI-STATE and stays that way on the wire: null = not
// configured (thinking frames keep flowing — the pre-existing behavior),
// true/false = an explicit choice. A plain bool would flatten "unset" onto
// "off" and lose the distinction the write side keeps with *bool.
// ShowToUser 是三态的,且在线上保持如此:null = 未
// 配置(thinking 帧继续流出 —— 既有行为),
// true/false = 显式选择。普通 bool 会把「未设置」压平到
// 「关闭」上,丢掉写侧用 *bool 保持的区分。
ShowToUser *bool `json:"showToUser"`
// Mode is the explicit hard switch: default | on | off ("" = default).
// Mode 是显式的硬开关:default | on | off("" = default)。
Mode string `json:"mode"`
}
// ChatBackgroundSummary mirrors the persona chatBackground on the wire.
// ChatBackgroundSummary 在线上镜像人格的 chatBackground。
type ChatBackgroundSummary struct {
Landscape string `json:"landscape,omitempty"`
Portrait string `json:"portrait,omitempty"`
}
// MemorySummary is the wire shape of a memory entry in the admin list response.
// MemorySummary 是管理列表响应中一条记忆条目的线上结构。
type MemorySummary struct {
ID string `json:"id"`
Scope string `json:"scope"`
Text string `json:"text"`
}
// SessionSummary is the wire shape of a channel session in list responses.
// SessionSummary 是列表响应中一个渠道会话的线上结构。
type SessionSummary struct {
Platform string `json:"platform"`
ChannelID string `json:"channelId"`
@@ -83,15 +83,15 @@ type SessionSummary struct {
MemWrite bool `json:"memWritable"`
}
// loginRequest is the POST /api/v1/auth/login body.
// loginRequest 是 POST /api/v1/auth/login 的请求体。
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// loginResponse is the login success payload. RefreshToken is the opaque
// long-lived credential (POST /auth/refresh) added by B2; it is omitted when no
// refresh store is wired so the pre-B2 contract is unchanged.
// loginResponse 是登录成功的载荷。RefreshToken 是 B2 新增的非透明
// 长期凭据(POST /auth/refresh);未接线 refresh 存储时被省略,
// 因此 B2 之前的契约不变。
type loginResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
@@ -99,15 +99,15 @@ type loginResponse struct {
RefreshToken string `json:"refreshToken,omitempty"`
}
// chatRequest is the POST /api/v1/chat body. When Stream is true the gateway
// triggers streaming over the WebSocket channel and returns immediately. Image is
// an optional base64 data: URI or http(s) URL attached for the vision path; the
// loop routes it through the vision/OCR tool or the main model (design/06 §0).
// DeviceID is the client-produced identifier of the sending browser/device
// (design/16 §4.5), e.g. "web:chrome:a1b2c3"; it is optional and persisted as-is.
// Mode is the behavior mode the client sent the turn in (design/01 §3, design/16
// §5.1); it is optional and persisted per message for bubble rendering, falling
// back to "chat" when omitted.
// chatRequest 是 POST /api/v1/chat 的请求体。Stream 为 true 时网关
// 经 WebSocket 通道触发流式并立即返回。Image 是
// 为视觉路径附带的可选 base64 data: URI 或 http(s) URL;
// loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。
// DeviceID 是客户端产出的发送浏览器/设备标识
// (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。
// Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16
// §5.1);它可选,并按消息持久化以供气泡渲染,省略时
// 回退到 "chat"。
type chatRequest struct {
SessionID string `json:"sessionId"`
Text string `json:"text"`
@@ -117,19 +117,19 @@ type chatRequest struct {
Stream bool `json:"stream"`
}
// chatResponse is the synchronous chat reply payload.
// chatResponse 是同步聊天回复的载荷。
type chatResponse struct {
Reply string `json:"reply"`
Steps int `json:"steps"`
StopReason string `json:"stopReason"`
}
// handleHealth reports liveness. It is unauthenticated because it is a probe.
// handleHealth 报告存活。它不认证,因为它是探针。
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
// handleLogin exchanges the admin username/password for a signed JWT.
// handleLogin 用管理用户名/密码换取已签名的 JWT。
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -152,9 +152,9 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
Role: string(auth.RoleOwner),
}
// Issue the long-lived refresh credential alongside the access token. A store
// failure fails the login rather than silently handing back a login without
// the advertised refresh token; the plaintext is never logged.
// 在签发访问令牌的同时签发长期 refresh 凭据。存储失败会让
// 登录失败,而不是静默交回一个没有所声称 refresh token 的
// 登录;明文绝不记日志。
if s.refresh != nil {
refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL())
if rerr != nil {
@@ -167,7 +167,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// handleChat runs one synchronous agent turn (or triggers streaming).
// handleChat 运行一次同步 agent 回合(或触发流式)。
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -190,9 +190,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if req.SessionID == "" {
req.SessionID = "web"
}
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior. The role
// comes from the session's binding (design/22 §4.5).
// Web 主会话是主人的私密对话(JWT = owner),因此它绑定
// 全访问 env:记忆读+写,行为不变。角色
// 来自会话的绑定(design/22 §4.5)。
msgs := userChatMessages(req.Text, req.Image)
env := s.envResolver.owner(r.Context(), req.SessionID)
if s.hub.orchestrator != nil {
@@ -205,8 +205,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
return
}
// A busy session returns a non-blocking ack (design/15 §2.1) instead of
// blocking the request on a turn that may run for a while.
// 忙碌会话返回非阻塞 ack(design/15 §2.1),而不是
// 让请求阻塞在一个可能跑一阵子的回合上。
if sr.Pending {
writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth})
return
@@ -219,8 +219,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
return
}
// Legacy synchronous path when no orchestrator is wired (a hub built without
// one keeps the exact pre-design/15 reply contract).
// 未接线编排器时的旧同步路径(未接线的 hub
// 保持完全与 design/15 之前相同的回复契约)。
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), env), req.SessionID, msgs)
if err != nil {
s.log.Error("chat run", "err", err, "session", req.SessionID)
@@ -230,10 +230,10 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
}
// handleListSessions returns the persistent chat-history sessions (main + sub) when a
// history store is wired, otherwise falls back to the channel-session view. The fallback
// keeps the pre-design/16 contract: a server built without history still lists the known
// channel sessions (or [] when no store is wired).
// handleListSessions 在接线了 history 存储时返回持久化聊天历史会话
// (main + sub),否则回退到渠道会话视图。回退保持
// design/16 之前的契约:未接 history 的服务器仍列出已知的
// 渠道会话(未接线任何存储时为 [])。
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
if s.history != nil {
s.handleHistorySessions(w, r)
@@ -254,16 +254,16 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// handleListPersonas returns the personas (or [] when no store is wired). It is
// the user-side shape: the role-level mood rides along, the admin-only thinking
// knobs do not.
// handleListPersonas 返回人格(未接线存储时为 [])。它是
// 用户侧形状:角色级 mood 一并带上,仅管理的 thinking
// 开关则不然。
func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectPersonas(false))
}
// collectPersonas returns a non-nil persona slice from the configured store.
// withThinking adds the admin-only `thinking` block; mood is always reported
// because the user UI renders the badge.
// collectPersonas 从已配置的存储返回非 nil 的人格切片。
// withThinking 加上仅管理的 `thinking` 块;mood 总是报告,
// 因为用户 UI 要渲染角标。
func (s *Server) collectPersonas(withThinking bool) []PersonaSummary {
out := make([]PersonaSummary, 0)
if s.personas == nil {
@@ -282,9 +282,9 @@ func (s *Server) collectPersonas(withThinking bool) []PersonaSummary {
Mode: firstMode(c.Modes),
Mood: s.moodOf(id),
}
// Read this persona's own visual assets (avatar / chat background). Each
// role carries its own; a missing persona or unset visual yields empty,
// never another role's — the frontend falls back to a neutral default.
// 读取该人格自己的视觉资产(头像 / 聊天背景)。每个
// 角色携带自己的;人格缺失或视觉未设置得到空,
// 绝不是别的角色的 —— 前端回退到中性默认。
if p, ok := s.personas.Persona(id); ok && p != nil {
if p.Visual.Avatar != "" {
sum.Avatar = p.Visual.Avatar
@@ -302,10 +302,10 @@ func (s *Server) collectPersonas(withThinking bool) []PersonaSummary {
return out
}
// moodOf reports the role's current emotion state. Emotion is owned by the
// persona store (design/01 §2), read through the optional PersonaMoodStore
// capability; a store that does not track emotion, or a role with no machine,
// reports "" instead of a made-up mood.
// moodOf 报告该角色的当前情绪状态。情绪归
// 人格存储所有(design/01 §2),经可选的 PersonaMoodStore
// 能力读取;不跟踪情绪的存储,或没有情绪机的角色,
// 报告 "" 而不是编造一个 mood。
func (s *Server) moodOf(id string) string {
store, ok := s.personas.(PersonaMoodStore)
if !ok {
@@ -322,11 +322,11 @@ func (s *Server) moodOf(id string) string {
return string(machine.Current())
}
// thinkingSummaryOf maps a stored thinking block onto the wire. It returns nil
// when the persona configures none — the key is then absent, so a client can tell
// "not configured" from an explicit value (the read-side mirror of the write-side
// rule that an omitted block keeps the stored one). include is false for the
// user-side list, which does not carry these knobs.
// thinkingSummaryOf 把已存 thinking 块映射到线上。人格未配置时
// 它返回 nil —— 该键因而缺席,使客户端能把「未配置」
// 与显式取值区分开(写侧「省略的块保留已存块」规则的
// 读侧镜像)。用户侧列表的 include 为 false,
// 因为用户侧不携带这些开关。
func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSummary {
if !include || !thinkingConfigured(t) {
return nil
@@ -339,13 +339,13 @@ func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSu
}
}
// thinkingConfigured reports whether any thinking knob is set. An empty block
// must read as "not configured" rather than as an all-zero object.
// thinkingConfigured 报告是否有任何 thinking 开关被设置。空块
// 必须读作「未配置」,而不是全零对象。
func thinkingConfigured(t loader.ThinkingParams) bool {
return t.BudgetTokens != 0 || t.Effort != "" || t.ShowToUser != nil || t.Mode != ""
}
// firstMode returns the first declared behavior mode, or "" when none are set.
// firstMode 返回首个声明的行为模式,未设置时返回 ""。
func firstMode(modes []string) string {
if len(modes) == 0 {
return ""
@@ -353,9 +353,9 @@ func firstMode(modes []string) string {
return modes[0]
}
// userChatMessages builds the user turn for a chat / stream request. When image is
// present the turn becomes an image message (with optional text); otherwise a
// plain text message. An empty text with an image yields an image-only turn.
// userChatMessages 为 chat / stream 请求构建用户回合。image
// 存在时该回合成为图片消息(可带文本);否则是
// 纯文本消息。空文本加图片得到仅图片的回合。
func userChatMessages(text, image string) []model.Message {
if strings.TrimSpace(image) != "" {
return []model.Message{model.ImageMessage(model.RoleUser, text, image)}
@@ -363,7 +363,7 @@ func userChatMessages(text, image string) []model.Message {
return []model.Message{model.TextMessage(model.RoleUser, text)}
}
// summaryOfSession maps a session.Session to its wire shape.
// summaryOfSession 把 session.Session 映射为其线上结构。
func summaryOfSession(sess session.Session) SessionSummary {
return SessionSummary{
Platform: sess.Platform,
@@ -376,15 +376,15 @@ func summaryOfSession(sess session.Session) SessionSummary {
}
}
// writeJSON encodes v as the {data: v} envelope with the given status.
// writeJSON 把 v 编码为 {data: v} 信封并带上给定状态。
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"data": v})
}
// writeError encodes err as the {error:{code,message}} envelope with the status
// resolved by apperr.HTTPStatus.
// writeError 把 err 编码为 {error:{code,message}} 信封,状态由
// apperr.HTTPStatus 解析。
func writeError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apperr.HTTPStatus(err))
+76 -76
View File
@@ -6,22 +6,22 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// This file owns the HTTP surface: the route table (REST /api/v1, the realtime
// WebSocket hub, /api/v1/admin/* and externally-mounted webhook callbacks) and
// the outer middleware chain. Route registration is split by audience so the
// RBAC gate guarding each group stays visible: public, authenticated user,
// private history, admin reads and owner-only writes.
// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时
// WebSocket hub、/api/v1/admin/* 与外部挂载的 webhook 回调)以及
// 外层中间件链。路由注册按受众切分,使守护每组
// 的 RBAC 门保持可见:公开、已认证用户、
// 私有历史、管理读与仅 owner 写。
// Handler returns the fully assembled http.Handler: the ServeMux routing
// REST + WS + admin, wrapped in recovery, CORS and request logging.
// Handler 返回完整组装的 http.Handler:路由 REST + WS + 管理的
// ServeMux,外面包上 recover、CORS 与请求日志。
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// The RBAC middlewares are built once per role set and shared by every route
// that requires it (owner>admin>viewer, DevDocs/02 §九). viewerAuth is "every
// authenticated role": it names the read-only monitoring surface the viewer
// role exists for (module inventories, runtime metrics/traces, the log tail
// and the notification feed) — see registerAdminRoutes.
// RBAC 中间件按角色集各构建一次,并被每条需要它的路由
// 共用(owner>admin>viewer,DevDocs/02 §九)。viewerAuth 是「每个
// 已认证角色」:它指 viewer 角色存在的那个只读监控
// 表面(模块清单、运行时指标/trace、日志尾部
// 与通知 feed)—— 见 registerAdminRoutes。
anyAuth := s.auth.Authorize()
ownerAuth := s.auth.Authorize(auth.RoleOwner)
writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
@@ -36,11 +36,11 @@ func (s *Server) Handler() http.Handler {
return s.withMiddleware(mux)
}
// registerPublicRoutes mounts the unauthenticated surface: liveness plus the
// credential endpoints. Refresh/logout are public by design — the opaque refresh
// token is itself the credential, so a client with an expired access token can
// still rotate or revoke it. Both fail closed with 401 invalid_grant on a bad
// token.
// registerPublicRoutes 挂载未认证表面:存活探针加上
// 凭据端点。refresh/logout 按设计是公开的 —— 非透明 refresh
// token 本身就是凭据,因此访问令牌已过期的客户端仍能
// 轮换或吊销它。二者在 token 非法时都以 401 invalid_grant
// 失败关闭。
func (s *Server) registerPublicRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
@@ -48,11 +48,11 @@ func (s *Server) registerPublicRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
}
// registerUserRoutes mounts the authenticated user-facing surface. Chat is a
// write (owner/admin); reads accept any authenticated role so a viewer can still
// list personas/sessions. Audio STT/TTS and image upload are owner/admin-only (the
// owner's private assistant, audited) and are the REST surface native clients use
// to bypass the 4KB WebSocket frame limit.
// registerUserRoutes 挂载已认证的面向用户表面。Chat 是
// 写(owner/admin);读接受任何已认证角色,因此 viewer 仍能
// 列出人格/会话。音频 STT/TTS 与图片上传仅 owner/admin(主人的
// 私密助手,带审计),它们是原生客户端用来
// 绕过 4KB WebSocket 帧上限的 REST 表面。
func (s *Server) registerUserRoutes(
mux *http.ServeMux,
anyAuth func(http.Handler) http.Handler,
@@ -62,74 +62,74 @@ func (s *Server) registerUserRoutes(
mux.Handle("POST /api/v1/chat", writeAuth(http.HandlerFunc(s.handleChat)))
mux.Handle("GET /api/v1/sessions", anyAuth(http.HandlerFunc(s.handleListSessions)))
mux.Handle("GET /api/v1/personas", anyAuth(http.HandlerFunc(s.handleListPersonas)))
// Shared client enum tables (Android collaboration B4, design/07 §6): the
// authority every client maps ids to labels from, so a source badge or a mood
// label cannot drift per client. A read-only projection of
// internal/contract/enums with nothing sensitive in it, so any authenticated
// role may read it (same gate as /sessions above).
// 共享的客户端枚举表(Android 协作 B4,design/07 §6):
// 每个客户端据此把 id 映射为标签的权威,因此来源徽标或
// 情绪标签不会逐客户端漂移。它是
// internal/contract/enums 的只读投影,不含任何敏感内容,因此任何已认证
// 角色都可读它(上面的 /sessions 同一道门)。
mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums)))
mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe)))
mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech)))
mux.Handle("POST /api/v1/audio/voice-clone", ownerAuth(http.HandlerFunc(s.handleVoiceClone)))
// Image upload (B1): a multipart POST that stores a raster image under the
// resolved data dir and returns a same-origin URL usable directly as
// chat.image. Upload is owner/admin + audited; the static route is a
// capability URL (unguessable name) because chat.image / <img> cannot carry a
// bearer token. Both ride the one gateway port (single-binary red line).
// 图片上传(B1):一个 multipart POST,把光栅图片存到
// 解析后的数据目录下,返回可直接用作
// chat.image 的同源 URL。上传是 owner/admin + 带审计;静态路由是
// 能力 URL(名字不可猜)因为 chat.image / <img> 无法携带
// bearer token。二者都走同一个网关端口(单二进制红线)。
mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload)))
mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile)
}
// registerHistoryRoutes mounts the persistent chat-history surface (design/16)
// plus the realtime endpoint. Session/model reads and writes are the owner's
// private history, so owner/admin only (chat uses the same roles); the message
// content (and thinking) is owner/admin-only per design/16 §6, while the session
// metadata in GET /sessions above stays readable by any authenticated role.
// Creating and deleting a sub session mutate persistent state.
// registerHistoryRoutes 挂载持久化聊天历史表面(design/16)
// 加上实时端点。会话/模型读与写是主人的
// 私有历史,因此仅 owner/admin(chat 用同样的角色);消息
// 内容(与 thinking)按 design/16 §6 仅 owner/admin,而上面
// GET /sessions 中的会话元数据仍可被任何已认证角色读取。
// 创建与删除子会话会改变持久化状态。
func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.Handler) http.Handler) {
mux.Handle("GET /api/v1/sessions/{id}/messages", writeAuth(http.HandlerFunc(s.handleListMessages)))
mux.Handle("POST /api/v1/sessions", writeAuth(http.HandlerFunc(s.handleCreateSubSession)))
mux.Handle("DELETE /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handleDeleteSession)))
mux.Handle("PATCH /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handlePatchSession)))
// Read cursor (D4 / Android collaboration B3): advancing it clears the session's
// unread badge on every end of the account, so it is an owner/admin history write
// like the other session mutations. It broadcasts a `session_read` frame.
// 读游标(D4 / Android 协作 B3):推进它会为该账号
// 每一端清除该会话的未读角标,因此它和其他会话变更一样
// 是 owner/admin 历史写。它会广播一个 `session_read` 帧。
mux.Handle("POST /api/v1/sessions/{id}/read", writeAuth(http.HandlerFunc(s.handleMarkSessionRead)))
// Single-message edit/delete (Android collaboration batch ③a, D3). A message is
// addressed by its own opaque id; both are owner/admin-gated history writes.
// 单条消息编辑/删除(Android 协作批次 ③a,D3)。消息由
// 自身非透明 id 定位;二者都是受 owner/admin 门控的历史写。
mux.Handle("PATCH /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handlePatchMessage)))
mux.Handle("DELETE /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handleDeleteMessage)))
// Realtime.
// 实时。
mux.HandleFunc("/ws/chat", s.handleWS)
}
// registerAdminRoutes mounts the admin API. Three read tiers plus two write
// tiers, mirroring the role hierarchy (RBAC red line):
// registerAdminRoutes 挂载管理 API。三个读层加两个写
// 层,镜像角色层级(RBAC 红线):
//
// - viewerAuth (owner/admin/viewer): the read-only monitoring surface — module
// inventories, runtime metrics/traces, the backend log tail and the
// notification feed. Every payload here is a non-sensitive projection with no
// credential, no other account's data and no message body, which is what
// makes the viewer role a usable dashboard/logs/monitoring account.
// - writeAuth reads (owner/admin): everything a viewer must not see — memories,
// the LLM provider stack, accounts/API keys, the backthink monitor, the audit
// trail, channel message logs/transcripts, full session export.
// - writeAuth writes (owner/admin): the audited knowledge search.
// - ownerAuth (owner): the audited policy write, every credential-acquiring
// channel endpoint (login-qr/login-status) and every other mutation.
// - viewerAuth(owner/admin/viewer):只读监控表面 —— 模块
// 清单、运行时指标/trace、后端日志尾部与
// 通知 feed。这里每个载荷都是非敏感投影,不含
// 凭据、不含其他账号的数据、不含消息体,这正是
// 让 viewer 角色成为可用的看板/日志/监控账号的原因。
// - writeAuth 读(owner/admin):viewer 不得看到的一切 —— 记忆、
// LLM provider 栈、账号/API key、后台思考监控、审计
// 链、渠道消息日志/转录、完整会话导出。
// - writeAuth 写(owner/admin):带审计的知识搜索。
// - ownerAuth(owner):带审计的策略写、每个获取凭据的
// 渠道端点(login-qr/login-status)以及每一个其他变更。
//
// Each write handler persists to the real backing store or answers honestly with
// a clear "M10 / not-wired" error rather than fabricating a success.
// 每个写 handler 都会持久化到真实后端存储,或用明确的
// "M10 / not-wired" 错误诚实作答,而不是编造成功。
func (s *Server) registerAdminRoutes(
mux *http.ServeMux,
ownerAuth func(http.Handler) http.Handler,
writeAuth func(http.Handler) http.Handler,
viewerAuth func(http.Handler) http.Handler,
) {
// Admin reads open to viewer: the read-only monitoring/inventory surface.
// 对 viewer 开放的管理读:只读监控/清单表面。
mux.Handle("GET /api/v1/admin/personas", viewerAuth(http.HandlerFunc(s.adminListPersonas)))
mux.Handle("GET /api/v1/admin/sessions", viewerAuth(http.HandlerFunc(s.adminListSessions)))
mux.Handle("GET /api/v1/admin/plugins", viewerAuth(http.HandlerFunc(s.adminListPlugins)))
@@ -137,21 +137,21 @@ func (s *Server) registerAdminRoutes(
mux.Handle("GET /api/v1/admin/channels", viewerAuth(http.HandlerFunc(s.adminListChannels)))
mux.Handle("GET /api/v1/admin/iot", viewerAuth(http.HandlerFunc(s.adminListIoT)))
mux.Handle("GET /api/v1/admin/automations", viewerAuth(http.HandlerFunc(s.adminListAutomations)))
// Knowledge-base document list: names/descriptions plus chunk/embedding
// status. The `?query=` search rides along here and stays read-only.
// 知识库文档列表:名字/描述加上切分/嵌入
// 状态。`?query=` 搜索在旁一并提供,且保持只读。
mux.Handle("GET /api/v1/admin/rag", viewerAuth(http.HandlerFunc(s.adminListRag)))
mux.Handle("GET /api/v1/admin/observability/metrics", viewerAuth(http.HandlerFunc(s.adminObservabilityMetrics)))
mux.Handle("GET /api/v1/admin/observability/trace", viewerAuth(http.HandlerFunc(s.adminObservabilityTrace)))
// Backend log tail: the logger's own bounded ring buffer, injected by the
// composition root (501 until then). Newest first, `?level=`/`?limit=` filtered.
// 后端日志尾部:logger 自带有界环形缓冲,由组合根注入
// (在那之前 501)。最新优先,按 `?level=`/`?limit=` 过滤。
mux.Handle("GET /api/v1/admin/observability/logs", viewerAuth(http.HandlerFunc(s.adminObservabilityLogs)))
// Unified notification feed (design/10 §3 #16): a read-only projection of the
// audit trail + backthink pushes, so the panel's notification page needs no
// new persisted model.
// 统一通知 feed(design/10 §3 #16):审计链 + 后台思考推送的
// 只读投影,因此面板的通知页不需要
// 任何新的持久化模型。
mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications)))
// Admin reads reserved for owner/admin: private content, other accounts'
// data and the audit trail. Not open to viewer.
// 保留给 owner/admin 的管理读:私有内容、其他账号的
// 数据与审计链。不对 viewer 开放。
mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))
mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages)))
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
@@ -163,7 +163,7 @@ func (s *Server) registerAdminRoutes(
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy)))
// Admin writes: owner-only, audited.
// 管理写:仅 owner,带审计。
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))
mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona)))
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
@@ -200,9 +200,9 @@ func (s *Server) registerAdminRoutes(
mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore)))
}
// mountWebhooks mounts externally-supplied inbound callbacks (e.g. the webhook
// channel) without gateway auth; each handler enforces its own
// signature/authorization.
// mountWebhooks 挂载外部提供的入站回调(例如 webhook
// 渠道)且不经网关认证;每个 handler 强制自己的
// 签名/授权。
func (s *Server) mountWebhooks(mux *http.ServeMux) {
for path, hdl := range s.webhooks {
if path == "" {
@@ -212,9 +212,9 @@ func (s *Server) mountWebhooks(mux *http.ServeMux) {
}
}
// withMiddleware wraps the routed handler in the outer layers, outermost first:
// the upload capability-URL guard, the SPA fallback (when a frontend is
// configured), panic recovery, CORS and request logging.
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
// panic recover、CORS 与请求日志。
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
var h http.Handler = mux
h = guardUploadPaths(h)
@@ -13,13 +13,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file covers the end-to-end half of design/22 §7-5: a session that switched
// roles must keep the conversation it had, and the model must be able to tell
// which utterances came from the earlier role. The chain asserted here is:
// chat_messages (per-message persona_id) → the orchestrator's seeded turn context
// → the loop's speaker attribution → the model.Request the adapter receives.
// 本文件覆盖 design/22 §7-5 的端到端一半:切换过角色的
// 会话必须保留它曾有过的对话,且模型必须能分辨
// 哪些话语来自更早的角色。这里断言的链条是:
// chat_messages(每消息 persona_id)→ 编排器播种的回合上下文
// → loop 的说话者归属 → 适配器收到的 model.Request。
// requestTexts returns the text of every message of req, in order.
// requestTexts 按顺序返回 req 每条消息的文本。
func requestTexts(req model.Request) []string {
out := make([]string, 0, len(req.Messages))
for _, m := range req.Messages {
@@ -28,7 +28,7 @@ func requestTexts(req model.Request) []string {
return out
}
// messageTextOf concatenates the text parts of m.
// messageTextOf 拼接 m 的文本部分。
func messageTextOf(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
@@ -39,11 +39,11 @@ func messageTextOf(m model.Message) string {
return b.String()
}
// TestChatTurnCarriesSpeakerAttributionFromTranscript is design/22 §7-5 accepted
// from the process boundary: the session already holds turns spoken by role X, it
// is now bound to role Y, and one more chat turn arrives. The request must carry
// X's earlier utterance with its speaker attributed to X (not to Y), keep every
// stored body verbatim, and still open with Y's own instructions.
// TestChatTurnCarriesSpeakerAttributionFromTranscript 是从进程边界验收的
// design/22 §7-5:该会话已持有角色 X 说过的话,它现在
// 绑定到角色 Y,且又来了一个聊天回合。请求必须携带
// X 更早的话语且其说话者归属到 X(不是 Y),原样保留每个
// 已存消息体,并仍以 Y 自己的指令开头。
func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -53,8 +53,8 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
// The conversation as it stood while the session spoke as X, with per-message
// persona ids exactly as the orchestrator persists them (design/22 §7-5).
// 该会话以 X 说话时的对话原貌,每消息
// persona id 与编排器持久化的完全一致(design/22 §7-5)。
for _, m := range []history.Message{
{SessionID: sub, Role: history.RoleUser, Content: "你还记得吗", PersonaID: "xilian", CreatedAt: 1000},
{SessionID: sub, Role: history.RoleAssistant, Content: "我记住了,你喜欢冰美式", PersonaID: "xilian", CreatedAt: 1001},
@@ -86,13 +86,13 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
}
req := ad.reqs[len(ad.reqs)-1]
// The answering role still owns the head of the prompt.
// 应答角色仍然占据提示词的开头。
if got := firstSystemText(req); got != "你是由纪。" {
t.Fatalf("first system = %q, want role Y's prompt", got)
}
// X's earlier utterance reached the model, verbatim (the turn is not amnesiac
// across the role switch).
// X 更早的话语原样抵达了模型(该回合没有
// 在角色切换处失忆)。
texts := requestTexts(req)
xIdx := -1
for i, txt := range texts {
@@ -111,7 +111,7 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
t.Fatalf("speaker label = %q, want xilian (the role that actually spoke it)", req.Messages[xIdx].SenderName)
}
// The speaker boundary names X and marks the stretch as not Y's own words.
// 说话者边界点明 X,并把那一段标记为不是 Y 自己的话。
note := ""
for _, m := range req.Messages {
if m.Role == model.RoleSystem && strings.Contains(messageTextOf(m), "「昔涟」(xilian)") {
@@ -123,8 +123,8 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
t.Fatalf("no speaker boundary for X found: %q", texts)
}
// No stored body was rewritten, and the newly sent user text carries no system
// metadata (the user lane stays free text, design/11 §10).
// 没有任何已存消息体被改写,且新发送的用户文本不携带 system
// 元数据(用户通道保持自由文本,design/11 §10)。
for _, m := range req.Messages {
if m.Role != model.RoleUser {
continue
+136 -136
View File
@@ -12,39 +12,39 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file implements the session-message orchestrator (DevDocs/design/15).
// 本文件实现会话消息编排器(DevDocs/design/15)。
//
// It is deliberately NOT a task engine. design/04 §2 (task/subagent) decides
// whether a heavy job gets pushed to the background; this orchestrator decides
// how NEW user messages queue and resume while a session is busy. The two layers
// are orthogonal and compose: a session runs a task (04), a new message arrives
// mid-run (15), and the orchestrator holds it in a bounded pending queue and
// resumes it with the previous turn's context once the current turn finishes.
// 它刻意不是任务引擎。design/04 §2(task/subagent)决定
// 一个重活是否被推到后台;本编排器决定
// 会话忙碌时新的用户消息如何排队与续跑。两层
// 正交且可组合:一个会话跑一个任务(04),新消息在
// 运行中途到达(15),编排器把它握在有界待处理队列中,
// 并在当前回合结束后带着上一个回合的上下文续跑它。
//
// Non-blocking is a hard constraint: Submit/Interrupt never wait on a running
// turn. The queue is bounded per session and isolated across sessions, and no
// identity decision is made here — the orchestrator only orders intent/messages,
// identity stays in the channel actor metadata (project red line).
// 非阻塞是硬约束:Submit/Interrupt 绝不等待运行中的
// 回合。队列按会话有界,且跨会话隔离,这里不做
// 任何身份判定 —— 编排器只排序意图/消息,
// 身份始终留在 channel 的 actor 元数据中(项目红线)。
// Mode is the per-session busy-state lane of the orchestrator. It is exposed so
// a loop/subagent hook can raise it to executing/subagent (an injection point
// the composition root may wire later), but the orchestrator itself tracks an
// abstract "busy" via mode != ModeIdle.
// Mode 是编排器的每会话忙碌状态通道。它被暴露出来,
// 使 loop/subagent 钩子能把它升到 executing/subagent(一个
// 组合根之后可能接线的注入点),但编排器自身只用
// mode != ModeIdle 跟踪抽象的「忙碌」。
type Mode string
const (
// ModeIdle means the session has no turn in flight and no resume pending.
// ModeIdle 意味着该会话没有在飞回合、也没有待处理续跑。
ModeIdle Mode = "idle"
// ModeThinking means a turn is in flight (the initial start state).
// ModeThinking 意味着有回合在飞(起始的初始状态)。
ModeThinking Mode = "thinking"
// ModeExecuting means a turn is executing tools/substeps.
// ModeExecuting 意味着回合正在执行工具/子步骤。
ModeExecuting Mode = "executing"
// ModeSubagent means a turn is delegated to a subagent (design/04 §2).
// ModeSubagent 意味着回合被委派给子代理(design/04 §2)。
ModeSubagent Mode = "subagent"
)
// State event types pushed over the WS channel to the frontend. The data map of
// each carries sessionId so a client can correlate per-session state.
// 经 WS 通道推给前端的状态事件类型。每个的 data map 都
// 携带 sessionId,使客户端能按会话关联状态。
const (
EventSessionBusy = "session_busy"
EventPendingAck = "pending_ack"
@@ -55,85 +55,85 @@ const (
)
const (
// defaultMaxPending bounds the per-session pending queue (design/15 §4).
// defaultMaxPending 限定每会话的待处理队列(design/15 §4)。
defaultMaxPending = 5
// defaultMaxTail bounds the conversation tail kept for continuation, so the
// resumed context is incremental, not a full replay (design/15 §2.2, §4).
// defaultMaxTail 限定为续跑而保留的对话尾部,使
// 续跑的上下文是增量的,而不是全量重放(design/15 §2.2、§4)。
defaultMaxTail = 20
// defaultSessionID is used when a caller passes an empty session id.
// 调用方传入空会话 id 时使用 defaultSessionID。
defaultSessionID = "web"
)
// SubmitResult is the outcome of handing one user turn to the orchestrator. It
// is always returned without blocking: Accepted is false only when the turn was
// rejected (pending queue full, or an empty turn), never because a turn is busy.
// SubmitResult 是把一个用户回合交给编排器的结果。它
// 总是不阻塞地返回:Accepted 为 false 仅在回合被
// 拒绝时(待处理队列满,或空回合),绝不因为回合忙碌。
type SubmitResult struct {
// Accepted reports whether the turn was taken. A false value with
// PendingFull or Empty carries the reject reason; it never means "try later".
// Accepted 报告回合是否被接收。为 false 且带
// PendingFull 或 Empty 时携带拒绝原因;它绝不意味着「稍后重试」。
Accepted bool
// Immediate reports that a turn started now because the session was idle.
// Immediate 报告因会话空闲而立即开始了一个回合。
Immediate bool
// Pending reports that the turn was queued behind a busy turn.
// Pending 报告该回合排在忙碌回合之后入队了。
Pending bool
// QueueDepth is the pending depth after this insertion (0 when Immediate).
// QueueDepth 是本次插入后的待处理深度(Immediate 时为 0)。
QueueDepth int
// PendingFull reports the turn was rejected because the queue is at capacity
// (design/15 §4: bounded pending, over-capacity is refused with a hint).
// PendingFull 报告回合被拒绝,因为队列已满
// (design/15 §4:有界待处理,超容量时拒绝并给出提示)。
PendingFull bool
// Empty reports the turn was rejected because it carried no text or image.
// Empty 报告回合因既无文本也无图片而被拒绝。
Empty bool
// Err is set on the synchronous path when the run errored; the REST caller
// uses it to return a 5xx rather than a fabricated reply.
// Err 在同步路径上于运行出错时设置;REST 调用方
// 用它返回 5xx,而不是编造回复。
Err error
}
// TurnRunner executes one agent turn and returns its result. The gateway wires
// it to the loop plus the streaming broadcast, so the orchestrator stays agnostic
// of how a turn is physically driven (it never re-implements a loop engine).
// TurnRunner 执行一个 agent 回合并返回其结果。网关把它
// 接到 loop 加上流式广播上,因此编排器对回合
// 实际如何驱动保持不可知(它绝不重新实现 loop 引擎)。
type TurnRunner func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error)
// StateSink reports a per-session state transition (busy/pending/idle/interrupt)
// to the transport. The gateway wires it to the WS hub broadcast.
// StateSink 把每会话状态转移(busy/pending/idle/interrupt)报告给
// 传输层。网关把它接到 WS hub 广播上。
type StateSink func(sessionID, typ string, data map[string]any)
// OrchestratorOptions configures an Orchestrator. Run and Log are required
// (Log may be a discard logger); Emit is optional (state events are dropped when
// nil). MaxPending/MaxTail fall back to the defaults when non-positive.
// OrchestratorOptions 配置 Orchestrator。Run 与 Log 必需
// (Log 可以是丢弃型 logger);Emit 可选(为 nil 时状态事件被丢弃)。
// MaxPending/MaxTail 非正时回退到默认值。
type OrchestratorOptions struct {
Run TurnRunner
Emit StateSink
Log *slog.Logger
MaxPending int
MaxTail int
// History is the optional persistent chat-history store (design/16 §3.3).
// When nil every Append is skipped, so the orchestrator runs without storage
// (tests / a gateway built without a history store).
// History 是可选的持久化聊天历史存储(design/16 §3.3)。
// 为 nil 时每次 Append 都被跳过,因此编排器无需存储也能运行
// (测试 / 未接 history 存储构建的网关)。
History HistoryStore
// OnPersisted, when non-nil, is called with the complete stored message right
// after a user or assistant message is written, so the transport can broadcast
// a `message_persisted` event for cross-end realtime sync (design/16 §4.2).
// Dropped when nil (tests / a gateway built without a transport hook).
// OnPersisted 非 nil 时,会在用户或助手消息写入后立即
// 带着完整已存消息被调用,使传输层能广播
// 一个 `message_persisted` 事件用于跨端实时同步(design/16 §4.2)。
// 为 nil 时丢弃(测试 / 未接传输钩子构建的网关)。
OnPersisted func(history.Message)
}
// sessionState is the per-session message state (design/15 §2). Every mutation
// happens under the Orchestrator mutex; the actual loop run is done outside the
// lock so a slow model call never blocks Submit.
// sessionState 是每会话的消息状态(design/15 §2)。每次变更
// 都在 Orchestrator 互斥锁下发生;实际的 loop 运行在锁外
// 完成,因此慢的模型调用绝不阻塞 Submit。
type sessionState struct {
mode Mode
gen uint64 // generation: bumped on interrupt and on a fresh top-level turn
gen uint64 // generation:在打断时以及新的顶层回合时递增
cancel context.CancelFunc
pending []pendingMsg
tail []model.Message // bounded conversation tail for context continuation
tail []model.Message // 用于上下文续跑的有界对话尾部
}
// pendingMsg is one queued user turn. It carries its own env so a resume runs
// under the same memory-isolation boundary — and, since design/22, the same role
// (env.PersonaID) — the message arrived with, plus the client deviceID of the
// originating turn (design/16 §4.5) so the resumed assistant reply is tagged with
// the same client that sent the message, and the turn's behavior mode (design/16
// §5.1) so the resumed reply is persisted under the same mode as the user message
// that started it.
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
// 在该消息到达时所用的同一记忆隔离边界 —— 且自 design/22 起
// 同一角色(env.PersonaID)—— 之下运行,并携带产出该回合的
// 客户端 deviceID(design/16 §4.5),使续跑的助手回复被标上
// 发送该消息的同一个客户端;以及该回合的行为模式(design/16
// §5.1),使续跑回复与发起它的用户消息以同一模式
// 被持久化。
type pendingMsg struct {
messages []model.Message
env loop.SessionEnv
@@ -142,10 +142,10 @@ type pendingMsg struct {
at time.Time
}
// Orchestrator owns the per-session message state machine. It is concurrency-safe
// and non-blocking: Submit/Interrupt lock only to mutate state, then hand the
// actual run off to a goroutine. It is the single owner of "is this session busy"
// for the chat path; the loop/task/subagent engines are reused, not re-created.
// Orchestrator 拥有每会话的消息状态机。它对并发安全
// 且非阻塞:Submit/Interrupt 只在变更状态时加锁,然后把
// 实际运行交给一个 goroutine。它是 chat 路径上「该会话是否
// 忙碌」的唯一归属者;loop/task/subagent 引擎被复用,而不是重建。
type Orchestrator struct {
mu sync.Mutex
states map[string]*sessionState
@@ -158,10 +158,10 @@ type Orchestrator struct {
maxTail int
}
// NewOrchestrator builds an orchestrator from opts. Run is required: it is the
// turn-driver the orchestrator reuses (the loop, never a re-implemented engine).
// A nil Run makes every Submit reject rather than run, so a misconfiguration
// cannot silently leave a session busy. Tests inject a fake runner here.
// NewOrchestrator 从 opts 构建编排器。Run 必需:它是编排器
// 复用的回合驱动器(loop,绝不是重新实现的引擎)。
// Run 为 nil 会让每次 Submit 拒绝而非运行,因此错误配置
// 不可能静默地让会话处于忙碌。测试在此注入假 runner。
func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
maxPending := opts.MaxPending
if maxPending <= 0 {
@@ -186,27 +186,27 @@ func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
}
}
// Submit hands one user turn to the session. It never blocks: an idle session
// starts a turn on a background goroutine and returns Immediate; a busy session
// enqueues the turn into the bounded pending queue and returns Pending; a full
// queue refuses with PendingFull. Interrupt words are NOT auto-detected here —
// the caller (frontend/intent classifier) calls Interrupt when the user asks to
// stop, per design/15 §3 (interrupt is an intent, not an identity decision).
// Submit 把一个用户回合交给会话。它绝不阻塞:空闲会话在
// 后台 goroutine 上启动回合并返回 Immediate;忙碌会话把
// 回合入队到有界待处理队列并返回 Pending;队列
// 满时以 PendingFull 拒绝。此处不自动检测打断词 ——
// 调用方(前端/意图分类器)在用户要求停止时调用 Interrupt,
// 依 design/15 §3(打断是一种意图,不是身份判定)。
func (o *Orchestrator) Submit(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) SubmitResult {
sr, _ := o.submitInternal(ctx, o.normalize(sessionID), messages, env, false)
return sr
}
// SubmitSync is the synchronous variant used by the REST non-streaming path. An
// idle session runs the turn to completion and returns the Result; a busy session
// returns a Pending result without blocking so the caller can reply with a
// non-blocking ack (design/15 §2.1). It never waits on an in-flight turn.
// SubmitSync 是 REST 非流式路径所用的同步变体。空闲会话
// 把回合跑完并返回 Result;忙碌会话不阻塞地返回
// Pending 结果,使调用方能以非阻塞 ack 回复
// (design/15 §2.1)。它绝不等待在飞回合。
func (o *Orchestrator) SubmitSync(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (SubmitResult, *loop.Result) {
return o.submitInternal(ctx, o.normalize(sessionID), messages, env, true)
}
// submitInternal is the shared Submit/SubmitSync body. sync selects the
// synchronous turn path (REST) vs the background goroutine path (WS).
// submitInternal 是 Submit/SubmitSync 共用的主体。sync 选择
// 同步回合路径(REST)还是后台 goroutine 路径(WS)。
func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv, sync bool) (SubmitResult, *loop.Result) {
if o.run == nil {
o.log.Error("orchestrator submit", "err", "no runner configured", "session", sessionID)
@@ -243,14 +243,14 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
st.gen++
gen := st.gen
st.mode = ModeThinking
// A fresh turn continues the session's conversation: the persisted transcript
// is seeded in front of the just-sent messages (design/15 §2.2/§4, design/22
// §7-5), and its assistant utterances keep the role that spoke them. The read
// happens here, before persistUserMessages below writes the new message, so the
// message just sent is never duplicated in the context. A store without history
// (or an empty session) leaves the turn on the caller's messages alone. It is a
// bounded indexed read (transcriptContextCount rows) under the state lock, which
// no running turn ever holds, so it cannot block a Submit behind a turn.
// 新回合延续该会话的对话:持久化转录被播种在
// 刚发送的消息之前(design/15 §2.2/§4,design/22
// §7-5),且其助手话语保持说话时的角色。该读取
// 在此发生,在下面 persistUserMessages 写入新消息之前,因此刚
// 发送的消息绝不在上下文中重复。没有历史(或空会话)的
// 存储让该回合只使用调用方给出的消息。它是在状态锁下
// 的有界索引读取(transcriptContextCount 行),而状态锁
// 从不会被运行中的回合持有,因此它无法把 Submit 堵在回合后面。
turn := o.startTurnMessages(ctx, sessionID, messages)
st.tail = o.boundedTail(turn)
runCtx, cancel := context.WithCancel(ctx)
@@ -258,8 +258,8 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
o.mu.Unlock()
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
// Only the messages that arrived now are persisted: the seeded transcript is
// already durable, and re-appending it would duplicate the whole conversation.
// 只有此刻到达的消息会被持久化:已播种的转录
// 已经持久,重新追加它会重复整个对话。
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
if sync {
@@ -275,12 +275,12 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
return SubmitResult{Accepted: true, Immediate: true}, nil
}
// goTurn runs a single turn on a background goroutine and, when it finishes,
// hands the outcome to completeTurn so the orchestrator can resume the queue.
// deviceID tags the persistent assistant reply with the client that sent the turn
// (design/16 §4.5), mode with the turn's behavior mode (design/16 §5.1) and env is
// the turn's resolved role/isolation environment (design/22 §4.5), so the reply is
// filed under the role that produced it.
// goTurn 在后台 goroutine 上运行单个回合,并在它结束时
// 把结果交给 completeTurn,使编排器能续跑队列。
// deviceID 用发送该回合的客户端标注持久化助手回复
// (design/16 §4.5),mode 用该回合的行为模式(design/16 §5.1),而 env 是
// 该回合解析出的角色/隔离环境(design/22 §4.5),因此回复被
// 归到产出它的角色名下。
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string) {
defer cancel()
res, err := o.run(ctx, sessionID, messages, env)
@@ -290,15 +290,15 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
_ = o.completeTurn(sessionID, gen, res, err, deviceID, mode, env.PersonaID)
}
// completeTurn runs after a turn settles (either it finished, errored, or was
// canceled). It folds the assistant reply into the session's continuation tail,
// then pops the next pending turn and resumes it carrying that tail — so the next
// message "remembers" the prior context instead of starting from a blank slate
// (design/15 §2.2: incremental, not full replay). A stale generation (an
// interrupt or a newer top-level turn superseded this one) is a no-op.
// deviceID is the originating turn's client id, mode its behavior mode and
// personaID the role it spoke as, all used to tag the persisted reply
// (design/16 §4.5, §5.1; design/22 §7-5).
// completeTurn 在一个回合结算后运行(它完成、出错或被
// 取消)。它把助手回复折进会话的续跑尾部,
// 然后弹出下一条待处理回合并带着该尾部续跑它 —— 因此下一条
// 消息「记得」先前上下文,而不是从空白开始
// (design/15 §2.2:增量,而不是全量重放)。陈旧的代次(一次
// 打断或一个更新的顶层回合取代了本次)是空操作。
// deviceID 是产出回合的客户端 id,mode 是其行为模式,
// personaID 是它说话时的角色,都用于标注持久化的回复
// (design/16 §4.5、§5.1;design/22 §7-5)。
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
o.mu.Lock()
@@ -337,18 +337,18 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
return nil
}
// Interrupt cancels the in-flight turn, clears the pending queue and returns the
// session to idle (design/15 §3, the "停下/算了/换个话题" path). It is the only
// way a busy session resets without draining the queue; the running goroutine's
// completion becomes a stale generation and does not resume anything. It returns
// the number of pending turns that were dropped.
// Interrupt 取消在飞回合、清空待处理队列并把会话
// 回到空闲(design/15 §3,「停下/算了/换个话题」路径)。它是忙碌
// 会话不必排空队列就能复位的唯一方式;运行中 goroutine 的
// 完成变成陈旧代次,不续跑任何东西。它返回
// 被丢弃的待处理回合数。
func (o *Orchestrator) Interrupt(sessionID string) int {
return o.interrupt(o.normalize(sessionID))
}
// InterruptAll cancels every in-flight session and clears all pending queues. It
// is used on gateway shutdown so no background turn outlives the hub. It returns
// the total number of dropped pending turns.
// InterruptAll 取消每一个在飞会话并清空所有待处理队列。它
// 在网关关闭时使用,使没有后台回合比 hub 活得更久。它返回
// 被丢弃的待处理回合总数。
func (o *Orchestrator) InterruptAll() int {
o.mu.Lock()
total := 0
@@ -381,10 +381,10 @@ func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState) int {
return dropped
}
// SetMode raises the reported busy-state lane of a session (thinking →
// executing → subagent). It is an injection point a loop/subagent hook can call;
// any non-idle mode keeps the session busy, and idle un-busies it. A nil session
// is created lazily so a hook may set a mode before the first message.
// SetMode 提升某会话所报告的忙碌状态通道(thinking →
// executing → subagent)。它是 loop/subagent 钩子可调用的注入点;
// 任何非空闲模式都让会话保持忙碌,而 idle 解除忙碌。为 nil 的会话
// 被惰性创建,因此钩子可以在第一条消息之前设模式。
func (o *Orchestrator) SetMode(sessionID string, mode Mode) {
sessionID = o.normalize(sessionID)
o.mu.Lock()
@@ -403,7 +403,7 @@ func (o *Orchestrator) SetMode(sessionID string, mode Mode) {
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": mode})
}
// Busy reports whether the session currently has a turn in flight.
// Busy 报告该会话当前是否有在飞回合。
func (o *Orchestrator) Busy(sessionID string) bool {
o.mu.Lock()
defer o.mu.Unlock()
@@ -411,7 +411,7 @@ func (o *Orchestrator) Busy(sessionID string) bool {
return st != nil && st.mode != ModeIdle
}
// QueueDepth reports the number of pending turns for a session.
// QueueDepth 报告某会话的待处理回合数。
func (o *Orchestrator) QueueDepth(sessionID string) int {
o.mu.Lock()
defer o.mu.Unlock()
@@ -422,7 +422,7 @@ func (o *Orchestrator) QueueDepth(sessionID string) int {
return len(st.pending)
}
// ModeOf reports the current busy-state lane of a session (idle when unknown).
// ModeOf 报告某会话当前的忙碌状态通道(未知时为 idle)。
func (o *Orchestrator) ModeOf(sessionID string) Mode {
o.mu.Lock()
defer o.mu.Unlock()
@@ -433,8 +433,8 @@ func (o *Orchestrator) ModeOf(sessionID string) Mode {
return st.mode
}
// normalize maps an empty session id to the default so the state map is stable
// for callers that omit it (the Web main session).
// normalize 把空会话 id 映射为默认值,使状态 map 对省略它的
// 调用方保持稳定(Web 主会话)。
func (o *Orchestrator) normalize(sessionID string) string {
if sessionID == "" {
return defaultSessionID
@@ -442,33 +442,33 @@ func (o *Orchestrator) normalize(sessionID string) string {
return sessionID
}
// emitState forwards a state transition to the transport when one is wired.
// emitState 在接线了传输层时把状态转移转发给它。
func (o *Orchestrator) emitState(sessionID, typ string, data map[string]any) {
if o.emit != nil {
o.emit(sessionID, typ, data)
}
}
// notifyPersisted hands a just-persisted message to the transport hook when one is
// wired, so the gateway can broadcast `message_persisted` for cross-end sync.
// notifyPersisted 在接线了传输钩子时把刚持久化的消息交给它,
// 使网关能广播 `message_persisted` 以进行跨端同步。
func (o *Orchestrator) notifyPersisted(msg history.Message) {
if o.onPersisted != nil {
o.onPersisted(msg)
}
}
// deviceIDContextKey is the context key carrying a message's client deviceID
// (design/16 §4.5) from the HTTP/WS boundary into the orchestrator's persistence.
// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键
// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。
type deviceIDContextKey struct{}
// withDeviceID returns a context carrying deviceID so the orchestrator can tag the
// persisted user message. An empty deviceID is a no-op (no tag is written).
// withDeviceID 返回携带 deviceID 的 context,使编排器能标注
// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。
func withDeviceID(ctx context.Context, deviceID string) context.Context {
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
}
// deviceIDFrom returns the deviceID carried by ctx, or "" when none was set. It
// returns only well-formed non-empty values; anything else is treated as absent.
// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它
// 只返回格式良好的非空值;其他一律视为缺失。
func deviceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(deviceIDContextKey{}).(string)
return v
@@ -13,7 +13,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// callRecord is one invocation captured by the fake runner.
// callRecord 是假 runner 捕获的一次调用。
type callRecord struct {
sessionID string
messages []model.Message
@@ -21,19 +21,19 @@ type callRecord struct {
ctx context.Context
}
// gatedRunner is a controllable TurnRunner. Each Run blocks until it consumes one
// ticket from tickets, or until ctx is canceled (the interrupt path). When it
// proceeds it records the call and returns a deterministic reply. A nil err
// produces a reply holding the *incoming* messages as text, so a test can assert
// exactly what context the resumed turn carried.
// gatedRunner 是一个可控 TurnRunner。每次 Run 都会阻塞,直到它
// 从 tickets 消费一张票,或直到 ctx 被取消(打断路径)。当它
// 继续时,它记录这次调用并返回确定性回复。err 为 nil
// 会产出一个把*传入*消息作为文本持有的回复,使测试能断言
// 续跑回合究竟携带了什么上下文。
//
// Like the real loop runner, it never executes a turn whose context is already
// canceled, and it counts those canceled returns so a test can wait for an
// interrupted turn to actually stop (waitCanceled) before releasing the next
// ticket. Without that, a turn that was canceled *while parked in the select
// below* can still win the ticket case in the same scheduler step (both cases are
// ready) and be recorded as if it had run — a test-harness artifact, not an
// orchestrator ordering guarantee.
// 与真实的 loop runner 一样,它绝不执行 context 已被取消的
// 回合,并统计那些被取消的返回,使测试能等待一个
// 被中断的回合真正停下(waitCanceled),然后再释放下一张
// 票。没有这一点,一个在下面 select 中驻留时被取消的回合
// 仍可能在同一调度步骤中赢下 ticket 分支(两个分支都
// 就绪)并被记录成仿佛已经运行 —— 这是测试夹具的产物,不是
// 编排器的顺序保证。
type gatedRunner struct {
mu sync.Mutex
calls []callRecord
@@ -68,8 +68,8 @@ func (g *gatedRunner) Run(ctx context.Context, sessionID string, messages []mode
return &loop.Result{Text: fmt.Sprintf("reply-%d", reply), Steps: 1, StopReason: "stop"}, nil
}
// waitCalls blocks until at least n calls are recorded and returns a copy of the
// first n, so a test can advance the gated chain deterministically.
// waitCalls 阻塞直到至少记录 n 次调用,并返回前 n 次的
// 副本,使测试能确定性地推进受门控的链条。
func (g *gatedRunner) waitCalls(n int) []callRecord {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
@@ -85,25 +85,25 @@ func (g *gatedRunner) waitCalls(n int) []callRecord {
panic("timed out waiting for runner calls")
}
// count returns the total recorded calls.
// count 返回已记录调用的总数。
func (g *gatedRunner) count() int {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.calls)
}
// recordCanceled notes one invocation that returned because its context was
// canceled, i.e. a turn that never executed.
// recordCanceled 记下一次因其 context 被取消而返回的调用,
// 即一个从未执行的回合。
func (g *gatedRunner) recordCanceled() {
g.mu.Lock()
g.canceled++
g.mu.Unlock()
}
// waitCanceled blocks until at least n invocations have stopped because their
// context was canceled, reporting false when that never happens within the
// deadline. It is the barrier a test uses between an Interrupt and the release of
// the next ticket: once it holds, no canceled turn can still consume a ticket.
// waitCanceled 阻塞直到至少 n 次调用因 context 被取消而
// 停止,未能在期限内发生时报 false。它是测试在 Interrupt
// 与释放下一张票之间使用的屏障:它一旦成立,就没有
// 被取消的回合还能消费一张票。
func (g *gatedRunner) waitCanceled(n int) bool {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
@@ -118,10 +118,10 @@ func (g *gatedRunner) waitCanceled(n int) bool {
return false
}
// waitIdle blocks until the session reports idle with an empty pending queue,
// reporting false when it never settles within the deadline. It lets a test assert
// the settled state deterministically instead of sleeping, and turns "a dropped
// turn was resumed anyway" into a test failure rather than a silent pass.
// waitIdle 阻塞直到该会话报告空闲且待处理队列为空,
// 未能在期限内安定时报 false。它让测试能确定性地断言
// 安定的状态而不是睡眠等待,并把「被丢弃的回合竟仍被
// 续跑」变成测试失败,而不是静默通过。
func waitIdle(o *Orchestrator, sessionID string) bool {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
@@ -133,13 +133,13 @@ func waitIdle(o *Orchestrator, sessionID string) bool {
return false
}
// eventRec is one state transition captured by the recording sink.
// eventRec 是记录型汇聚点捕获的一次状态转移。
type eventRec struct {
sessionID, typ string
data map[string]any
}
// recordingSink captures every emitted state transition.
// recordingSink 捕获每一次发出的状态转移。
type recordingSink struct {
mu sync.Mutex
events []eventRec
@@ -170,12 +170,12 @@ func (s *recordingSink) last() (string, bool) {
return s.events[len(s.events)-1].typ, true
}
// userMsg builds a text-only user message.
// userMsg 构建一条纯文本用户消息。
func userMsg(text string) model.Message {
return model.TextMessage(model.RoleUser, text)
}
// messageText returns the concatenated text of a message's text parts.
// messageText 返回消息文本部分拼接后的文本。
func messageText(m model.Message) string {
var out string
for _, c := range m.Content {
@@ -186,8 +186,8 @@ func messageText(m model.Message) string {
return out
}
// seedOrchestrator builds an orchestrator with a gated runner and a recording
// sink, both returned so the test can drive the chain and assert state events.
// seedOrchestrator 构建一个带受门控 runner 与记录型汇聚点的
// 编排器,二者都返回,使测试能驱动链条并断言状态事件。
func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordingSink) {
sink := &recordingSink{}
o := NewOrchestrator(OrchestratorOptions{
@@ -200,13 +200,13 @@ func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordi
return o, sink
}
// readOnlyEnv returns the group-chat / stranger read-only memory lane.
// readOnlyEnv 返回群聊 / 陌生人的只读记忆通道。
func readOnlyEnv() loop.SessionEnv {
return loop.SessionEnv{MemWritable: false}
}
// fakeHistory records every Append so tests assert the orchestrator persists the
// user stream and the assistant reply into the chat-history store.
// fakeHistory 记录每一次 Append,使测试能断言编排器把用户
// 消息流与助手回复持久化进聊天历史存储。
type fakeHistory struct {
mu sync.Mutex
appends []history.Message
@@ -286,9 +286,9 @@ func (f *fakeHistory) ListSessions(context.Context) ([]history.SessionMeta, erro
return nil, nil
}
// MarkSessionRead satisfies the HistoryStore contract for the read-cursor path (D4).
// The orchestrator never advances a cursor, so the fake only reports the requested
// cursor back without storing it.
// MarkSessionRead 为读游标路径(D4)满足 HistoryStore 契约。
// 编排器绝不推进游标,因此该假实现只把请求的游标
// 原样报告回去,不予存储。
func (f *fakeHistory) MarkSessionRead(_ context.Context, _ string, before time.Time) (int64, error) {
return before.UnixMilli(), nil
}
@@ -326,7 +326,7 @@ func TestSubmitPersistsUserAndAssistant(t *testing.T) {
if recs[1].SessionID != "web" || recs[1].Role != history.RoleAssistant || recs[1].Content == "" {
t.Fatalf("assistant record = %+v", recs[1])
}
// No mode on the ctx → both fall back to "chat" (design/16 §5.1).
// ctx 上没有 mode → 二者都回退到 "chat"(design/16 §5.1)。
for i, rec := range recs {
if rec.Mode != "chat" {
t.Fatalf("record[%d] Mode = %q, want chat fallback", i, rec.Mode)
@@ -344,9 +344,9 @@ func TestSubmitPersistsMessageMode(t *testing.T) {
History: hist,
})
run.tickets <- struct{}{}
// The HTTP/WS boundary threads the client's behavior mode through ctx
// (design/01 §3, design/16 §5.1); both the user turn and the assistant reply
// must be persisted under that mode.
// HTTP/WS 边界把客户端的行为模式经 ctx 串下来
// (design/01 §3、design/16 §5.1);用户回合与助手回复
// 都必须以该模式被持久化。
ctx := withMode(context.Background(), "code")
sr, res := o.SubmitSync(ctx, "web", []model.Message{userMsg("改代码")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
@@ -377,8 +377,8 @@ func TestSubmitPersistsDeviceID(t *testing.T) {
History: hist,
})
run.tickets <- struct{}{}
// The HTTP/WS boundary threads the browser deviceID through ctx (design/16 §4.5);
// both the user turn and the assistant reply must be tagged with it.
// HTTP/WS 边界把浏览器 deviceID 经 ctx 串下来(design/16 §4.5);
// 用户回合与助手回复都必须被它标注。
ctx := withDeviceID(context.Background(), "web:chrome:a1b2c3")
sr, res := o.SubmitSync(ctx, "web", []model.Message{userMsg("你好")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
@@ -441,7 +441,7 @@ func TestSubmitDoesNotPersistRejectedTurn(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{Run: newGatedRunner(0).Run, Log: slog.Default(), History: hist, MaxPending: 1})
// Empty turn is rejected before any persistence.
// 空回合在任何持久化之前就被拒绝。
sr := o.Submit(context.Background(), "web", nil, loop.OwnerEnv())
if sr.Accepted || !sr.Empty {
t.Fatalf("empty submit = %+v, want Empty reject", sr)
@@ -475,12 +475,12 @@ func TestSubmitBusyPendingNotDropped(t *testing.T) {
run := newGatedRunner(4)
o, sink := seedOrchestrator(run, 5)
// Turn A starts and blocks (no ticket yet), so the session is busy.
// 回合 A 启动并阻塞(还没有票),因此会话处于忙碌。
a := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
if !a.Immediate {
t.Fatalf("A = %+v, want Immediate", a)
}
// B and C arrive while A is in flight: they must be queued, not dropped.
// B 与 C 在 A 在飞时到达:它们必须被排队,而不是被丢弃。
b := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv())
c := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv())
if !b.Accepted || !b.Pending || b.QueueDepth != 1 {
@@ -493,8 +493,8 @@ func TestSubmitBusyPendingNotDropped(t *testing.T) {
t.Fatalf("queue depth = %d, want 2", o.QueueDepth("s1"))
}
// Release the chain: A, then B, then C. The emitted ack should include the
// pending depth, and the sink should have seen pending_ack events.
// 释放链条:A,然后 B,然后 C。发出的 ack 应包含
// 待处理深度,且汇聚点应已看到 pending_ack 事件。
types := sink.types()
foundAck := false
for _, ty := range types {
@@ -537,27 +537,27 @@ func TestSubmitBusyResumeCarriesPreviousContext(t *testing.T) {
run.tickets <- struct{}{} // C
got := run.waitCalls(3)
// A starts from a blank turn.
// A 从空白回合开始。
if len(got[0].messages) != 1 || messageText(got[0].messages[0]) != "A" {
t.Fatalf("A messages = %v, want [A]", messageTextList(got[0].messages))
}
// B must resume carrying A's assistant reply folded into the tail.
// B 必须带着折进尾部的 A 助手回复续跑。
if !matchesText(got[1].messages, "A", "B") {
t.Fatalf("B messages = %v, want B to resume carrying the prior tail", messageTextList(got[1].messages))
}
// C must resume carrying A's and B's assistant replies.
// C 必须带着 A 与 B 的助手回复续跑。
if !matchesText(got[2].messages, "A", "C") {
t.Fatalf("C messages = %v, want C to resume carrying the prior tail", messageTextList(got[2].messages))
}
// B's env must be the one the message arrived with (read-only lane).
// B 的 env 必须是该消息到达时所用的那个(只读通道)。
if got[1].env.MemWritable {
t.Fatal("B resumed with MemWritable=true, want the caller's read-only env")
}
}
// matchesText reports whether a resumed turn begins with the captured first user
// turn (so the prior context was carried over), ends with the expected new user
// turn, and contains at least one assistant reply in between.
// matchesText 报告一个续跑回合是否以捕获的首个用户
// 回合开头(即先前上下文被带了过来)、以预期的新闻用户
// 回合结尾,且中间至少含有一条助手回复。
func matchesText(msgs []model.Message, first, last string) bool {
if len(msgs) < 3 {
return false
@@ -587,7 +587,7 @@ func messageTextList(msgs []model.Message) []string {
func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, _ := seedOrchestrator(run, 3) // tight queue
o, _ := seedOrchestrator(run, 3) // 紧凑队列
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
@@ -598,7 +598,7 @@ func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) {
t.Fatalf("pending %d = %+v, want Pending depth=%d", i, sr, i+1)
}
}
// Queue is at capacity (3), so the next message is refused, not dropped.
// 队列已达容量(3),因此下一条消息被拒绝,而不是被丢弃。
over := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv())
if over.Accepted || !over.PendingFull {
t.Fatalf("overflow = %+v, want PendingFull reject", over)
@@ -620,7 +620,7 @@ func TestInterruptCancelsTurnAndClearsPending(t *testing.T) {
t.Fatalf("B = %+v, want Pending", sr)
}
// Release no ticket: A is blocked in flight. Interrupt cancels it and clears B.
// 不释放任何票:A 阻塞在飞。Interrupt 取消它并清除 B。
dropped := o.Interrupt("s1")
if dropped != 1 {
t.Fatalf("dropped = %d, want 1", dropped)
@@ -629,16 +629,16 @@ func TestInterruptCancelsTurnAndClearsPending(t *testing.T) {
t.Fatalf("session not idle after interrupt: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
// The cancel must reach the in-flight turn (design/15 §3: interrupt is a
// graceful context cancel) and it must stop before any further turn is
// released: a canceled turn never executes, so it can never consume the ticket
// meant for the next one.
// 取消必须抵达在飞回合(design/15 §3:打断是优雅的
// context 取消),且它必须在释放任何后续回合之前停下:
// 被取消的回合绝不执行,因此它绝不可能消费本该属于
// 下一个回合的票。
if !run.waitCanceled(1) {
t.Fatal("interrupted turn never observed its context cancellation")
}
// A new message after the interrupt must start fresh (Immediate), and the
// canceled chain must not have resumed B.
// 打断之后的新消息必须全新开始(Immediate),且
// 被取消的链条绝不续跑过 B。
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("C = %+v, want Immediate after interrupt", sr)
}
@@ -651,18 +651,18 @@ func TestInterruptCancelsTurnAndClearsPending(t *testing.T) {
t.Fatalf("only surviving call = %v, want the C turn", messageTextList(got[0].messages))
}
// C's chain settles with nothing left to run: had the interrupt failed to
// cancel A or to clear B, the surviving chain would have resumed a turn and the
// session would never go idle again.
// C 的链条安定下来且无待运行项:若打断未能取消 A
// 或未能清除 B,存活的链条本会续跑一个回合,
// 而该会话就再也不会回到空闲。
if !waitIdle(o, "s1") {
t.Fatalf("session never settled idle: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
if n := run.count(); n != 1 {
t.Fatalf("runner calls = %d, want 1 (neither the canceled A nor the dropped B may execute)", n)
}
// The canceled turn's completion is a no-op for session state (its generation is
// stale), so it must not emit the idle event of the turn now running: exactly
// one session_idle belongs to this scenario (C settling).
// 被取消回合的完成对会话状态是空操作(其代次已
// 陈旧),因此它不得发出正在运行那个回合的 idle 事件:
// 本场景恰好有一个 session_idle(C 安定)。
idles := 0
for _, ty := range sink.types() {
if ty == EventSessionIdle {
@@ -705,8 +705,8 @@ func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) {
t.Fatalf("unexpected submit result: %+v", sr)
}
}
// With maxPending=3: exactly 1 turn runs immediately and up to 3 queue behind
// it; the remaining senders are refused at capacity rather than dropped.
// maxPending=3 时:恰好 1 个回合立即运行,最多 3 个排在其后;
// 其余发送者在容量处被拒绝,而不是被丢弃。
if immediate != 1 {
t.Fatalf("immediate = %d, want 1", immediate)
}
@@ -720,7 +720,7 @@ func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) {
t.Fatalf("queue depth = %d, want 3", o.QueueDepth("s1"))
}
// Drain the chain: 1 running + 3 queued = 4 turns. No panic / no lost message.
// 排空链条:1 个在跑 + 3 个排队 = 4 个回合。无 panic / 无消息丢失。
for i := 0; i < 4; i++ {
run.tickets <- struct{}{}
}
@@ -750,8 +750,8 @@ func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) {
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
// Idle → synchronous run returns the reply immediately. Push a ticket so the
// gated runner's blocking select resolves during the synchronous wait.
// 空闲 → 同步运行立即返回回复。推入一张票,使
// 受门控 runner 的阻塞 select 在同步等待期间得以解决。
run.tickets <- struct{}{}
sr, res := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
@@ -761,8 +761,8 @@ func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) {
t.Fatal("sync idle should return a result")
}
// Hold a busy turn in flight via an async Submit (blocked, no ticket), then a
// sync submit must return a Pending ack without blocking and without a result.
// 经异步 Submit 握一个在飞的忙碌回合(阻塞,无票),随后
// 同步 submit 必须不阻塞、无结果地返回 Pending ack。
if sr2 := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr2.Immediate {
t.Fatalf("async A = %+v, want Immediate", sr2)
}
@@ -773,7 +773,7 @@ func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) {
if res3 != nil {
t.Fatal("sync busy should not return a result (must not block)")
}
run.tickets <- struct{}{} // let A finish so the orchestrator is idle again
run.tickets <- struct{}{} // 让 A 结束,使编排器回到空闲
}
func TestInterruptAllClearsEverySession(t *testing.T) {
+39 -39
View File
@@ -8,18 +8,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file owns the orchestrator's chat-history persistence hooks (design/16
// §3.3): the user stream is written at submit time and the assistant reply when a
// turn settles. Both are silent no-ops when no history store is wired, so the
// orchestrator runs unchanged without a store (tests / an unwired gateway).
// 本文件拥有编排器的聊天历史持久化钩子(design/16
// §3.3):用户消息流在提交时写入,助手回复在
// 回合结算时写入。未接线 history 存储时二者都是静默空操作,
// 因此编排器无存储也能原样运行(测试 / 未接线的网关)。
// modeChat is the behavior mode persisted when a turn carries none (design/01 §3,
// design/16 §5.1). It is also the only mode the IM paths ever write, because IM
// carries chat text alone.
// modeChat 是回合未携带行为模式时所持久化的模式(design/01 §3,
// design/16 §5.1)。它也是 IM 路径唯一会写入的模式,因为 IM
// 只携带聊天文本。
const modeChat = "chat"
// modeOrDefault returns mode, or modeChat when the caller supplied none, so a
// persisted message never carries an empty behavior mode (design/16 §5.1).
// modeOrDefault 返回 mode,调用方未提供时返回 modeChat,使
// 持久化消息绝不携带空的行为模式(design/16 §5.1)。
func modeOrDefault(mode string) string {
if mode == "" {
return modeChat
@@ -27,34 +27,34 @@ func modeOrDefault(mode string) string {
return mode
}
// messageModeContextKey is the context key carrying a turn's behavior mode
// (design/01 §3, design/16 §5.1) from the HTTP/WS boundary into the orchestrator's
// persistence, so each persisted message is tagged with the mode it was sent in.
// messageModeContextKey 是携带回合行为模式的 context 键
// (design/01 §3、design/16 §5.1),从 HTTP/WS 边界引入编排器的
// 持久化,使每条持久化消息被它发送时的模式标注。
type messageModeContextKey struct{}
// withMode returns a context carrying mode so the orchestrator can tag the
// persisted user message and assistant reply. An empty mode is a no-op (the
// persistence layer falls back to modeChat).
// withMode 返回携带 mode 的 context,使编排器能标注
// 持久化的用户消息与助手回复。空 mode 是空操作
// (持久化层回退到 modeChat)。
func withMode(ctx context.Context, mode string) context.Context {
return context.WithValue(ctx, messageModeContextKey{}, mode)
}
// modeFrom returns the behavior mode carried by ctx, or "" when none was set.
// modeFrom 返回 ctx 携带的行为模式,未设置时返回 ""。
func modeFrom(ctx context.Context) string {
v, _ := ctx.Value(messageModeContextKey{}).(string)
return v
}
// persistUserMessages writes each RoleUser message of a submitted turn to the
// history store so the raw user stream is durable before the turn resolves, then
// notifies the transport so every browser of the same account can append it
// without a manual refresh (design/16 §3.3, §4.2). deviceID tags the message with
// the sending client (design/16 §4.5), mode is the behavior mode the turn was
// sent in (design/16 §5.1), falling back to modeChat when empty, and personaID is
// the role the turn speaks as (design/22 §7-5) — the role of the moment, so a
// session that later switches roles keeps an accurate "who said this" stream. It
// is a no-op when no history store is wired. Rejected turns (empty, pending-full)
// never reach it, so nothing vacuous is persisted.
// persistUserMessages 把一次已提交回合中每条 RoleUser 消息写入
// 历史存储,使原始用户消息流在回合结算前就持久,随后
// 通知传输层,使同一账号的每个浏览器都能追加它
// 而无需手动刷新(design/16 §3.3、§4.2)。deviceID 用发送客户端标注
// 消息(design/16 §4.5),mode 是该回合发送时的行为模式
// (design/16 §5.1),为空时回退到 modeChat,而 personaID 是
// 该回合说话时的角色(design/22 §7-5)—— 即当时的角色,因此
// 之后切换角色的会话仍保有准确的「谁说了这个」流。未接线
// history 存储时它是空操作。被拒绝的回合(空、待处理已满)
// 绝不触达它,因此不会持久化任何空内容。
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) {
if o.history == nil {
return
@@ -76,17 +76,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
}
}
// persistAssistantReply writes the final assistant text of a settled turn to the
// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags
// the reply with the client that sent the turn (design/16 §4.5), mode is the
// behavior mode of that turn (design/16 §5.1), falling back to modeChat when
// empty, and personaID is the role that produced the reply (design/22 §7-5). Mood
// is the persona's mood for that turn (design/01 §2 情绪状态机), taken off the loop
// result, so the stored/DTO/`message_persisted` mood is real data rather than a
// permanently empty column; a turn with no mood tracking stores an empty mood. It
// is a no-op when no history store is wired or the turn produced no text
// (interrupted/errored turns leave res nil). The write runs under a fresh
// background context so a canceled turn context never aborts it.
// persistAssistantReply 把已结算回合的最终助手文本写入
// 历史存储,然后通知传输层(design/16 §3.3、§4.2)。deviceID 用
// 发送该回合的客户端标注回复(design/16 §4.5),mode 是
// 该回合的行为模式(design/16 §5.1),为空时回退到 modeChat,
// 而 personaID 是产出该回复的角色(design/22 §7-5)。Mood
// 是该回合中人格的 mood(design/01 §2 情绪状态机),取自 loop
// 结果,因此已存/DTO/`message_persisted` 的 mood 是真实数据,而不是
// 永远为空的列;不跟踪 mood 的回合存一个空 mood。它
// 在未接线 history 存储,或该回合没有产出文本
// 时是空操作(被中断/出错的回合保持 res 为 nil)。该写入在一个全新的
// 后台 context 下运行,因此被取消的回合 context 绝不可能中断它。
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode, personaID string) {
if o.history == nil || res == nil || res.Text == "" {
return
@@ -99,8 +99,8 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result,
o.notifyPersisted(full)
}
// messageTextImage returns the concatenated text and the first image URL of a
// message's content parts, for persistence into the chat_messages columns.
// messageTextImage 返回消息内容部分拼接后的文本与首个图片
// URL,供持久化进 chat_messages 各列。
func messageTextImage(m model.Message) (text, image string) {
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
+28 -28
View File
@@ -11,13 +11,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// These tests cover the D4 read-cursor contract (Android collaboration B3): the
// unreadCount carried by GET /api/v1/sessions, the advance endpoint
// POST /api/v1/sessions/{id}/read, and the `session_read` frame that clears the badge
// on the account's other browsers.
// 这些测试覆盖 D4 读游标契约(Android 协作 B3):
// GET /api/v1/sessions 携带的 unreadCount、推进端点
// POST /api/v1/sessions/{id}/read,以及为该账号其他浏览器清除角标的
// `session_read` 帧。
// unreadBySession decodes GET /api/v1/sessions into a map of session id → unreadCount via
// the wire key, so the test asserts the JSON contract rather than the in-package struct.
// unreadBySession 经线上键把 GET /api/v1/sessions 解码为 session id → unreadCount 的
// map,因此测试断言的是 JSON 契约,而不是包内结构体。
func unreadBySession(t *testing.T, ts *httptest.Server, tok string) map[string]int {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
@@ -36,10 +36,10 @@ func unreadBySession(t *testing.T, ts *httptest.Server, tok string) map[string]i
return out
}
// TestSessionsUnreadCountAndMarkRead covers the happy path end to end: a session with
// unread messages reports the count, POST /read moves the cursor and answers
// {sessionId, readAt}, the list drops to the messages sent after the cursor, and a
// body-less POST (mark everything seen) clears the badge.
// TestSessionsUnreadCountAndMarkRead 端到端覆盖顺路径:有未读消息的
// 会话报告计数,POST /read 推进游标并返回
// {sessionId, readAt},列表降到游标之后发送的消息,而无请求体的
// POST(全部标记为已见)清除角标。
func TestSessionsUnreadCountAndMarkRead(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
@@ -61,12 +61,12 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) {
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/read"
// Nothing read yet: the main session is fully unread, the sub session independently so.
// 尚未读过任何内容:主会话全未读,子会话各自独立地同样如此。
if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 3 || got[sub] != 1 {
t.Fatalf("initial unread = %+v, want main 3 / sub 1", got)
}
// Advance the main cursor to 2000: messages at or before it are read.
// 把主游标推进到 2000:等于或早于它的消息即已读。
resp, body := doReq(t, http.MethodPost, base, map[string]any{"before": 2000}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("mark read status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -86,12 +86,12 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) {
t.Fatalf("read data = %+v, want sessionId %s readAt 2000", data, history.MainSessionID)
}
// Only the later message stays unread, and the sub session is untouched.
// 只有较晚那条消息保持未读,且子会话未被触碰。
if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 1 || got[sub] != 1 {
t.Fatalf("unread after cursor 2000 = %+v, want main 1 / sub 1", got)
}
// No body = "mark everything I have seen read" (resolved to now server-side).
// 无请求体 = 「把我已见的一切标记为已读」(服务端解析为 now)。
resp, body = doReq(t, http.MethodPost, base, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("body-less mark status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -107,7 +107,7 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) {
}
markedNow := data.ReadAt
// The cursor only moves forward: re-posting a stale cursor must not re-open the badge.
// 游标只向前移动:重发陈旧游标不得重新打开角标。
resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("stale mark status = %d, want 200 (body=%s)", resp.StatusCode, body)
@@ -123,9 +123,9 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) {
}
}
// TestMarkSessionReadValidation covers the failure contract: a present but unusable
// `before` answers 400, an unknown session 404, a viewer 403 and a missing HistoryStore
// 501 — none of which may be mistaken for a successful read.
// TestMarkSessionReadValidation 覆盖失败契约:存在但不可用的
// `before` 返回 400,未知会话 404,viewer 403,HistoryStore
// 缺失 501 —— 其中任何一个都不得被误认为读成功。
func TestMarkSessionReadValidation(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
@@ -150,34 +150,34 @@ func TestMarkSessionReadValidation(t *testing.T) {
})
}
// An empty object means "now", not "no-op".
// 空对象意味着「现在」,而不是「空操作」。
resp, body := doReq(t, http.MethodPost, base, map[string]any{}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty object status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// Unknown session → 404; the session id is not auto-created.
// 未知会话 → 404;会话 id 不会被自动创建。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 1000}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown session status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// A viewer cannot write the cursor (owner/admin history write, like PATCH /sessions).
// viewer 不能写游标(owner/admin 历史写,与 PATCH /sessions 同)。
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
// And no token at all is 401, not a silent success.
// 而完全不带 token 是 401,不是静默成功。
resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body)
}
}
// TestMarkSessionReadNotWired pins the honest degradation: with no HistoryStore injected
// the endpoint answers 501 not_implemented rather than pretending the cursor moved.
// TestMarkSessionReadNotWired 钉住诚实的退化:未注入 HistoryStore 时
// 端点返回 501 not_implemented,而不是假装游标已移动。
func TestMarkSessionReadNotWired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
@@ -190,9 +190,9 @@ func TestMarkSessionReadNotWired(t *testing.T) {
wantErrorShape(t, body)
}
// TestMarkSessionReadBroadcasts asserts the cross-end sync frame: a successful advance
// pushes exactly one `session_read` frame scoped to that session and carrying the new
// cursor, while a failed one (404) pushes nothing.
// TestMarkSessionReadBroadcasts 断言跨端同步帧:成功的推进恰好
// 推送一个 `session_read` 帧,作用域为该会话并携带新的
// 游标;而失败的推进(404)什么也不推。
func TestMarkSessionReadBroadcasts(t *testing.T) {
t.Parallel()
srv, ts, jwt, _ := newHistoryEnv(t)
@@ -222,7 +222,7 @@ func TestMarkSessionReadBroadcasts(t *testing.T) {
t.Fatal(err)
}
// A failed advance must not tell other ends the badge was cleared.
// 失败的推进不得告诉其他端角标已被清除。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 2000}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown status = %d, want 404 (body=%s)", resp.StatusCode, body)
+12 -12
View File
@@ -2,15 +2,15 @@ package gateway
import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
// This file owns the conversation-tail helpers of the session orchestrator
// (design/15 §2.2/§4): the bounded window that continuation resumes from and the
// emptiness check that keeps a vacuous turn from marking a session busy. They are
// pure functions over []model.Message, kept apart from the state machine so the
// orchestrator file stays small.
// 本文件拥有会话编排器的对话尾部辅助件
// (design/15 §2.2/§4):续跑所依据的有界窗口,以及
// 防止空洞回合把会话标记为忙碌的空判定。它们是
// 作用于 []model.Message 的纯函数,与状态机分开
// 放置,使编排器文件保持小。
// boundedTail returns a fresh copy of the last maxTail messages, so continuation
// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input
// slice or another state's tail.
// boundedTail 返回最后 maxTail 条消息的全新副本,使续跑
// 保持增量且有界(design/15 §2.2、§4),且绝不与输入
// 切片或另一个状态的尾部共享底层数组。
func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message {
if len(msgs) <= o.maxTail {
return append([]model.Message(nil), msgs...)
@@ -20,8 +20,8 @@ func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message {
return out
}
// messagesEmpty reports whether a turn carries no usable text or image, so the
// orchestrator does not mark a session busy on a vacuous message.
// messagesEmpty 报告回合是否不携带可用文本或图片,使
// 编排器不会因空洞消息把会话标记为忙碌。
func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool {
for _, m := range msgs {
for _, c := range m.Content {
@@ -36,8 +36,8 @@ func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool {
return true
}
// concatMessages returns a new slice combining a and b (never reusing a or b
// backing arrays) so the continuation tail is safe to hand to a concurrent run.
// concatMessages 返回合并 a 与 b 的新切片(绝不复用 a 或 b
// 的底层数组),因此续跑尾部可以安全地交给并发运行。
func concatMessages(a []model.Message, b ...model.Message) []model.Message {
out := make([]model.Message, 0, len(a)+len(b))
out = append(out, a...)
+50 -50
View File
@@ -7,47 +7,47 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file owns the reconstruction of a turn's conversation context from the
// persisted transcript (design/15 §2.2/§4 "上下文有界:只保留最近 N 条上下文", design/02
// §1 主会话承载完整上下文, design/22 §7-5). A turn used to start from the newly sent
// message alone, which left the model with no sight of the conversation it was
// continuing; the assistant utterances of earlier turns also carried no record of
// which role had spoken them, so a role switched into a session could read another
// role's words as its own.
// 本文件拥有从持久化转录重建一个回合对话上下文的工作
// (design/15 §2.2/§4「上下文有界:只保留最近 N 条上下文」、design/02
// §1 主会话承载完整上下文、design/22 §7-5)。一个回合此前只从刚
// 发送的消息开始,使模型完全看不到它正在延续的
// 对话;更早回合的助手话语也没有记录
// 是谁说的,因此切换进会话的角色会把另一个
// 角色的话读成自己的。
//
// The seeding fixes both halves: a fresh turn carries the session's most recent
// persisted messages, and every assistant message is labelled with the role that
// spoke it (model.Message.SenderName) so the loop's context assembly can attribute
// it (loop/attribution.go). Only labels travel on the messages — no body is
// rewritten.
// 播种修好了这两半:新回合携带该会话最近的
// 持久化消息,且每条助手消息都被说话的角色标注
// (model.Message.SenderName),使 loop 的上下文组装能归属它
// (loop/attribution.go)。消息上只走标签 —— 没有任何消息体
// 被改写。
//
// Scope note: the seeded window is the conversation, so the loop's per-step
// background memory extraction now reads those recent messages instead of the
// single newly sent one. Extracted items are still filed under the turn's role
// (loop.persistMemories, design/22 §4.2) and re-extraction of the same content is
// deduplicated by the extractor, but a fact first stated while another role was
// active can now be picked up by the current role if it was never extracted at the
// time. Narrowing the extraction input to the current turn's messages is a
// separate decision and is left as a follow-up.
// 范围说明:被播种的窗口就是该对话,因此 loop 的逐步
// 后台记忆抽取现在读的是这些近期消息,而不是
// 单条刚发送的消息。抽取出的条目仍归到该回合的角色
// 名下(loop.persistMemories,design/22 §4.2),且同一内容的重复抽取
// 会被抽取器去重,但一个在另一角色活跃时首次说出的事实,
// 若当时从未被抽取,现在就可能被当前角色拾取。
// 把抽取输入收窄为当前回合的消息是一个
// 独立的决定,留作后续跟进。
// transcriptContextCount bounds how many persisted messages a fresh turn carries
// into the model context. It mirrors the orchestrator's continuation tail bound
// (defaultMaxTail), so a seeded turn and a resumed turn stay the same size; the
// loop's own context budget still governs compaction beyond that.
// transcriptContextCount 限定一个新回合带入模型上下文的持久化
// 消息数。它与编排器的续跑尾部上限
// (defaultMaxTail)一致,使播种回合与续跑回合保持同样大小;
// 超出之后仍由 loop 自身的上下文预算治理压缩。
const transcriptContextCount = 20
// transcriptReader is the narrow history view the seeding needs. *history.Store
// satisfies it; a store that does not (a test fake, or a gateway built without
// persistence) simply yields no seeded context, so the orchestrator keeps running
// exactly as before.
// transcriptReader 是播种所需的窄历史视图。*history.Store
// 满足它;未满足的存储(测试替身,或未接持久化
// 构建的网关)只是得不到播种上下文,因此编排器
// 完全照旧继续运行。
type transcriptReader interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// startTurnMessages returns the conversation a fresh turn runs on: the session's
// seeded transcript followed by the messages the client just sent. When the
// transcript is unavailable the caller's messages are returned untouched, which is
// the pre-existing behavior.
// startTurnMessages 返回新回合运行其上的对话:该会话
// 已播种的转录,后跟客户端刚发送的消息。转录
// 不可用时,调用方的消息原样返回,这正是
// 既有行为。
func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message {
seed := o.sessionTranscript(ctx, sessionID)
if len(seed) == 0 {
@@ -56,11 +56,11 @@ func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string,
return concatMessages(seed, messages...)
}
// sessionTranscript reads the session's most recent persisted messages and maps
// them to model messages (oldest first). A nil/unreadable store or a read error
// yields nil so a chat is never blocked by a metadata lookup, and a session with
// no history yields nothing — both degrade to the pre-existing "new message only"
// context.
// sessionTranscript 读取该会话最近的持久化消息并把它们
// 映射为模型消息(最旧优先)。nil/不可读的存储或读错误
// 都得到 nil,因此聊天绝不被元数据查找阻塞;没有
// 历史的会话也什么都不产出 —— 二者都退化为既有的
// 「只有新消息」上下文。
func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) []model.Message {
if o.history == nil || sessionID == "" {
return nil
@@ -81,12 +81,12 @@ func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string)
return out
}
// transcriptMessage maps one persisted chat message onto the model message a turn
// continues from. An assistant message carries the role that spoke it (the stored
// persona_id of the turn) as its speaker label, so the context can attribute it to
// that role even after the session was switched; a non-assistant message keeps its
// stored sender label (the third-party display name of design/16 §4.6) and is never
// relabelled as a speaking role.
// transcriptMessage 把一条持久化聊天消息映射为回合延续所依据的
// 模型消息。助手消息携带说话的角色(该回合已存的
// persona_id)作为其说话者标签,因此即使会话被切换过,
// 上下文也能把它归属到那个角色;非助手消息保持其
// 已存发送者标签(design/16 §4.6 的第三方显示名),绝不
// 被重新标为说话角色。
func transcriptMessage(h history.Message) model.Message {
role := historyRoleToModel(h.Role)
msg := model.Message{
@@ -104,8 +104,8 @@ func transcriptMessage(h history.Message) model.Message {
return msg
}
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
// defaulting an unknown role to the user lane.
// historyRoleToModel 把已存聊天角色映射为模型的角色词汇,
// 未知角色默认归到用户通道。
func historyRoleToModel(role string) model.Role {
switch role {
case history.RoleAssistant:
@@ -117,11 +117,11 @@ func historyRoleToModel(role string) model.Role {
}
}
// assistantTailMessage builds the continuation-tail entry for one settled turn's
// reply, labelled with the role that spoke it (design/22 §7-5): the tail is the
// in-memory twin of the seeded transcript, so a role switched in later must be able
// to tell an earlier role's reply apart from its own words on the very next queued
// turn.
// assistantTailMessage 为一个已结算回合的回复构建续跑尾部
// 条目,并用说话的角色标注它(design/22 §7-5):该尾部是
// 播种转录的内存孪生,因此之后切换进来的角色必须能
// 在紧接着的那个排队回合上,把更早角色的回复与自己的话
// 区分开。
func assistantTailMessage(text, personaID string) model.Message {
msg := model.TextMessage(model.RoleAssistant, text)
msg.SenderName = personaID
+11 -11
View File
@@ -6,10 +6,10 @@ import (
"strings"
)
// spa wraps the API mux with the SPA file server. Requests under /api/ and /ws/
// are served by the API handler; every other path is served from the configured
// frontend filesystem with a fallback to index.html so client-side routes (e.g.
// /chat) resolve in a single-page app.
// spa 用 SPA 文件服务器包裹 API mux。/api/ 与 /ws/
// 之下的请求由 API handler 服务;其他所有路径都由已配置的
// 前端文件系统服务,并回退到 index.html,使客户端路由(例如
// /chat)能在单页应用中解析。
func (s *Server) spa(api http.Handler) http.Handler {
fileServer := s.spaFileServer()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -21,9 +21,9 @@ func (s *Server) spa(api http.Handler) http.Handler {
})
}
// isWebhookRoute reports whether path is a registered external webhook callback
// route. Such paths must reach the API mux (where the webhook handler is
// mounted) rather than the SPA file fallback, so inbound callbacks are served.
// isWebhookRoute 报告 path 是否是已注册的外部 webhook 回调
// 路由。这类路径必须抵达 API mux(webhook handler 挂载之处)
// 而不是 SPA 文件回退,使入站回调得到服务。
func (s *Server) isWebhookRoute(p string) bool {
if len(s.webhooks) == 0 {
return false
@@ -32,8 +32,8 @@ func (s *Server) isWebhookRoute(p string) bool {
return ok
}
// spaFileServer serves static assets, falling back to index.html for paths that
// are not real files in the embedded/external dist.
// spaFileServer 提供静态资产,对不是内嵌/外部 dist 中真实
// 文件的路径回退到 index.html。
func (s *Server) spaFileServer() http.Handler {
fsrv := http.FileServer(http.FS(s.frontend))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -51,8 +51,8 @@ func (s *Server) spaFileServer() http.Handler {
})
}
// isAPIRoute reports whether path belongs to the REST/WS API surface the
// gateway owns, which the SPA handler must leave untouched.
// isAPIRoute 报告 path 是否属于网关拥有的 REST/WS API 表面,
// SPA handler 必须对之不作干预。
func isAPIRoute(p string) bool {
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/ws/")
}
+63 -63
View File
@@ -17,9 +17,9 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// maxUploadBytes caps a single uploaded image at 5 MiB. The multipart envelope
// (boundaries, part headers) gets a little headroom on top of that; the decoded
// file itself is checked against this exact limit.
// maxUploadBytes 把单张上传图片限制在 5 MiB。multipart 信封
// (边界、部件头)在其上多留一点余量;解码后的
// 文件本身按这个确切上限校验。
const (
maxUploadBytes = 5 << 20
uploadMultipartSlack = 1 << 20
@@ -27,16 +27,16 @@ const (
uploadSniffBytes = 512
)
// uploadFilePrefix is the same-origin static route that serves stored uploads
// (registered in Handler). It lives under /api/v1/ so the gateway mux — not the
// SPA fallback — owns it, and so it is part of the versioned client contract.
// uploadFilePrefix 是提供已存上传的同源静态路由
// (在 Handler 中注册)。它位于 /api/v1/ 之下,因此归网关 mux 而非
// SPA 回退所有,也因此属于版本化客户端契约。
const uploadFilePrefix = "/api/v1/files/"
// uploadResponse is the POST /api/v1/upload success payload. URL is an absolute
// http(s) URL built from the request authority so it can be handed straight to
// chat.image: the vision layer classifies only http(s):// as a URL and would
// otherwise treat a bare path as base64. Name is the server-generated storage
// name; the client's original filename is never echoed back or persisted.
// uploadResponse 是 POST /api/v1/upload 的成功载荷。URL 是由请求
// 权威构建的绝对 http(s) URL,因此能直接交给
// chat.image:视觉层只把 http(s):// 归类为 URL,否则会把
// 裸路径当作 base64。Name 是服务端生成的存储
// 名;客户端原始文件名绝不回显或持久化。
type uploadResponse struct {
URL string `json:"url"`
Size int64 `json:"size"`
@@ -44,12 +44,12 @@ type uploadResponse struct {
Name string `json:"name"`
}
// handleUpload accepts a multipart/form-data request with a single `file` part,
// validates it is a raster image within the size limit, stores it under
// <dataDir>/uploads under a cryptographically random name, audits the write and
// returns the URL to attach as chat.image. It is owner/admin only (registered in
// Handler). The client's original filename is deliberately ignored: it is
// attacker-controlled and could carry a path separator or overwrite a file.
// handleUpload 接受带单个 `file` 部件的 multipart/form-data 请求,
// 校验它是在大小限制内的光栅图片,并以密码学随机的
// 名字存到 <dataDir>/uploads 下,审计该写操作,
// 返回可作为 chat.image 附上的 URL。它仅 owner/admin(注册在
// Handler 中)。客户端原始文件名被刻意忽略:它由
// 攻击者控制,可能携带路径分隔符或覆盖文件。
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+uploadMultipartSlack)
if err := r.ParseMultipartForm(1 << 20); err != nil {
@@ -104,12 +104,12 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
})
}
// guardUploadPaths rejects a static-upload request whose decoded path contains a
// parent-directory segment, before ServeMux can clean it and redirect the client
// to a non-upload path (which the SPA would then answer with index.html). The
// {name} route only ever matches one segment, so a `..` can only be an escape
// attempt; answering 404 here keeps every traversal shape uniformly rejected.
// Non-upload paths pass through untouched.
// guardUploadPaths 拒绝解码后路径含父目录段的静态上传
// 请求,赶在 ServeMux 清理它并把客户端重定向到一个非上传
// 路径(SPA 随后会用 index.html 应答)之前。{name} 路由
// 只匹配一个段,因此 `..` 只可能是逃逸尝试;这里返回 404
// 使每一种穿越形态都被统一拒绝。非上传路径
// 原样放行。
func guardUploadPaths(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, uploadFilePrefix) && hasDotDotSegment(r.URL.Path) {
@@ -120,7 +120,7 @@ func guardUploadPaths(next http.Handler) http.Handler {
})
}
// hasDotDotSegment reports whether p has a literal ".." path segment.
// hasDotDotSegment 报告 p 是否有字面的 ".." 路径段。
func hasDotDotSegment(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
@@ -130,11 +130,11 @@ func hasDotDotSegment(p string) bool {
return false
}
// handleUploadFile serves a stored upload from <dataDir>/uploads. It is
// unauthenticated by design: the returned URL is handed to chat.image and to
// <img> tags, neither of which can attach a bearer token. The random 128-bit
// name is the capability; the handler accepts only a strict hex+known-extension
// name and never lets a request path escape the uploads directory.
// handleUploadFile 从 <dataDir>/uploads 提供一份已存上传。它
// 按设计不认证:返回的 URL 会交给 chat.image 与
// <img> 标签,二者都无法附带 bearer token。随机的 128 位
// 名字就是能力;handler 只接受严格的十六进制+已知扩展名
// 名字,且绝不让请求路径逃出 uploads 目录。
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validUploadName(name) {
@@ -173,8 +173,8 @@ func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, name, info.ModTime(), f)
}
// writeUploadBodyError maps a multipart parse failure to its wire error: an
// over-limit body is 413, anything else (not multipart, malformed part) is 400.
// writeUploadBodyError 把 multipart 解析失败映射为其线上错误:
// 超限请求体是 413,其他(非 multipart、部件畸形)是 400。
func writeUploadBodyError(w http.ResponseWriter, err error) {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
@@ -184,9 +184,9 @@ func writeUploadBodyError(w http.ResponseWriter, err error) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid multipart body"))
}
// sniffImageMIME detects the content type of the upload head with the stdlib
// sniffer (never the client-supplied Content-Type header, which is untrusted)
// and rewinds the part so the caller can copy it in full.
// sniffImageMIME 用标准库嗅探器检测上传头部的类型
// (绝不用客户端提供的 Content-Type 头,它不可信),
// 并回绕部件,使调用方能完整复制它。
func sniffImageMIME(f multipart.File) (string, error) {
buf := make([]byte, uploadSniffBytes)
n, err := io.ReadFull(f, buf)
@@ -199,9 +199,9 @@ func sniffImageMIME(f multipart.File) (string, error) {
return http.DetectContentType(buf[:n]), nil
}
// uploadExtForMIME maps a sniffed image MIME to the extension used on disk. The
// whitelist is deliberately raster-only: SVG (and HTML) would be served inline
// and could execute script, so they are not accepted.
// uploadExtForMIME 把嗅探出的图片 MIME 映射为磁盘上使用的扩展名。
// 白名单刻意只含光栅:SVG(与 HTML)会被内联服务
// 并可能执行脚本,因此不被接受。
func uploadExtForMIME(mime string) (string, bool) {
switch mime {
case "image/png":
@@ -219,9 +219,9 @@ func uploadExtForMIME(mime string) (string, bool) {
}
}
// uploadMIMEForExt returns the Content-Type for a stored upload extension. It is
// the read-side twin of uploadExtForMIME: only whitelisted extensions resolve,
// which is what makes validUploadName strict.
// uploadMIMEForExt 返回已存上传扩展名对应的 Content-Type。它是
// uploadExtForMIME 的读侧孪生:只有白名单内的扩展名能解析,
// 这正是 validUploadName 严格的原因。
func uploadMIMEForExt(ext string) (string, bool) {
switch strings.ToLower(ext) {
case ".png":
@@ -239,10 +239,10 @@ func uploadMIMEForExt(ext string) (string, bool) {
}
}
// storeUpload writes f to <dataDir>/uploads/<random>.<ext> and returns the
// generated name. It writes to a dot-prefixed temp file first and renames it
// into place, so a partial upload is never visible under a servable name. The
// directory is created 0700 and the file inherits the temp file's 0600 mode.
// storeUpload 把 f 写入 <dataDir>/uploads/<random>.<ext> 并返回
// 生成的名字。它先写一个点前缀的临时文件再把它重命名
// 到位,因此部分上传绝不会以可服务的名字可见。
// 目录以 0700 创建,文件继承临时文件的 0600 权限。
func (s *Server) storeUpload(f io.Reader, ext string) (string, error) {
dir, err := s.uploadDir()
if err != nil {
@@ -276,10 +276,10 @@ func (s *Server) storeUpload(f io.Reader, ext string) (string, error) {
return name, nil
}
// uploadDir returns <dataDir>/uploads. The resolved data dir is injected by the
// composition root (Deps.DataDir, the same value resolveDataDir hands the DB and
// the runtime configs); the configured cfg.DataDir is the fallback so a Server
// built without the injection still resolves its uploads.
// uploadDir 返回 <dataDir>/uploads。解析后的数据目录由
// 组合根注入(Deps.DataDir,即 resolveDataDir 交给 DB 与
// 运行时配置的同一个值);已配置的 cfg.DataDir 是回退,使
// 未注入构建的 Server 仍能解析其上传目录。
func (s *Server) uploadDir() (string, error) {
base := strings.TrimSpace(s.dataDir)
if base == "" {
@@ -291,8 +291,8 @@ func (s *Server) uploadDir() (string, error) {
return filepath.Join(base, "uploads"), nil
}
// randomUploadName returns a 128-bit random hex name with ext appended. It is
// the only filename component ever used: the client's original name is dropped.
// randomUploadName 返回追加 ext 的 128 位随机十六进制名。它
// 是唯一被使用的文件名组成部分:客户端原始名被丢弃。
func randomUploadName(ext string) (string, error) {
buf := make([]byte, uploadNameBytes)
if _, err := rand.Read(buf); err != nil {
@@ -301,10 +301,10 @@ func randomUploadName(ext string) (string, error) {
return hex.EncodeToString(buf) + ext, nil
}
// validUploadName reports whether name is exactly a 32-lowercase-hex stem plus a
// whitelisted image extension. This is the path-traversal guard for the static
// route: separators, dots, dots-dots and any other shape are rejected before the
// filesystem is touched.
// validUploadName 报告 name 是否恰好是 32 位小写十六进制主名
// 加上白名单内的图片扩展名。这是静态路由的路径穿越
// 防护:分隔符、点、双点及任何其他形态都在
// 触碰文件系统之前被拒绝。
func validUploadName(name string) bool {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") {
return false
@@ -322,15 +322,15 @@ func validUploadName(name string) bool {
return ok
}
// isLowerHex reports whether c is an ASCII lowercase hex digit.
// isLowerHex 报告 c 是否是 ASCII 小写十六进制数字。
func isLowerHex(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
}
// uploadURL builds the absolute, same-origin URL for a stored upload. The scheme
// honours X-Forwarded-Proto (reverse proxy) and the authority is the request
// host, sanitized so a malformed Host header cannot smuggle path syntax into the
// returned URL; the configured listen address is the fallback.
// uploadURL 为一份已存上传构建绝对同源 URL。scheme
// 遵循 X-Forwarded-Proto(反向代理),权威是请求
// host,并做净化,使畸形的 Host 头无法把路径语法夹带进
// 返回的 URL;已配置的监听地址是回退。
func (s *Server) uploadURL(r *http.Request, name string) string {
scheme := "http"
if r.TLS != nil {
@@ -346,8 +346,8 @@ func (s *Server) uploadURL(r *http.Request, name string) string {
return scheme + "://" + host + uploadFilePrefix + name
}
// forwardedProto returns the first X-Forwarded-Proto value when it is http or
// https, else "".
// forwardedProto 在首个 X-Forwarded-Proto 值为 http 或
// https 时返回它,否则返回 ""。
func forwardedProto(r *http.Request) string {
v := r.Header.Get("X-Forwarded-Proto")
if v == "" {
@@ -360,8 +360,8 @@ func forwardedProto(r *http.Request) string {
return ""
}
// sanitizeHost rejects a host value containing path, userinfo or whitespace
// syntax; an empty result makes uploadURL fall back to the configured address.
// sanitizeHost 拒绝含路径、userinfo 或空白语法的 host
// 值;结果为空会让 uploadURL 回退到已配置的地址。
func sanitizeHost(host string) string {
host = strings.TrimSpace(host)
if host == "" || strings.ContainsAny(host, "/\\@ \t") {
+19 -19
View File
@@ -19,13 +19,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// testPNG is the smallest byte string the stdlib sniffer recognizes as
// image/png (the 8-byte signature plus padding); the handler only sniffs the
// head, it never decodes the image.
// testPNG 是标准库嗅探器能识别为 image/png 的最小字节串
// (8 字节签名加填充);handler 只嗅探头部,
// 绝不解码该图片。
var testPNG = append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
// uploadTestEnv builds a Server whose data dir is a fresh temp dir and returns
// the httptest server, the JWT service and the resolved data dir.
// uploadTestEnv 构建一个 Server,其数据目录是全新的临时目录,返回
// httptest 服务器、JWT 服务与解析后的数据目录。
func uploadTestEnv(t *testing.T) (*httptest.Server, *auth.JWTService, string) {
t.Helper()
cfg := cfgDefault()
@@ -34,9 +34,9 @@ func uploadTestEnv(t *testing.T) (*httptest.Server, *auth.JWTService, string) {
return ts, jwt, cfg.DataDir
}
// postUpload sends a multipart/form-data POST to /api/v1/upload. field is the
// part name (normally "file"); filename is the client-supplied name, which the
// handler must ignore.
// postUpload 向 /api/v1/upload 发送 multipart/form-data POST。field 是
// 部件名(通常为 "file");filename 是客户端提供的名字,
// handler 必须忽略它。
func postUpload(t *testing.T, ts *httptest.Server, token, field, filename string, content []byte) (*http.Response, []byte) {
t.Helper()
var buf bytes.Buffer
@@ -71,7 +71,7 @@ func postUpload(t *testing.T, ts *httptest.Server, token, field, filename string
return resp, body
}
// uploadData is the decoded upload response.
// uploadData 是解码后的上传响应。
type uploadData struct {
URL string `json:"url"`
Size int64 `json:"size"`
@@ -110,7 +110,7 @@ func TestUploadAndServeImage(t *testing.T) {
if !strings.HasPrefix(data.URL, "http://") || !strings.Contains(data.URL, uploadFilePrefix) {
t.Fatalf("url = %q, want absolute %s URL", data.URL, uploadFilePrefix)
}
// The client's original filename must never reach disk or the response.
// 客户端原始文件名绝不得到达磁盘或响应。
if strings.Contains(data.Name, "evil") || strings.ContainsAny(data.Name, "/\\") {
t.Fatalf("name = %q, want a server-generated hex name", data.Name)
}
@@ -121,8 +121,8 @@ func TestUploadAndServeImage(t *testing.T) {
t.Fatalf("stored file: %v", err)
}
// The static route is a capability URL: no bearer token, and the bytes and
// caching/type headers must come back intact.
// 静态路由是能力 URL:不带 bearer token,且字节与
// 缓存/类型头必须原样返回。
get, err := ts.Client().Get(data.URL)
if err != nil {
t.Fatalf("get upload: %v", err)
@@ -230,8 +230,8 @@ func TestUploadFileServedThroughSPA(t *testing.T) {
t.Parallel()
cfg := cfgDefault()
cfg.DataDir = t.TempDir()
// A non-nil frontend enables the SPA wrapper in Handler; the /api/v1/files/
// route must still reach the API mux instead of falling back to index.html.
// 非 nil 的 frontend 会在 Handler 中启用 SPA 包装;
// /api/v1/files/ 路由仍必须抵达 API mux,而不是回退到 index.html。
frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>spa</html>")}}
_, ts, jwt := buildEnv(t, cfg, Deps{Frontend: frontend})
tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour)
@@ -259,13 +259,13 @@ func TestUploadFileRejectsTraversal(t *testing.T) {
t.Parallel()
cfg := cfgDefault()
cfg.DataDir = t.TempDir()
// A non-nil frontend reproduces production routing: without the explicit
// guard, ServeMux would clean the raw `..` path and redirect to a path the SPA
// then answers with index.html.
// 非 nil 的 frontend 复现生产路由:没有显式
// 防护时,ServeMux 会清理原始 `..` 路径并重定向到一个 SPA
// 随后用 index.html 应答的路径。
frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>spa</html>")}}
_, ts, _ := buildEnv(t, cfg, Deps{Frontend: frontend})
// A file that really exists directly under the data dir (not in uploads/)
// must not be reachable through the static route.
// 直接存在于数据目录下(不在 uploads/ 中)的真实文件
// 绝不得经静态路由可达。
if err := os.WriteFile(filepath.Join(cfg.DataDir, "secret.png"), testPNG, 0o600); err != nil {
t.Fatalf("write decoy: %v", err)
}
+51 -51
View File
@@ -12,38 +12,38 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// VoiceService runs speech-to-text and text-to-speech for the voice_input WS
// route. *voice.Chain (the degrading provider chain) and *voice.Service (the
// background worker pool) both satisfy it. It is nil-tolerant: the voice route
// replies with a clear error when no service is injected.
// VoiceService 为 voice_input WS 路由运行语音转文本与
// 文本转语音。*voice.Chain(降级 provider 链)与 *voice.Service
// (后台工作池)都满足它。它容忍 nil:未注入服务时
// voice 路由返回明确的错误。
type VoiceService interface {
// STT transcribes audio to text.
// STT 把音频转写为文本。
STT(ctx context.Context, audio voice.Audio) (string, error)
// TTS synthesizes audio from text. A per-request emotion/tone instruction may
// be threaded through ctx (voice.WithTTSInstruction) to override the configured
// VOICE_TTS_INSTRUCTION for one call.
// TTS 从文本合成音频。每请求的情绪/语气指令可以
// 经 ctx(voice.WithTTSInstruction)串下来,以在单次调用中覆盖
// 已配置的 VOICE_TTS_INSTRUCTION。
TTS(ctx context.Context, text string) (voice.Audio, error)
// CloneVoice creates a custom voice (声音复刻 create_voice) from a public
// reference audio URL and returns its voice_id.
// CloneVoice 从公开参考音频 URL 创建自定义音色(声音复刻
// create_voice)并返回其 voice_id。
CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error)
}
// voiceProviderHeader is the response header carrying the provider rung on the
// binary /api/v1/audio/speech response, where a JSON signal field cannot be added.
// voiceProviderHeader 是二进制 /api/v1/audio/speech 响应上携带 provider
// 档位的响应头,因为那里无法添加 JSON 信号字段。
const voiceProviderHeader = "X-Voice-Provider"
// voiceProviderReporter is the optional extension of VoiceService: a service that
// can report which provider rung (cloud / local / edge-tts / espeak / silent)
// produced a result. *voice.Chain and *voice.Service implement it; a custom
// VoiceService that does not is still supported and falls back to the plain
// STT/TTS methods with no reported rung.
// voiceProviderReporter 是 VoiceService 的可选扩展:一个能报告
// 结果是哪个 provider 档位(cloud / local / edge-tts / espeak / silent)
// 产出的服务。*voice.Chain 与 *voice.Service 实现了它;未实现的
// 自定义 VoiceService 仍被支持,回退到朴素的
// STT/TTS 方法且不报告档位。
type voiceProviderReporter interface {
STTWithProvider(ctx context.Context, audio voice.Audio) (string, string, error)
TTSWithProvider(ctx context.Context, text string) (voice.Audio, string, error)
}
// sttWithProvider runs STT and returns the transcript plus the provider rung that
// served it ("" when the injected service cannot report one).
// sttWithProvider 运行 STT 并返回转写文本加上服务它的
// provider 档位(注入的服务无法报告时为 "")。
func sttWithProvider(ctx context.Context, v VoiceService, audio voice.Audio) (string, string, error) {
if r, ok := v.(voiceProviderReporter); ok {
return r.STTWithProvider(ctx, audio)
@@ -52,8 +52,8 @@ func sttWithProvider(ctx context.Context, v VoiceService, audio voice.Audio) (st
return text, "", err
}
// ttsWithProvider runs TTS and returns the audio plus the provider rung that
// served it.
// ttsWithProvider 运行 TTS 并返回音频加上服务它的
// provider 档位。
func ttsWithProvider(ctx context.Context, v VoiceService, text string) (voice.Audio, string, error) {
if r, ok := v.(voiceProviderReporter); ok {
return r.TTSWithProvider(ctx, text)
@@ -62,10 +62,10 @@ func ttsWithProvider(ctx context.Context, v VoiceService, text string) (voice.Au
return audio, "", err
}
// providerSignal returns the "provider" degraded signal for a voice result: the
// actual provider rung when the result is empty, "silent" when the rung is
// unknown, and "" (omit the field) when the result carries real text/audio. It
// only flags "no real speech/audio"; it is never a business error.
// providerSignal 为语音结果返回 "provider" 降级信号:结果为
// 空时返回实际 provider 档位,档位未知时返回 "silent",
// 而结果携带真实文本/音频时返回 ""(省略该字段)。它
// 只标记「没有真实语音/音频」;它绝不是业务错误。
func providerSignal(rung string, empty bool) string {
if !empty {
return ""
@@ -76,11 +76,11 @@ func providerSignal(rung string, empty bool) string {
return rung
}
// voiceInputRequest is the decoded payload of a voice_input frame. Audio is a
// base64 (or data: URI) audio blob; Format is its encoding ("wav"/"mp3"/"ogg");
// Text overrides STT when present (a client that already transcribed can skip
// recognition); SessionID defaults to "web"; DeviceID is the client-produced
// end identifier (design/16 §4.5) threaded into the persisted voice turn.
// voiceInputRequest 是 voice_input 帧解码后的载荷。Audio 是
// base64(或 data: URI)音频块;Format 是其编码("wav"/"mp3"/"ogg");
// Text 存在时覆盖 STT(已自行转写的客户端可跳过
// 识别);SessionID 默认 "web";DeviceID 是客户端产出的
// 端标识(design/16 §4.5),串进持久化的语音回合。
type voiceInputRequest struct {
Audio string `json:"audio"`
Format string `json:"format"`
@@ -89,19 +89,19 @@ type voiceInputRequest struct {
DeviceID string `json:"deviceId,omitempty"`
}
// SetVoice injects the voice STT/TTS service used by the voice_input WS route.
// It is the gateway's exposed injection point so the composition root can wire a
// *voice.Chain or *voice.Service without touching Deps. A nil value disables the
// route (it replies with a clear error).
// SetVoice 注入 voice_input WS 路由所用的语音 STT/TTS 服务。
// 它是网关暴露的注入点,使组合根能接线 *voice.Chain 或
// *voice.Service 而无需触碰 Deps。nil 值会禁用该
// 路由(它以明确错误应答)。
func (s *Server) SetVoice(v VoiceService) {
s.voice = v
s.hub.voice = v
}
// handleVoiceInput routes one voice_input frame. Without an injected voice
// service it replies with a clear error (never a crash); otherwise it decodes the
// request and runs the STT→chat→TTS turn asynchronously so the read pump is never
// blocked by provider latency.
// handleVoiceInput 路由一个 voice_input 帧。未注入语音
// 服务时它以明确错误应答(绝不崩溃);否则它解码
// 请求并异步运行 STT→chat→TTS 回合,使读泵绝不被
// provider 延迟阻塞。
func (c *wsConn) handleVoiceInput(in wsInbound) {
if c.h.voice == nil {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "voice unavailable"}})
@@ -116,13 +116,13 @@ func (c *wsConn) handleVoiceInput(in wsInbound) {
go c.h.runVoiceTurn(c, req)
}
// runVoiceTurn drives one voice turn: STT (or a supplied transcript) → agent chat
// → TTS, then replies to the requesting connection with a voice_output frame
// carrying the base64 audio plus the recognized transcript and assistant reply.
// runVoiceTurn 驱动一个语音回合:STT(或提供的转写)→ agent chat
// → TTS,然后向请求连接回复一个 voice_output 帧,
// 携带 base64 音频加上识别出的转写与助手回复。
func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
ctx := context.Background()
// Thread the client's deviceID so the persisted voice turn is tagged with the
// sending end (design/16 §4.5), mirroring ws_hub.go:240.
// 把客户端的 deviceID 串下来,使持久化的语音回合被发送端
// 标注(design/16 §4.5),与 ws_hub.go:240 一致。
ctx = withDeviceID(ctx, req.DeviceID)
if req.SessionID == "" {
req.SessionID = "web"
@@ -155,9 +155,9 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
h.voiceError(c, "chat service is not configured")
return
}
// The Web main session is the owner's private conversation, so it binds the
// full-access env: memory read+write, unchanged behavior. The role comes from the
// session's binding (design/22 §4.5).
// Web 主会话是主人的私密对话,因此它绑定
// 全访问 env:记忆读+写,行为不变。角色来自该
// 会话的绑定(design/22 §4.5)。
env := h.envResolver.owner(ctx, req.SessionID)
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
if err != nil {
@@ -187,14 +187,14 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
c.enqueueJSON(wsEvent{Type: wsVoiceOutput, Data: data})
}
// voiceError replies with a typed error frame for a failed voice turn.
// voiceError 为失败的语音回合回复一个带类型的错误帧。
func (h *hub) voiceError(c *wsConn, message string) {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": message}})
}
// decodeVoiceAudio turns a base64 (or data:) audio payload into voice.Audio. The
// format is taken from req when set, guessed from a data: URI mime otherwise, and
// defaulted to "wav".
// decodeVoiceAudio 把 base64(或 data:)音频载荷转成 voice.Audio。
// 格式在 req 设置时取自 req,否则从 data: URI mime 猜测,
// 再默认到 "wav"。
func decodeVoiceAudio(src, format string) (voice.Audio, error) {
payload := src
if strings.HasPrefix(src, "data:") {
@@ -218,7 +218,7 @@ func decodeVoiceAudio(src, format string) (voice.Audio, error) {
return voice.Audio{Format: format, Data: data}, nil
}
// formatFromMime guesses an audio format from a data: URI mime segment.
// formatFromMime 从 data: URI 的 mime 段猜测音频格式。
func formatFromMime(meta string) string {
lower := strings.ToLower(meta)
for _, f := range []string{"wav", "mp3", "ogg", "webm", "mpeg"} {
+24 -24
View File
@@ -9,9 +9,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
)
// eventBridge maps a producer bus subject to its outbound WS event type. The hub
// subscribes to these (and re-normalizes into ws.* types) so the frontend's
// existing consumers light up without the producers changing their subjects.
// eventBridge 把生产者 bus 主题映射为其下行 WS 事件类型。hub
// 订阅这些(并重新归一化为 ws.* 类型),使前端既有的
// 消费方亮起来,而生产者无需改变其主题名。
var eventBridge = map[string]string{
"iot.device_update": "device_update",
"task.output": "task_output",
@@ -20,8 +20,8 @@ var eventBridge = map[string]string{
"reminder.trigger": "reminder",
}
// bridgePatterns are the additional bus patterns the hub subscribes to on top of
// ws.> to capture producer events and normalize them for WS delivery.
// bridgePatterns 是 hub 在 ws.> 之外额外订阅的 bus 模式,用于
// 捕获生产者事件并把它们归一化以供 WS 投递。
var bridgePatterns = []string{
"iot.>",
"task.>",
@@ -30,11 +30,11 @@ var bridgePatterns = []string{
"cyrene.agent.*.event",
}
// normalizeEvent maps a bus event to an outbound wsEvent. A ws.* subject keeps
// its design type (the ws. prefix is stripped); a known producer subject is
// bridged to its ws.* type; a sub-agent progress event is shaped into
// ws.subagent_event. It returns ok=false for events that have no WS-visible
// representation (e.g. internal cyrene.agent ask/reply traffic).
// normalizeEvent 把 bus 事件映射为下行 wsEvent。ws.* 主题保持
// 其设计类型(ws. 前缀被剥掉);已知的生产者主题被
// 桥接为其 ws.* 类型;子代理进度事件被塑形为
// ws.subagent_event。对没有 WS 可见表示的事件返回 ok=false
// (例如内部的 cyrene.agent ask/reply 流量)。
func normalizeEvent(e bus.Event) (wsEvent, bool) {
if typ, ok := eventBridge[e.Subject]; ok {
return wsEvent{Type: typ, Data: e.Payload}, true
@@ -48,11 +48,11 @@ func normalizeEvent(e bus.Event) (wsEvent, bool) {
return wsEvent{}, false
}
// subagentProgressEvent maps an orchestration progress event — broadcast on
// cyrene.agent.<from>.event via comm.PublishEvent — into a ws.subagent_event
// frame. Only the five sub-agent progress names are surfaced; any other event
// broadcast on the same subject (ask/reply/addressed events, or foreign event
// names) is ignored so internal agent-to-agent traffic stays internal.
// subagentProgressEvent 把编排进度事件 —— 经 comm.PublishEvent 广播在
// cyrene.agent.<from>.event 上 —— 映射为 ws.subagent_event
// 帧。只有五个子代理进度名会被浮现;同一主题上广播的
// 其他任何事件(ask/reply/addressed 事件,或外来事件
// 名)都被忽略,使内部 agent 间流量保持内部。
func subagentProgressEvent(e bus.Event) (wsEvent, bool) {
if !isBroadcastAgentEvent(e.Subject) {
return wsEvent{}, false
@@ -80,11 +80,11 @@ func subagentProgressEvent(e bus.Event) (wsEvent, bool) {
}}, true
}
// streamEventData maps a streamed model.StreamKind to its downstream WS event
// type and builds a typed data payload. It is what turns the loop's structured
// events into the message.*/thinking.*/tool_call.*/tool_execution.* frames the
// frontend renders. Unknown kinds fall back to the legacy stream_chunk shape so
// an unrecognized event still reaches the client instead of being dropped.
// streamEventData 把流式的 model.StreamKind 映射为其下游 WS 事件
// 类型并构建带类型的 data 载荷。它正是把 loop 的结构化
// 事件变成前端所渲染的 message.*/thinking.*/tool_call.*/tool_execution.*
// 帧的东西。未知类型回退到旧的 stream_chunk 形状,使
// 未识别的事件仍能抵达客户端而不是被丢弃。
func streamEventData(ev model.StreamEvent) (string, map[string]any) {
switch ev.Kind {
case model.StreamKindTextStart:
@@ -112,10 +112,10 @@ func streamEventData(ev model.StreamEvent) (string, map[string]any) {
}
}
// isBroadcastAgentEvent reports whether subject has the exact 4-token broadcast
// shape cyrene.agent.<from>.event, which is how the scheduler publishes progress.
// It deliberately does not match addressed events (cyrene.agent.<from>.<to>.event)
// or the ask/reply subjects.
// isBroadcastAgentEvent 报告 subject 是否恰为 4 段广播形态
// cyrene.agent.<from>.event,调度器正是这样发布进度的。
// 它刻意不匹配寻址事件(cyrene.agent.<from>.<to>.event)
// 或 ask/reply 主题。
func isBroadcastAgentEvent(subject string) bool {
parts := strings.Split(subject, ".")
if len(parts) != 4 {
+16 -16
View File
@@ -15,9 +15,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// wsConn wraps one WebSocket connection. Writes are serialized through the
// buffered send channel drained by a single writePump, so the gorilla conn is
// only ever touched by one goroutine at a time.
// wsConn 包装一个 WebSocket 连接。写入经缓冲的 send
// 通道串行化,由单个 writePump 排空,因此 gorilla conn
// 在任一时刻只被一个 goroutine 触碰。
type wsConn struct {
conn *websocket.Conn
send chan []byte
@@ -27,9 +27,9 @@ type wsConn struct {
once sync.Once
}
// handleWS upgrades /ws/chat after authenticating the JWT from the query token or
// the Authorization header. A failed upgrade writes a 401 {error:...} before the
// handshake, so a browser/IM client sees a clean reject.
// handleWS 在校验来自查询 token 或 Authorization 头的 JWT 之后
// 升级 /ws/chat。升级失败会在握手前写出 401 {error:...},
// 使浏览器/IM 客户端看到干净的拒绝。
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "websocket requires GET"))
@@ -59,7 +59,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
go c.readPump()
}
// readPump reads client frames until the connection closes, routing each by type.
// readPump 读取客户端帧直到连接关闭,按类型路由每个帧。
func (c *wsConn) readPump() {
defer func() {
c.h.unregister(c)
@@ -87,8 +87,8 @@ func (c *wsConn) readPump() {
}
}
// writePump drains the send channel (and sends keepalive pings) until the
// connection closes. It is the only writer on the gorilla conn.
// writePump 排空 send 通道(并发送 keepalive ping)直到
// 连接关闭。它是 gorilla conn 上唯一的写入者。
func (c *wsConn) writePump() {
ticker := time.NewTicker(wsPingPeriod)
defer func() {
@@ -113,7 +113,7 @@ func (c *wsConn) writePump() {
}
}
// publishPublic publishes a bus event without any per-connection reply.
// publishPublic 发布一个 bus 事件,且不做任何按连接的回复。
func (c *wsConn) publishPublic(subject string, payload any) {
if c.h.bus == nil {
return
@@ -121,8 +121,8 @@ func (c *wsConn) publishPublic(subject string, payload any) {
_ = c.h.bus.Publish(context.Background(), subject, payload)
}
// shutdown closes the connection exactly once. It tolerates a nil *websocket.Conn
// so tests that build a fake connection can rely on a safe teardown.
// shutdown 恰好关闭连接一次。它容忍 nil 的 *websocket.Conn,
// 因此构建假连接的测试可以依赖安全的清理。
func (c *wsConn) shutdown() {
c.once.Do(func() {
close(c.done)
@@ -132,8 +132,8 @@ func (c *wsConn) shutdown() {
})
}
// enqueue queues a serialized frame, dropping it when a slow client's buffer is
// full so the producer never blocks.
// enqueue 把序列化后的帧入队,慢客户端缓冲满时丢弃它,
// 使生产者绝不阻塞。
func (c *wsConn) enqueue(b []byte) {
select {
case c.send <- b:
@@ -141,7 +141,7 @@ func (c *wsConn) enqueue(b []byte) {
}
}
// enqueueJSON serializes ev and queues it.
// enqueueJSON 序列化 ev 并把它入队。
func (c *wsConn) enqueueJSON(ev wsEvent) {
b, err := json.Marshal(ev)
if err != nil {
@@ -151,7 +151,7 @@ func (c *wsConn) enqueueJSON(ev wsEvent) {
c.enqueue(b)
}
// wsToken extracts the JWT from the ?token= query or the Authorization header.
// wsToken 从 ?token= 查询或 Authorization 头中提取 JWT。
func wsToken(r *http.Request) string {
if t := r.URL.Query().Get("token"); t != "" {
return t
+18 -18
View File
@@ -5,14 +5,14 @@ import (
"strings"
)
// originChecker allows same-origin, same-host, and whitelisted WebSocket origins,
// mirroring the CORS allow list. A "*" allows all.
// originChecker 允许同源、同 host 以及白名单内的 WebSocket 来源,
// 镜像 CORS 允许列表。"*" 允许全部。
//
// Same-host relaxation: when cyrene serves its own embedded frontend (production
// mode), the page origin is exactly the gateway's Host (e.g. http://localhost:10942),
// which is not in the dev-phase ALLOWED_ORIGINS list. We must let that through so a
// user who changes PORT does not need to edit .env for the WS to connect. Only truly
// cross-origin clients (external IM / dashboards on another origin) hit the allow-list.
// 同 host 放宽:cyrene 提供自己的内嵌前端时(生产
// 模式),页面来源恰好是网关的 Host(例如 http://localhost:10942),
// 它不在开发期的 ALLOWED_ORIGINS 列表中。我们必须放行它,使
// 改过 PORT 的用户不必为了 WS 能连上而编辑 .env。只有真正的
// 跨源客户端(外部 IM / 另一来源上的看板)才会走允许列表。
func originChecker(allowed map[string]bool, allowAll bool) func(*http.Request) bool {
return func(r *http.Request) bool {
origin := r.Header.Get("Origin")
@@ -25,16 +25,16 @@ func originChecker(allowed map[string]bool, allowAll bool) func(*http.Request) b
if allowed[origin] {
return true
}
// Same-host: the Origin matches this request's Host (scheme://host:port == host[:port]).
// 同 host:Origin 与该请求的 Host 匹配(scheme://host:port == host[:port])。
return sameOriginAsHost(origin, r)
}
}
// sameOriginAsHost reports whether origin (a full URL origin string such as
// "http://localhost:10942") corresponds to this request's Host header. It compares
// the host[:port] portion with loopback equivalence: "localhost" and "127.0.0.1"
// (and the IPv6 form) are treated as the same host, so serving the embedded frontend
// on 127.0.0.1 while a client connects via localhost (or vice versa) still passes.
// sameOriginAsHost 报告 origin(完整的 URL 来源串,例如
// "http://localhost:10942")是否对应本请求的 Host 头。它比较
// host[:port] 部分并做回环等价:"localhost" 与 "127.0.0.1"
// (以及 IPv6 形式)被视为同一 host,因此在 127.0.0.1 上提供内嵌
// 前端而客户端经 localhost 连接(或反之)仍然通过。
func sameOriginAsHost(origin string, r *http.Request) bool {
if origin == "" || r == nil {
return false
@@ -49,21 +49,21 @@ func sameOriginAsHost(origin string, r *http.Request) bool {
return hostEquivalent(after, r.Host)
}
// hostEquivalent compares two host[:port] strings, treating empty-port variants,
// case, and loopback aliases as equal. It handles "localhost" vs "127.0.0.1" and
// bracketed IPv6 loopback "[::1]".
// hostEquivalent 比较两个 host[:port] 串,把空端口变体、
// 大小写与回环别名都视为相等。它处理 "localhost" 与 "127.0.0.1"
// 以及带括号的 IPv6 回环 "[::1]"。
func hostEquivalent(a, b string) bool {
na := normalizeHost(a)
nb := normalizeHost(b)
if na == nb {
return true
}
// Loopback aliases: localhost, 127.0.0.1, [::1]
// 回环别名:localhost、127.0.0.1、[::1]
loop := map[string]bool{"localhost": true, "127.0.0.1": true, "[::1]": true, "::1": true, "127.0.0.2": true}
return loop[na] && loop[nb]
}
// normalizeHost strips the port (if present), lowercases, and trims brackets.
// normalizeHost 剥掉端口(若有)、转小写并去掉括号。
func normalizeHost(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
if i := strings.LastIndex(h, ":"); i >= 0 && !strings.Contains(h[i:], "]") {