feat: sync AI compose and ChatBot features

This commit is contained in:
A 2026-07-03 19:43:20 +08:00
parent 35e5d38f4d
commit b7269b135f
75 changed files with 5426 additions and 123 deletions

View file

@ -100,6 +100,17 @@ type Config struct {
// 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路;
// "template" 使用 quick reply 模板。
BusinessAIProvider string
// AIEnabled 控制客户端输入框 AI 改写/润色能力;关闭时 getTones 返回空集合以隐藏入口。
AIEnabled bool
// AIProviders 是 compose AI provider 链路,按顺序尝试;默认 local,不出网。
AIProviders []AIProviderConfig
// AITimeout 是单次 provider 调用总超时。
AITimeout time.Duration
// AIRateLimit/AIRateWindow 是账号级 compose AI 限流。
AIRateLimit int
AIRateWindow time.Duration
// AIPrivacyLogContent 为 false 时日志只写长度/provider/状态,不写用户输入和生成文本。
AIPrivacyLogContent bool
// TempKeyResolveCacheMaxEntries 是 Router temp→perm 解析缓存容量。
TempKeyResolveCacheMaxEntries int
@ -232,6 +243,18 @@ type Config struct {
SFUAdvertiseIP string
}
type AIProviderConfig struct {
Name string
Kind string
BaseURL string
APIKey string
Model string
MaxOutputTokens int
Temperature float64
OmitTemperature bool
Thinking string
}
// Load 从环境变量与可选配置文件读取配置并填充默认值。环境变量优先于配置文件。
func Load() (Config, error) {
fileEnv, err := loadConfigEnv()
@ -294,6 +317,12 @@ func Load() (Config, error) {
WebPagePreviewMaxBytes: int64(envIntOr("TELESRV_WEBPAGE_PREVIEW_MAX_BYTES", 5<<20)),
WebPagePreviewRatePerMin: envIntOr("TELESRV_WEBPAGE_PREVIEW_RATE_PER_MIN", 300),
BusinessAIProvider: envOr("TELESRV_BUSINESS_AI_PROVIDER", "echo"),
AIEnabled: envBoolOr("TELESRV_AI_ENABLED", true),
AIProviders: loadAIProviders(fileEnv),
AITimeout: envDurationOr("TELESRV_AI_TIMEOUT", 15*time.Second),
AIRateLimit: envIntOr("TELESRV_AI_RATE_LIMIT", 20),
AIRateWindow: envDurationOr("TELESRV_AI_RATE_WINDOW", time.Minute),
AIPrivacyLogContent: envBoolOr("TELESRV_AI_LOG_CONTENT", false),
TempKeyResolveCacheMaxEntries: envIntOr("TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES", 4096),
ChannelRowCacheMaxEntries: envIntOr("TELESRV_CHANNEL_ROW_CACHE_MAX", 50000),
ChannelMemberCacheMaxEntries: envIntOr("TELESRV_CHANNEL_MEMBER_CACHE_MAX", 100000),
@ -355,6 +384,74 @@ func Load() (Config, error) {
return cfg, nil
}
func loadAIProviders(env envSource) []AIProviderConfig {
names := env.envListOr("TELESRV_AI_PROVIDERS", []string{"local"})
out := make([]AIProviderConfig, 0, len(names))
for _, name := range names {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
suffix := providerEnvSuffix(name)
kind := env.envOr("TELESRV_AI_"+suffix+"_KIND", defaultAIProviderKind(name))
out = append(out, AIProviderConfig{
Name: name,
Kind: strings.ToLower(strings.TrimSpace(kind)),
BaseURL: env.envOr("TELESRV_AI_"+suffix+"_BASE_URL", ""),
APIKey: env.envOr("TELESRV_AI_"+suffix+"_API_KEY", defaultAIProviderAPIKey(env, name)),
Model: env.envOr("TELESRV_AI_"+suffix+"_MODEL", ""),
MaxOutputTokens: env.envIntOr("TELESRV_AI_"+suffix+"_MAX_OUTPUT_TOKENS", 1024),
Temperature: env.envFloatOr("TELESRV_AI_"+suffix+"_TEMPERATURE", 0.2),
OmitTemperature: env.envBoolOr("TELESRV_AI_"+suffix+"_OMIT_TEMPERATURE", false),
Thinking: strings.ToLower(strings.TrimSpace(env.envOr("TELESRV_AI_"+suffix+"_THINKING", ""))),
})
}
if len(out) == 0 {
out = append(out, AIProviderConfig{Name: "local", Kind: "local", MaxOutputTokens: 1024, Temperature: 0.2})
}
return out
}
func providerEnvSuffix(name string) string {
var b strings.Builder
for _, r := range strings.ToUpper(name) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return strings.Trim(b.String(), "_")
}
func defaultAIProviderKind(name string) string {
switch name {
case "openai":
return "openai_responses"
case "openai_chat", "openai-compatible", "openai_compat":
return "openai_chat"
case "gemini":
return "gemini"
case "anthropic":
return "anthropic"
default:
return name
}
}
func defaultAIProviderAPIKey(env envSource, name string) string {
switch name {
case "openai", "openai_chat", "openai-compatible", "openai_compat":
return env.envOr("OPENAI_API_KEY", "")
case "gemini":
return env.envOr("GEMINI_API_KEY", "")
case "anthropic":
return env.envOr("ANTHROPIC_API_KEY", "")
default:
return ""
}
}
type envSource map[string]string
func loadConfigEnv() (envSource, error) {
@ -505,6 +602,15 @@ func (e envSource) envInt64Or(key string, def int64) int64 {
return def
}
func (e envSource) envFloatOr(key string, def float64) float64 {
if v := e.envOr(key, ""); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n
}
}
return def
}
// envDurationOr 读取 time.ParseDuration 格式(如 "200ms"、"30s")的时长配置;解析失败回退默认值。
func (e envSource) envDurationOr(key string, def time.Duration) time.Duration {
if v := e.envOr(key, ""); v != "" {

View file

@ -20,13 +20,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
}
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10")
t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AdvertiseIP != "192.0.2.10" {
if cfg.AdvertiseIP != "203.0.113.10" {
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
}
}
@ -55,6 +55,40 @@ func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
}
}
func TestLoadAIProviders(t *testing.T) {
t.Setenv("TELESRV_AI_PROVIDERS", "local,openai,gemini")
t.Setenv("TELESRV_AI_OPENAI_API_KEY", "openai-key")
t.Setenv("TELESRV_AI_OPENAI_MODEL", "gpt-test")
t.Setenv("TELESRV_AI_GEMINI_API_KEY", "gemini-key")
t.Setenv("TELESRV_AI_GEMINI_BASE_URL", "https://gemini.example")
t.Setenv("TELESRV_AI_GEMINI_TEMPERATURE", "0.6")
t.Setenv("TELESRV_AI_GEMINI_OMIT_TEMPERATURE", "true")
t.Setenv("TELESRV_AI_GEMINI_THINKING", "disabled")
t.Setenv("TELESRV_AI_TIMEOUT", "3s")
t.Setenv("TELESRV_AI_RATE_LIMIT", "7")
t.Setenv("TELESRV_AI_RATE_WINDOW", "30s")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.AIProviders) != 3 {
t.Fatalf("AIProviders len = %d, want 3", len(cfg.AIProviders))
}
if cfg.AIProviders[0].Kind != "local" {
t.Fatalf("AIProviders[0].Kind = %q, want local", cfg.AIProviders[0].Kind)
}
if cfg.AIProviders[1].Kind != "openai_responses" || cfg.AIProviders[1].APIKey != "openai-key" || cfg.AIProviders[1].Model != "gpt-test" {
t.Fatalf("openai provider = %#v", cfg.AIProviders[1])
}
if cfg.AIProviders[2].Kind != "gemini" || cfg.AIProviders[2].BaseURL != "https://gemini.example" || cfg.AIProviders[2].Temperature != 0.6 || !cfg.AIProviders[2].OmitTemperature || cfg.AIProviders[2].Thinking != "disabled" {
t.Fatalf("gemini provider = %#v", cfg.AIProviders[2])
}
if cfg.AITimeout != 3*time.Second || cfg.AIRateLimit != 7 || cfg.AIRateWindow != 30*time.Second {
t.Fatalf("AI timing/rate config = %v/%d/%v", cfg.AITimeout, cfg.AIRateLimit, cfg.AIRateWindow)
}
}
func TestLoadReadsEnvStyleConfigFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "telesrv.env")
writeConfigFile(t, path, `