- 引用计数真实维护(documents 表唯一写入面):增减点只有四处 —— history 的 insert / UpdateMessageDocument / DeleteMessage / DeleteSession,且**全在同一事务**内(计数写失败 ⇒ 消息写入整体回滚,有钉住用例);UpdateMessageDocument 按新旧名字**集合差**增减(三阶段回写是常态,重算会重复计数 ⇒ 文件永远回收不掉)。 - GC:候选 = ref_count = 0;判据 = last_referenced_at(NULL 回落 created_at)早于 now − 保留期;动作 = 删 sidecar + 原件 + 元数据行(删行带 AND ref_count = 0 原子再确认);路径双检;汇总日志(含实占字节);单件失败 WARN 并继续;删除顺序 = 先文件后行(失败则行留着下轮重试,不留孤儿)。 - 组合根:启动期清扫一次(不阻塞启动)+ ticker 常驻(可停,close 顺序在关库之前);env DOCUMENT_GC_INTERVAL 默认 1h / DOCUMENT_RETENTION 默认 168h,**任一 ≤0 = 关闭 GC**(RETENTION=0 不解释成立即回收,关闭时记 WARN);.env.example 已同步(AGENTS §10)。 - 主会话独立验收:隔离 worktree(S7 的 18 文件,排除 F2 在途的 mcp/vision)build/vet 绿、全树 59 包 0 FAIL、GC/引用用例 20 PASS、双平台交叉编译绿;**变异亲自复现**(候选查询去掉 ref_count = 0 ⇒ TestGCDoesNotDeleteReferencedDocument 红:candidates = 1, want 0)。 - 未做(任务书允许的「只做后端」):面板手动「清理未引用附件」入口 —— 后端能力已就绪(documents.NewGC + GC.Sweep),接成端点属小切片(含 admin 路由 + 审计 document.gc + 契约登记)。 - 已知窄窗口(已在 gc.go 文件头登记):一轮 GC 与一次落库几乎同时发生时可能删掉刚被引用的文件(行不删、记 WARN);坏 document 列只记 WARN 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
436 lines
22 KiB
Go
436 lines
22 KiB
Go
// Package config loads, defaults, and validates the Cyrene v2 configuration
|
||
// from environment variables and an optional .env file (development only).
|
||
//
|
||
// Every setting is sourced from an environment variable (no hard-coded values
|
||
// in code). Nested groups map to struct fields tagged with `env:"NAME"`.
|
||
// Missing required values cause Load/MustLoad to return an error so startup
|
||
// can fail fast.
|
||
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"reflect"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
|
||
)
|
||
|
||
// DataDirPlatform 是 DATA_DIR 的保留字哨兵:显式配置它就走平台标准数据目录
|
||
// (design/13 §2,裁决 12.4.2 + 15.4.1);未配置时仍是标签里的默认 `./data`。
|
||
const DataDirPlatform = "platform"
|
||
|
||
// DefaultAdminPath 是 ADMIN_PATH 的默认值:面板挂在 /admin
|
||
// (design/07 §0.2,裁决 1.6 / 16.4.7)。
|
||
const DefaultAdminPath = "admin"
|
||
|
||
// Config is the root configuration tree.
|
||
type Config struct {
|
||
Env string `env:"ENV" default:"development"`
|
||
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
||
Timezone string `env:"TZ" default:"Asia/Shanghai"`
|
||
// DataDir 是数据目录。两种取法都实现(design/13 §2,裁决 12.4.2/15.4.1):
|
||
// 未配置 → `./data`(标签默认,也是历史默认);显式配置保留字哨兵
|
||
// DataDirPlatform(`DATA_DIR=platform`)→ 平台标准数据目录,见
|
||
// resolveConfiguredDataDir。
|
||
DataDir string `env:"DATA_DIR" default:"./data"`
|
||
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
||
|
||
Server ServerConfig
|
||
Auth AuthConfig
|
||
DB DBConfig
|
||
LLM LLMConfig
|
||
Embedding EmbeddingConfig
|
||
IoT IoTConfig
|
||
Voice VoiceConfig
|
||
Background BackgroundConfig
|
||
Subagent SubagentConfig
|
||
PushOutlet PushOutletConfig
|
||
WSHub WSHubConfig
|
||
ExecSandbox ExecSandbox
|
||
MCP MCPConfig
|
||
Capacity CapacityConfig
|
||
Channels ChannelsConfig
|
||
Context ContextConfig
|
||
Guardrail GuardrailConfig
|
||
}
|
||
|
||
// ServerConfig holds the single gateway listener settings.
|
||
type ServerConfig struct {
|
||
Host string `env:"HOST" default:"0.0.0.0"`
|
||
Port int `env:"PORT" default:"8080"`
|
||
|
||
// AdminPath 是管理面板前端路由的**安全入口**路径前缀(裁决 1.6 / 16.4.7,
|
||
// design/07 §0.2):形如 `admin`(默认,面板挂在 `/admin`)或用户自定义的
|
||
// `xxx/admin`(面板挂在 `/xxx/admin`)。网关按它挂载面板入口,猜错的前缀
|
||
// 直接 404;该值只注入到面板入口页,用户端 bundle 不含它。
|
||
// REST 路径**不随它变化**——`/api/v1/admin/*` 照旧,安全靠 JWT + 角色门
|
||
// (design/07 §0.1「API 不藏」)。
|
||
AdminPath string `env:"ADMIN_PATH" default:"admin"`
|
||
|
||
// ChatImageMaxBytes 是 POST /api/v1/chat 的 image 允许的**解码后图像字节**
|
||
// 上限(协同文档 G3),与 POST /api/v1/upload 的 5 MiB 同口径。默认 5242880
|
||
// 与引入这项配置之前的硬编码常量逐字节一致;**≤0(含未配置的零值)视为
|
||
// 「用默认」**,不做「禁用上限」的解释——一张图能有多大只能有一个答案。
|
||
// 网关只在入口层用换算出的字符上限拦截,绝不把图片内容写进日志。
|
||
ChatImageMaxBytes int `env:"CHAT_IMAGE_MAX_BYTES" default:"5242880"`
|
||
|
||
// ChatDocumentMaxBytes 是 POST /api/v1/documents 允许的**单文件字节**上限,
|
||
// 默认 20971520(20 MiB)。它与图片上限(CHAT_IMAGE_MAX_BYTES)刻意解耦:
|
||
// 图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定,
|
||
// 共用同一个数会让「改文档上限」静默改掉图片行为(design/24 §3.3)。
|
||
// **≤0(含未配置的零值)视为「用默认」**,不是「禁用上限」。
|
||
ChatDocumentMaxBytes int `env:"CHAT_DOCUMENT_MAX_BYTES" default:"20971520"`
|
||
|
||
// DocumentGCInterval 是文档回收(GC)的定时清扫间隔(Go duration string,
|
||
// 如 "1h"),默认 1h(design/24 §3.2 的配额与清理,切片 S7)。**≤0 = 关闭
|
||
// GC**(含启动期那一次清扫):关闭是显式选择,日志会写明。
|
||
DocumentGCInterval time.Duration `env:"DOCUMENT_GC_INTERVAL" default:"1h"`
|
||
|
||
// DocumentRetention 是「未被任何消息引用」的文档还要留多久才可回收(Go
|
||
// duration string),默认 168h = 7 天。**≤0 = 关闭 GC** —— 0 刻意不解释成
|
||
// 「立即回收」,一次笔误不该把整库未引用附件清空。判据与动作见
|
||
// internal/runtime/documents/gc.go。
|
||
DocumentRetention time.Duration `env:"DOCUMENT_RETENTION" default:"168h"`
|
||
}
|
||
|
||
// AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。
|
||
// 容忍配置里的首尾斜杠与空白;值非法(空、含 `.`/`..` 段,或含 `?`/`#`
|
||
// 这类会截断路径的字符)时回退到默认前缀,宁可挂回默认也不挂到不可预测的
|
||
// 路径上。
|
||
func (s ServerConfig) AdminPathPrefix() string {
|
||
raw := strings.Trim(strings.TrimSpace(s.AdminPath), "/")
|
||
if raw == "" {
|
||
return "/" + DefaultAdminPath
|
||
}
|
||
segs := strings.Split(raw, "/")
|
||
clean := make([]string, 0, len(segs))
|
||
for _, seg := range segs {
|
||
seg = strings.TrimSpace(seg)
|
||
if seg == "" || seg == "." || seg == ".." || strings.ContainsAny(seg, "?#") {
|
||
return "/" + DefaultAdminPath
|
||
}
|
||
clean = append(clean, seg)
|
||
}
|
||
return "/" + strings.Join(clean, "/")
|
||
}
|
||
|
||
// AuthConfig holds authentication and admin credential settings.
|
||
type AuthConfig struct {
|
||
JWTSecret string `env:"JWT_SECRET" validate:"required,min=32"`
|
||
AdminUser string `env:"ADMIN_USERNAME" default:"admin"`
|
||
AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"`
|
||
RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"`
|
||
|
||
// RefreshTTL is how long an issued opaque refresh token stays valid. It is a
|
||
// Go duration string (e.g. "720h" = 30 days). Access tokens keep their 24h
|
||
// lifetime; the refresh token is the long-lived credential that renews them.
|
||
RefreshTTL time.Duration `env:"AUTH_REFRESH_TTL" default:"720h"`
|
||
|
||
// RefreshRotateDisabled opts out of single-use refresh rotation. Rotation is
|
||
// on by default (the safe default): a successful /auth/refresh revokes the
|
||
// presented token and returns a fresh one, so a replayed token fails. Set
|
||
// AUTH_REFRESH_ROTATE_DISABLED=true only for clients that cannot persist a
|
||
// rotated token. The field is negative-named so its zero value (an
|
||
// unset/undecoded config) means rotation is enabled.
|
||
RefreshRotateDisabled bool `env:"AUTH_REFRESH_ROTATE_DISABLED" default:"false"`
|
||
}
|
||
|
||
// DBConfig holds database connection settings. SQLite is the default; PGURL is
|
||
// the optional PostgreSQL+pgvector enhancement.
|
||
type DBConfig struct {
|
||
SQLitePath string `env:"DB_SQLITE_PATH" default:"./data/cyrene.db"`
|
||
PGURL string `env:"DB_PG_URL"`
|
||
}
|
||
|
||
// EmbeddingConfig holds the optional semantic embedding API settings. When
|
||
// unset, the runtime falls back to built-in n-gram/FTS vectors.
|
||
type EmbeddingConfig struct {
|
||
APIURL string `env:"EMBEDDING_API_URL"`
|
||
APIKey string `env:"EMBEDDING_API_KEY"`
|
||
Model string `env:"EMBEDDING_MODEL"`
|
||
}
|
||
|
||
// IoTConfig selects the IoT device provider.
|
||
type IoTConfig struct {
|
||
Provider string `env:"IOT_PROVIDER" default:"mock"`
|
||
}
|
||
|
||
// VoiceConfig holds TTS/STT provider settings and the Alibaba Bailian DashScope
|
||
// credentials/models that back the cloud rung of the voice chain.
|
||
type VoiceConfig struct {
|
||
// TTSProvider selects the non-cloud local TTS rung: "edge-tts" or "espeak".
|
||
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
|
||
// STTProvider selects the cloud STT provider ("dashscope").
|
||
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
|
||
// DashscopeAPIKey is the Bailian API key backing STT + TTS. When empty the
|
||
// dashscope adapters return ErrNotConfigured and the chain degrades.
|
||
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
|
||
|
||
// DashscopeBaseURL is the Bailian API base; the STT/TTS service paths are
|
||
// appended to it.
|
||
DashscopeBaseURL string `env:"DASHSCOPE_BASE_URL" default:"https://dashscope.aliyuncs.com/api/v1"`
|
||
|
||
// STTModel is the DashScope speech-transcription model.
|
||
STTModel string `env:"VOICE_STT_MODEL" default:"qwen-audio-3.0-asr-flash"`
|
||
STTLanguage string `env:"VOICE_STT_LANGUAGE" default:"zh"`
|
||
|
||
// TTSModel is the DashScope speech-synthesis model. cosyvoice-v3.5-plus and
|
||
// cosyvoice-v2 reject the HTTP SpeechSynthesizer call (engine 418); verified
|
||
// working is cosyvoice-v3-flash.
|
||
TTSModel string `env:"VOICE_TTS_MODEL" default:"cosyvoice-v3-flash"`
|
||
// TTSVoiceID selects the synthesized timbre. Voices are model-scoped (a v3
|
||
// model needs a _v3 voice). After cloning a custom voice in Bailian
|
||
// (音色复刻) fill its voice_id here.
|
||
TTSVoiceID string `env:"VOICE_TTS_VOICE_ID" default:"longxiaochun_v3"`
|
||
TTSFormat string `env:"VOICE_TTS_FORMAT" default:"wav"`
|
||
TTSSampleRate int `env:"VOICE_TTS_SAMPLE_RATE" default:"24000"`
|
||
// VoiceTTSInstruction is the emotion/tone instruction driving a cloned/designed
|
||
// TTS voice (e.g. "语气开朗俏皮,像女朋友撒娇"). cosyvoice v3 system voices (the
|
||
// long*_v3 family) require a fixed instruction format, so a free-form instruction
|
||
// is never sent for them — only for a cloned/designed voice (声音复刻).
|
||
VoiceTTSInstruction string `env:"VOICE_TTS_INSTRUCTION" default:""`
|
||
// VoiceClonePrefix is the name prefix used when creating a custom voice (声音复刻
|
||
// create_voice). It must be ≤10 alphanumeric chars; the empty value falls back
|
||
// to "cyrene". target_model stays TTSModel (must match the synthesis model).
|
||
VoiceClonePrefix string `env:"VOICE_CLONE_PREFIX" default:"cyrene"`
|
||
}
|
||
|
||
// BackgroundConfig holds the autonomous thinking settings. QuietStartHour and
|
||
// QuietEndHour bound the do-not-disturb window in local clock hours; they are the
|
||
// single source shared by the backthink service and the channel bridge's
|
||
// minimal-disturbance quiet-hours gate (design/12 §6 策略五). Equal non-zero hours
|
||
// disable the window. The Push*/Kind fields tune the proactive-push frequency of
|
||
// design/11 §6 + design/12 §6 and are mapped into backthink.Config.
|
||
type BackgroundConfig struct {
|
||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"`
|
||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
|
||
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
|
||
PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"`
|
||
PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"`
|
||
PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"`
|
||
RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"`
|
||
KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"`
|
||
}
|
||
|
||
// SubagentConfig holds the sub-agent orchestration settings (design/05 §7).
|
||
type SubagentConfig struct {
|
||
// TimeoutSec bounds one sub-agent's whole run. It feeds the dispatch tool's
|
||
// default (orchestration.WithSpawnTimeout), overriding the scheduler's code
|
||
// default; a per-dispatch timeout_seconds argument still overrides it per call
|
||
// (裁决 02.4.4: agent_timeout 可配置,默认 10min,支持按调用覆盖). Zero or
|
||
// negative selects the code default (600s = 10 minutes).
|
||
TimeoutSec int `env:"SUBAGENT_TIMEOUT_SEC" default:"600"`
|
||
}
|
||
|
||
// PushOutletConfig configures the proactive push outlet (D1, design/12 §4/§6).
|
||
// Proactive messages are mirrored to the owner's self-hosted unified push
|
||
// service (primary); Getui (个推) is the deferred fallback for when that service
|
||
// is unavailable. The Getui* fields are reserved placeholders — the SDK is not
|
||
// integrated, so an unavailable primary logs the pending fallback rather than
|
||
// pretending to push.
|
||
type PushOutletConfig struct {
|
||
// Enabled turns the outlet hook on. When false no export is attempted.
|
||
Enabled bool `env:"PUSH_EXPORTER_ENABLED" default:"false"`
|
||
// URL is the self-hosted unified push service endpoint (HTTP POST JSON).
|
||
// Empty leaves the primary tier unconfigured.
|
||
URL string `env:"PUSH_EXPORTER_URL"`
|
||
// Token is sent as "Authorization: Bearer <token>"; it is never logged.
|
||
Token string `env:"PUSH_EXPORTER_TOKEN"`
|
||
// TimeoutSec bounds one export attempt. Zero uses the service default (5s).
|
||
TimeoutSec int `env:"PUSH_EXPORTER_TIMEOUT_SEC" default:"5"`
|
||
|
||
// Getui* are reserved for the fallback tier; the SDK is not integrated yet,
|
||
// so these values are read but unused (kept so the env contract is stable).
|
||
GetuiAppID string `env:"GETUI_APP_ID"`
|
||
GetuiAppKey string `env:"GETUI_APP_KEY"`
|
||
GetuiMasterSecret string `env:"GETUI_MASTER_SECRET"`
|
||
GetuiBaseURL string `env:"GETUI_BASE_URL"`
|
||
}
|
||
|
||
// WSHubConfig holds the WebSocket hub limits.
|
||
type WSHubConfig struct {
|
||
MaxConnections int `env:"WS_MAX_CONNECTIONS" default:"1000"`
|
||
SessionIdleTimeout int `env:"SESSION_IDLE_TIMEOUT_MIN" default:"30"`
|
||
}
|
||
|
||
// MCPConfig holds Model Context Protocol server settings. The reflection-based
|
||
// loader only supports scalars and []string, so servers are supplied as a JSON
|
||
// array string (ServersJSON, env MCP_SERVERS_JSON) and parsed at startup by the
|
||
// MCP wiring (cmd/cyrene/mcp.go).
|
||
type MCPConfig struct {
|
||
ServersJSON string `env:"MCP_SERVERS_JSON"`
|
||
}
|
||
|
||
// CapacityConfig configures the awakened-capacity decision layer
|
||
// (DevDocs/design/14-唤醒式能力渐进.md §4). It is opt-in: the loop exposes every
|
||
// tool until Enabled is true.
|
||
type CapacityConfig struct {
|
||
// Enabled turns on the capacity decider. When false the loop advertises every
|
||
// registered tool (pre-existing behavior). Note: it is wired at the loop
|
||
// periphery, not back-end (Invoke stays unblocked).
|
||
Enabled bool `env:"CAPACITY_ENABLED" default:"false"`
|
||
// MinConfidence is the wake threshold below which the state is forced toward
|
||
// the persona default (设计 §2.3 默认保守). Zero selects the code default 0.6.
|
||
MinConfidence float64 `env:"CAPACITY_MIN_CONFIDENCE" default:"0.6"`
|
||
// ToolSetsJSON, when non-empty, is a JSON map of state→[]tool name, e.g.
|
||
// {"quiet":["terminal","file"],"awake":["terminal","file","web-search"],
|
||
// "full":null}. A missing state selects the built-in default set; null ("full")
|
||
// means unrestricted. Parsed by the capacity wiring at startup (the
|
||
// reflection-based loader only supports scalars and []string), matching the
|
||
// MCP config pattern.
|
||
ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"`
|
||
// L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3;
|
||
// 裁决 14.4.4, keys chosen as the landing suggestion the owner authorized).
|
||
// It costs one extra model call for an undecided turn, so it defaults to off:
|
||
// off means the layer never runs and L1/L2/L4 behave exactly as before.
|
||
L3Enabled bool `env:"CAPACITY_L3_ENABLED" default:"false"`
|
||
// L3CacheTTL is how long a model-assisted classification of the same turn text
|
||
// is reused (a Go duration string such as "5m"). Zero or unparsable disables
|
||
// reuse; empty selects the code default of 5m.
|
||
L3CacheTTL string `env:"CAPACITY_L3_CACHE_TTL" default:"5m"`
|
||
}
|
||
|
||
// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or
|
||
// an SSE endpoint (URL). Enabled gates activation at startup.
|
||
type MCPServer struct {
|
||
Name string `json:"name"`
|
||
Command string `json:"command"`
|
||
Args []string `json:"args"`
|
||
URL string `json:"url"`
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
|
||
// ChannelsConfig holds optional multi-account IM channel settings (design/16
|
||
// §4.6 §5). The single-account credentials are read from CHANNEL_<NAME>_* env
|
||
// vars in cmd/cyrene/channels.go; this block carries the optional multi-account
|
||
// registry plus the per-account allow/deny black-or-white lists via a JSON
|
||
// string (the reflection-based loader only supports scalars and []string, so a
|
||
// structured value is passed as JSON — matching the MCP and Capacity configs).
|
||
type ChannelsConfig struct {
|
||
// AccountsJSON is the JSON shape map[platform]→[]ChannelAccount, e.g.
|
||
// {"telegram":[{"id":"conf0","config":{"bot_token":"..."},
|
||
// "denyUsers":["12345"]}]}. Empty means the single-account env path applies.
|
||
AccountsJSON string `env:"CHANNELS_JSON"`
|
||
}
|
||
|
||
// ChannelAccount is one IM access config (one account) for a platform. It pairs
|
||
// an id (used to name the adapter instance and to build the platform:confId
|
||
// account key) with the platform-specific credential map and the allow/deny
|
||
// black-or-white lists applied at the channelbridge ingress (design/16 §4.6), so
|
||
// a blacklisted or non-whitelisted group/user never reaches the LLM.
|
||
type ChannelAccount struct {
|
||
// ID is the local config id, unique within its platform. It names the adapter
|
||
// instance and the platform:confId account key (design/16 §4.5 deviceID).
|
||
ID string `json:"id"`
|
||
// Config is the platform-specific credential/feature map decoded into the
|
||
// adapter's typed Config (bot_token, base_url, remote_url, ...). Missing keys
|
||
// fall back to the adapter's own zero values.
|
||
Config map[string]any `json:"config,omitempty"`
|
||
// AllowGroups, when non-empty, is a white list of group ids: a group message
|
||
// is dropped unless its GroupID is in this list.
|
||
AllowGroups []string `json:"allowGroups,omitempty"`
|
||
// DenyGroups is a black list of group ids: a group message whose GroupID is in
|
||
// this list is dropped at ingress.
|
||
DenyGroups []string `json:"denyGroups,omitempty"`
|
||
// AllowUsers, when non-empty, is a white list of user uids: a direct (private)
|
||
// message from a user not in this list is dropped.
|
||
AllowUsers []string `json:"allowUsers,omitempty"`
|
||
// DenyUsers is a black list of user uids: a direct message from a user in this
|
||
// list is dropped at ingress.
|
||
DenyUsers []string `json:"denyUsers,omitempty"`
|
||
// AdminIDs 是该账号声明的管理员 id 列表(裁决 06.4.2;design/08 §1.1、design/11 §0):
|
||
// 命中的 uid 才可能进 owner 车道,可声明多个账户。**适配器上报的角色字段不参与判主**
|
||
// ——群主/群管理员一律是 RoleMember,伪造 role=owner 无效。只有需要的渠道才声明;
|
||
// weixin-ilink 只允许与绑定微信号私聊,不需要该项。这些 id 会与 owner.yaml 的账号
|
||
// 白名单取并集,共同构成唯一的判主依据(见 runtimeconfig.AdminIDs)。
|
||
AdminIDs []string `json:"adminIds,omitempty"`
|
||
// GroupMsgPerMin caps group-chat inbound messages per minute for this account
|
||
// (design/12 §6 策略三). Zero follows the protective default (6); a negative
|
||
// value disables the group rate limit; a positive value pins it.
|
||
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
|
||
// MsgPerMin caps total inbound non-owner messages per minute for this account.
|
||
// Zero or negative disables it.
|
||
MsgPerMin int `json:"msgPerMin,omitempty"`
|
||
// MaxMessages is the total pending/active message cap for this account
|
||
// (design/12 §6 策略四). Zero follows the protective default (50); a negative
|
||
// value disables the cap; a positive value pins it. When the pending set nears
|
||
// this cap the review model prunes low-value messages.
|
||
MaxMessages int `json:"maxMessages,omitempty"`
|
||
// PersonaID is the role this account answers as (design/22 §2 方案 b: 渠道级绑定).
|
||
// It is a persona id from the persona library, not a display name. It only
|
||
// provides the role for the owner's private chat with this account and only when
|
||
// the main session binds no role of its own: a session binding wins over it, the
|
||
// global default (owner.yaml defaultPersona) is the final fallback, and a
|
||
// group/stranger conversation is always forced onto the default role
|
||
// (design/22 §7-4). Empty means "no channel-level binding".
|
||
PersonaID string `json:"personaId,omitempty"`
|
||
}
|
||
|
||
// Load reads configuration, merging an optional .env file (development only)
|
||
// with process environment variables. Process environment variables always take
|
||
// precedence over .env values, which take precedence over code defaults.
|
||
//
|
||
// Passing an empty envPath skips .env entirely. A missing .env file is not an
|
||
// error; only missing required values are.
|
||
func Load(envPath string) (*Config, error) {
|
||
fromEnv := map[string]string{}
|
||
if envPath != "" {
|
||
m, err := parseDotEnv(envPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("config: read %s: %w", envPath, err)
|
||
}
|
||
fromEnv = m
|
||
}
|
||
|
||
cfg := &Config{}
|
||
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
|
||
return nil, err
|
||
}
|
||
// 「未设置」与「显式 0」必须分开(见 ContextConfig.MaxTokensSet):反射解码只能
|
||
// 给出值本身,因此这里补上「这个键到底有没有被配过」。
|
||
cfg.Context.MaxTokensSet = envProvided(ContextMaxTokensEnv, fromEnv)
|
||
if cfg.LLM.ContextWindow < 0 {
|
||
return nil, fmt.Errorf("config: LLM_CONTEXT_WINDOW 不能为负(%d):0 = 未声明(按回落窗口推导)",
|
||
cfg.LLM.ContextWindow)
|
||
}
|
||
cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir)
|
||
|
||
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
|
||
parts := make([]string, 0, len(errs))
|
||
for _, e := range errs {
|
||
parts = append(parts, e.Error())
|
||
}
|
||
return nil, fmt.Errorf("config: validation failed: %s", strings.Join(parts, "; "))
|
||
}
|
||
|
||
return cfg, nil
|
||
}
|
||
|
||
// MustLoad is Load that panics on error. It is intended for startup, matching
|
||
// the "schema validation fails at boot" rule.
|
||
func MustLoad(envPath string) *Config {
|
||
cfg, err := Load(envPath)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
// resolveConfiguredDataDir 在读取阶段把 DATA_DIR 的哨兵值换成平台标准数据
|
||
// 目录(design/13 §2,裁决 12.4.2 + 15.4.1),使「显式 `./data`」与「平台
|
||
// 标准路径」两条路都真实可达:
|
||
// - 未配置 → 标签默认 `./data`(调用方拿到的就是它,行为与历史一致);
|
||
// - `DATA_DIR=platform`(大小写不敏感)→ platform.DefaultDataDir();
|
||
// - 其余值 → 原样保留。
|
||
//
|
||
// 空串保持原样:它由组合根的兜底分支处理,语义不变。
|
||
func resolveConfiguredDataDir(raw string) string {
|
||
if strings.EqualFold(strings.TrimSpace(raw), DataDirPlatform) {
|
||
return platform.DefaultDataDir()
|
||
}
|
||
return raw
|
||
}
|