Version: 0.9.25.dev.260417

后端:
1. AIHub 模型分级从 Worker/Strategist 两级重构为 Lite/Pro/Max 三级
- AIHub 结构体从 Worker + Strategist 改为 Lite + Pro + Max,分别对应轻量(标题生成)、标准(Chat 路由/闲聊/交付总结)、高能力(Plan 规划/Execute ReAct)三个能力层级
- config.example.yaml 新增 liteModel / proModel / maxModel 三个模型配置项,替代原 workerModel / strategistModel
- 启动层 InitEino 改为创建三个独立模型实例,抽取公共 baseURL 和 apiKey 减少重复
- pickChatModel 统一返回 Pro 模型,旧 strategist 参数不再生效;pickTitleModel 从 Worker 切到 Lite
- runNewAgentGraph 按 Plan/Execute→Max、Chat/Deliver→Pro 分级注入;Graph 出错回退也切到 Pro
- Memory 模块初始化从 Worker 改为 Pro
2. Plan 节点从"两阶段评估"简化为"单轮深度规划",thinking 开关改为全配置化
- 移除 Phase 1(快速评估 1600 token)+ Phase 2(深度规划 3200 token)的两轮调用逻辑,改为单轮不限 token 深度规划
- PlanDecision 移除 need_thinking 字段,prompt 规则和 JSON contract 同步删除该字段
- 各节点(Plan / Execute / Deliver)thinking 开关从硬编码改为从 AgentGraphDeps 读取,由 config.yaml 的 agent.thinking 段按节点注入
- 新增 agent.thinking 配置段(plan / execute / deliver / memory 四个独立布尔开关),config.example.yaml 补齐默认值
- 新增 resolveThinkingMode 公共函数,plan / execute / deliver 和 memory 决策/抽取链路统一使用
3. Memory 模块 LLM 调用支持 thinking 开关
- Config 新增 LLMThinking 字段,config_loader 从 agent.thinking.memory 读取
- LLMDecisionOrchestrator.Compare 和 LLMWriteOrchestrator.ExtractFacts 的 thinking 模式从硬编码 Disabled 改为读取配置
前端:
1. 移除助手输入区模型选择器及全部偏好持久化逻辑
- 删除 ModelType 类型、selectedModel ref、MODEL_PREFERENCE_STORAGE_KEY 常量
- 删除 isModelType / loadModelPreferenceMap / persistModelPreferenceMap / savePreferredModel / resolvePreferredModel / applyPreferredModelForConversation 六个函数及 modelPreferenceMap ref
- 删除 selectedModel watch 监听、发送消息时的 savePreferredModel 调用、切会话时的 applyPreferredModelForConversation 调用、会话迁移时的模型偏好迁移
- fetchChatStream 的 model 参数硬编码为 'worker'
- 删除模板中"模型"下拉选择器(标准/策略)及对应的全局样式 .assistant-model-select-panel
2. 上下文窗口指示器简化为仅显示总占用
- ContextWindowMeter 移除 msg0~msg3 四段彩色分段逻辑(ContextSegment 接口、segments computed、v-for 渲染)
- 进度条改为单一蓝色条,按 total/budget 比例填充;超预算时变红
- Tooltip 简化为仅显示"总计 X / 预算 Y(Z%)"

仓库:无
This commit is contained in:
Losita
2026-04-17 12:27:04 +08:00
parent dd6638f8db
commit d47a8bcabd
19 changed files with 147 additions and 306 deletions

View File

