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
+13 -2
View File
@@ -141,12 +141,22 @@ export class VoiceClient {
// startListening 开一轮:连接(若需)→ 若正在朗读先打断 → 采麦克风 → 发 start。
async startListening(graph?: string): Promise<void> {
await this.connect();
// 关键:在**用户手势**(点麦克风)里就把播放 AudioContext 建好并 resume——否则它在 WS 回调里
// 惰性创建会处于 suspended 态,TTS 音频静默播不出(Chrome/WKWebView 的自动播放策略)。
this.ensurePlayCtx();
if (this.state === "speaking") this.bargeIn();
this.send({ type: "start", graph });
await this.startMic();
this.setState("listening");
}
// ensurePlayCtx 建/复用下行播放 AudioContext,并在 suspended 时 resume(自动播放策略要求手势内唤醒)。
private ensurePlayCtx(): AudioContext {
if (!this.playCtx) this.playCtx = new AudioContext();
if (this.playCtx.state === "suspended") void this.playCtx.resume();
return this.playCtx;
}
// stopListening 结束本轮说话:停麦克风 + 发 end(服务端拿最终转写→提交任务)。
stopListening(): void {
this.stopMic();
@@ -168,6 +178,7 @@ export class VoiceClient {
this.micStream = stream;
const ctx = new AudioContext();
this.micCtx = ctx;
if (ctx.state === "suspended") await ctx.resume(); // 防采集上下文挂起(无回调=不上行音频)
const src = ctx.createMediaStreamSource(stream);
const node = ctx.createScriptProcessor(4096, 1, 1);
node.onaudioprocess = (ev) => {
@@ -194,8 +205,8 @@ export class VoiceClient {
private enqueueAudio(buf: ArrayBuffer): void {
if (buf.byteLength === 0) return;
if (!this.playCtx) this.playCtx = new AudioContext();
const ctx = this.playCtx;
const ctx = this.ensurePlayCtx(); // 建/复用并 resume(防 WS 回调里上下文仍 suspended → 静默)
const i16 = new Int16Array(buf);
const f32 = new Float32Array(i16.length);
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 0x8000;
@@ -65,7 +65,9 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
// 无图/空图:退化为 compose 单轮对话。
if ferr != nil || flow == nil || len(flow.Nodes) == 0 {
tr.info("task", "system", "无结构化图", "按单轮对话执行(compose")
if !b.useVoice { // 语音用用户设的 JARVIS persona,不拉主偏好记忆(两者分开,互不污染)
b.profile = o.fetchMemory(ctx, b.uid, b.query)
}
b.history = o.fetchHistory(ctx, b.sid)
o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent", "模型流式推理")
return b.answer, refsOf(b), b.fatalErr // 模型失败 → 上抛判 failed(对齐 graph.go
@@ -101,7 +103,9 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
}
}
if !hasMemory {
if !b.useVoice { // 语音不拉主偏好记忆,改用用户为 JARVIS 单设的 persona(系统提示里注入)
b.profile = o.fetchMemory(ctx, b.uid, b.query)
}
b.history = o.fetchHistory(ctx, b.sid)
}
}
@@ -0,0 +1,83 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/secrets"
)
// 每用户 JARVIS 设置的用户级存取(非 admin):名字 / 人设 / 自带豆包配置。
// 桌面端「JARVIS 设置」面板用它读写。api_key 密文入库、脱敏回显。
// GetMyJarvis: GET /api/v1/me/jarvis —— 当前用户的 JARVIS 设置。
func (h *Handler) GetMyJarvis(c *gin.Context) {
j := h.db.GetUserJarvis(c.Request.Context(), userID(c))
if j == nil {
c.JSON(http.StatusOK, gin.H{"name": "", "persona": "", "asr_resource_id": "", "tts_resource_id": "", "tts_voice_type": "", "api_key": "", "has_own_voice": false})
return
}
apiKey := ""
if j.APIKey != "" {
if plain, err := secrets.Decrypt(j.APIKey); err == nil {
apiKey = mask(plain)
}
}
c.JSON(http.StatusOK, gin.H{
"name": j.Name, "persona": j.Persona,
"asr_resource_id": j.ASRResourceID, "tts_resource_id": j.TTSResourceID, "tts_voice_type": j.TTSVoiceType,
"api_key": apiKey,
// 是否自带一套完整豆包配置(齐全才会覆盖系统;否则只是名字/人设生效、语音仍走系统)。
"has_own_voice": j.APIKey != "" && j.ASRResourceID != "" && j.TTSResourceID != "" && j.TTSVoiceType != "",
})
}
// SaveMyJarvis: PUT /api/v1/me/jarvis —— 保存当前用户的 JARVIS 设置。api_key 留空/掩码=沿用已存。
func (h *Handler) SaveMyJarvis(c *gin.Context) {
var b struct {
Name string `json:"name"`
Persona string `json:"persona"`
APIKey string `json:"api_key"`
ASRResourceID string `json:"asr_resource_id"`
TTSResourceID string `json:"tts_resource_id"`
TTSVoiceType string `json:"tts_voice_type"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
ctx := c.Request.Context()
uid := userID(c)
// api_key:空或掩码占位 → 沿用已存密文;否则加密新值。
key := strings.TrimSpace(b.APIKey)
enc := ""
if key == "" || strings.Contains(key, "•") {
if cur := h.db.GetUserJarvis(ctx, uid); cur != nil {
enc = cur.APIKey
}
} else {
e, err := secrets.Encrypt(key)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败:" + err.Error()})
return
}
enc = e
}
j := &store.UserJarvis{
UserID: uid, Name: strings.TrimSpace(b.Name), Persona: strings.TrimSpace(b.Persona),
APIKey: enc,
ASRResourceID: strings.TrimSpace(b.ASRResourceID),
TTSResourceID: strings.TrimSpace(b.TTSResourceID),
TTSVoiceType: strings.TrimSpace(b.TTSVoiceType),
}
if err := h.db.SaveUserJarvis(ctx, j); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+5 -1
View File
@@ -35,7 +35,8 @@ func (h *Handler) VoiceStream(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
return
}
cfg := h.loadVoiceConfig(c.Request.Context())
// 每用户解析:火山配置(用户自带优先、系统兜底)+ 助手名 + 人设。
cfg, jname, jpersona := h.resolveJarvis(c.Request.Context(), uid)
if !cfg.ASREnabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "语音服务未配置(缺 API Key / ASR resource-id"})
return
@@ -52,6 +53,7 @@ func (h *Handler) VoiceStream(c *gin.Context) {
sess := &voiceSession{
conn: conn, uid: uid, cfg: cfg, h: h,
tenantID: tenantID(c), sessionID: sessionID(c),
jarvisName: jname, jarvisPersona: jpersona,
}
sess.send(voice.ServerMsg{Type: voice.ServerReady})
sess.run()
@@ -68,6 +70,8 @@ type voiceSession struct {
tenantID string // 升级时抓取(读循环里无 gin.Context
sessionID string
cfg voice.Config
jarvisName string // 用户自定义助手名(空=默认 JARVIS)
jarvisPersona string // 用户为该助手设的语气人设(与主偏好记忆分开)
writeMu sync.Mutex // gorilla WS 不允许并发写:读循环与 ASR 结果 goroutine 都会 send,须串行化
asr *voice.ASRSession
@@ -28,6 +28,24 @@ func (h *Handler) loadVoiceConfig(ctx context.Context) voice.Config {
return c.DecryptFromStore()
}
// resolveJarvis 解析某用户**有效**的 JARVIS 设定:火山配置 + 助手名 + 人设。
// - 火山配置:用户自带豆包齐全(Enabled)→ 用用户的;否则回落系统配置(voice_config)。
// - 名字/人设:来自用户的 UserJarvis 记录(可空,空则代码里再兜默认);与火山配置相互独立
// (常见情形:多数用户不配自己的豆包、只改名字和人设)。
func (h *Handler) resolveJarvis(ctx context.Context, uid string) (cfg voice.Config, name, persona string) {
cfg = h.loadVoiceConfig(ctx) // 系统兜底
j := h.db.GetUserJarvis(ctx, uid)
if j == nil {
return cfg, "", ""
}
name, persona = j.Name, j.Persona
user := voice.Config{APIKey: j.APIKey, ASRResourceID: j.ASRResourceID, TTSResourceID: j.TTSResourceID, TTSVoiceType: j.TTSVoiceType}.DecryptFromStore()
if user.Enabled() { // 用户自带豆包齐全 → 整套用用户的(key 与 resource-id 必须同账号,不混用)
cfg = user
}
return cfg, name, persona
}
// AdminGetVoiceConfig: GET /api/v1/admin/voice —— 回显语音配置(api_key 明文,RequireAdmin 已拦)。
func (h *Handler) AdminGetVoiceConfig(c *gin.Context) {
cfg := h.loadVoiceConfig(c.Request.Context())
@@ -14,20 +14,35 @@ import (
// 语音只是"嘴替键盘",一行编排/工具/计费逻辑都不新造:走的正是 HTTP SubmitTask 那条关卡
// (见记忆 execution-single-entry「提交必走 preflight()+launch() 共用关卡」)。
// voiceAgentSystem 是语音默认单 agent 的系统提示词:口语化、**极简**、适合朗读(下行要过 TTS)
// 语音场景务必短——长答案既拖慢首字出声(要等第一句合成),又让人干听十几秒。
const voiceAgentSystem = "你是用户的语音助手 JARVIS。这是语音对话,回答必须简短——像真人聊天," +
"通常一两句话说清重点即可,最多不超过三句,别长篇大论、别列清单、别念代码。" +
"先直接给结论。口语化、自然、适合朗读。需要更多细节时再由用户追问。"
// defaultJarvisName 是用户没自定义名字时的默认助手名
const defaultJarvisName = "JARVIS"
// buildVoiceGraph 把一句转写组成最简可执行图:input(转写) → agent(JARVIS)。
// voiceSystemPrompt 组语音 agent 的系统提示:**简短**是硬基线(语音场景要抢首字、别让人干听十几秒),
// **名字与语气/人设由用户决定**——name 用户自定义(你叫 JARVIS、别人叫星期五都行),persona 是用户为
// 这个助手单设的语气人设(与主偏好记忆分开)。persona 为空则默认平和礼貌。
func voiceSystemPrompt(name, persona string) string {
n := strings.TrimSpace(name)
if n == "" {
n = defaultJarvisName
}
s := "你是 " + n + "——用户的私人语音助手。这是语音对话,务必简短:先直接给结论," +
"一两句话说清,通常不超过三句,别铺垫、别列清单、别念代码、别复述问题。口语化、自然。"
if p := strings.TrimSpace(persona); p != "" {
s += "\n你的语气与人设:" + p
} else {
s += "语气平和、礼貌。"
}
return s
}
// buildVoiceGraph 把一句转写组成最简可执行图:input(转写) → agent(用户的 JARVIS,带其名字+人设)。
// 与前端画布 exportDsl 同构(kind=input/agent、config.text/system),dispatcher 直接吃。
func buildVoiceGraph(query string) json.RawMessage {
func buildVoiceGraph(query, name, persona string) json.RawMessage {
g := map[string]any{
"version": "voice-1",
"nodes": []map[string]any{
{"id": "voice_in", "kind": "input", "config": map[string]any{"text": query}},
{"id": "voice_agent", "kind": "agent", "config": map[string]any{"system": voiceAgentSystem}},
{"id": "voice_agent", "kind": "agent", "config": map[string]any{"system": voiceSystemPrompt(name, persona)}},
},
"edges": []map[string]any{
{"source": "voice_in", "target": "voice_agent"},
@@ -50,7 +65,7 @@ func (s *voiceSession) submitVoiceTask(transcript, graphOverride string) (string
if strings.TrimSpace(graphOverride) != "" {
raw = json.RawMessage(graphOverride) // 语音触发画布上的既有编排图
} else {
raw = buildVoiceGraph(transcript)
raw = buildVoiceGraph(transcript, s.jarvisName, s.jarvisPersona) // 带上用户的助手名+人设
}
task, err := dsl.ParseAndAssemble(raw)
if err != nil {
@@ -11,7 +11,7 @@ import (
// buildVoiceGraph 的产物必须是 dsl.ParseAndAssemble 能吃下的合法图,且带上转写文本。
func TestBuildVoiceGraph_Valid(t *testing.T) {
const q = "帮我查一下明天上海的天气"
raw := buildVoiceGraph(q)
raw := buildVoiceGraph(q, "", "")
// 1) 能通过 DSL 解析与拓扑校验(与 HTTP SubmitTask 同一条解析)。
task, err := dsl.ParseAndAssemble(raw)
@@ -65,8 +65,39 @@ func TestBuildVoiceGraph_Valid(t *testing.T) {
// 空转写不该组图触发(提交侧兜底:submitVoiceTask 空转写返错)——这里只校验组图函数对空串仍产出结构。
func TestBuildVoiceGraph_EmptyStillStructured(t *testing.T) {
raw := buildVoiceGraph("")
raw := buildVoiceGraph("", "", "")
if _, err := dsl.ParseAndAssemble(raw); err != nil {
t.Fatalf("空转写图仍应结构合法: %v", err)
}
}
// 用户自定义名字 + 人设应注入到 agent 节点的 system 里(名字替 JARVIS、人设附上)。
func TestBuildVoiceGraph_NamePersonaInjected(t *testing.T) {
raw := buildVoiceGraph("你好", "星期五", "简洁专业不说脏话")
var g struct {
Nodes []struct {
Kind string `json:"kind"`
Config map[string]any `json:"config"`
} `json:"nodes"`
}
if err := json.Unmarshal(raw, &g); err != nil {
t.Fatalf("反解失败: %v", err)
}
for _, n := range g.Nodes {
if n.Kind != "agent" {
continue
}
sys, _ := n.Config["system"].(string)
if !strings.Contains(sys, "星期五") {
t.Errorf("system 未含自定义名字「星期五」: %q", sys)
}
if strings.Contains(sys, "JARVIS") {
t.Errorf("有自定义名字时不应再出现 JARVIS: %q", sys)
}
if !strings.Contains(sys, "简洁专业不说脏话") {
t.Errorf("system 未含用户人设: %q", sys)
}
return
}
t.Fatal("没找到 agent 节点")
}
@@ -95,6 +95,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
p.POST("/tenants/current/invites", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.CreateTenantInvite)
p.DELETE("/tenants/current/invites/:id", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.RevokeTenantInvite)
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
p.GET("/me/jarvis", h.GetMyJarvis) // 我的 JARVIS 设置(名字/人设/自带豆包)
p.PUT("/me/jarvis", h.SaveMyJarvis) // 保存我的 JARVIS 设置
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
+1 -1
View File
@@ -68,7 +68,7 @@ func migratedModels() []any {
&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{},
&AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &Space{}, &SpaceMember{},
&UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{},
&RedeemCode{}, &SubscriptionPlan{}, &Subscription{}, &SchemaMigration{},
&RedeemCode{}, &SubscriptionPlan{}, &Subscription{}, &UserJarvis{}, &SchemaMigration{},
}
}
@@ -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
}
Binary file not shown.
Binary file not shown.