Files
Cyrene_v2/internal/config/config.go
T
AskaEth fa33c79e11
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model,capacity,vision,io): per-persona 路由 + 本地三级(默认关) + L3 分类 + OCR 独立模型 + image-crop 工具(裁决 03.4.1/03.4.3/14.4.1/14.4.4/14.4.5)
- 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()),
  Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型
- 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源
- 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false**
- 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具;
  vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威)
- 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切
- 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行)
- 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异)
- 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面)
2026-10-04 12:44:52 +08:00

417 lines
21 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"
)
// 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"`
// 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
}