@@ -83,7 +83,7 @@ func Start() {
memoryMetrics := memoryobserve.NewMetricsRegistry()
memoryModule := memory.NewModuleWithObserve(
db,
infrallm.WrapArkClient(aiHub.Worker),
infrallm.WrapArkClient(aiHub.Pro),
ragRuntime,
memoryCfg,
memory.ObserveDeps{

View File

@@ -67,16 +67,27 @@ time:
# 智能体模型与规划参数。
agent:
# 日常执行链路使用的主模型
workerModel: "doubao-seed-2-0-code-preview-260215"
# 规划、拆解、策略推导使用的模型
strategistModel: "doubao-seed-2-0-code-preview-260215"
# 轻量模型:标题生成等低复杂度、低延迟场景
liteModel: "doubao-seed-2-0-code-preview-260215"
# 标准模型Chat 路由/闲聊/深度回答/Deliver 总结
proModel: "doubao-seed-2-0-code-preview-260215"
# 高能力模型Plan 规划 + Execute ReAct 等深度推理场景。
maxModel: "doubao-seed-2-0-code-preview-260215"
# 模型服务根路径。
baseURL: "https://ark.cn-beijing.volces.com/api/v3"
# 日内并发优化并发度,建议按模型配额调整。
dailyRefineConcurrency: 7
# 周级跨天配平额度上限,防止过度调整。
weeklyAdjustBudget: 5
thinking:
# plan 节点(单轮深度规划),默认开 thinking。
plan: true
# execute 节点ReAct 深度推理),默认开 thinking。
execute: true
# deliver 节点(交付总结),默认关 thinking。
deliver: false
# 记忆模块(决策比对 + 抽取),默认关 thinking。
memory: false
# 通用 RAG 配置。
rag:

View File

@@ -8,32 +8,53 @@ import (
"github.com/spf13/viper"
)
// AIHub 存储不同能力的模型实例
// AIHub 存储三级模型实例,按能力分级调度。
//
// 分级策略:
// 1. Lite轻量模型用于标题生成等低复杂度、低延迟场景
// 2. Pro标准模型用于 Chat 路由/闲聊/深度回答/Deliver 总结;
// 3. Max高能力模型用于 Plan 规划和 Execute ReAct 等需要深度推理的场景。
type AIHub struct {
Strategist *ark.ChatModel // 智力担当:处理复杂排程逻辑
Worker *ark.ChatModel // 效率担当:处理简单任务、总结
Lite *ark.ChatModel // 轻量模型:标题生成等低复杂度任务
Pro *ark.ChatModel // 标准模型Chat 路由、闲聊、交付总结
Max *ark.ChatModel // 高能力模型Plan 规划、Execute ReAct
}
func InitEino() (*AIHub, error) {
ctx := context.Background()
worker, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.workerModel"), // 使用的模型版本
BaseURL: viper.GetString("agent.baseURL"), // Eino API 的基础 URL
APIKey: os.Getenv("ARK_API_KEY"), // API 密钥
baseURL := viper.GetString("agent.baseURL")
apiKey := os.Getenv("ARK_API_KEY")
// 1. Lite 模型:标题生成等低复杂度场景,优先控制成本和延迟。
lite, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.liteModel"),
BaseURL: baseURL,
APIKey: apiKey,
})
if err != nil {
return nil, err
}
strategist, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.strategistModel"), // 使用的模型版本
BaseURL: viper.GetString("agent.baseURL"), // Eino API 的基础 URL
APIKey: os.Getenv("ARK_API_KEY"), // API 密钥
// 2. Pro 模型Chat 路由/闲聊/交付总结等标准复杂度场景。
pro, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.proModel"),
BaseURL: baseURL,
APIKey: apiKey,
})
if err != nil {
return nil, err
}
// 3. Max 模型Plan 规划和 Execute ReAct 等需要深度推理的场景。
maxModel, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.maxModel"),
BaseURL: baseURL,
APIKey: apiKey,
})
if err != nil {
return nil, err
}
return &AIHub{
Strategist: strategist,
Worker: worker,
Lite: lite,
Pro: pro,
Max: maxModel,
}, nil
}

View File

@@ -72,6 +72,9 @@ type Config struct {
// 2. 默认 0.5,与"守门员"prompt 的 confidence>=0.5 输出规则配合;
// 3. fallback 路径 confidence 设为 0.45低于默认阈值LLM 不可用时不写入。
WriteMinConfidence float64
// 记忆模块 LLM 调用是否开启 thinking由 config.yaml 的 agent.thinking.memory 注入。
LLMThinking bool
}
// NormalizeReadMode 统一读取模式字符串。

View File

@@ -62,10 +62,7 @@ func (o *LLMDecisionOrchestrator) Compare(
infrallm.GenerateOptions{
Temperature: 0.1,
MaxTokens: defaultDecisionCompareMaxTokens,
Thinking: infrallm.ThinkingModeDisabled,
Metadata: map[string]any{
"stage": "memory_decision_compare",
},
Thinking: resolveMemoryThinkingMode(o.cfg.LLMThinking),
},
)
if err != nil {
@@ -128,3 +125,11 @@ func buildDecisionCompareUserPrompt(fact memorymodel.NormalizedFact, candidate m
candidate.MemoryType, candidate.Content,
)
}
// resolveMemoryThinkingMode 根据配置布尔值返回对应的 ThinkingMode。
func resolveMemoryThinkingMode(enabled bool) infrallm.ThinkingMode {
if enabled {
return infrallm.ThinkingModeEnabled
}
return infrallm.ThinkingModeDisabled
}

View File

