diff --git a/sundynix-desktop/frontend/src/lib/voice.ts b/sundynix-desktop/frontend/src/lib/voice.ts index e0775dc..295f1ae 100644 --- a/sundynix-desktop/frontend/src/lib/voice.ts +++ b/sundynix-desktop/frontend/src/lib/voice.ts @@ -141,12 +141,22 @@ export class VoiceClient { // startListening 开一轮:连接(若需)→ 若正在朗读先打断 → 采麦克风 → 发 start。 async startListening(graph?: string): Promise { 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; diff --git a/sundynix-dispatcher/internal/eino/compose_compiler.go b/sundynix-dispatcher/internal/eino/compose_compiler.go index 4212015..3f5bc6c 100644 --- a/sundynix-dispatcher/internal/eino/compose_compiler.go +++ b/sundynix-dispatcher/internal/eino/compose_compiler.go @@ -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)") - b.profile = o.fetchMemory(ctx, b.uid, b.query) + 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 { - b.profile = o.fetchMemory(ctx, b.uid, b.query) + if !b.useVoice { // 语音不拉主偏好记忆,改用用户为 JARVIS 单设的 persona(系统提示里注入) + b.profile = o.fetchMemory(ctx, b.uid, b.query) + } b.history = o.fetchHistory(ctx, b.sid) } } diff --git a/sundynix-gateway/internal/handler/jarvis.go b/sundynix-gateway/internal/handler/jarvis.go new file mode 100644 index 0000000..0a3a81a --- /dev/null +++ b/sundynix-gateway/internal/handler/jarvis.go @@ -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"}) +} diff --git a/sundynix-gateway/internal/handler/voice.go b/sundynix-gateway/internal/handler/voice.go index d26e8d1..06535c5 100644 --- a/sundynix-gateway/internal/handler/voice.go +++ b/sundynix-gateway/internal/handler/voice.go @@ -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() @@ -64,10 +66,12 @@ func (h *Handler) VoiceStream(c *gin.Context) { type voiceSession struct { conn *websocket.Conn h *Handler // 复用 preflightCore/launchCore 提交任务 - uid string - tenantID string // 升级时抓取(读循环里无 gin.Context) - sessionID string - cfg voice.Config + uid string + 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 diff --git a/sundynix-gateway/internal/handler/voice_config.go b/sundynix-gateway/internal/handler/voice_config.go index 486b2eb..6d81aa6 100644 --- a/sundynix-gateway/internal/handler/voice_config.go +++ b/sundynix-gateway/internal/handler/voice_config.go @@ -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()) diff --git a/sundynix-gateway/internal/handler/voice_task.go b/sundynix-gateway/internal/handler/voice_task.go index 3d583f6..c6bcd5e 100644 --- a/sundynix-gateway/internal/handler/voice_task.go +++ b/sundynix-gateway/internal/handler/voice_task.go @@ -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 { diff --git a/sundynix-gateway/internal/handler/voice_task_test.go b/sundynix-gateway/internal/handler/voice_task_test.go index c873472..284930d 100644 --- a/sundynix-gateway/internal/handler/voice_task_test.go +++ b/sundynix-gateway/internal/handler/voice_task_test.go @@ -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 节点") +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 19e5eef..827d832 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -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) // 列出当前用户偏好(记忆面板) diff --git a/sundynix-gateway/internal/store/migrate.go b/sundynix-gateway/internal/store/migrate.go index 067e7e9..1da2539 100644 --- a/sundynix-gateway/internal/store/migrate.go +++ b/sundynix-gateway/internal/store/migrate.go @@ -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{}, } } diff --git a/sundynix-gateway/internal/store/user_jarvis.go b/sundynix-gateway/internal/store/user_jarvis.go new file mode 100644 index 0000000..ca773c1 --- /dev/null +++ b/sundynix-gateway/internal/store/user_jarvis.go @@ -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 +} diff --git a/sundynix-gateway/sim_answer.wav b/sundynix-gateway/sim_answer.wav index 326e983..b64ba04 100644 Binary files a/sundynix-gateway/sim_answer.wav and b/sundynix-gateway/sim_answer.wav differ diff --git a/sundynix-gateway/sim_question.wav b/sundynix-gateway/sim_question.wav index cfa0c72..992177d 100644 Binary files a/sundynix-gateway/sim_question.wav and b/sundynix-gateway/sim_question.wav differ