Files
Cyrene_v2/internal/config/config.go
T
AskaEth c96a7db7f1
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,config,web,docs): backthink 实时帧显示打扰原因 + 图片上限可配 + api-reference 端点表补齐
- 前端:`lib/wsParse.ts` 的 `parseBackthinkItem` 现在也读原始 `PushResult` 的 route/detail/bypassed(兼容
  camelCase),与历史 DTO 路径**同名同义**(空串 → undefined、未报告的 bool 不编成 false);
  Backthink 页 Notice 改为如实描述。证据=用后端两条路径的**真实序列化**喂同一个 PushResult 做逐字段比对
- 图片上限可配:`CHAT_IMAGE_MAX_BYTES`(默认 5242880 = 与 /upload 同口径;**未配置/0/负 一律用默认**,
  不存在「禁用上限」);删掉重复的字符常量,换算只留一份 `chatImageCharsFor`;补 3 条用例
  (默认值==upload 预算、配 1 MiB 真生效且只有合法那条驱动回合、非正值回落默认)
- `Docs/api-reference.md`:§1 端点表按 `routes.go` **补齐**(auth refresh/logout、upload、files、三支 audio、
  /admin/plugins/installed、/admin/llm/catalog、observability/logs、notifications、owner-profile、reminders),
  既有编号一律未动(保交叉引用);两张 `historyMessage` 字段表补 `personaId`(+ `clientMsgId`);
  修正 §8.1「主人档案保存仍 501」与实现不符(已真实实现)
- 复核:build/vet 绿 + gateway/config 包测试绿 + 前端 tsc 0 错 + vite build 成功
- 登记:§1 各表的编号跨表重复(不重编号以保交叉引用);`admin_plugins.go:217` 注释写错鉴权档(未改);
  §6.4 的 WS 事件帧表仍未补全
2026-10-04 16:24:36 +08:00

495 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"`
}
// 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"`
}
// LLMConfig holds the multi-supplier LLM adapter settings. Provider selects the
// wire protocol; every provider has its own independent base/key/model/fallback
// so multiple vendors can coexist.
type LLMConfig struct {
// Provider declares which protocol to use: "openai" (OpenAI-compatible
// /chat/completions) or "anthropic" (Anthropic Messages /v1/messages).
Provider string `env:"LLM_PROVIDER" default:"openai"`
// OpenAI-compatible provider (default). Kept flat for backward compatibility
// with the historic LLM_BASE_URL/API_KEY/MODEL/FALLBACK_MODEL env names.
BaseURL string `env:"LLM_BASE_URL"`
APIKey string `env:"LLM_API_KEY"`
Model string `env:"LLM_MODEL"`
Fallback string `env:"LLM_FALLBACK_MODEL"`
// VisionModel is the dedicated model behind the vision tool (image
// understanding). It is the tool's model, not the main model's ability: the
// per-persona/per-request routing decision belongs to the vision Router, and
// the main model's own image input capability is declared by the active
// models.json group's multimodal flag (裁决 14.4.1/14.4.5)。
VisionModel string `env:"LLM_VISION_MODEL"`
// OCRModel is the dedicated model behind the OCR tool. Empty reuses
// VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1
// 的「独立视觉模型 + OCR 模型」)。
OCRModel string `env:"LLM_OCR_MODEL"`
// Local is the optional third degradation level (裁决 03.4.3): primary →
// fallback → local. It defaults to off because the current machine cannot run
// a local model comfortably; when off the chain keeps its two-level shape.
Local LocalLLMConfig
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
// (primary → fallback). It is the final safety budget: it only applies when
// the chain actually has a fallback step, that group sets no per-group
// MaxTokens (models.json maxTokens) and the request carries no explicit
// budget. Zero disables the cap (the adapter default then applies).
FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"`
// ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch
// wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider
// 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers
// the vendor from the model name/base URL and sends nothing when the shape is
// ambiguous (the conservative default, so an existing .env is unchanged);
// "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape
// explicitly. It is the env-fallback twin of the models.json group's
// `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited).
ReasoningKind string `env:"LLM_REASONING_KIND" default:""`
// Anthropic native provider, used when Provider=anthropic. Independent
// config block so each vendor is a self-contained config.
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
AnthropicModel string `env:"ANTHROPIC_MODEL"`
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
}
// LocalLLMConfig configures the optional local model rung — the third level of
// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in
// and defaults to off: a machine that cannot run a local model comfortably must
// not silently pay for one. It becomes an active chain step only when Enabled is
// true and both BaseURL and Model are set; anything less is reported and skipped
// rather than producing a half-configured step.
type LocalLLMConfig struct {
// Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关).
Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"`
// Provider is the local endpoint's wire protocol: "openai" (llama.cpp /
// ollama / vLLM compatible endpoints) or "anthropic". Empty means openai.
Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"`
// BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1).
BaseURL string `env:"LLM_LOCAL_BASE_URL"`
// APIKey is the local endpoint's key; empty is normal for a local server.
APIKey string `env:"LLM_LOCAL_API_KEY"`
// Model is the local model name. Without it the rung stays off.
Model string `env:"LLM_LOCAL_MODEL"`
}
// Configured reports whether the local rung has enough configuration to act as a
// chain step. Enabled alone is not enough: a missing endpoint or model must not
// become a step that can only fail.
func (l LocalLLMConfig) Configured() bool {
return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != ""
}
// 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
}
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
}