- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
· deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
· vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
· openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
· 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
(其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
(design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
427 lines
21 KiB
Go
427 lines
21 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"
|
||
)
|
||
|
||
// 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 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
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
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
|
||
}
|