@@ -67,7 +67,7 @@ func (o *LLMWriteOrchestrator) ExtractFacts(ctx context.Context, payload memorym
infrallm.GenerateOptions{
Temperature: clampTemperature(o.cfg.LLMTemperature),
MaxTokens: defaultMemoryExtractMaxTokens,
Thinking: infrallm.ThinkingModeDisabled,
Thinking: resolveMemoryThinkingMode(o.cfg.LLMThinking),
Metadata: map[string]any{
"stage": "memory_extract",
"user_id": payload.UserID,

View File

@@ -40,6 +40,7 @@ func LoadConfigFromViper() memorymodel.Config {
DecisionFallbackMode: viper.GetString("memory.decision.fallbackMode"),
WriteMode: viper.GetString("memory.write.mode"),
WriteMinConfidence: viper.GetFloat64("memory.write.minConfidence"),
LLMThinking: viper.GetBool("agent.thinking.memory"),
}
if cfg.Threshold <= 0 {

View File

@@ -72,6 +72,11 @@ type AgentGraphDeps struct {
RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口
WriteSchedulePreview WriteSchedulePreviewFunc // 按 Service 注入,排程预览写入入口
// thinking 开关:由 config.yaml 的 agent.thinking 段注入,各节点按需读取。
ThinkingPlan bool
ThinkingExecute bool
ThinkingDeliver bool
// 记忆预取管线:由 service 层启动的后台检索 goroutine 写入。
// channel 携带已渲染的文本内容(非原始 ItemDTO节点直接写入 pinned block。
MemoryFuture chan string // buffered(1),携带 renderMemoryPinnedContentByMode 的输出

View File

@@ -52,7 +52,6 @@ type PlanDecision struct {
Action PlanAction `json:"action"`
Reason string `json:"reason,omitempty"`
Complexity PlanComplexity `json:"complexity"`
NeedThinking bool `json:"need_thinking"`
PlanSteps []PlanStep `json:"plan_steps,omitempty"`
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
TaskClassIDs []int `json:"task_class_ids,omitempty"`

View File

@@ -120,6 +120,7 @@ func (n *AgentNodes) Plan(ctx context.Context, st *newagentmodel.AgentGraphState
ChunkEmitter: st.EnsureChunkEmitter(),
ResumeNode: "plan",
AlwaysExecute: st.Request.AlwaysExecute,
ThinkingEnabled: st.Deps.ThinkingPlan,
},
); err != nil {
return nil, err
@@ -230,6 +231,7 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
WriteSchedulePreview: st.Deps.WriteSchedulePreview,
OriginalScheduleState: st.OriginalScheduleState,
AlwaysExecute: st.Request.AlwaysExecute,
ThinkingEnabled: st.Deps.ThinkingExecute,
},
); err != nil {
return nil, err
@@ -277,6 +279,7 @@ func (n *AgentNodes) Deliver(ctx context.Context, st *newagentmodel.AgentGraphSt
ConversationContext: st.EnsureConversationContext(),
Client: st.Deps.ResolveDeliverClient(),
ChunkEmitter: st.EnsureChunkEmitter(),
ThinkingEnabled: st.Deps.ThinkingDeliver,
},
); err != nil {
return nil, err

View File

@@ -32,6 +32,7 @@ type DeliverNodeInput struct {
ConversationContext *newagentmodel.ConversationContext
Client *infrallm.Client
ChunkEmitter *newagentstream.ChunkEmitter
ThinkingEnabled bool // 是否开启 thinking由 config.yaml 的 agent.thinking.deliver 注入
}
// RunDeliverNode 执行一轮交付节点逻辑。
@@ -64,7 +65,7 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
}
// 2. 调 LLM 生成交付总结。
summary := generateDeliverSummary(ctx, input.Client, flowState, conversationContext)
summary := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled)
// 3. 伪流式推送总结。
if strings.TrimSpace(summary) != "" {
@@ -98,6 +99,7 @@ func generateDeliverSummary(
client *infrallm.Client,
flowState *newagentmodel.CommonState,
conversationContext *newagentmodel.ConversationContext,
thinkingEnabled bool,
) string {
if flowState != nil {
switch {
@@ -119,7 +121,7 @@ func generateDeliverSummary(
infrallm.GenerateOptions{
Temperature: 0.5,
MaxTokens: 800,
Thinking: infrallm.ThinkingModeDisabled,
Thinking: resolveThinkingMode(thinkingEnabled),
Metadata: map[string]any{
"stage": deliverStageName,
},

View File

@@ -59,6 +59,7 @@ type ExecuteNodeInput struct {
WriteSchedulePreview newagentmodel.WriteSchedulePreviewFunc
OriginalScheduleState *schedule.ScheduleState
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行
ThinkingEnabled bool // 是否开启 thinking由 config.yaml 的 agent.thinking.execute 注入
}
// ExecuteRoundObservation 记录执行阶段每轮的关键观察。
@@ -203,7 +204,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
infrallm.GenerateOptions{
Temperature: 1.0, // thinking 模式强制要求 temperature=1
MaxTokens: 16000, // 需为 thinking chain 留出足够预算
Thinking: infrallm.ThinkingModeEnabled,
Thinking: resolveThinkingMode(input.ThinkingEnabled),
Metadata: map[string]any{
"stage": executeStageName,
"step_index": flowState.CurrentStep,

View File

@@ -35,19 +35,19 @@ type PlanNodeInput struct {
ChunkEmitter *newagentstream.ChunkEmitter
ResumeNode string
AlwaysExecute bool // true 时计划生成后自动确认,不进入 confirm 节点
ThinkingEnabled bool // 是否开启 thinking由 config.yaml 的 agent.thinking.plan 注入
}
// RunPlanNode 执行一轮规划节点逻辑。
//
// 步骤说明:
// 1. 先校验最小依赖,并推送一条正在规划的状态,避免用户空等;
// 2. Phase 1快速评估不开 thinking让 LLM 同时产出复杂度评估和规划结果
// 3. Phase 2深度规划若 LLM 自评需要深度思考且规划已完成,开 thinking 重跑
// 4. 若模型先对用户说了话,则先把 speak 伪流式推给前端,并写回 history
// 5. 最后按 action 推进流程:
// 5.1 continue继续停留在 planning
// 5.2 ask_user打开 pending interaction后续交给 interrupt 收口;
// 5.3 plan_done固化完整计划刷新 pinned context并进入 waiting_confirm。
// 1. 先校验最小依赖,并推送一条"正在规划"的状态,避免用户空等;
// 2. 单轮深度规划:开 thinking、无 token 上限,让 LLM 一步到位产出完整计划
// 3. 若模型先对用户说了话,则先把 speak 伪流式推给前端,并写回 history
// 4. 最后按 action 推进流程:
// 4.1 continue继续停留在 planning
// 4.2 ask_user打开 pending interaction后续交给 interrupt 收口
// 4.3 plan_done固化完整计划刷新 pinned context并进入 waiting_confirm。
func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
runtimeState, conversationContext, emitter, err := preparePlanNodeInput(input)
if err != nil {
@@ -69,68 +69,31 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
// 2. 构造本轮规划输入。
messages := newagentprompt.BuildPlanMessages(flowState, conversationContext, input.UserInput)
// 3. Phase 1快速评估开 thinking让 LLM 同时产出复杂度评估和规划结果
// 3. 单轮深度规划:由配置决定是否开启 thinking不做 token 上限约束
decision, rawResult, err := infrallm.GenerateJSON[newagentmodel.PlanDecision](
ctx,
input.Client,
messages,
infrallm.GenerateOptions{
Temperature: 0.2,
MaxTokens: 1600,
Thinking: infrallm.ThinkingModeEnabled,
Thinking: resolveThinkingMode(input.ThinkingEnabled),
Metadata: map[string]any{
"stage": planStageName,
"phase": "assessment",
"phase": "planning",
},
},
)
if err != nil {
if rawResult != nil && strings.TrimSpace(rawResult.Text) != "" {
return fmt.Errorf("规划评估解析失败,原始输出=%s错误=%w", strings.TrimSpace(rawResult.Text), err)
return fmt.Errorf("规划解析失败,原始输出=%s错误=%w", strings.TrimSpace(rawResult.Text), err)
}
return fmt.Errorf("规划评估阶段模型调用失败: %w", err)
return fmt.Errorf("规划阶段模型调用失败: %w", err)
}
if err := decision.Validate(); err != nil {
return fmt.Errorf("规划评估决策不合法: %w", err)
return fmt.Errorf("规划决策不合法: %w", err)
}
// 4. Phase 2若 LLM 自评需要深度思考且本轮规划已完成,则开启 thinking 重跑
// 条件NeedThinking=true + Action=plan_done → 说明 LLM 认为当前无 thinking 的计划质量不够。
// 其他 actioncontinue / ask_user不需要 thinking直接用 Phase 1 结果。
if decision.NeedThinking && decision.Action == newagentmodel.PlanActionDone {
if err := emitter.EmitStatus(
planStatusBlockID,
planStageName,
"deep_planning",
"正在深入思考,生成更完善的计划。",
false,
); err != nil {
return fmt.Errorf("深度规划状态推送失败: %w", err)
}
deepDecision, _, deepErr := infrallm.GenerateJSON[newagentmodel.PlanDecision](
ctx,
input.Client,
messages,
infrallm.GenerateOptions{
Temperature: 0.2,
MaxTokens: 3200,
Thinking: infrallm.ThinkingModeEnabled,
Metadata: map[string]any{
"stage": planStageName,
"phase": "deep_planning",
},
},
)
if deepErr == nil && deepDecision != nil {
if validateErr := deepDecision.Validate(); validateErr == nil {
decision = deepDecision
}
}
// 深度规划失败时静默降级到 Phase 1 结果,不中断流程。
}
// 5. 若模型先对用户说了话,且不是 ask_userask_user 交给 interrupt 收口),则先以伪流式推送,再写回 history。
// 4. 若模型先对用户说了话,且不是 ask_userask_user 交给 interrupt 收口),则先以伪流式推送,再写回 history
if strings.TrimSpace(decision.Speak) != "" && decision.Action != newagentmodel.PlanActionAskUser {
if err := emitter.EmitPseudoAssistantText(
ctx,
@@ -144,7 +107,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
conversationContext.AppendHistory(schema.AssistantMessage(decision.Speak, nil))
}
// 6. 按规划动作推进流程状态。
// 5. 按规划动作推进流程状态。
switch decision.Action {
case newagentmodel.PlanActionContinue:
flowState.Phase = newagentmodel.PhasePlanning
@@ -169,10 +132,10 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
}
}
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
// 这样可以与 Execute 节点的写工具跳过确认语义保持一致。
// 这样可以与 Execute 节点的"写工具跳过确认"语义保持一致。
if input.AlwaysExecute {
// 1. 自动执行模式不会经过 Confirm 卡片,因此这里先把完整计划明确展示给用户。
// 2. 摘要格式复用 Confirm 节点,保证手动确认”和“自动执行两条链路文案一致。
// 2. 摘要格式复用 Confirm 节点,保证"手动确认"和"自动执行"两条链路文案一致。
// 3. 推流后同步写入历史,确保后续 Execute 阶段的上下文也能看到这份计划。
summary := strings.TrimSpace(buildPlanSummary(decision.PlanSteps))
if summary != "" {
@@ -296,3 +259,12 @@ func buildPinnedPlanText(steps []newagentmodel.PlanStep) string {
}
return strings.TrimSpace(strings.Join(lines, "\n\n"))
}
// resolveThinkingMode 根据配置布尔值返回对应的 ThinkingMode。
// 供 plan / execute / deliver 节点统一使用。
func resolveThinkingMode(enabled bool) infrallm.ThinkingMode {
if enabled {
return infrallm.ThinkingModeEnabled
}
return infrallm.ThinkingModeDisabled
}

View File

@@ -21,8 +21,7 @@ const planSystemPrompt = `
5. plan_steps 必须使用自然语言,便于后端将完整 plan 重新注入到后续上下文顶部。
6. 只输出 JSON不要输出 markdown不要输出额外解释不要在 JSON 外再补文字。
7. 每次输出前先评估任务复杂度simple简单明确无复杂依赖、moderate多步操作需要一定推理、complex需要深度推理、多方案比较或复杂依赖关系
8. 根据复杂度判断 need_thinking你是否需要深度思考才能生成高质量计划当不确定时倾向于 false。
9. 粗排识别规则:若满足以下两个条件,在 action=plan_done 时附加 needs_rough_build=true 和 task_class_ids
8. 粗排识别规则:若满足以下两个条件,在 action=plan_done 时附加 needs_rough_build=true 和 task_class_ids
条件1用户输入中存在"任务类 ID"字段(见上下文"任务类 ID"部分);
条件2用户意图明确是"批量安排/帮我排课/把任务类排进日程"等批量调度需求。
满足时:后端会在用户确认计划后自动运行粗排算法(硬性约束已由算法保证,无需 LLM 校验)。
@@ -99,7 +98,6 @@ func BuildPlanDecisionContractText() string {
- action只能是 %s / %s / %s
- reason给后端和日志看的简短说明
- complexity任务复杂度只能是 simple / moderate / complex
- need_thinking是否需要深度思考才能生成高质量计划只能是 true / false
- plan_steps仅当 action=%s 时允许返回;返回时必须是完整计划,不是增量
- plan_steps[].content步骤正文必填
- plan_steps[].done_when可选建议写"什么情况下算这一步做完"
@@ -112,7 +110,6 @@ func BuildPlanDecisionContractText() string {
"action": "%s",
"reason": "当前信息已足够继续规划",
"complexity": "moderate",
"need_thinking": false
}
{
@@ -120,7 +117,6 @@ func BuildPlanDecisionContractText() string {
"action": "%s",
"reason": "当前时间范围仍不明确",
"complexity": "simple",
"need_thinking": false
}
{
@@ -128,7 +124,7 @@ func BuildPlanDecisionContractText() string {
"action": "%s",
"reason": "当前计划已具备执行条件",
"complexity": "simple",
"need_thinking": false,
"plan_steps": [
{
"content": "先确认本周可用时间范围",

View File

@@ -104,14 +104,10 @@ func thinkingModeToBool(mode string) bool {
// pickChatModel 根据请求选择模型。
// 当前约定:
// - strategist策略模型
// - 其余值默认 worker包含空字符串场景
// - 旧链路已全面切到 newAgent graph这里仅作为 runNormalChatFlow 回退时的模型选择入口
// - 统一返回 Pro 模型,旧 strategist 参数不再生效
func (s *AgentService) pickChatModel(requestModel string) (*ark.ChatModel, string) {
modelName := strings.TrimSpace(requestModel)
if strings.EqualFold(modelName, "strategist") {
return s.AIHub.Strategist, "strategist"
}
return s.AIHub.Worker, "worker"
return s.AIHub.Pro, "pro"
}
// PersistChatHistory 是 Agent 聊天链路唯一的“消息持久化入口”。

View File

@@ -278,15 +278,15 @@ func (s *AgentService) generateConversationTitle(ctx context.Context, history []
}
// pickTitleModel 选择用于标题生成的模型。
// 优先 worker(成本低、速度快);worker 不可用时回退 strategist
// 优先 Lite(成本低、速度快);Lite 不可用时回退 Pro
func (s *AgentService) pickTitleModel() *ark.ChatModel {
if s.AIHub == nil {
return nil
}
if s.AIHub.Worker != nil {
return s.AIHub.Worker
if s.AIHub.Lite != nil {
return s.AIHub.Lite
}
return s.AIHub.Strategist
return s.AIHub.Pro
}
// buildConversationTitleUserPrompt 把消息历史拼成可读文本供模型总结。

View File

@@ -15,6 +15,7 @@ import (
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
schedule "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
"github.com/cloudwego/eino/schema"
"github.com/spf13/viper"
agentchat "github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/LoveLosita/smartflow/backend/conv"
@@ -149,10 +150,12 @@ func (s *AgentService) runNewAgentGraph(
graphRequest.Normalize()
// 7. 适配 LLM clients从 AIHub 的 ark.ChatModel 转换为 newAgent LLM Client
chatClient := infrallm.WrapArkClient(s.AIHub.Worker)
planClient := infrallm.WrapArkClient(s.AIHub.Worker)
executeClient := infrallm.WrapArkClient(s.AIHub.Worker)
deliverClient := infrallm.WrapArkClient(s.AIHub.Worker)
// 7.1 Chat/Deliver 使用 Pro 模型:路由分流、闲聊、交付总结属于标准复杂度。
// 7.2 Plan/Execute 使用 Max 模型:规划和 ReAct 循环需要深度推理能力。
chatClient := infrallm.WrapArkClient(s.AIHub.Pro)
planClient := infrallm.WrapArkClient(s.AIHub.Max)
executeClient := infrallm.WrapArkClient(s.AIHub.Max)
deliverClient := infrallm.WrapArkClient(s.AIHub.Pro)
// 8. 适配 SSE emitter。
sseEmitter := newagentstream.NewSSEPayloadEmitter(outChan)
@@ -173,6 +176,9 @@ func (s *AgentService) runNewAgentGraph(
RoughBuildFunc: s.makeRoughBuildFunc(),
WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(),
MemoryFuture: memoryFuture,
ThinkingPlan: viper.GetBool("agent.thinking.plan"),
ThinkingExecute: viper.GetBool("agent.thinking.execute"),
ThinkingDeliver: viper.GetBool("agent.thinking.deliver"),
}
// 10. 构造 AgentGraphRunInput 并运行 graph。
@@ -190,8 +196,8 @@ func (s *AgentService) runNewAgentGraph(
log.Printf("[ERROR] newAgent graph 执行失败 trace=%s chat=%s: %v", traceID, chatID, graphErr)
pushErrNonBlocking(errChan, fmt.Errorf("graph 执行失败: %w", graphErr))
// Graph 出错时回退普通聊天,保证可用性。
s.runNormalChatFlow(requestCtx, s.AIHub.Worker, resolvedModelName, userMessage, "", nil, retryMeta, thinkingModeToBool(thinkingMode), userID, chatID, traceID, requestStart, outChan, errChan)
// Graph 出错时回退普通聊天,保证可用性。回退使用 Pro 模型。
s.runNormalChatFlow(requestCtx, s.AIHub.Pro, resolvedModelName, userMessage, "", nil, retryMeta, thinkingModeToBool(thinkingMode), userID, chatID, traceID, requestStart, outChan, errChan)
return
}

View File

@@ -3,14 +3,6 @@ import { computed } from 'vue'
import type { ConversationContextStats } from '@/types/dashboard'
interface ContextSegment {
key: 'msg0' | 'msg1' | 'msg2' | 'msg3'
label: string
value: number
widthPercent: number
color: string
}
const props = withDefaults(
defineProps<{
stats?: ConversationContextStats | null
@@ -33,6 +25,14 @@ const usagePercent = computed(() => {
return Math.round((safeStats.value.total / safeStats.value.budget) * 100)
})
const barWidthPercent = computed(() => {
if (!safeStats.value || safeStats.value.budget <= 0) {
return 0
}
// 1. 按 total / budget 计算宽度,上限 100%(超预算时撑满进度条)。
return Math.min(100, (safeStats.value.total / safeStats.value.budget) * 100)
})
const isOverBudget = computed(() => {
if (!safeStats.value) {
return false
@@ -40,31 +40,6 @@ const isOverBudget = computed(() => {
return safeStats.value.total > safeStats.value.budget
})
const segments = computed<ContextSegment[]>(() => {
const stats = safeStats.value
if (!stats) {
return []
}
// 1. 进度条固定做成紧凑胶囊,因此按 max(total, budget) 计算比例,既保留预算留白,也兼容超预算占满。
// 2. 四段颜色继续对应后端 msg0~msg3 的真实语义,避免前端为了视觉压缩而打乱统计含义。
// 3. 零值段不渲染,减少窄尺寸下的噪点,让小组件也能保留基本可读性。
const base = Math.max(stats.total, stats.budget, 1)
const rawSegments = [
{ key: 'msg0', label: '规则', value: stats.msg0, color: 'linear-gradient(90deg, #2556c7, #3b82f6)' },
{ key: 'msg1', label: '历史', value: stats.msg1, color: 'linear-gradient(90deg, #0f766e, #14b8a6)' },
{ key: 'msg2', label: '执行', value: stats.msg2, color: 'linear-gradient(90deg, #b45309, #f59e0b)' },
{ key: 'msg3', label: '当前', value: stats.msg3, color: 'linear-gradient(90deg, #15803d, #22c55e)' },
] as const
return rawSegments
.filter((segment) => segment.value > 0)
.map((segment) => ({
...segment,
widthPercent: Math.max(0, Math.min(100, (segment.value / base) * 100)),
}))
})
const usageText = computed(() => {
if (props.loading) {
return '...'
@@ -86,9 +61,7 @@ const tooltipText = computed(() => {
return props.disabled ? '新会话发送首条消息后展示上下文窗口统计' : '当前会话暂无上下文窗口统计'
}
const segmentText = segments.value.map((segment) => `${segment.label} ${segment.value}`).join(' / ')
const usageSummary = `总计 ${safeStats.value.total} / 预算 ${safeStats.value.budget}${usagePercent.value}%`
return segmentText ? `${usageSummary}${segmentText}` : usageSummary
return `总计 ${safeStats.value.total} / 预算 ${safeStats.value.budget}${usagePercent.value}%`
})
</script>
@@ -106,18 +79,7 @@ const tooltipText = computed(() => {
<div class="assistant-context-meter__track" aria-hidden="true">
<div v-if="loading" class="assistant-context-meter__loading-bar" />
<template v-else>
<div
v-for="segment in segments"
:key="segment.key"
class="assistant-context-meter__segment"
:style="{
width: `${segment.widthPercent}%`,
background: segment.color,
}"
/>
</template>
<div v-else-if="barWidthPercent > 0" class="assistant-context-meter__bar" :style="{ width: `${barWidthPercent}%` }" />
</div>
<span class="assistant-context-meter__value">{{ usageText }}</span>
@@ -195,7 +157,6 @@ const tooltipText = computed(() => {
background:
linear-gradient(180deg, rgba(232, 238, 246, 0.95), rgba(243, 247, 251, 0.95)),
#edf2f7;
display: flex;
}
.assistant-context-meter--disabled .assistant-context-meter__track {
@@ -204,9 +165,15 @@ const tooltipText = computed(() => {
#eef2f7;
}
.assistant-context-meter__segment {
.assistant-context-meter__bar {
height: 100%;
flex: 0 0 auto;
border-radius: inherit;
background: linear-gradient(90deg, #2556c7, #3b82f6);
transition: width 0.3s ease;
}
.assistant-context-meter--danger .assistant-context-meter__bar {
background: linear-gradient(90deg, #b42318, #ef4444);
}
.assistant-context-meter__loading-bar {

View File

@@ -48,7 +48,6 @@ interface StreamEventPayload {
error?: StreamErrorPayload
}
type ModelType = 'worker' | 'strategist'
interface ConversationGroup {
key: string
@@ -86,7 +85,7 @@ const conversationLoadingMore = ref(false)
const chatLoading = ref(false)
const historyExpanded = ref(true)
const selectedConversationId = ref('')
const selectedModel = ref<ModelType>('worker')
const selectedThinkingMode = ref<ThinkingModeType>('auto')
const messageInput = ref('')
const historyPanelWidth = ref(props.initialHistoryWidth)
@@ -120,7 +119,7 @@ const quickActions = [
'给我一个更稳妥的推进方案',
]
const MODEL_PREFERENCE_STORAGE_KEY = 'smartflow.assistant.model.byConversation.v1'
const DEFAULT_PLANNING_PROMPT = '请基于这些任务类帮我做一版智能编排。'
let messageScrollRaf = 0
@@ -336,85 +335,6 @@ const contextStatsDisabled = computed(() => {
return !selectedConversationId.value || isDraftConversationId(selectedConversationId.value)
})
function isModelType(value: unknown): value is ModelType {
return value === 'worker' || value === 'strategist'
}
function loadModelPreferenceMap() {
if (typeof window === 'undefined') {
return {} as Record<string, ModelType>
}
try {
const raw = window.localStorage.getItem(MODEL_PREFERENCE_STORAGE_KEY)
if (!raw) {
return {} as Record<string, ModelType>
}
const parsed = JSON.parse(raw) as unknown
const normalized: Record<string, ModelType> = {}
const entries = typeof parsed === 'object' && parsed ? Object.entries(parsed) : []
// 1. 只接收结构合法且值在白名单内的记录,避免脏数据把模型值污染为非法字符串。
// 2. 键为空字符串的记录直接丢弃,防止“新建会话未落库”场景写入无效索引。
// 3. 解析失败时回退为空对象,不阻塞聊天主流程。
for (const [conversationId, model] of entries) {
if (!conversationId || !isModelType(model)) {
continue
}
normalized[conversationId] = model
}
return normalized
} catch {
return {} as Record<string, ModelType>
}
}
const modelPreferenceMap = ref<Record<string, ModelType>>(loadModelPreferenceMap())
function persistModelPreferenceMap() {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(MODEL_PREFERENCE_STORAGE_KEY, JSON.stringify(modelPreferenceMap.value))
} catch {
// 1. 本地存储失败只影响“记忆体验”,不影响消息收发主链路。
// 2. 这里静默处理,避免用户每次切模型都被错误提示打断。
// 3. 若用户清理缓存或隐私模式限制写入,后续会自动退化为会话内临时选择。
}
}
function savePreferredModel(conversationId: string, model: ModelType) {
if (!conversationId || modelPreferenceMap.value[conversationId] === model) {
return
}
modelPreferenceMap.value = {
...modelPreferenceMap.value,
[conversationId]: model,
}
persistModelPreferenceMap()
}
function resolvePreferredModel(conversationId: string) {
if (!conversationId) {
return null
}
return modelPreferenceMap.value[conversationId] ?? null
}
function applyPreferredModelForConversation(conversationId: string) {
const preferredModel = resolvePreferredModel(conversationId)
if (!preferredModel || preferredModel === selectedModel.value) {
return
}
selectedModel.value = preferredModel
}
function ensureConversationBucket(conversationId: string) {
if (!conversationMessagesMap[conversationId]) {
@@ -476,16 +396,6 @@ function migrateConversationState(fromConversationId: string, toConversationId:
delete conversationMetaMap[fromConversationId]
}
if (modelPreferenceMap.value[fromConversationId]) {
const migratedModelMap = { ...modelPreferenceMap.value }
if (!migratedModelMap[toConversationId]) {
migratedModelMap[toConversationId] = migratedModelMap[fromConversationId]!
}
delete migratedModelMap[fromConversationId]
modelPreferenceMap.value = migratedModelMap
persistModelPreferenceMap()
}
const latestMap = new Map<string, ConversationListItem>()
const deduplicated: ConversationListItem[] = []
const seen = new Set<string>()
@@ -1299,7 +1209,6 @@ async function loadConversationContextStats(conversationId: string, forceReload
async function selectConversation(conversationId: string) {
cancelEditUserMessage()
selectedConversationId.value = conversationId
applyPreferredModelForConversation(conversationId)
await Promise.allSettled([
loadConversationMessages(conversationId),
ensureConversationMeta(conversationId),
@@ -1502,7 +1411,7 @@ async function streamAssistantReply(
const response = await fetchChatStream({
conversation_id: isDraftConversationId(draftConversationId) ? undefined : draftConversationId,
message: text,
model: selectedModel.value,
model: 'worker',
thinking: selectedThinkingMode.value,
extra: requestExtra,
})
@@ -1577,8 +1486,6 @@ async function sendMessage(preset?: string) {
if (!selectedConversationId.value || shouldStartFreshPlanningConversation) {
selectedConversationId.value = draftConversationId
}
savePreferredModel(draftConversationId, selectedModel.value)
ensureConversationBucket(draftConversationId)
unavailableHistoryMap[draftConversationId] = false
@@ -1734,16 +1641,6 @@ watch(
},
)
watch(
selectedModel,
(nextModel) => {
const conversationId = selectedConversationId.value
if (!conversationId) {
return
}
savePreferredModel(conversationId, nextModel)
},
)
onMounted(async () => {
reasoningTicker = window.setInterval(() => {
@@ -2126,20 +2023,6 @@ onBeforeUnmount(() => {
</el-select>
</div>
<div class="assistant-toolbar__pill assistant-toolbar__pill--select assistant-toolbar__pill--ds-model">
<span class="assistant-toolbar__select-label">模型</span>
<el-select
v-model="selectedModel"
class="assistant-toolbar__select-box"
size="small"
popper-class="assistant-model-select-panel"
placement="top-start"
:teleported="true"
>
<el-option value="worker" label="标准" />
<el-option value="strategist" label="策略" />
</el-select>
</div>
<ContextWindowMeter
class="assistant-toolbar__context-meter"
@@ -3183,7 +3066,6 @@ onBeforeUnmount(() => {
font-weight: 600;
}
.assistant-toolbar__pill--ds-model,
.assistant-toolbar__pill--ds-thinking {
height: 32px;
padding: 0 8px 0 10px;
@@ -3200,10 +3082,6 @@ onBeforeUnmount(() => {
min-width: 138px;
}
.assistant-toolbar__pill--ds-model {
min-width: 144px;
}
.assistant-toolbar__context-meter {
width: 144px;
min-width: 144px;
@@ -3435,30 +3313,5 @@ onBeforeUnmount(() => {
}
</style>
<style>
.assistant-model-select-panel.el-popper {
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
padding: 6px;
}
.assistant-model-select-panel .el-select-dropdown__item {
height: 36px;
line-height: 36px;
border-radius: 8px;
padding: 0 12px;
color: #4d5d73;
font-size: 14px;
font-weight: 600;
}
.assistant-model-select-panel .el-select-dropdown__item.hover,
.assistant-model-select-panel .el-select-dropdown__item:hover {
background: rgba(51, 95, 194, 0.1);
}
.assistant-model-select-panel .el-select-dropdown__item.is-selected {
color: #2f56b0;
background: rgba(51, 95, 194, 0.16);
}
</style>