diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index c82f157..c5cffba 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -71,7 +71,7 @@ export async function me(): Promise { } // ---- 模型配置(id 为雪花字符串)---- -export type Kind = "chat" | "embedding"; +export type Kind = "chat" | "voice" | "embedding"; export interface Model { id: string; diff --git a/sundynix-admin/src/pages/ModelsPage.tsx b/sundynix-admin/src/pages/ModelsPage.tsx index 1a8de49..297fdc7 100644 --- a/sundynix-admin/src/pages/ModelsPage.tsx +++ b/sundynix-admin/src/pages/ModelsPage.tsx @@ -1,9 +1,17 @@ import { useState } from "react"; import { ModelManager } from "../components/ModelManager"; +import type { Kind } from "../api"; + +// 模型配置页:工作主力(chat) / JARVIS 语音(voice) / 向量化(embedding) 三 Tab 统一管理。 +// 工作与语音分开:编排/报告用强模型(chat),JARVIS 语音对话用快模型(voice, 低时延)——各配各激活。 +const TABS: { key: Kind; label: string }[] = [ + { key: "chat", label: "工作主力模型" }, + { key: "voice", label: "JARVIS 语音模型" }, + { key: "embedding", label: "向量化模型" }, +]; -// 模型配置页:Chat / Embedding 双 Tab 统一管理。 export function ModelsPage() { - const [tab, setTab] = useState<"chat" | "embedding">("chat"); + const [tab, setTab] = useState("chat"); return (
@@ -11,46 +19,46 @@ export function ModelsPage() {

模型管理

-

配置全平台的对话模型(LLM)与向量化模型(Embedding)

+

工作主力模型跑编排/报告(要强)· JARVIS 语音模型跑实时对话(要快)· 向量化模型跑 RAG

- +
- - + {TABS.map((t) => ( + + ))}
{/* 模型管理组件 */}
- {tab === "chat" ? ( + {tab === "chat" && ( + )} + {tab === "voice" && ( + - ) : ( + )} + {tab === "embedding" && ( @@ -59,4 +67,3 @@ export function ModelsPage() {
); } - diff --git a/sundynix-dispatcher/cmd/dispatcher/main.go b/sundynix-dispatcher/cmd/dispatcher/main.go index f9a2ccf..31f2439 100644 --- a/sundynix-dispatcher/cmd/dispatcher/main.go +++ b/sundynix-dispatcher/cmd/dispatcher/main.go @@ -34,7 +34,8 @@ func main() { natsURL := envOr("NATS_URL", "nats://localhost:4222") - pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群 + pool := llm.NewPool() // 工作主力模型池(chat) + voicePool := llm.NewPool() // JARVIS 语音模型池(voice;未配置则空、语音任务回落 pool) breaker := harness.NewCircuitBreaker() // Harness: 熔断降级中心 // Harness: LLM 自动化评测(规则 + LLM-as-judge,模型就绪时启用)。 llmChat := func(ctx context.Context, sys, user string) (string, error) { @@ -53,6 +54,12 @@ func main() { log.Printf("[dispatcher] subscribe model config: %v", err) } go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig) + // 语音模型(JARVIS)配置:同机制热更新 + 后台重试。未配置语音模型时拉不到、voicePool 保持空, + // 语音任务在 agentPool 里透明回落工作模型池——不影响现有功能。 + if _, err := sub.SubscribeVoiceConfigUpdated(voicePool.SetConfig); err != nil { + log.Printf("[dispatcher] subscribe voice model config: %v", err) + } + go sub.FetchVoiceConfigWithRetry(context.Background(), voicePool.SetConfig) // Prompt 控制面:拉激活集覆盖内置默认 + 订阅热更新(管理端激活某版即生效,不重启)。 go sub.FetchPromptsWithRetry(context.Background(), prompts.ApplyOverrides) @@ -66,8 +73,9 @@ func main() { if err != nil { log.Fatalf("[dispatcher] build eino graph: %v", err) } - orch.SetGuardian(guardian) // 输入护栏 Tier2 - orch.SetUsageSink(sub) // 成本护栏:token 用量回写网关累计/计费 + orch.SetGuardian(guardian) // 输入护栏 Tier2 + orch.SetUsageSink(sub) // 成本护栏:token 用量回写网关累计/计费 + orch.SetVoicePool(voicePool) // JARVIS 语音任务用快模型(未配置则回落工作模型) // HITL 持久化中断/恢复:开 checkpoint 存储 + 审批决定流,审批节点改走中断模型 // (compose.Interrupt 落盘释放 goroutine、抗 dispatcher 重启)。任一步失败则降级回阻塞模型。 diff --git a/sundynix-dispatcher/internal/eino/board_serde.go b/sundynix-dispatcher/internal/eino/board_serde.go index 8bba6d6..4e3ee6e 100644 --- a/sundynix-dispatcher/internal/eino/board_serde.go +++ b/sundynix-dispatcher/internal/eino/board_serde.go @@ -18,6 +18,7 @@ type boardSnapshot struct { UID string `json:"uid,omitempty"` SID string `json:"sid,omitempty"` Query string `json:"query,omitempty"` + UseVoice bool `json:"use_voice,omitempty"` Profile string `json:"profile,omitempty"` History []*schema.Message `json:"history,omitempty"` KB string `json:"kb,omitempty"` @@ -35,6 +36,7 @@ func (b *board) MarshalJSON() ([]byte, error) { UID: b.uid, SID: b.sid, Query: b.query, + UseVoice: b.useVoice, Profile: b.profile, History: b.history, KB: b.kb, @@ -56,6 +58,7 @@ func (b *board) UnmarshalJSON(data []byte) error { b.uid = s.UID b.sid = s.SID b.query = s.Query + b.useVoice = s.UseVoice b.profile = s.Profile b.history = s.History b.kb = s.KB diff --git a/sundynix-dispatcher/internal/eino/compose_compiler.go b/sundynix-dispatcher/internal/eino/compose_compiler.go index 26b8736..4212015 100644 --- a/sundynix-dispatcher/internal/eino/compose_compiler.go +++ b/sundynix-dispatcher/internal/eino/compose_compiler.go @@ -56,9 +56,10 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t flow, ferr := dsl.Parse(t.Graph) plan := dsl.Compile(t.Graph) b := &board{ - uid: meta(t, contract.MetaUserID), - sid: meta(t, contract.MetaSessionID), - query: plan.Query, + uid: meta(t, contract.MetaUserID), + sid: meta(t, contract.MetaSessionID), + query: plan.Query, + useVoice: meta(t, contract.MetaModelProfile) == contract.ModelProfileVoice, // 语音任务 → 走语音模型池 } // 无图/空图:退化为 compose 单轮对话。 diff --git a/sundynix-dispatcher/internal/eino/compose_graph.go b/sundynix-dispatcher/internal/eino/compose_graph.go index a118b62..838f2ba 100644 --- a/sundynix-dispatcher/internal/eino/compose_graph.go +++ b/sundynix-dispatcher/internal/eino/compose_graph.go @@ -17,7 +17,7 @@ import ( // START → ChatModel 节点 → END,编译为 Runnable 后流式执行;可观测经 callbacks 桥到 ExecEvent。 // 模型未就绪 / 编译失败时降级回 runAgent(同样的流式回流,保证不回归)。 func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) { - cm := o.pool.ChatModel() + cm := o.agentPool(b).ChatModel() // 语音任务走语音模型池 if cm == nil { o.runAgent(ctx, taskID, b, system, tr, node, label) // 无模型 → 降级桩 return diff --git a/sundynix-dispatcher/internal/eino/coordinator.go b/sundynix-dispatcher/internal/eino/coordinator.go index 32e48aa..1280087 100644 --- a/sundynix-dispatcher/internal/eino/coordinator.go +++ b/sundynix-dispatcher/internal/eino/coordinator.go @@ -154,7 +154,7 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS usedFuncNames := map[string]bool{} for i, spec := range specs { sys := firstNonEmpty(spec.System, defaultAgentSystem) + specialistCondensedSuffix - run := o.specialistRunner(ctx, spec, sys, byName) + run := o.specialistRunner(ctx, b, spec, sys, byName) if run == nil { tr.info("coordinator", "system", "专家跳过", "无可用模型:"+spec.Name) continue @@ -175,7 +175,7 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS // specialistRunner 构造专家执行闭包:带工具且模型支持函数调用→react.Agent.Generate;否则→ChatModel.Generate。 // 无可用模型返回 nil(该专家被跳过)。 -func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec, sys string, byName map[string]tool.BaseTool) func(context.Context, string) (string, error) { +func (o *Orchestrator) specialistRunner(ctx context.Context, b *board, spec specialistSpec, sys string, byName map[string]tool.BaseTool) func(context.Context, string) (string, error) { var tools []tool.BaseTool for _, tn := range spec.Tools { if t, ok := byName[tn]; ok { @@ -183,7 +183,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec } } if len(tools) > 0 { - if tcm := o.pool.ToolCallingModel(); tcm != nil { + if tcm := o.agentPool(b).ToolCallingModel(); tcm != nil { ag, err := react.NewAgent(ctx, &react.AgentConfig{ ToolCallingModel: tcm, ToolsConfig: compose.ToolsNodeConfig{Tools: tools}, @@ -202,7 +202,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec } // 工具型专家但模型不支持函数调用 → 退纯对话(下方) } - cm := o.pool.ChatModel() + cm := o.agentPool(b).ChatModel() if cm == nil { return nil } @@ -219,7 +219,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec // Anthropic 配方(分解→定制简报→并行派发→综合)。无 ToolCallingModel / 0 可用专家 → 降级单 agent。 func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) { specs := parseSpecialists(n.Config) - tcm := o.pool.ToolCallingModel() + tcm := o.agentPool(b).ToolCallingModel() if tcm == nil || len(specs) == 0 { tr.info(node, "system", "协调者降级", "模型不支持函数调用或未配置专家,退回普通对话") o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调")) diff --git a/sundynix-dispatcher/internal/eino/graph.go b/sundynix-dispatcher/internal/eino/graph.go index a3ff4b8..7896782 100644 --- a/sundynix-dispatcher/internal/eino/graph.go +++ b/sundynix-dispatcher/internal/eino/graph.go @@ -21,6 +21,7 @@ const defaultAgentSystem = "你是 sundynix-agentix 平台的 AI 助手。" type board struct { uid, sid string query string + useVoice bool // 该任务用 JARVIS 语音模型池(网关 Meta[model_profile]==voice) profile string history []*schema.Message kb string // 最近一个检索节点的 owner 作用域库名(供 map 并行各项检索) @@ -178,10 +179,11 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy var reasoning strings.Builder onReasoning := func(s string) { reasoning.WriteString(s) } var err error - if o.pool.Ready() { - err = o.pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning) + pool := o.agentPool(b) // 语音任务走语音模型池(否则工作池) + if pool.Ready() { + err = pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning) } else { - err = o.pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) }) + err = pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) }) } if rc := reasoning.String(); rc != "" { tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200))) diff --git a/sundynix-dispatcher/internal/eino/orchestrator.go b/sundynix-dispatcher/internal/eino/orchestrator.go index 3537050..a3f0712 100644 --- a/sundynix-dispatcher/internal/eino/orchestrator.go +++ b/sundynix-dispatcher/internal/eino/orchestrator.go @@ -99,6 +99,7 @@ const specialistTimeout = 3 * time.Minute // Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。 type Orchestrator struct { pool LLM + voicePool LLM // JARVIS 语音模型池(可为 nil → 语音任务回落 pool) breaker *harness.CircuitBreaker eval *harness.Evaluator sink TokenSink @@ -122,6 +123,19 @@ func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Ev return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval, evalSink: evalSink}, nil } +// SetVoicePool 注入 JARVIS 语音模型池(可选)。注入且就绪时,标了 model_profile==voice 的任务 +// 用它跑,抢首字时延;不注入或未就绪则语音任务透明回落工作模型池(o.pool)。 +func (o *Orchestrator) SetVoicePool(p LLM) { o.voicePool = p } + +// agentPool 按黑板选本任务该用的模型池:语音任务且语音池就绪 → 语音池;否则工作池。 +// 只用于面向用户的 agent 生成(对话/协作);报告/护栏/记忆抽取等固定走工作池。 +func (o *Orchestrator) agentPool(b *board) LLM { + if b != nil && b.useVoice && o.voicePool != nil && o.voicePool.Ready() { + return o.voicePool + } + return o.pool +} + // SetGuardian 注入输入护栏 Tier2 的 LLM 分类器(可选;不注入则灰区任务直接放行执行)。 func (o *Orchestrator) SetGuardian(c *harness.Classifier) { o.guard = c } diff --git a/sundynix-dispatcher/internal/eino/react_agent.go b/sundynix-dispatcher/internal/eino/react_agent.go index 2d90d3c..3e329d5 100644 --- a/sundynix-dispatcher/internal/eino/react_agent.go +++ b/sundynix-dispatcher/internal/eino/react_agent.go @@ -170,7 +170,7 @@ func (o *Orchestrator) discoverTools(subject func(string) string, b *board, task // runReactAgent 执行带"自主工具"的 agent 节点:模型在 ReAct 循环里自行决定调哪些 MCP 工具。 // 模型不支持函数调用 / 无工具时降级回普通 runAgent。最终答复流式回流;工具调用由适配器落轨迹。 func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) { - tcm := o.pool.ToolCallingModel() + tcm := o.agentPool(b).ToolCallingModel() tools := o.agentTools(b, taskID, tr) if tcm == nil || len(tools) == 0 { tr.info(node, "system", "ReAct 降级", "模型不支持函数调用或无可用工具,退回普通对话") diff --git a/sundynix-dispatcher/internal/nats/subscriber.go b/sundynix-dispatcher/internal/nats/subscriber.go index 1c6f586..79a4225 100644 --- a/sundynix-dispatcher/internal/nats/subscriber.go +++ b/sundynix-dispatcher/internal/nats/subscriber.go @@ -146,6 +146,16 @@ func (s *Subscriber) FetchModelConfigWithRetry(ctx context.Context, apply func(* s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindChat, apply) } +// SubscribeVoiceConfigUpdated 订阅 JARVIS 语音模型配置热更新(可空——未配置语音模型时不生效)。 +func (s *Subscriber) SubscribeVoiceConfigUpdated(onUpdate func(*contract.ModelConfig)) (func() error, error) { + return s.inner.SubscribeConfigUpdated(contract.ConfigKindVoice, onUpdate) +} + +// FetchVoiceConfigWithRetry 后台重试拉取初始语音模型配置(未配置则一直拿不到,语音任务回落工作模型)。 +func (s *Subscriber) FetchVoiceConfigWithRetry(ctx context.Context, apply func(*contract.ModelConfig)) { + s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindVoice, apply) +} + // FetchPromptsWithRetry 后台重试拉取初始激活 prompt 集(覆盖内置默认)。 func (s *Subscriber) FetchPromptsWithRetry(ctx context.Context, apply func(map[string]string)) { s.inner.RequestPromptsWithRetry(ctx, apply) diff --git a/sundynix-gateway/cmd/server/main.go b/sundynix-gateway/cmd/server/main.go index 8071130..ab048b1 100644 --- a/sundynix-gateway/cmd/server/main.go +++ b/sundynix-gateway/cmd/server/main.go @@ -74,8 +74,8 @@ func main() { bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流 defer bus.Close() - // 配置控制面:按 kind 响应消费方(Dispatcher=chat / mcp-go=embedding)的配置请求。 - for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} { + // 配置控制面:按 kind 响应消费方(Dispatcher=chat/voice / mcp-go=embedding)的配置请求。 + for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding, contract.ConfigKindVoice} { k := kind if _, err := bus.ServeConfig(k, func() *contract.ModelConfig { return db.ActiveConfig(context.Background(), k) // chat 含 Fallbacks(其它模型作备用) diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index ada2c39..3c21f21 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -566,7 +566,7 @@ func (h *Handler) TestModel(c *gin.Context) { // broadcastActive 重新广播各 kind 当前激活配置,触发对应消费方热更新。 // chat 配置带 Fallbacks(其它已登记 chat 模型作备用),dispatcher 据此重建 failover 链。 func (h *Handler) broadcastActive(ctx context.Context) { - for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} { + for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding, contract.ConfigKindVoice} { if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil { // 广播失败 = dispatcher/mcp-go 拿不到新配置,症状是"控制台改了模型却不生效", // 而改配置的人这边一切正常。必须留痕,否则只能靠猜。 diff --git a/sundynix-gateway/internal/handler/voice_task.go b/sundynix-gateway/internal/handler/voice_task.go index 02ba684..3d583f6 100644 --- a/sundynix-gateway/internal/handler/voice_task.go +++ b/sundynix-gateway/internal/handler/voice_task.go @@ -65,6 +65,7 @@ func (s *voiceSession) submitVoiceTask(transcript, graphOverride string) (string task.Meta[contract.MetaUserID] = s.uid task.Meta[contract.MetaTenantID] = billingTenant task.Meta[contract.MetaSessionID] = s.sessionID + task.Meta[contract.MetaModelProfile] = contract.ModelProfileVoice // 语音任务走 JARVIS 快模型(未配则回落工作模型) if err := s.h.launchCore(ctx, s.uid, task); err != nil { return "", err diff --git a/sundynix-shared/contract/task.go b/sundynix-shared/contract/task.go index 5fcfedc..5ecfb67 100644 --- a/sundynix-shared/contract/task.go +++ b/sundynix-shared/contract/task.go @@ -153,8 +153,14 @@ const ( // 配置控制面按 kind 寻址:sundynix.config..get / .updated。 // Gateway 持有配置,消费方(Dispatcher/mcp-go)经 NATS 取用/订阅变更。 - ConfigKindChat = "chat" // 对话模型(Dispatcher 用) + ConfigKindChat = "chat" // 工作主力对话模型(Dispatcher 默认用,要强) ConfigKindEmbedding = "embedding" // 向量模型(mcp-go RAG 用) + ConfigKindVoice = "voice" // JARVIS 语音对话模型(Dispatcher 给语音任务用,要快/低时延) + + // MetaModelProfile 指定该任务用哪档模型:为 ModelProfileVoice 时 Dispatcher 走语音模型池 + // (未配置语音模型则透明回落工作模型)。语音任务由网关置此标记,与 intent==report 同类路由。 + MetaModelProfile = "model_profile" + ModelProfileVoice = "voice" // 报告生成:Task.Meta[MetaIntent]==IntentReport 时,Dispatcher 走专用多步编排 // (规划大纲 → 各章节并行检索+撰写 → 汇聚 → 渲染 Word),而非通用对话图。