docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
This commit is contained in:
1 parent
91e8d7a029
commit
074ffc4ebf
73 files changed
+2529
-2529
No files matched your search
+108
-108
@@ -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{})
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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, "'", "''")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:], "]") {
|
||||
|
||||
Reference in new issue
Block a user