feat(voice): 每用户 JARVIS——自定义名字/人设 + 自带豆包配置回落 + 语音不串主记忆

把 JARVIS 做成每用户独立:名字(你叫JARVIS别人叫星期五)、人设(与主偏好记忆分开)、
可自带豆包配置(齐全则用用户的,否则回落系统)。

- store/user_jarvis.go: sundynix_user_jarvis(user_id唯一;name/persona/火山creds,APIKey密文)
  + Get/Save(upsert) + AutoMigrate 注册
- voice_config.go resolveJarvis(uid): 火山配置用户优先系统兜底 + 取名字/人设
- voice_task.go: voiceSystemPrompt(name,persona)——简短是硬基线,名字/语气由用户定;
  buildVoiceGraph 带 name+persona;voice.go 会话升级时按用户解析
- dispatcher compose_compiler: 语音任务(useVoice)不拉主偏好记忆,改用用户 persona(保留短期历史)
- jarvis.go + 路由: GET/PUT /api/v1/me/jarvis(用户级,api_key 脱敏/留空沿用)

真机验证:设 name=星期五+干净人设→语音答"我是星期五…"(自称新名、无糙话、4.4s),
has_own_voice=false 用系统豆包。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 14:05:29 +08:00
parent fb3685532c
commit 99ce07c1b0
12 changed files with 238 additions and 21 deletions
@@ -0,0 +1,49 @@
package store
import (
"context"
"gorm.io/gorm/clause"
)
// UserJarvis 是**每用户**的 JARVIS 语音助手配置(客户端配、用户级)。
// 与系统级语音配置(Setting voice_config)、与主偏好记忆(user_profile) 都分开:
// - Name/Persona:这个用户的助手叫什么、什么语气人设(persona 独立于主偏好记忆,互不污染)。
// - APIKey/…ResourceID/VoiceType:用户自带的豆包(火山)配置;齐全则语音走用户的,否则回落系统。
// APIKey 密文入库(AES-256-GCM,同 LLMModel/微信配置)。表名 sundynix_user_jarvis。
type UserJarvis struct {
BaseModel
UserID string `gorm:"size:32;uniqueIndex"` // 雪花 user.id,每用户唯一一条
Name string `gorm:"size:32"` // 助手名(空=用系统默认 "JARVIS"
Persona string `gorm:"size:1024"` // 语气/人设(空=用系统默认)
APIKey string `gorm:"size:255"` // 用户自带火山 API Key(密文;空=用系统)
ASRResourceID string `gorm:"size:64"`
TTSResourceID string `gorm:"size:64"`
TTSVoiceType string `gorm:"size:64"`
}
func (UserJarvis) TableName() string { return "sundynix_user_jarvis" }
// GetUserJarvis 取某用户的 JARVIS 配置;不存在返回 nil(调用方回落系统默认)。
func (p *Postgres) GetUserJarvis(ctx context.Context, uid string) *UserJarvis {
if p.db == nil || uid == "" {
return nil
}
var j UserJarvis
if err := p.db.WithContext(ctx).Where("user_id = ?", uid).First(&j).Error; err != nil {
return nil
}
return &j
}
// SaveUserJarvis 幂等写某用户的 JARVIS 配置(按 user_id 唯一,重复即覆盖)。
// APIKey 传空串表示"沿用已存"(由 handler 决定是否覆盖),此处只负责按传入值落库。
func (p *Postgres) SaveUserJarvis(ctx context.Context, j *UserJarvis) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "persona", "api_key", "asr_resource_id", "tts_resource_id", "tts_voice_type", "updated_at"}),
}).Create(j).Error
}