Version: 0.7.8.dev.260325
后端: 迁移了schedule_plan逻辑并探索了新的架构组织思路 删除了一些Codex测试时产生的单测文件 前端: 做了一些改进
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
9. 对于明显过大的文件(尤其是同时承载编排、业务、模型交互、工具分发的文件),后续重构时必须拆分职责,禁止继续向单文件堆砌新逻辑。
|
||||
10. Prompt、State、模型交互、Graph 连线应尽量分目录/分文件管理,禁止把大段 prompt、节点逻辑、模型 helper 长期混写在同一文件中。
|
||||
11. 若本轮任务包含“结构迁移”,最终答复中必须明确说明:本轮迁了什么、哪些旧实现仍保留、当前切流点在哪里、下一轮建议迁什么。
|
||||
|
||||
12. 若后续在 `backend/agent2` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent2/通用能力接入文档.md`,否则视为重构信息不完整。
|
||||
13. 跑完单元测试后,必须删除单元测试的test.go文件,禁止把测试文件长期留在项目中。
|
||||
|
||||
## 注释规范(强制)
|
||||
|
||||
|
||||
@@ -1,30 +1,164 @@
|
||||
package agentgraph
|
||||
|
||||
import agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
const (
|
||||
SchedulePlanGraphName = "schedule_plan"
|
||||
ScheduleRefineGraphName = "schedule_refine"
|
||||
|
||||
ScheduleNodeIntentRoute = "schedule.intent.route"
|
||||
ScheduleNodePlan = "schedule.plan"
|
||||
ScheduleNodeRoughBuild = "schedule.rough_build"
|
||||
ScheduleNodeReact = "schedule.react"
|
||||
ScheduleNodeHardCheck = "schedule.hard_check"
|
||||
ScheduleNodeReply = "schedule.reply"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// SchedulePlanGraph 是“首次排程”图编排骨架。
|
||||
type SchedulePlanGraph struct {
|
||||
Nodes *agentnode.SchedulePlanNodes
|
||||
const (
|
||||
// SchedulePlanGraphName 是首次排程 graph 的稳定标识。
|
||||
SchedulePlanGraphName = "schedule_plan"
|
||||
// ScheduleRefineGraphName 先保留给 refine 链路使用。
|
||||
ScheduleRefineGraphName = "schedule_refine"
|
||||
)
|
||||
|
||||
// RunSchedulePlanGraph 执行“智能排程”图编排。
|
||||
//
|
||||
// 当前链路:
|
||||
// START
|
||||
// -> plan
|
||||
// -> roughBuild
|
||||
// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine)
|
||||
// -> finalCheck
|
||||
// -> returnPreview
|
||||
// -> END
|
||||
//
|
||||
// 说明:
|
||||
// 1. exit 分支可从 plan/roughBuild 直接提前终止;
|
||||
// 2. 本文件只负责“连线与分支”,节点内业务都在 node 层实现;
|
||||
// 3. 这轮已经去掉旧 runner 适配层,graph 直接挂 node 方法,减少一跳阅读成本。
|
||||
func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraphRunInput) (*agentmodel.SchedulePlanState, error) {
|
||||
// 1. 启动前硬校验。
|
||||
if input.Model == nil {
|
||||
return nil, errors.New("schedule plan graph: model is nil")
|
||||
}
|
||||
if input.State == nil {
|
||||
return nil, errors.New("schedule plan graph: state is nil")
|
||||
}
|
||||
if err := input.Deps.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 注入运行时配置(可选覆盖)。
|
||||
if input.DailyRefineConcurrency > 0 {
|
||||
input.State.DailyRefineConcurrency = input.DailyRefineConcurrency
|
||||
}
|
||||
if input.WeeklyAdjustBudget > 0 {
|
||||
input.State.WeeklyAdjustBudget = input.WeeklyAdjustBudget
|
||||
}
|
||||
|
||||
nodes, err := agentnode.NewSchedulePlanNodes(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
graph := compose.NewGraph[*agentmodel.SchedulePlanState, *agentmodel.SchedulePlanState]()
|
||||
|
||||
// 3. 注册节点。
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeRoughBuild, compose.InvokableLambda(nodes.RoughBuild)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeExit, compose.InvokableLambda(nodes.Exit)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailySplit, compose.InvokableLambda(nodes.DailySplit)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeQuickRefine, compose.InvokableLambda(nodes.QuickRefine)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailyRefine, compose.InvokableLambda(nodes.DailyRefine)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeMerge, compose.InvokableLambda(nodes.Merge)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeWeeklyRefine, compose.InvokableLambda(nodes.WeeklyRefine)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeFinalCheck, compose.InvokableLambda(nodes.FinalCheck)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeReturnPreview, compose.InvokableLambda(nodes.ReturnPreview)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 固定入口:START -> plan。
|
||||
if err = graph.AddEdge(compose.START, agentnode.SchedulePlanGraphNodePlan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. plan 分支:roughBuild | exit。
|
||||
if err = graph.AddBranch(agentnode.SchedulePlanGraphNodePlan, compose.NewGraphBranch(
|
||||
nodes.NextAfterPlan,
|
||||
map[string]bool{
|
||||
agentnode.SchedulePlanGraphNodeRoughBuild: true,
|
||||
agentnode.SchedulePlanGraphNodeExit: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. roughBuild 分支:dailySplit | quickRefine | weeklyRefine | exit。
|
||||
if err = graph.AddBranch(agentnode.SchedulePlanGraphNodeRoughBuild, compose.NewGraphBranch(
|
||||
nodes.NextAfterRoughBuild,
|
||||
map[string]bool{
|
||||
agentnode.SchedulePlanGraphNodeDailySplit: true,
|
||||
agentnode.SchedulePlanGraphNodeQuickRefine: true,
|
||||
agentnode.SchedulePlanGraphNodeWeeklyRefine: true,
|
||||
agentnode.SchedulePlanGraphNodeExit: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 7. 固定边:quickRefine -> weeklyRefine;dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END。
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeQuickRefine, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailySplit, agentnode.SchedulePlanGraphNodeDailyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailyRefine, agentnode.SchedulePlanGraphNodeMerge); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeMerge, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeWeeklyRefine, agentnode.SchedulePlanGraphNodeFinalCheck); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeFinalCheck, agentnode.SchedulePlanGraphNodeReturnPreview); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeReturnPreview, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeExit, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. 编译并执行。
|
||||
// 路径最多约 8~9 个节点,保守预留 20 步避免误判。
|
||||
runnable, err := graph.Compile(ctx,
|
||||
compose.WithGraphName(SchedulePlanGraphName),
|
||||
compose.WithMaxRunSteps(20),
|
||||
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runnable.Invoke(ctx, input.State)
|
||||
}
|
||||
|
||||
// NewSchedulePlanGraph 创建首次排程图骨架。
|
||||
func NewSchedulePlanGraph(nodes *agentnode.SchedulePlanNodes) *SchedulePlanGraph {
|
||||
return &SchedulePlanGraph{Nodes: nodes}
|
||||
}
|
||||
|
||||
// ScheduleRefineGraph 是“连续微调排程”图编排骨架。
|
||||
// ScheduleRefineGraph 先保留骨架,避免本轮“只迁 schedule_plan”时误动 refine 主链路。
|
||||
type ScheduleRefineGraph struct {
|
||||
Nodes *agentnode.ScheduleRefineNodes
|
||||
}
|
||||
|
||||
@@ -1,22 +1,175 @@
|
||||
package agentllm
|
||||
|
||||
// ScheduleIntentOutput 是智能排程一级意图识别的模型契约草案。
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
einoModel "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// ScheduleIntentOutput 是 plan 节点要求模型返回的结构化结果。
|
||||
//
|
||||
// 兼容说明:
|
||||
// 1. 新主语义是 task_class_ids(数组);
|
||||
// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析;
|
||||
// 3. TaskTags 的 key 兼容两种写法:
|
||||
// 3.1 推荐:task_item_id(例如 "12");
|
||||
// 3.2 兼容:任务名称(例如 "高数复习")。
|
||||
type ScheduleIntentOutput struct {
|
||||
Intent string `json:"intent"`
|
||||
NeedRefine bool `json:"need_refine"`
|
||||
AdjustmentScope string `json:"adjustment_scope"`
|
||||
Intent string `json:"intent"`
|
||||
Constraints []string `json:"constraints"`
|
||||
TaskClassIDs []int `json:"task_class_ids"`
|
||||
TaskClassID int `json:"task_class_id"`
|
||||
Strategy string `json:"strategy"`
|
||||
TaskTags map[string]string `json:"task_tags"`
|
||||
Restart bool `json:"restart"`
|
||||
AdjustmentScope string `json:"adjustment_scope"`
|
||||
Reason string `json:"reason"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// SchedulePlanOutput 是首次排程规划节点的模型契约草案。
|
||||
type SchedulePlanOutput struct {
|
||||
Goal string `json:"goal"`
|
||||
Constraints []string `json:"constraints"`
|
||||
Strategy string `json:"strategy"`
|
||||
// ReactToolCall 是 LLM 输出的单个工具调用。
|
||||
type ReactToolCall struct {
|
||||
Tool string `json:"tool"`
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
|
||||
// ScheduleRefineOutput 是连续微调阶段的模型契约草案。
|
||||
type ScheduleRefineOutput struct {
|
||||
Decision string `json:"decision"`
|
||||
HardAssertions []string `json:"hard_assertions"`
|
||||
NextAction string `json:"next_action"`
|
||||
// ReactLLMOutput 是 ReAct 节点要求模型返回的统一 JSON。
|
||||
type ReactLLMOutput struct {
|
||||
Done bool `json:"done"`
|
||||
Summary string `json:"summary"`
|
||||
ToolCalls []ReactToolCall `json:"tool_calls"`
|
||||
}
|
||||
|
||||
// IdentifySchedulePlanIntent 调用模型识别“排程意图 + 约束 + 任务类集合”。
|
||||
func IdentifySchedulePlanIntent(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
nowText string,
|
||||
userMessage string,
|
||||
adjustmentHint string,
|
||||
) (*ScheduleIntentOutput, error) {
|
||||
prompt := fmt.Sprintf(
|
||||
"当前时间(北京时间):%s\n用户输入:%s%s\n\n请提取排程意图与约束。",
|
||||
strings.TrimSpace(nowText),
|
||||
strings.TrimSpace(userMessage),
|
||||
strings.TrimSpace(adjustmentHint),
|
||||
)
|
||||
|
||||
parsed, _, err := CallArkJSON[ScheduleIntentOutput](ctx, chatModel, agentprompt.SchedulePlanIntentPrompt, prompt, ArkCallOptions{
|
||||
Temperature: 0,
|
||||
MaxTokens: 256,
|
||||
Thinking: ThinkingModeDisabled,
|
||||
})
|
||||
return parsed, err
|
||||
}
|
||||
|
||||
// ParseScheduleReactOutput 解析 ReAct 节点的 JSON 输出。
|
||||
func ParseScheduleReactOutput(raw string) (*ReactLLMOutput, error) {
|
||||
return ParseJSONObject[ReactLLMOutput](raw)
|
||||
}
|
||||
|
||||
// GenerateScheduleDailyReactRound 调用模型生成“单天日内优化”的一轮决策。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责统一关闭 thinking、设置温度,并返回纯文本;
|
||||
// 2. 不负责工具执行,不负责结果回灌。
|
||||
func GenerateScheduleDailyReactRound(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
messages []*schema.Message,
|
||||
) (string, error) {
|
||||
resp, err := chatModel.Generate(
|
||||
ctx,
|
||||
messages,
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||||
einoModel.WithTemperature(0),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("日内优化调用返回为空")
|
||||
}
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("日内优化调用返回内容为空")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// GenerateScheduleWeeklyReactRound 调用模型生成“单周单步优化”的一轮决策。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 周级仍保留 thinking,提高复杂排程准确率;
|
||||
// 2. 仅返回最终 content,是否透出思考流由上层决定。
|
||||
func GenerateScheduleWeeklyReactRound(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
messages []*schema.Message,
|
||||
) (string, error) {
|
||||
resp, err := chatModel.Generate(
|
||||
ctx,
|
||||
messages,
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}),
|
||||
einoModel.WithTemperature(0.2),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("周级单步调用返回为空")
|
||||
}
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("周级单步调用返回内容为空")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// GenerateScheduleHumanSummary 调用模型生成“用户可读”的最终总结。
|
||||
func GenerateScheduleHumanSummary(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
entries []model.HybridScheduleEntry,
|
||||
constraints []string,
|
||||
actionLogs []string,
|
||||
) (string, error) {
|
||||
if chatModel == nil {
|
||||
return "", fmt.Errorf("final summary model is nil")
|
||||
}
|
||||
|
||||
entriesJSON, _ := json.Marshal(entries)
|
||||
constraintText := "无"
|
||||
if len(constraints) > 0 {
|
||||
constraintText = strings.Join(constraints, "、")
|
||||
}
|
||||
actionLogText := "无"
|
||||
if len(actionLogs) > 0 {
|
||||
start := 0
|
||||
if len(actionLogs) > 30 {
|
||||
start = len(actionLogs) - 30
|
||||
}
|
||||
actionLogText = strings.Join(actionLogs[start:], "\n")
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"以下是最终排程方案(JSON):\n%s\n\n用户约束:%s\n\n以下是本次周级优化动作日志(按时间顺序):\n%s\n\n请基于“结果+过程”输出2-3句自然中文总结,重点说明本方案的优点和改进点。",
|
||||
string(entriesJSON),
|
||||
constraintText,
|
||||
actionLogText,
|
||||
)
|
||||
|
||||
return CallArkText(ctx, chatModel, agentprompt.SchedulePlanFinalCheckPrompt, userPrompt, ArkCallOptions{
|
||||
Temperature: 0.4,
|
||||
MaxTokens: 256,
|
||||
Thinking: ThinkingModeDisabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,205 @@
|
||||
package agentmodel
|
||||
|
||||
// SchedulePlanState 是“首次排程”skill 的运行时状态骨架。
|
||||
type SchedulePlanState struct {
|
||||
TraceID string
|
||||
UserID int
|
||||
ConversationID string
|
||||
UserInput string
|
||||
TaskClassIDs []int
|
||||
UseQuickRefineOnly bool
|
||||
Completed bool
|
||||
FinalSummary string
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// SchedulePlanTimezoneName 是排程链路默认业务时区。
|
||||
// 与随口记保持一致,固定东八区,避免容器运行在 UTC 导致“明天/今晚”偏移。
|
||||
SchedulePlanTimezoneName = "Asia/Shanghai"
|
||||
|
||||
// SchedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。
|
||||
SchedulePlanDatetimeLayout = "2006-01-02 15:04"
|
||||
|
||||
// SchedulePlanDefaultDailyRefineConcurrency 是日内并发优化默认并发度。
|
||||
// 这里给一个保守默认值,避免未配置时直接把模型并发打满导致限流。
|
||||
SchedulePlanDefaultDailyRefineConcurrency = 3
|
||||
|
||||
// SchedulePlanDefaultWeeklyAdjustBudget 是周级配平默认调整额度。
|
||||
// 额度存在的目的:
|
||||
// 1. 防止周级 ReAct 过度调整导致震荡;
|
||||
// 2. 控制 token 与时延成本;
|
||||
// 3. 让方案改动更可解释。
|
||||
SchedulePlanDefaultWeeklyAdjustBudget = 5
|
||||
|
||||
// SchedulePlanDefaultWeeklyTotalBudget 是周级“总尝试次数”默认预算。
|
||||
//
|
||||
// 设计意图:
|
||||
// 1. 总预算统计“动作尝试次数”(成功/失败都记一次);
|
||||
// 2. 有效预算统计“成功动作次数”(仅成功时记一次);
|
||||
// 3. 通过双预算把“探索次数”和“有效改动次数”分离,降低模型无效空转成本。
|
||||
SchedulePlanDefaultWeeklyTotalBudget = 8
|
||||
|
||||
// SchedulePlanDefaultWeeklyRefineConcurrency 是周级“按周并发”默认并发度。
|
||||
// 说明:
|
||||
// 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流;
|
||||
// 2. 可在运行时按请求状态覆盖。
|
||||
SchedulePlanDefaultWeeklyRefineConcurrency = 2
|
||||
|
||||
// SchedulePlanAdjustmentScopeSmall 表示“小改动微调”。
|
||||
// 语义:优先走快速路径,只做轻量周级调整。
|
||||
SchedulePlanAdjustmentScopeSmall = "small"
|
||||
// SchedulePlanAdjustmentScopeMedium 表示“中等改动微调”。
|
||||
// 语义:跳过日内拆分,直接进入周级配平。
|
||||
SchedulePlanAdjustmentScopeMedium = "medium"
|
||||
// SchedulePlanAdjustmentScopeLarge 表示“大改动重排”。
|
||||
// 语义:必要时重新走全量路径(日内并发 + 周级配平)。
|
||||
SchedulePlanAdjustmentScopeLarge = "large"
|
||||
)
|
||||
|
||||
const (
|
||||
schedulePlanTimezoneName = SchedulePlanTimezoneName
|
||||
schedulePlanDatetimeLayout = SchedulePlanDatetimeLayout
|
||||
schedulePlanDefaultDailyRefineConcurrency = SchedulePlanDefaultDailyRefineConcurrency
|
||||
schedulePlanDefaultWeeklyAdjustBudget = SchedulePlanDefaultWeeklyAdjustBudget
|
||||
schedulePlanDefaultWeeklyTotalBudget = SchedulePlanDefaultWeeklyTotalBudget
|
||||
schedulePlanDefaultWeeklyRefineConcurrency = SchedulePlanDefaultWeeklyRefineConcurrency
|
||||
schedulePlanAdjustmentScopeSmall = SchedulePlanAdjustmentScopeSmall
|
||||
schedulePlanAdjustmentScopeMedium = SchedulePlanAdjustmentScopeMedium
|
||||
schedulePlanAdjustmentScopeLarge = SchedulePlanAdjustmentScopeLarge
|
||||
)
|
||||
|
||||
// DayGroup 是“按天拆分后”的最小优化单元。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 把全量周视角数据拆成“单天小包”,降低日内 ReAct 输入规模;
|
||||
// 2. 支持并发优化不同天的数据,缩短整体等待;
|
||||
// 3. 通过 SkipRefine 让低收益天数直接跳过,节省模型调用成本。
|
||||
type DayGroup struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
Entries []model.HybridScheduleEntry
|
||||
SkipRefine bool
|
||||
}
|
||||
|
||||
// ScheduleRefineState 是“连续微调排程”skill 的运行时状态骨架。
|
||||
// SchedulePlanState 是“智能排程”链路在 graph 节点间传递的统一状态容器。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散落;
|
||||
// 2) 支持“粗排 -> 日内并发优化 -> 周级配平 -> 终审校验”的完整链路追踪;
|
||||
// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。
|
||||
type SchedulePlanState struct {
|
||||
// ── 基础上下文 ──
|
||||
TraceID string
|
||||
UserID int
|
||||
ConversationID string
|
||||
RequestNow time.Time
|
||||
RequestNowText string
|
||||
|
||||
// ── plan 节点输出 ──
|
||||
UserIntent string
|
||||
Constraints []string
|
||||
TaskClassIDs []int
|
||||
Strategy string
|
||||
TaskTags map[int]string
|
||||
TaskTagHintsByName map[string]string
|
||||
|
||||
// ── preview 节点输出 ──
|
||||
CandidatePlans []model.UserWeekSchedule
|
||||
AllocatedItems []model.TaskClassItem
|
||||
HasPlanningWindow bool
|
||||
PlanStartWeek int
|
||||
PlanStartDay int
|
||||
PlanEndWeek int
|
||||
PlanEndDay int
|
||||
|
||||
// ── 日内并发优化阶段 ──
|
||||
DailyGroups map[int]map[int]*DayGroup
|
||||
DailyResults map[int]map[int][]model.HybridScheduleEntry
|
||||
DailyRefineConcurrency int
|
||||
|
||||
// ── 周级 ReAct 精排阶段 ──
|
||||
HybridEntries []model.HybridScheduleEntry
|
||||
MergeSnapshot []model.HybridScheduleEntry
|
||||
ReactRound int
|
||||
ReactMaxRound int
|
||||
ReactSummary string
|
||||
ReactDone bool
|
||||
WeeklyAdjustBudget int
|
||||
WeeklyAdjustUsed int
|
||||
WeeklyTotalBudget int
|
||||
WeeklyTotalUsed int
|
||||
WeeklyRefineConcurrency int
|
||||
WeeklyActionLogs []string
|
||||
|
||||
// ── 连续对话微调 ──
|
||||
PreviousPlanJSON string
|
||||
IsAdjustment bool
|
||||
RestartRequested bool
|
||||
AdjustmentScope string
|
||||
AdjustmentReason string
|
||||
AdjustmentConfidence float64
|
||||
HasPreviousPreview bool
|
||||
PreviousTaskClassIDs []int
|
||||
PreviousHybridEntries []model.HybridScheduleEntry
|
||||
PreviousAllocatedItems []model.TaskClassItem
|
||||
PreviousCandidatePlans []model.UserWeekSchedule
|
||||
|
||||
// ── 最终输出 ──
|
||||
FinalSummary string
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// NewSchedulePlanState 创建排程状态对象并初始化默认值。
|
||||
func NewSchedulePlanState(traceID string, userID int, conversationID string) *SchedulePlanState {
|
||||
now := schedulePlanNowToMinute()
|
||||
return &SchedulePlanState{
|
||||
TraceID: traceID,
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
RequestNow: now,
|
||||
RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout),
|
||||
Strategy: "steady",
|
||||
TaskTags: make(map[int]string),
|
||||
TaskTagHintsByName: make(map[string]string),
|
||||
DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency,
|
||||
WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency,
|
||||
AdjustmentScope: schedulePlanAdjustmentScopeLarge,
|
||||
ReactMaxRound: 2,
|
||||
WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget,
|
||||
WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget,
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeSchedulePlanAdjustmentScope 归一化排程微调力度字段。
|
||||
//
|
||||
// 兜底策略:
|
||||
// 1. 只接受 small/medium/large;
|
||||
// 2. 任何未知值都回退为 large,保证不会误走“过轻”路径。
|
||||
func NormalizeSchedulePlanAdjustmentScope(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case schedulePlanAdjustmentScopeSmall:
|
||||
return schedulePlanAdjustmentScopeSmall
|
||||
case schedulePlanAdjustmentScopeMedium:
|
||||
return schedulePlanAdjustmentScopeMedium
|
||||
default:
|
||||
return schedulePlanAdjustmentScopeLarge
|
||||
}
|
||||
}
|
||||
|
||||
// schedulePlanLocation 返回排程链路使用的业务时区。
|
||||
func schedulePlanLocation() *time.Location {
|
||||
loc, err := time.LoadLocation(schedulePlanTimezoneName)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// schedulePlanNowToMinute 返回当前时间并截断到分钟级。
|
||||
func schedulePlanNowToMinute() time.Time {
|
||||
return time.Now().In(schedulePlanLocation()).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
func normalizeAdjustmentScope(raw string) string {
|
||||
return NormalizeSchedulePlanAdjustmentScope(raw)
|
||||
}
|
||||
|
||||
// ScheduleRefineState 先保留现有骨架,避免本轮“只迁 schedule_plan”时误动 refine。
|
||||
type ScheduleRefineState struct {
|
||||
TraceID string
|
||||
UserID int
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
571
backend/agent2/node/schedule_plan_tool.go
Normal file
571
backend/agent2/node/schedule_plan_tool.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
// SchedulePlanToolDeps 描述“智能排程 graph”运行所需的外部业务依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责声明“需要哪些能力”,不负责具体实现(实现由 service 层注入)。
|
||||
// 2. 只收口函数签名,不承载业务状态,避免跨请求共享可变数据。
|
||||
// 3. 当前统一采用 task_class_ids 语义,不再依赖单 task_class_id 主路径。
|
||||
type SchedulePlanToolDeps struct {
|
||||
// SmartPlanningMultiRaw 是可选依赖:
|
||||
// 1) 用于需要单独输出“粗排预览”时复用;
|
||||
// 2) 当前主链路已由 HybridScheduleWithPlanMulti 覆盖,可不注入。
|
||||
SmartPlanningMultiRaw func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
|
||||
|
||||
// HybridScheduleWithPlanMulti 把“既有日程 + 粗排结果”合并成统一的 HybridScheduleEntry 切片,
|
||||
// 供 daily/weekly ReAct 节点在内存中继续优化。
|
||||
HybridScheduleWithPlanMulti func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
|
||||
|
||||
// ResolvePlanningWindow 根据 task_class_ids 解析“全局排程窗口”的相对周/天边界。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. startWeek/startDay:窗口起点(含);
|
||||
// 2. endWeek/endDay:窗口终点(含);
|
||||
// 3. error:解析失败(如任务类不存在、日期非法)。
|
||||
//
|
||||
// 用途:
|
||||
// 1. 给周级 Move 工具加硬边界,避免把任务移动到窗口外的天数;
|
||||
// 2. 解决“首尾不足一周”场景下的周内越界问题。
|
||||
ResolvePlanningWindow func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error)
|
||||
}
|
||||
|
||||
// Validate 校验依赖完整性。
|
||||
//
|
||||
// 失败处理:
|
||||
// 1. 任意依赖缺失都直接返回错误,避免 graph 运行到中途才 panic。
|
||||
// 2. 调用方(runSchedulePlanFlow)收到错误后会走回退链路,不影响普通聊天可用性。
|
||||
func (d SchedulePlanToolDeps) Validate() error {
|
||||
if d.HybridScheduleWithPlanMulti == nil {
|
||||
return errors.New("schedule plan tool deps: HybridScheduleWithPlanMulti is nil")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtraInt 从 extra map 中安全提取整数值。
|
||||
//
|
||||
// 兼容策略:
|
||||
// 1) JSON 数字默认解析为 float64,做 int 转换;
|
||||
// 2) 兼容字符串形式(如 "42"),用 Atoi 解析;
|
||||
// 3) 其余类型返回 false,由调用方决定后续处理。
|
||||
func ExtraInt(extra map[string]any, key string) (int, bool) {
|
||||
v, ok := extra[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case string:
|
||||
i, err := strconv.Atoi(n)
|
||||
return i, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// ExtraIntSlice 从 extra map 中安全提取整数切片。
|
||||
//
|
||||
// 兼容输入:
|
||||
// 1) []any(JSON 数组反序列化后的常见类型);
|
||||
// 2) []int;
|
||||
// 3) []float64;
|
||||
// 4) 逗号分隔字符串(例如 "1,2,3")。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1) ok=true:至少成功解析出一个整数;
|
||||
// 2) ok=false:字段不存在或全部解析失败。
|
||||
func ExtraIntSlice(extra map[string]any, key string) ([]int, bool) {
|
||||
v, exists := extra[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
parseOne := func(raw any) (int, error) {
|
||||
switch n := raw.(type) {
|
||||
case int:
|
||||
return n, nil
|
||||
case float64:
|
||||
return int(n), nil
|
||||
case string:
|
||||
i, err := strconv.Atoi(n)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return i, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported type: %T", raw)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]int, 0)
|
||||
switch arr := v.(type) {
|
||||
case []int:
|
||||
for _, item := range arr {
|
||||
out = append(out, item)
|
||||
}
|
||||
case []float64:
|
||||
for _, item := range arr {
|
||||
out = append(out, int(item))
|
||||
}
|
||||
case []any:
|
||||
for _, item := range arr {
|
||||
if parsed, err := parseOne(item); err == nil {
|
||||
out = append(out, parsed)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
parts := strings.Split(arr, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(part); err == nil {
|
||||
out = append(out, parsed)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// ── ReAct Tool 调用/结果结构 ──
|
||||
|
||||
// reactToolCall 是 LLM 输出的单个工具调用。
|
||||
type reactToolCall = agentllm.ReactToolCall
|
||||
|
||||
// reactToolResult 是单个工具调用的执行结果。
|
||||
type reactToolResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// reactLLMOutput 是 LLM 输出的完整 JSON 结构。
|
||||
type reactLLMOutput = agentllm.ReactLLMOutput
|
||||
|
||||
// weeklyPlanningWindow 表示周级优化可用的全局周/天窗口。
|
||||
//
|
||||
// 语义:
|
||||
// 1. Enabled=false:不启用窗口硬边界,仅做基础合法性校验;
|
||||
// 2. Enabled=true:Move 必须落在 [StartWeek/StartDay, EndWeek/EndDay] 内;
|
||||
// 3. 该窗口用于处理“首尾不足一周”场景下的越界移动问题。
|
||||
type weeklyPlanningWindow struct {
|
||||
Enabled bool
|
||||
StartWeek int
|
||||
StartDay int
|
||||
EndWeek int
|
||||
EndDay int
|
||||
}
|
||||
|
||||
// ── 工具分发器 ──
|
||||
|
||||
// dispatchReactTool 根据工具名分发调用,返回(可能修改后的)entries 和执行结果。
|
||||
func dispatchReactTool(entries []model.HybridScheduleEntry, call reactToolCall) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
switch call.Tool {
|
||||
case "Swap":
|
||||
return reactToolSwap(entries, call.Params)
|
||||
case "Move":
|
||||
return reactToolMove(entries, call.Params)
|
||||
case "TimeAvailable":
|
||||
return entries, reactToolTimeAvailable(entries, call.Params)
|
||||
case "GetAvailableSlots":
|
||||
return entries, reactToolGetAvailableSlots(entries, call.Params)
|
||||
default:
|
||||
return entries, reactToolResult{Tool: call.Tool, Success: false, Result: fmt.Sprintf("未知工具: %s", call.Tool)}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchWeeklySingleActionTool 是“周级单步动作模式”的专用分发器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅允许 Move / Swap 两个工具,禁止 TimeAvailable / GetAvailableSlots;
|
||||
// 2. 强制 Move 的目标周必须等于 currentWeek,避免并发周优化时发生跨周写穿;
|
||||
// 3. 统一返回工具执行结果,供上层决定预算扣减与下一轮上下文拼接。
|
||||
func dispatchWeeklySingleActionTool(entries []model.HybridScheduleEntry, call reactToolCall, currentWeek int, window weeklyPlanningWindow) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
tool := strings.TrimSpace(call.Tool)
|
||||
switch tool {
|
||||
case "Swap":
|
||||
return reactToolSwap(entries, call.Params)
|
||||
case "Move":
|
||||
// 1. 周级并发模式下,每个 worker 只负责单周数据。
|
||||
// 2. 为避免“一个 worker 改到别的周”导致并发写冲突,这里做硬约束。
|
||||
// 3. 失败时不抛异常,返回工具失败结果,让上层继续下一轮决策。
|
||||
toWeek, ok := paramInt(call.Params, "to_week")
|
||||
if !ok {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week"}
|
||||
}
|
||||
if toWeek != currentWeek {
|
||||
return entries, reactToolResult{
|
||||
Tool: "Move",
|
||||
Success: false,
|
||||
Result: fmt.Sprintf("当前仅允许优化本周:worker_week=%d,目标周=%d", currentWeek, toWeek),
|
||||
}
|
||||
}
|
||||
// 4. 若已配置全局窗口边界,再做“首尾不足一周”硬校验。
|
||||
// 4.1 这样可避免把任务移动到窗口外的天数(例如起始周的起始日前、结束周的结束日后)。
|
||||
// 4.2 窗口未启用时不阻断,保持兼容旧链路。
|
||||
if window.Enabled {
|
||||
toDay, ok := paramInt(call.Params, "to_day")
|
||||
if !ok {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_day"}
|
||||
}
|
||||
allowed, dayFrom, dayTo := isDayWithinPlanningWindow(window, toWeek, toDay)
|
||||
if !allowed {
|
||||
return entries, reactToolResult{
|
||||
Tool: "Move",
|
||||
Success: false,
|
||||
Result: fmt.Sprintf("目标日期超出排程窗口:W%d 仅允许 D%d-D%d,当前目标为 D%d", toWeek, dayFrom, dayTo, toDay),
|
||||
}
|
||||
}
|
||||
}
|
||||
return reactToolMove(entries, call.Params)
|
||||
default:
|
||||
return entries, reactToolResult{
|
||||
Tool: tool,
|
||||
Success: false,
|
||||
Result: fmt.Sprintf("周级单步模式不支持工具: %s,仅允许 Move/Swap", tool),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isDayWithinPlanningWindow 判断目标 week/day 是否落在窗口范围内。
|
||||
//
|
||||
// 返回值:
|
||||
// 1. allowed:是否允许;
|
||||
// 2. dayFrom/dayTo:该周允许的 day 区间(用于错误提示)。
|
||||
func isDayWithinPlanningWindow(window weeklyPlanningWindow, week int, day int) (allowed bool, dayFrom int, dayTo int) {
|
||||
// 1. 窗口未启用时默认允许(调用方会跳过此分支,这里是兜底)。
|
||||
if !window.Enabled {
|
||||
return true, 1, 7
|
||||
}
|
||||
// 2. 先做周范围校验。
|
||||
if week < window.StartWeek || week > window.EndWeek {
|
||||
return false, 1, 7
|
||||
}
|
||||
// 3. 计算当前周允许的 day 边界。
|
||||
from := 1
|
||||
to := 7
|
||||
if week == window.StartWeek {
|
||||
from = window.StartDay
|
||||
}
|
||||
if week == window.EndWeek {
|
||||
to = window.EndDay
|
||||
}
|
||||
if day < from || day > to {
|
||||
return false, from, to
|
||||
}
|
||||
return true, from, to
|
||||
}
|
||||
|
||||
// ── 参数提取辅助 ──
|
||||
|
||||
func paramInt(params map[string]any, key string) (int, bool) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// findSuggestedByID 在 entries 中查找指定 TaskItemID 的 suggested 条目索引。
|
||||
func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int {
|
||||
for i, e := range entries {
|
||||
if e.TaskItemID == taskItemID && e.Status == "suggested" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// sectionsOverlap 判断两个节次区间是否有交集。
|
||||
func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool {
|
||||
return aFrom <= bTo && bFrom <= aTo
|
||||
}
|
||||
|
||||
// entryBlocksSuggested 判断某条目是否应阻塞 suggested 任务占位。
|
||||
//
|
||||
// 规则:
|
||||
// 1. suggested 任务永远阻塞(任务之间不能重叠);
|
||||
// 2. existing 条目按 BlockForSuggested 字段决定;
|
||||
// 3. 其余场景默认阻塞(保守策略,避免放出脏可用槽)。
|
||||
func entryBlocksSuggested(entry model.HybridScheduleEntry) bool {
|
||||
if entry.Status == "suggested" {
|
||||
return true
|
||||
}
|
||||
// existing 走显式字段语义。
|
||||
if entry.Status == "existing" {
|
||||
return entry.BlockForSuggested
|
||||
}
|
||||
// 未知状态兜底:按阻塞处理。
|
||||
return true
|
||||
}
|
||||
|
||||
// hasConflict 检查目标时间段是否与 entries 中任何条目冲突(排除 excludeIdx)。
|
||||
func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st, excludeIdx int) (bool, string) {
|
||||
for i, e := range entries {
|
||||
if i == excludeIdx {
|
||||
continue
|
||||
}
|
||||
// 1. 可嵌入且未占用的课程槽(BlockForSuggested=false)不参与冲突判断。
|
||||
// 2. 这样可以避免把“水课可嵌入位”误判为硬冲突。
|
||||
if !entryBlocksSuggested(e) {
|
||||
continue
|
||||
}
|
||||
if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) {
|
||||
return true, fmt.Sprintf("%s(%s)", e.Name, e.Type)
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Tool 1: Swap — 交换两个 suggested 任务的时间
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
func reactToolSwap(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
idA, okA := paramInt(params, "task_a")
|
||||
idB, okB := paramInt(params, "task_b")
|
||||
if !okA || !okB {
|
||||
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:需要 task_a 和 task_b(task_item_id)"}
|
||||
}
|
||||
if idA == idB {
|
||||
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 和 task_b 不能相同"}
|
||||
}
|
||||
|
||||
idxA := findSuggestedByID(entries, idA)
|
||||
idxB := findSuggestedByID(entries, idB)
|
||||
if idxA == -1 {
|
||||
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idA)}
|
||||
}
|
||||
if idxB == -1 {
|
||||
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idB)}
|
||||
}
|
||||
|
||||
// 交换时间坐标
|
||||
a, b := &entries[idxA], &entries[idxB]
|
||||
a.Week, b.Week = b.Week, a.Week
|
||||
a.DayOfWeek, b.DayOfWeek = b.DayOfWeek, a.DayOfWeek
|
||||
a.SectionFrom, b.SectionFrom = b.SectionFrom, a.SectionFrom
|
||||
a.SectionTo, b.SectionTo = b.SectionTo, a.SectionTo
|
||||
|
||||
return entries, reactToolResult{
|
||||
Tool: "Swap", Success: true,
|
||||
Result: fmt.Sprintf("已交换 [%s](id=%d) 和 [%s](id=%d) 的时间", a.Name, idA, b.Name, idB),
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Tool 2: Move — 将一个 suggested 任务移动到新时间
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
func reactToolMove(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
taskID, ok := paramInt(params, "task_item_id")
|
||||
if !ok {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 task_item_id"}
|
||||
}
|
||||
toWeek, ok1 := paramInt(params, "to_week")
|
||||
toDay, ok2 := paramInt(params, "to_day")
|
||||
toSF, ok3 := paramInt(params, "to_section_from")
|
||||
toST, ok4 := paramInt(params, "to_section_to")
|
||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week, to_day, to_section_from, to_section_to"}
|
||||
}
|
||||
|
||||
// 基础校验
|
||||
if toDay < 1 || toDay > 7 {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 不合法,应为 1-7", toDay)}
|
||||
}
|
||||
if toSF < 1 || toST > 12 || toSF > toST {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次范围 %d-%d 不合法,应为 1-12 且 from<=to", toSF, toST)}
|
||||
}
|
||||
|
||||
idx := findSuggestedByID(entries, taskID)
|
||||
if idx == -1 {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", taskID)}
|
||||
}
|
||||
|
||||
// 节次跨度必须一致
|
||||
origSpan := entries[idx].SectionTo - entries[idx].SectionFrom
|
||||
newSpan := toST - toSF
|
||||
if origSpan != newSpan {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false,
|
||||
Result: fmt.Sprintf("节次跨度不一致:原任务占 %d 节,目标占 %d 节", origSpan+1, newSpan+1)}
|
||||
}
|
||||
|
||||
// 冲突检测(排除自身)
|
||||
if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, idx); conflict {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false,
|
||||
Result: fmt.Sprintf("目标时间 W%dD%d 第%d-%d节 已被 %s 占用", toWeek, toDay, toSF, toST, name)}
|
||||
}
|
||||
|
||||
// 执行移动
|
||||
e := &entries[idx]
|
||||
oldDesc := fmt.Sprintf("W%dD%d 第%d-%d节", e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo)
|
||||
e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo = toWeek, toDay, toSF, toST
|
||||
newDesc := fmt.Sprintf("W%dD%d 第%d-%d节", toWeek, toDay, toSF, toST)
|
||||
|
||||
return entries, reactToolResult{
|
||||
Tool: "Move", Success: true,
|
||||
Result: fmt.Sprintf("已将 [%s](id=%d) 从 %s 移动到 %s", e.Name, taskID, oldDesc, newDesc),
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Tool 3: TimeAvailable — 检查目标时间段是否可用
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
func reactToolTimeAvailable(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
|
||||
week, ok1 := paramInt(params, "week")
|
||||
day, ok2 := paramInt(params, "day_of_week")
|
||||
sf, ok3 := paramInt(params, "section_from")
|
||||
st, ok4 := paramInt(params, "section_to")
|
||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||
return reactToolResult{Tool: "TimeAvailable", Success: false, Result: "参数缺失:需要 week, day_of_week, section_from, section_to"}
|
||||
}
|
||||
|
||||
if conflict, name := hasConflict(entries, week, day, sf, st, -1); conflict {
|
||||
return reactToolResult{Tool: "TimeAvailable", Success: true,
|
||||
Result: fmt.Sprintf(`{"available":false,"conflict_with":"%s"}`, name)}
|
||||
}
|
||||
return reactToolResult{Tool: "TimeAvailable", Success: true, Result: `{"available":true}`}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Tool 4: GetAvailableSlots — 返回可用时间段列表
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
func reactToolGetAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
|
||||
filterWeek, _ := paramInt(params, "week") // 0 表示不过滤
|
||||
|
||||
// 1. 收集所有周次范围
|
||||
minW, maxW := 999, 0
|
||||
for _, e := range entries {
|
||||
if e.Week < minW {
|
||||
minW = e.Week
|
||||
}
|
||||
if e.Week > maxW {
|
||||
maxW = e.Week
|
||||
}
|
||||
}
|
||||
if minW > maxW {
|
||||
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: "[]"}
|
||||
}
|
||||
|
||||
// 2. 构建占用集合
|
||||
type slotKey struct{ W, D, S int }
|
||||
occupied := make(map[slotKey]bool)
|
||||
for _, e := range entries {
|
||||
if !entryBlocksSuggested(e) {
|
||||
continue
|
||||
}
|
||||
for s := e.SectionFrom; s <= e.SectionTo; s++ {
|
||||
occupied[slotKey{e.Week, e.DayOfWeek, s}] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 遍历所有时间格,找出空闲并合并连续节次
|
||||
type availSlot struct {
|
||||
Week, Day, From, To int
|
||||
}
|
||||
var slots []availSlot
|
||||
|
||||
startW, endW := minW, maxW
|
||||
if filterWeek > 0 {
|
||||
startW, endW = filterWeek, filterWeek
|
||||
}
|
||||
|
||||
for w := startW; w <= endW; w++ {
|
||||
for d := 1; d <= 7; d++ {
|
||||
runStart := 0
|
||||
for s := 1; s <= 12; s++ {
|
||||
if !occupied[slotKey{w, d, s}] {
|
||||
if runStart == 0 {
|
||||
runStart = s
|
||||
}
|
||||
} else {
|
||||
if runStart > 0 {
|
||||
slots = append(slots, availSlot{w, d, runStart, s - 1})
|
||||
runStart = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if runStart > 0 {
|
||||
slots = append(slots, availSlot{w, d, runStart, 12})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 按自然顺序排序(已经是了,但确保)
|
||||
sort.Slice(slots, func(i, j int) bool {
|
||||
if slots[i].Week != slots[j].Week {
|
||||
return slots[i].Week < slots[j].Week
|
||||
}
|
||||
if slots[i].Day != slots[j].Day {
|
||||
return slots[i].Day < slots[j].Day
|
||||
}
|
||||
return slots[i].From < slots[j].From
|
||||
})
|
||||
|
||||
// 5. 序列化
|
||||
type slotJSON struct {
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
SectionFrom int `json:"section_from"`
|
||||
SectionTo int `json:"section_to"`
|
||||
}
|
||||
out := make([]slotJSON, 0, len(slots))
|
||||
for _, s := range slots {
|
||||
out = append(out, slotJSON{s.Week, s.Day, s.From, s.To})
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(out)
|
||||
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: string(data)}
|
||||
}
|
||||
|
||||
// ── 辅助:解析 LLM 输出 ──
|
||||
|
||||
// parseReactLLMOutput 解析 LLM 的 JSON 输出。
|
||||
// 兼容 ```json ... ``` 包裹。
|
||||
func parseReactLLMOutput(raw string) (*reactLLMOutput, error) {
|
||||
return agentllm.ParseScheduleReactOutput(raw)
|
||||
}
|
||||
|
||||
// truncate 截断字符串到指定长度。
|
||||
func truncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
)
|
||||
|
||||
// TestExtractExplicitLimitFromUser_Number 验证阿拉伯数字条数可以被正确提取。
|
||||
func TestExtractExplicitLimitFromUser_Number(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("给我3个优先级低的任务")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到显式数量")
|
||||
}
|
||||
if limit != 3 {
|
||||
t.Fatalf("数量识别错误,期望=3 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractExplicitLimitFromUser_ChineseNumber 验证中文数字也可以被正确提取。
|
||||
func TestExtractExplicitLimitFromUser_ChineseNumber(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("前五个简单任务给我看看")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到中文数量")
|
||||
}
|
||||
if limit != 5 {
|
||||
t.Fatalf("数量识别错误,期望=5 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractExplicitLimitFromUser_LaiYiGe 验证“来一个”这类口语表达也能命中数量提取。
|
||||
func TestExtractExplicitLimitFromUser_LaiYiGe(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("来一个我的简单任务")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到‘来一个’的显式数量")
|
||||
}
|
||||
if limit != 1 {
|
||||
t.Fatalf("数量识别错误,期望=1 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskQueryFinalReply_RespectsLimit 验证最终回复严格遵守 plan.limit。
|
||||
func TestBuildTaskQueryFinalReply_RespectsLimit(t *testing.T) {
|
||||
items := []agentmodel.TaskQueryItem{
|
||||
{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"},
|
||||
{ID: 2, Title: "任务2", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-17 10:00"},
|
||||
{ID: 3, Title: "任务3", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-18 10:00"},
|
||||
}
|
||||
reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 2}, "好的")
|
||||
if !strings.Contains(reply, "整理了 2 条任务") {
|
||||
t.Fatalf("回复未体现 limit=2,reply=%s", reply)
|
||||
}
|
||||
if strings.Contains(reply, "3. ") {
|
||||
t.Fatalf("回复中不应出现第 3 条,reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskQueryFinalReply_NoDuplicateList 验证 llmReply 自带列表时不会与后端列表重复拼接。
|
||||
func TestBuildTaskQueryFinalReply_NoDuplicateList(t *testing.T) {
|
||||
items := []agentmodel.TaskQueryItem{{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"}}
|
||||
llmReply := "以下是你的任务:\n#1 任务1"
|
||||
reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 1}, llmReply)
|
||||
if strings.Contains(reply, "以下是你的任务") {
|
||||
t.Fatalf("不应保留 llm 列表头,reply=%s", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "整理了 1 条任务") {
|
||||
t.Fatalf("应保留后端确定性列表头,reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRetryPatch_RespectExplicitLimit 验证显式数量存在时,反思补丁不能覆盖 limit。
|
||||
func TestApplyRetryPatch_RespectExplicitLimit(t *testing.T) {
|
||||
plan := agentmodel.TaskQueryPlan{Limit: 1, SortBy: "deadline", Order: "asc"}
|
||||
limit := 10
|
||||
next := applyRetryPatch(plan, agentllm.TaskQueryRetryPatch{Limit: &limit}, 1)
|
||||
if next.Limit != 1 {
|
||||
t.Fatalf("显式数量锁应生效,期望=1 实际=%d", next.Limit)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package agentnode
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_Default 验证空输入会回填默认查询参数。
|
||||
func TestNormalizeTaskQueryToolInput_Default(t *testing.T) {
|
||||
req, err := normalizeTaskQueryToolInput(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if req.SortBy != "deadline" || req.Order != "asc" || req.Limit != 5 || req.IncludeCompleted {
|
||||
t.Fatalf("默认值异常: %+v", req)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_InvalidQuadrant 验证 quadrant 越界时会被拦截。
|
||||
func TestNormalizeTaskQueryToolInput_InvalidQuadrant(t *testing.T) {
|
||||
invalid := 6
|
||||
_, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{Quadrant: &invalid})
|
||||
if err == nil {
|
||||
t.Fatalf("期望 quadrant 越界时报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_DateRange 验证时间上下界可被正确解析。
|
||||
func TestNormalizeTaskQueryToolInput_DateRange(t *testing.T) {
|
||||
req, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{
|
||||
DeadlineAfter: "2026-03-01 08:00",
|
||||
DeadlineBefore: "2026-03-31",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if req.DeadlineAfter == nil || req.DeadlineBefore == nil {
|
||||
t.Fatalf("时间上下界不应为空: %+v", req)
|
||||
}
|
||||
if req.DeadlineAfter.After(*req.DeadlineBefore) {
|
||||
t.Fatalf("时间上下界关系异常: after=%v before=%v", req.DeadlineAfter, req.DeadlineBefore)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,172 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
const (
|
||||
// SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把自然语言转成结构化 JSON,供后端节点分流与执行;
|
||||
// 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段;
|
||||
// 3. 不负责做排程计算,不负责做工具调用。
|
||||
SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。
|
||||
请根据用户输入,提取排程意图与约束条件。
|
||||
|
||||
必须完成以下任务:
|
||||
1) 用一句话概括用户的排程意图(intent)。
|
||||
2) 提取所有硬约束(constraints),如“早八不排”“周末休息”等。
|
||||
3) 如果用户明确提到了任务类名称或ID,输出 task_class_ids(整数数组);否则输出空数组 []。
|
||||
4) 兼容字段 task_class_id:若 task_class_ids 非空,可填第一个ID;若无法判断填 -1。
|
||||
5) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。
|
||||
6) 尝试给任务打认知标签 task_tags(可选):
|
||||
- 推荐键:task_item_id(字符串形式,例如 "12")
|
||||
- 兼容键:任务名称(例如 "高数复习")
|
||||
- 值只能是:High-Logic / Memory / Review / General
|
||||
- 如果无法判断,输出空对象 {}
|
||||
7) 判定本轮是否要求“强制重排” restart:
|
||||
- 用户明确表达“重新排/推倒重来/忽略之前方案/全部重来”时,restart=true;
|
||||
- 否则 restart=false。
|
||||
8) 判定微调力度 adjustment_scope(small / medium / large):
|
||||
- small:局部微调,通常只改少量时段,不需要重建全局。
|
||||
- medium:中等调整,需要周级再平衡,但不必全量重粗排。
|
||||
- large:大范围调整,或首次创建排程,或约束变化很大,需要完整重排。
|
||||
9) 输出 reason(简短中文理由,<=30字)与 confidence(0~1)。
|
||||
|
||||
输出要求:
|
||||
- 仅输出 JSON,不要 markdown,不要解释。
|
||||
- 格式如下:
|
||||
{
|
||||
"intent": "用户排程意图摘要",
|
||||
"constraints": ["约束1", "约束2"],
|
||||
"task_class_ids": [12, 13],
|
||||
"task_class_id": 12,
|
||||
"strategy": "steady",
|
||||
"task_tags": {"12":"High-Logic","英语阅读":"Memory"},
|
||||
"restart": false,
|
||||
"adjustment_scope": "medium",
|
||||
"reason": "本次只调整局部时段",
|
||||
"confidence": 0.86
|
||||
}`
|
||||
|
||||
// SchedulePlanDailyReactPrompt 用于 daily_refine 节点。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理“单天”数据,避免跨天决策污染;
|
||||
// 2. 通过工具调用做小步调整;
|
||||
// 3. 不负责周级配平,不负责最终总结。
|
||||
SchedulePlanDailyReactPrompt = `你是 SmartFlow 日内排程优化器。
|
||||
|
||||
你将收到一天内的日程安排(JSON 数组),其中:
|
||||
- status="existing":已确定的课程或任务,不可移动
|
||||
- status="suggested":粗排算法建议的学习任务,你可以调整
|
||||
- context_tag:任务认知类型(High-Logic/Memory/Review/General)
|
||||
|
||||
你的目标是优化这一天内 suggested 任务的时间安排。
|
||||
|
||||
## 优化原则
|
||||
1. 上下文切换成本:相同 context_tag 的任务尽量相邻,减少认知切换。
|
||||
2. 时段适配性:
|
||||
- 第1-4节(上午):适合 High-Logic(数学、编程)
|
||||
- 第5-8节(下午):适合中等强度(专业课、阅读)
|
||||
- 第9-12节(晚间):适合 Memory 和 Review
|
||||
3. 学习效率曲线:避免连续超过 4 节高强度学习。
|
||||
4. 与 existing 条目衔接:避免高强度课程后立刻接高强度任务。
|
||||
|
||||
## 可用工具
|
||||
1. Swap — 交换两个 suggested 任务的时间
|
||||
参数:task_a(task_item_id),task_b(task_item_id)
|
||||
2. Move — 将一个 suggested 任务移动到新时间(仅限当天)
|
||||
参数:task_item_id, to_week, to_day, to_section_from, to_section_to
|
||||
3. TimeAvailable — 检查时段是否可用
|
||||
参数:week, day_of_week, section_from, section_to
|
||||
4. GetAvailableSlots — 获取可用时段
|
||||
参数:week
|
||||
|
||||
## 输出格式(严格 JSON,不要 markdown)
|
||||
调用工具时:
|
||||
{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}}]}
|
||||
|
||||
完成优化时:
|
||||
{"done":true,"summary":"简要说明优化理由"}
|
||||
|
||||
重要:只修改 suggested 任务,不要尝试移动 existing 条目。`
|
||||
|
||||
// SchedulePlanWeeklyReactPrompt 用于 weekly_refine 节点。
|
||||
//
|
||||
// 设计重点:
|
||||
// 1. 采用“单步动作”模式:每轮只做一个动作(Move/Swap)或直接 done;
|
||||
// 2. 显式区分总预算与有效预算,避免模型对“次数扣减”产生困惑;
|
||||
// 3. 明确“输入数据已过后端硬校验”,避免模型把合法嵌入误判为冲突;
|
||||
// 4. 工具失败结果会回传到下一轮,模型只需“走一步看一步”。
|
||||
SchedulePlanWeeklyReactPrompt = `你是 SmartFlow 周级排程配平器。
|
||||
|
||||
单日内的排程已优化完毕,你当前只负责“单周微调”。
|
||||
|
||||
## 数据可靠性前提(必须接受)
|
||||
1. 你收到的混合日程 JSON 已经过后端硬冲突检查。
|
||||
2. 如果看到课程与任务在同一节次重叠,这表示“任务嵌入课程”的合法状态,不是异常。
|
||||
3. 你不需要再次判断“输入本身是否冲突”,只需要在这个可信基线上进行优化。
|
||||
4. 工具内部会做可用性与冲突校验;你无需额外调用“检查可用性工具”。
|
||||
5. 字段语义补充:
|
||||
- existing 条目的 block_for_suggested=false:该课程格子允许嵌入 suggested 任务;
|
||||
- suggested 条目的 block_for_suggested=true:表示该 suggested 本身会占位,防止被其他 suggested 再次重叠覆盖。
|
||||
|
||||
## 预算语义(必须遵守,且必须严格区分)
|
||||
1. 总动作预算(剩余):{{action_total_remaining}}
|
||||
2. 总动作预算(固定):{{action_total_budget}}
|
||||
3. 总动作预算(已用):{{action_total_used}}
|
||||
4. 有效动作预算(剩余):{{action_effective_remaining}}
|
||||
5. 有效动作预算(固定):{{action_effective_budget}}
|
||||
6. 有效动作预算(已用):{{action_effective_used}}
|
||||
7. 规则:
|
||||
- 每次工具调用(无论成功失败)都会消耗 1 次“总动作预算”;
|
||||
- 仅当工具调用成功时,才会额外消耗 1 次“有效动作预算”。
|
||||
8. 你当前看到的是“剩余额度”,不是“总额度”,额度减少是前序动作正常消耗。
|
||||
|
||||
## 约束
|
||||
1. 只允许在当前周内优化(禁止跨周移动)。
|
||||
2. 每次回复只能做一件事:要么调用 1 个工具,要么 done。
|
||||
3. 严格遵守用户约束(如有)。
|
||||
4. 每个任务最多变动一次位置。
|
||||
|
||||
## 优化目标
|
||||
1. 疲劳度均衡:避免某一天堆积过多高强度任务(context_tag=High-Logic)。
|
||||
2. 间隔重复:同一科目任务适当分散到不同天。
|
||||
3. 科目多样性:尽量避免单一任务类型连续多天占据相同时段。
|
||||
4. 总量均衡:各天 suggested 数量大致均匀。
|
||||
|
||||
## 执行节奏(降低无效思考)
|
||||
1. 想一步做一步:本轮只做“一个最有价值动作”。
|
||||
2. 不要一次规划多步;上一轮工具结果会传给下一轮,你可以继续接力。
|
||||
3. 如果当前方案已经足够好,直接 done,不要空转。
|
||||
4. 禁止输出多个工具调用;如果需要连续调整,请分多轮逐步完成。
|
||||
|
||||
## 可用工具
|
||||
1. Move — 将一个 suggested 任务移动到当前周的另一天/时段
|
||||
参数:task_item_id, to_week, to_day, to_section_from, to_section_to
|
||||
注意:节次跨度必须与原任务一致
|
||||
2. Swap — 交换两个 suggested 任务的时间
|
||||
参数:task_a, task_b(task_item_id)
|
||||
|
||||
## 输出格式(严格 JSON,不要 markdown)
|
||||
调用工具时(注意:tool_calls 里只能有 1 个元素):
|
||||
{"tool_calls":[{"tool":"Move","params":{"task_item_id":10,"to_week":2,"to_day":3,"to_section_from":5,"to_section_to":6}}]}
|
||||
|
||||
完成优化时:
|
||||
{"done":true,"summary":"简要说明做了哪些跨天调整及理由"}`
|
||||
|
||||
// SchedulePlanFinalCheckPrompt 用于 final_check 节点的人性化总结。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做读数据总结,不参与工具调用与状态修改;
|
||||
// 2. 输出面向用户的自然语言;
|
||||
// 3. 失败由上层兜底文案处理。
|
||||
SchedulePlanFinalCheckPrompt = `你是 SmartFlow 排程方案总结专家。
|
||||
你的任务是为用户生成一段友好、自然的排程总结。
|
||||
|
||||
要求:
|
||||
1. 用 2-3 句话概括方案亮点。
|
||||
2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。
|
||||
3. 若用户有约束,说明方案如何满足这些约束。
|
||||
4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。
|
||||
5. 语气温暖自然。
|
||||
6. 只输出纯文本,不要输出 JSON。`
|
||||
)
|
||||
|
||||
const scheduleSystemPrompt = `
|
||||
你是 SmartFlow 的智能排程助手。
|
||||
你的职责是把用户的排程目标转成结构化计划,并与后端粗排/硬校验能力配合完成排程。
|
||||
|
||||
当前 agent2 还没正式迁移 schedule 相关旧代码,
|
||||
因此这里先定义 prompt 收口点,确保后面迁移时不会再把大段提示词散写回 nodes.go。
|
||||
`
|
||||
|
||||
// BuildScheduleSystemPrompt 返回排程系统提示词骨架。
|
||||
func BuildScheduleSystemPrompt() string {
|
||||
return strings.TrimSpace(scheduleSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildScheduleUserPrompt 构造排程用户提示词骨架。
|
||||
func BuildScheduleUserPrompt(nowText, userInput string) string {
|
||||
return fmt.Sprintf("当前时间(北京时间,精确到分钟):%s\n用户请求:%s", strings.TrimSpace(nowText), strings.TrimSpace(userInput))
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// TestNormalizeConversationTitle
|
||||
// 目的:确保标题清洗逻辑能去掉引号/前缀并裁剪到上限长度。
|
||||
func TestNormalizeConversationTitle(t *testing.T) {
|
||||
raw := "标题:\"明天上午去机场接人并顺路取快递,记得提前出门\""
|
||||
got := normalizeConversationTitle(raw)
|
||||
if strings.HasPrefix(got, "标题") {
|
||||
t.Fatalf("标题前缀未清洗,got=%s", got)
|
||||
}
|
||||
if len([]rune(got)) > conversationTitleMaxChars {
|
||||
t.Fatalf("标题长度超限,got=%s", got)
|
||||
}
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Fatalf("清洗后标题不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildConversationTitleUserPrompt
|
||||
// 目的:确保 prompt 构造时能正确标注用户/助手角色并包含有效内容。
|
||||
func TestBuildConversationTitleUserPrompt(t *testing.T) {
|
||||
msgs := []*schema.Message{
|
||||
{Role: schema.User, Content: "明天早上九点去机场接人"},
|
||||
{Role: schema.Assistant, Content: "收到,我帮你记下了。"},
|
||||
}
|
||||
prompt := buildConversationTitleUserPrompt(msgs)
|
||||
if !strings.Contains(prompt, "用户:明天早上九点去机场接人") {
|
||||
t.Fatalf("prompt 未包含用户内容,prompt=%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "助手:收到,我帮你记下了。") {
|
||||
t.Fatalf("prompt 未包含助手内容,prompt=%s", prompt)
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
|
||||
)
|
||||
|
||||
// TestParseQuickNoteRouteControlTag_QuickNote
|
||||
// 目的:
|
||||
// 1. 验证旧 quick note 兼容入口仍然可以解析控制码;
|
||||
// 2. 验证旧 action=quick_note 会被统一映射到新动作 quick_note_create;
|
||||
// 3. 验证 reason 仍然会被保留下来,方便上层做阶段提示与排障。
|
||||
func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
|
||||
nonce := "abc123nonce"
|
||||
raw := `<SMARTFLOW_ROUTE nonce="abc123nonce" action="quick_note"></SMARTFLOW_ROUTE>
|
||||
<SMARTFLOW_REASON>用户明确在请求未来提醒</SMARTFLOW_REASON>`
|
||||
|
||||
decision, err := agentrouter.ParseQuickNoteRouteControlTag(raw, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if decision == nil {
|
||||
t.Fatalf("decision 不应为空")
|
||||
}
|
||||
if decision.Action != agentrouter.ActionQuickNoteCreate {
|
||||
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionQuickNoteCreate, decision.Action)
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
t.Fatalf("reason 不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRouteControlTag_TaskQuery
|
||||
// 目的:验证通用分流控制码在 action=task_query 时可以被稳定解析。
|
||||
func TestParseRouteControlTag_TaskQuery(t *testing.T) {
|
||||
nonce := "taskquerynonce"
|
||||
raw := `<SMARTFLOW_ROUTE nonce="taskquerynonce" action="task_query"></SMARTFLOW_ROUTE>
|
||||
<SMARTFLOW_REASON>用户在查最紧急任务</SMARTFLOW_REASON>`
|
||||
|
||||
decision, err := agentrouter.ParseRouteControlTag(raw, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if decision == nil {
|
||||
t.Fatalf("decision 不应为空")
|
||||
}
|
||||
if decision.Action != agentrouter.ActionTaskQuery {
|
||||
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionTaskQuery, decision.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseQuickNoteRouteControlTag_NonceMismatch
|
||||
// 目的:确保 nonce 不匹配时直接报错,避免把别的请求控制码误判成当前请求。
|
||||
func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
|
||||
raw := `<SMARTFLOW_ROUTE nonce="wrongnonce" action="chat"></SMARTFLOW_ROUTE>`
|
||||
if _, err := agentrouter.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
|
||||
t.Fatalf("期望 nonce 不匹配时报错,但未报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID
|
||||
// 目的:
|
||||
// 1. 即使状态被错误标记为 Persisted=true;
|
||||
// 2. 只要没有有效 task_id,就不能回成功文案;
|
||||
// 3. 避免出现“回复成功但库里没数据”的假成功体验。
|
||||
func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
|
||||
state := &agentmodel.QuickNoteState{
|
||||
Persisted: true,
|
||||
PersistedTaskID: 0,
|
||||
ExtractedTitle: "去下馆子",
|
||||
}
|
||||
|
||||
reply := buildQuickNoteFinalReply(nil, nil, "我今天晚上6点要去下馆子,记得喊我", state)
|
||||
if strings.Contains(reply, "给你安排上了") || strings.Contains(reply, "已安排") {
|
||||
t.Fatalf("不应返回成功文案,实际回复=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildQuickNoteFinalReply_UseExtractedBanter
|
||||
// 目的:
|
||||
// 1. 当聚合规划阶段已经产出 banter 时,最终回复应直接复用;
|
||||
// 2. 避免为了润色再次调用模型,增加不必要时延。
|
||||
func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) {
|
||||
state := &agentmodel.QuickNoteState{
|
||||
Persisted: true,
|
||||
PersistedTaskID: 12,
|
||||
ExtractedTitle: "明天去取快递",
|
||||
ExtractedPriority: 2,
|
||||
ExtractedBanter: "取件路上注意保暖,别被风吹懵了。",
|
||||
}
|
||||
|
||||
reply := buildQuickNoteFinalReply(nil, nil, "明天上午12点我要去取快递,到时候记得q我", state)
|
||||
if !strings.Contains(reply, "取件路上注意保暖") {
|
||||
t.Fatalf("期望复用 ExtractedBanter,实际回复=%s", reply)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
|
||||
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
@@ -107,7 +109,7 @@ func (s *AgentService) runSchedulePlanFlow(
|
||||
// 4. 执行 graph 主流程。
|
||||
// 4.1 这里只负责参数拼装与调用,不在 service 层重复实现 graph 节点逻辑。
|
||||
// 4.2 并发度/预算从配置注入,避免把调优参数写死在代码中。
|
||||
state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID)
|
||||
state := agentmodel.NewSchedulePlanState(traceID, userID, chatID)
|
||||
// 4.3 连续对话微调注入:
|
||||
// 4.3.1 若命中上轮预览,则把任务类/混合条目/分配结果注入 state;
|
||||
// 4.3.2 这样 rough_build 可按需复用旧底板,避免每轮都重新粗排。
|
||||
@@ -118,10 +120,10 @@ func (s *AgentService) runSchedulePlanFlow(
|
||||
state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems)
|
||||
state.PreviousCandidatePlans = cloneWeekSchedules(previousPreview.CandidatePlans)
|
||||
}
|
||||
finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{
|
||||
finalState, runErr := agentgraph.RunSchedulePlanGraph(ctx, agentnode.SchedulePlanGraphRunInput{
|
||||
Model: selectedModel,
|
||||
State: state,
|
||||
Deps: scheduleplan.SchedulePlanToolDeps{
|
||||
Deps: agentnode.SchedulePlanToolDeps{
|
||||
SmartPlanningMultiRaw: s.SmartPlanningMultiRawFunc,
|
||||
HybridScheduleWithPlanMulti: s.HybridScheduleWithPlanMultiFunc,
|
||||
ResolvePlanningWindow: s.ResolvePlanningWindowFunc,
|
||||
@@ -155,6 +157,6 @@ func (s *AgentService) runSchedulePlanFlow(
|
||||
// 6. 旁路写入排程预览缓存(结构化 JSON),给查询接口拉取。
|
||||
// 6.1 失败只记日志,不影响本次对话回复;
|
||||
// 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。
|
||||
s.saveSchedulePlanPreview(ctx, userID, chatID, finalState)
|
||||
s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, finalState)
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
)
|
||||
@@ -69,6 +71,62 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
|
||||
}
|
||||
}
|
||||
|
||||
// saveSchedulePlanPreviewAgent2 把 agent2 的 schedule_plan 结果写入 Redis 预览与 MySQL 快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承接“新 agent2 首次排程链路”的最终状态;
|
||||
// 2. 负责沿用现有预览缓存/状态快照协议,保证查询接口与 refine 读取逻辑不需要跟着重写;
|
||||
// 3. 不负责 refine 状态转换,refine 仍继续走旧链路的 saveSchedulePlanPreview。
|
||||
func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
|
||||
// 1. 基础前置校验:state 为空时直接返回,避免写入半成品快照。
|
||||
if s == nil || finalState == nil {
|
||||
return
|
||||
}
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if normalizedChatID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 组装缓存快照。
|
||||
// 2.1 summary 优先取 final summary,空值时使用统一兜底文案;
|
||||
// 2.2 candidate_plans / hybrid_entries / allocated_items 统一深拷贝,避免缓存与 graph state 共用底层切片;
|
||||
// 2.3 generated_at 用于前端判断“当前预览是否为最新方案”。
|
||||
summary := strings.TrimSpace(finalState.FinalSummary)
|
||||
if summary == "" {
|
||||
summary = "排程流程已完成,但未生成结果摘要。"
|
||||
}
|
||||
preview := &model.SchedulePlanPreviewCache{
|
||||
UserID: userID,
|
||||
ConversationID: normalizedChatID,
|
||||
TraceID: strings.TrimSpace(finalState.TraceID),
|
||||
Summary: summary,
|
||||
CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans),
|
||||
TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...),
|
||||
HybridEntries: cloneHybridEntries(finalState.HybridEntries),
|
||||
AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems),
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 3. 先写 Redis 预览,保证前端查询接口能立即读取结构化结果。
|
||||
// 3.1 Redis 是“快路径”;
|
||||
// 3.2 失败只记录日志,不中断聊天主链路。
|
||||
if s.cacheDAO != nil {
|
||||
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
|
||||
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 同步写 MySQL 快照,保证 Redis 失效后仍能恢复预览与连续微调上下文。
|
||||
// 4.1 这里继续保持“同步写库”策略,因为下一轮微调对快照读取是强实时依赖;
|
||||
// 4.2 写库失败只打日志,不阻断本轮给用户的文本回复。
|
||||
if s.repo != nil {
|
||||
snapshot := buildSchedulePlanSnapshotFromAgent2State(userID, normalizedChatID, finalState)
|
||||
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
|
||||
log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
|
||||
//
|
||||
// 职责边界:
|
||||
@@ -137,62 +195,17 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
|
||||
|
||||
// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。
|
||||
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.UserWeekSchedule, 0, len(src))
|
||||
for _, week := range src {
|
||||
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
|
||||
copy(eventsCopy, week.Events)
|
||||
dst = append(dst, model.UserWeekSchedule{
|
||||
Week: week.Week,
|
||||
Events: eventsCopy,
|
||||
})
|
||||
}
|
||||
return dst
|
||||
return agentshared.CloneWeekSchedules(src)
|
||||
}
|
||||
|
||||
// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。
|
||||
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.HybridScheduleEntry, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
return agentshared.CloneHybridEntries(src)
|
||||
}
|
||||
|
||||
// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。
|
||||
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.TaskClassItem, 0, len(src))
|
||||
for _, item := range src {
|
||||
copied := item
|
||||
if item.CategoryID != nil {
|
||||
v := *item.CategoryID
|
||||
copied.CategoryID = &v
|
||||
}
|
||||
if item.Order != nil {
|
||||
v := *item.Order
|
||||
copied.Order = &v
|
||||
}
|
||||
if item.Content != nil {
|
||||
v := *item.Content
|
||||
copied.Content = &v
|
||||
}
|
||||
if item.Status != nil {
|
||||
v := *item.Status
|
||||
copied.Status = &v
|
||||
}
|
||||
if item.EmbeddedTime != nil {
|
||||
t := *item.EmbeddedTime
|
||||
copied.EmbeddedTime = &t
|
||||
}
|
||||
dst = append(dst, copied)
|
||||
}
|
||||
return dst
|
||||
return agentshared.CloneTaskClassItems(src)
|
||||
}
|
||||
|
||||
// buildSchedulePlanSnapshotFromState 把 graph 运行结果映射成可持久化快照 DTO。
|
||||
@@ -224,6 +237,35 @@ func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *s
|
||||
}
|
||||
}
|
||||
|
||||
// buildSchedulePlanSnapshotFromAgent2State 把 agent2 的排程状态映射成可持久化快照 DTO。
|
||||
//
|
||||
// 调用目的:
|
||||
// 1. 这轮只迁移 schedule_plan,不动 refine;
|
||||
// 2. 因此 preview/快照协议继续复用老结构,但要补一个“agent2 state -> snapshot DTO”的映射层;
|
||||
// 3. 这样可以做到:计划创建链路切到 agent2,而 refine / 预览查询链路暂时无需大改。
|
||||
func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.SchedulePlanStateSnapshot{
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
StateVersion: model.SchedulePlanStateVersionV1,
|
||||
TaskClassIDs: append([]int(nil), st.TaskClassIDs...),
|
||||
Constraints: append([]string(nil), st.Constraints...),
|
||||
HybridEntries: cloneHybridEntries(st.HybridEntries),
|
||||
AllocatedItems: cloneTaskClassItems(st.AllocatedItems),
|
||||
CandidatePlans: cloneWeekSchedules(st.CandidatePlans),
|
||||
UserIntent: strings.TrimSpace(st.UserIntent),
|
||||
Strategy: strings.TrimSpace(st.Strategy),
|
||||
AdjustmentScope: strings.TrimSpace(st.AdjustmentScope),
|
||||
RestartRequested: st.RestartRequested,
|
||||
FinalSummary: strings.TrimSpace(st.FinalSummary),
|
||||
Completed: st.Completed,
|
||||
TraceID: strings.TrimSpace(st.TraceID),
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotToSchedulePlanPreviewCache 把 MySQL 快照转换为 Redis 预览缓存结构。
|
||||
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
|
||||
if snapshot == nil {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/schedulerefine"
|
||||
)
|
||||
|
||||
func TestShouldPersistScheduleRefinePreviewSkipsFailedCompositeRoute(t *testing.T) {
|
||||
st := &schedulerefine.ScheduleRefineState{
|
||||
CompositeRouteSucceeded: true,
|
||||
HardCheck: schedulerefine.HardCheckReport{
|
||||
PhysicsPassed: true,
|
||||
OrderPassed: true,
|
||||
IntentPassed: false,
|
||||
},
|
||||
}
|
||||
|
||||
if shouldPersistScheduleRefinePreview(st) {
|
||||
t.Fatalf("期望复合分支终审失败时不覆盖上一版预览")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistScheduleRefinePreviewAllowsPassedCompositeRoute(t *testing.T) {
|
||||
st := &schedulerefine.ScheduleRefineState{
|
||||
CompositeRouteSucceeded: true,
|
||||
HardCheck: schedulerefine.HardCheckReport{
|
||||
PhysicsPassed: true,
|
||||
OrderPassed: true,
|
||||
IntentPassed: true,
|
||||
},
|
||||
}
|
||||
|
||||
if !shouldPersistScheduleRefinePreview(st) {
|
||||
t.Fatalf("期望复合分支终审通过时允许覆盖预览")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistScheduleRefinePreviewKeepsReactPathBehavior(t *testing.T) {
|
||||
st := &schedulerefine.ScheduleRefineState{
|
||||
CompositeRouteSucceeded: false,
|
||||
HardCheck: schedulerefine.HardCheckReport{
|
||||
PhysicsPassed: true,
|
||||
OrderPassed: true,
|
||||
IntentPassed: false,
|
||||
},
|
||||
}
|
||||
|
||||
if !shouldPersistScheduleRefinePreview(st) {
|
||||
t.Fatalf("期望非复合直出分支继续沿用原有预览持久化策略")
|
||||
}
|
||||
}
|
||||
115
frontend/package-lock.json
generated
115
frontend/package-lock.json
generated
@@ -11,11 +11,14 @@
|
||||
"@vue/shared": "^3.5.0",
|
||||
"axios": "^1.8.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
@@ -824,6 +827,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
@@ -835,16 +845,36 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1094,6 +1124,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@@ -1167,6 +1203,18 @@
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
@@ -1454,17 +1502,37 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -1477,6 +1545,29 @@
|
||||
"lodash-es": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
@@ -1547,6 +1638,15 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -1588,6 +1688,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1601,6 +1702,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -1609,6 +1711,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -1622,6 +1730,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -1835,6 +1944,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1938,6 +2048,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
"@vue/shared": "^3.5.0",
|
||||
"axios": "^1.8.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -42,6 +42,19 @@ interface StreamEventPayload {
|
||||
error?: StreamErrorPayload
|
||||
}
|
||||
|
||||
type ModelType = 'worker' | 'strategist'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialHistoryWidth?: number
|
||||
viewMode?: 'embedded' | 'standalone'
|
||||
}>(),
|
||||
{
|
||||
initialHistoryWidth: 228,
|
||||
viewMode: 'embedded',
|
||||
},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const assistantBodyRef = ref<HTMLElement | null>(null)
|
||||
@@ -52,10 +65,10 @@ const conversationLoadingMore = ref(false)
|
||||
const chatLoading = ref(false)
|
||||
const historyExpanded = ref(true)
|
||||
const selectedConversationId = ref('')
|
||||
const selectedModel = ref<'worker' | 'strategist'>('worker')
|
||||
const selectedModel = ref<ModelType>('worker')
|
||||
const thinkingEnabled = ref(false)
|
||||
const messageInput = ref('')
|
||||
const historyPanelWidth = ref(228)
|
||||
const historyPanelWidth = ref(props.initialHistoryWidth)
|
||||
const activeStreamingMessageId = ref('')
|
||||
|
||||
const conversationPage = ref(1)
|
||||
@@ -69,6 +82,8 @@ const conversationMessagesMap = reactive<Record<string, AssistantMessage[]>>({})
|
||||
const unavailableHistoryMap = reactive<Record<string, boolean>>({})
|
||||
const thinkingMessageMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningStartedAtMap = reactive<Record<string, number>>({})
|
||||
const reasoningDurationMap = reactive<Record<string, number>>({})
|
||||
|
||||
const quickActions = [
|
||||
'帮我梳理今天最重要的三件事',
|
||||
@@ -77,11 +92,23 @@ const quickActions = [
|
||||
'给我一个更稳妥的推进方案',
|
||||
]
|
||||
|
||||
let messageScrollRaf = 0
|
||||
const MODEL_PREFERENCE_STORAGE_KEY = 'smartflow.assistant.model.byConversation.v1'
|
||||
|
||||
const assistantBodyStyle = computed(() => ({
|
||||
'--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`,
|
||||
}))
|
||||
let messageScrollRaf = 0
|
||||
let reasoningTicker = 0
|
||||
const reasoningDisplayNow = ref(Date.now())
|
||||
|
||||
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
|
||||
|
||||
const assistantBodyStyle = computed(() => {
|
||||
if (isStandaloneMode.value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const selectedConversation = computed(() =>
|
||||
conversationList.value.find((item) => item.conversation_id === selectedConversationId.value),
|
||||
@@ -136,6 +163,86 @@ const shouldShowHistoryFallback = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
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]) {
|
||||
conversationMessagesMap[conversationId] = []
|
||||
@@ -196,6 +303,16 @@ 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>()
|
||||
@@ -276,7 +393,7 @@ function normalizeHistoryMessage(message: ConversationHistoryMessage, index: num
|
||||
reasoning: message.reasoning_content,
|
||||
}
|
||||
|
||||
thinkingMessageMap[id] = Boolean(message.reasoning_content?.trim())
|
||||
thinkingMessageMap[id] = false
|
||||
reasoningCollapsedMap[id] = Boolean(message.reasoning_content?.trim())
|
||||
return normalized
|
||||
}
|
||||
@@ -293,6 +410,47 @@ function isThinkingMessage(message: AssistantMessage) {
|
||||
return thinkingMessageMap[message.id] === true
|
||||
}
|
||||
|
||||
function markReasoningStart(message: AssistantMessage) {
|
||||
if (reasoningStartedAtMap[message.id]) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsedCreatedAt = Date.parse(message.createdAt)
|
||||
reasoningStartedAtMap[message.id] = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now()
|
||||
}
|
||||
|
||||
function markReasoningFinished(message: AssistantMessage) {
|
||||
const startedAt = reasoningStartedAtMap[message.id]
|
||||
if (startedAt && !reasoningDurationMap[message.id]) {
|
||||
reasoningDurationMap[message.id] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
|
||||
}
|
||||
|
||||
thinkingMessageMap[message.id] = false
|
||||
}
|
||||
|
||||
function getReasoningDurationSeconds(message: AssistantMessage) {
|
||||
const fixedDuration = reasoningDurationMap[message.id]
|
||||
if (fixedDuration) {
|
||||
return fixedDuration
|
||||
}
|
||||
|
||||
const startedAt = reasoningStartedAtMap[message.id]
|
||||
if (!startedAt) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round((reasoningDisplayNow.value - startedAt) / 1000))
|
||||
}
|
||||
|
||||
function getReasoningStatusLabel(message: AssistantMessage) {
|
||||
const durationSeconds = getReasoningDurationSeconds(message)
|
||||
if (durationSeconds > 0) {
|
||||
return `已思考(用时 ${durationSeconds} 秒)`
|
||||
}
|
||||
|
||||
return isStreamingMessage(message) && isThinkingMessage(message) ? '思考中' : '已思考'
|
||||
}
|
||||
|
||||
function isReasoningCollapsed(messageId: string) {
|
||||
return reasoningCollapsedMap[messageId] === true
|
||||
}
|
||||
@@ -308,6 +466,10 @@ function shouldShowReasoningBox(message: AssistantMessage) {
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowAnsweringIndicator(message: AssistantMessage) {
|
||||
return isStreamingMessage(message) && !isThinkingMessage(message) && !message.content.trim()
|
||||
}
|
||||
|
||||
function scheduleScrollMessagesToBottom(smooth = false) {
|
||||
if (messageScrollRaf) {
|
||||
cancelAnimationFrame(messageScrollRaf)
|
||||
@@ -395,7 +557,7 @@ function handleHistoryScroll(event: Event) {
|
||||
// 3. 拖拽结束后统一解绑事件并清理全局样式,防止页面残留 col-resize 状态。
|
||||
function startResizeHistoryPanel(event: PointerEvent) {
|
||||
const body = assistantBodyRef.value
|
||||
if (!body || window.innerWidth <= 960 || !historyExpanded.value) {
|
||||
if (isStandaloneMode.value || !body || window.innerWidth <= 960 || !historyExpanded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -463,6 +625,7 @@ async function ensureConversationMeta(conversationId: string) {
|
||||
|
||||
async function selectConversation(conversationId: string) {
|
||||
selectedConversationId.value = conversationId
|
||||
applyPreferredModelForConversation(conversationId)
|
||||
await Promise.allSettled([loadConversationMessages(conversationId), ensureConversationMeta(conversationId)])
|
||||
scheduleScrollMessagesToBottom(false)
|
||||
}
|
||||
@@ -542,6 +705,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
}
|
||||
|
||||
if (payload === '[DONE]') {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
markReasoningFinished(assistantMessage)
|
||||
}
|
||||
activeStreamingMessageId.value = ''
|
||||
reasoningCollapsedMap[assistantMessage.id] = true
|
||||
return
|
||||
@@ -562,16 +728,30 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
const delta = choice?.delta ?? parsed.delta ?? parsed
|
||||
const finishReason = choice?.finish_reason ?? parsed.finish_reason ?? null
|
||||
|
||||
if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content) {
|
||||
if (
|
||||
typeof delta?.reasoning_content === 'string' &&
|
||||
delta.reasoning_content &&
|
||||
!assistantMessage.content.trim()
|
||||
) {
|
||||
markReasoningStart(assistantMessage)
|
||||
assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}`
|
||||
thinkingMessageMap[assistantMessage.id] = true
|
||||
}
|
||||
|
||||
if (typeof delta?.content === 'string' && delta.content) {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
// 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。
|
||||
// 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。
|
||||
// 3. 若后端偶发交错发送 reasoning/content,也以前端阶段机兜底,优先保证阅读一致性。
|
||||
markReasoningFinished(assistantMessage)
|
||||
}
|
||||
assistantMessage.content += delta.content
|
||||
}
|
||||
|
||||
if (finishReason) {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
markReasoningFinished(assistantMessage)
|
||||
}
|
||||
activeStreamingMessageId.value = ''
|
||||
reasoningCollapsedMap[assistantMessage.id] = true
|
||||
}
|
||||
@@ -596,6 +776,7 @@ async function sendMessage(preset?: string) {
|
||||
if (!selectedConversationId.value) {
|
||||
selectedConversationId.value = draftConversationId
|
||||
}
|
||||
savePreferredModel(draftConversationId, selectedModel.value)
|
||||
|
||||
ensureConversationBucket(draftConversationId)
|
||||
unavailableHistoryMap[draftConversationId] = false
|
||||
@@ -691,7 +872,21 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedModel,
|
||||
(nextModel) => {
|
||||
const conversationId = selectedConversationId.value
|
||||
if (!conversationId) {
|
||||
return
|
||||
}
|
||||
savePreferredModel(conversationId, nextModel)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
reasoningTicker = window.setInterval(() => {
|
||||
reasoningDisplayNow.value = Date.now()
|
||||
}, 1000)
|
||||
await loadConversationListData(true)
|
||||
})
|
||||
|
||||
@@ -699,12 +894,16 @@ onBeforeUnmount(() => {
|
||||
if (messageScrollRaf) {
|
||||
cancelAnimationFrame(messageScrollRaf)
|
||||
}
|
||||
if (reasoningTicker) {
|
||||
window.clearInterval(reasoningTicker)
|
||||
reasoningTicker = 0
|
||||
}
|
||||
document.body.classList.remove('dashboard-resizing')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="assistant-shell glass-panel">
|
||||
<aside class="assistant-shell glass-panel" :class="{ 'assistant-shell--standalone': isStandaloneMode }">
|
||||
<header class="assistant-header">
|
||||
<div class="assistant-header__text">
|
||||
<span class="assistant-header__eyebrow">AI 对话</span>
|
||||
@@ -717,7 +916,10 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
ref="assistantBodyRef"
|
||||
class="assistant-body"
|
||||
:class="{ 'assistant-body--collapsed': !historyExpanded }"
|
||||
:class="{
|
||||
'assistant-body--collapsed': !historyExpanded,
|
||||
'assistant-body--standalone': isStandaloneMode,
|
||||
}"
|
||||
:style="assistantBodyStyle"
|
||||
>
|
||||
<aside class="assistant-history" :class="{ 'assistant-history--collapsed': !historyExpanded }">
|
||||
@@ -764,7 +966,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
class="assistant-splitter"
|
||||
:class="{ 'assistant-splitter--hidden': !historyExpanded }"
|
||||
:class="{ 'assistant-splitter--hidden': !historyExpanded || isStandaloneMode }"
|
||||
role="separator"
|
||||
aria-label="调整会话列表宽度"
|
||||
@pointerdown.prevent="startResizeHistoryPanel"
|
||||
@@ -801,26 +1003,50 @@ onBeforeUnmount(() => {
|
||||
<div v-if="shouldShowReasoningBox(message)" class="chat-message__reasoning">
|
||||
<div class="chat-message__reasoning-head">
|
||||
<div class="chat-message__reasoning-title">
|
||||
<span class="chat-message__reasoning-dot" />
|
||||
<strong>{{ isStreamingMessage(message) ? '深度思考中' : '深度思考' }}</strong>
|
||||
<span class="chat-message__reasoning-icon">
|
||||
<svg
|
||||
class="chat-message__reasoning-icon-svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M8.00195 6.64454C8.75029 6.64454 9.35735 7.25169 9.35742 8.00001C9.35742 8.74838 8.75033 9.35548 8.00195 9.35548C7.2537 9.35533 6.64746 8.74829 6.64746 8.00001C6.64753 7.25178 7.25374 6.64468 8.00195 6.64454Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M9.97168 1.29981C11.5854 0.718916 13.271 0.642197 14.3145 1.68555C15.3578 2.72902 15.2811 4.41466 14.7002 6.02833C14.4708 6.66561 14.1505 7.32937 13.75 8.00001C14.1505 8.67062 14.4708 9.33444 14.7002 9.97169C15.2811 11.5854 15.3579 13.271 14.3145 14.3145C13.271 15.3579 11.5854 15.2811 9.97168 14.7002C9.33443 14.4708 8.67062 14.1505 8 13.75C7.32936 14.1505 6.66561 14.4708 6.02832 14.7002C4.41464 15.2811 2.72902 15.3578 1.68555 14.3145C0.642186 13.271 0.718901 11.5854 1.29981 9.97169C1.52918 9.33454 1.84868 8.67049 2.24902 8.00001C1.84869 7.32953 1.52918 6.66544 1.29981 6.02833C0.718882 4.41459 0.6421 2.729 1.68555 1.68555C2.729 0.642112 4.41459 0.718887 6.02832 1.29981C6.66544 1.52918 7.32953 1.8487 8 2.24903C8.67048 1.84869 9.33454 1.52919 9.97168 1.29981ZM12.9404 9.2129C12.4391 9.893 11.8616 10.5681 11.2148 11.2149C10.5681 11.8616 9.89299 12.4391 9.21289 12.9404C9.62535 13.1579 10.0271 13.338 10.4121 13.4766C11.9146 14.0174 12.9173 13.8738 13.3955 13.3955C13.8737 12.9173 14.0174 11.9146 13.4766 10.4121C13.338 10.0271 13.1579 9.62535 12.9404 9.2129ZM3.05859 9.2129C2.84124 9.62523 2.662 10.0272 2.52344 10.4121C1.98255 11.9146 2.1263 12.9172 2.60449 13.3955C3.08281 13.8737 4.08548 14.0174 5.58789 13.4766C5.97267 13.338 6.37392 13.1577 6.78613 12.9404C6.10627 12.4393 5.43171 11.8614 4.78516 11.2149C4.13826 10.5679 3.55995 9.89313 3.05859 9.2129ZM7.99902 3.792C7.23182 4.31419 6.45309 4.95512 5.7041 5.70411C4.95512 6.45309 4.31418 7.23184 3.79199 7.99903C4.31434 8.76666 4.95474 9.54653 5.7041 10.2959C6.45312 11.0449 7.23274 11.6848 8 12.207C8.76728 11.6848 9.54686 11.0449 10.2959 10.2959C11.0449 9.54686 11.6848 8.76729 12.207 8.00001C11.6848 7.23275 11.0449 6.45312 10.2959 5.70411C9.54653 4.95475 8.76665 4.31434 7.99902 3.792ZM5.58789 2.52344C4.08536 1.98255 3.08275 2.12625 2.60449 2.6045C2.12624 3.08275 1.98255 4.08536 2.52344 5.5879C2.66192 5.97253 2.84143 6.37409 3.05859 6.78614C3.55986 6.10611 4.13843 5.43189 4.78516 4.78516C5.4319 4.13843 6.10609 3.55987 6.78613 3.0586C6.37408 2.84144 5.97252 2.66192 5.58789 2.52344ZM13.3955 2.6045C12.9172 2.12631 11.9146 1.98257 10.4121 2.52344C10.0272 2.66201 9.62522 2.84125 9.21289 3.0586C9.89313 3.55996 10.5679 4.13827 11.2148 4.78516C11.8614 5.43172 12.4392 6.10627 12.9404 6.78614C13.1577 6.37393 13.338 5.97267 13.4766 5.5879C14.0174 4.08549 13.8736 3.08281 13.3955 2.6045Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<strong>{{ getReasoningStatusLabel(message) }}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-message__reasoning-toggle"
|
||||
:aria-label="isReasoningCollapsed(message.id) ? '展开深度思考' : '折叠深度思考'"
|
||||
@click="toggleReasoningCollapse(message.id)"
|
||||
>
|
||||
{{ isReasoningCollapsed(message.id) ? '展开' : '折叠' }}
|
||||
<span
|
||||
class="chat-message__reasoning-chevron"
|
||||
:class="{ 'chat-message__reasoning-chevron--collapsed': isReasoningCollapsed(message.id) }"
|
||||
>
|
||||
⌄
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isReasoningCollapsed(message.id)">
|
||||
<div v-if="!isReasoningCollapsed(message.id)" class="chat-message__reasoning-body">
|
||||
<div
|
||||
v-if="message.reasoning"
|
||||
class="chat-message__markdown chat-message__markdown--reasoning"
|
||||
v-html="renderMessageMarkdown(message.reasoning)"
|
||||
/>
|
||||
<div v-else class="chat-message__streaming">
|
||||
<span>正在接收 reasoning 增量...</span>
|
||||
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
|
||||
<div class="typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
@@ -833,8 +1059,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="message.content" class="chat-message__assistant-content">
|
||||
<div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(message.content)" />
|
||||
</div>
|
||||
<div v-else-if="isStreamingMessage(message)" class="chat-message__streaming chat-message__streaming--plain">
|
||||
<span>{{ message.reasoning ? '正在生成正文内容...' : '正在建立连接...' }}</span>
|
||||
<div v-else-if="shouldShowAnsweringIndicator(message)" class="chat-message__streaming chat-message__streaming--plain">
|
||||
<div class="typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
@@ -887,13 +1112,20 @@ onBeforeUnmount(() => {
|
||||
深度思考
|
||||
</button>
|
||||
|
||||
<label class="assistant-toolbar__pill assistant-toolbar__pill--select">
|
||||
<span>模型</span>
|
||||
<select v-model="selectedModel" class="assistant-toolbar__select">
|
||||
<option value="worker">标准</option>
|
||||
<option value="strategist">策略</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="assistant-toolbar__pill assistant-toolbar__pill--select">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -915,6 +1147,41 @@ onBeforeUnmount(() => {
|
||||
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Segoe UI Variable Text', sans-serif;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone {
|
||||
border-radius: 18px;
|
||||
border-color: rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header,
|
||||
.assistant-shell--standalone .assistant-history__toolbar,
|
||||
.assistant-shell--standalone .assistant-actions,
|
||||
.assistant-shell--standalone .assistant-composer,
|
||||
.assistant-shell--standalone .assistant-toolbar {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header {
|
||||
padding: 14px 18px 12px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: #fafbfd;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header__eyebrow {
|
||||
background: rgba(57, 99, 213, 0.1);
|
||||
color: #315ec2;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header strong {
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header p {
|
||||
color: #7e8a9f;
|
||||
}
|
||||
|
||||
.assistant-header,
|
||||
.assistant-history__toolbar,
|
||||
.assistant-actions,
|
||||
@@ -984,6 +1251,14 @@ onBeforeUnmount(() => {
|
||||
grid-template-columns: 68px 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assistant-body--standalone {
|
||||
grid-template-columns: minmax(212px, 1fr) 8px minmax(0, 5fr);
|
||||
}
|
||||
|
||||
.assistant-body--standalone.assistant-body--collapsed {
|
||||
grid-template-columns: 68px 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assistant-history {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1067,6 +1342,17 @@ onBeforeUnmount(() => {
|
||||
background: linear-gradient(180deg, #f5f9ff, #eef5ff);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history {
|
||||
background: linear-gradient(180deg, #f8f9fc 0%, #f4f6fa 100%);
|
||||
border-right: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__item--active {
|
||||
border-color: rgba(49, 96, 202, 0.2);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 6px 16px rgba(36, 67, 127, 0.08);
|
||||
}
|
||||
|
||||
.assistant-history--collapsed .assistant-history__new,
|
||||
.assistant-history--collapsed .assistant-history__item {
|
||||
padding: 10px;
|
||||
@@ -1130,6 +1416,10 @@ onBeforeUnmount(() => {
|
||||
grid-template-rows: minmax(0, 1fr) auto auto auto;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-chat {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-messages {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1142,6 +1432,12 @@ onBeforeUnmount(() => {
|
||||
radial-gradient(circle at top center, rgba(129, 171, 255, 0.1), transparent 34%);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-messages {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(252, 253, 255, 1), rgba(255, 255, 255, 1)),
|
||||
radial-gradient(circle at top center, rgba(126, 150, 199, 0.08), transparent 36%);
|
||||
}
|
||||
|
||||
.assistant-chat__fallback,
|
||||
.chat-message__reasoning {
|
||||
border-radius: 16px;
|
||||
@@ -1210,9 +1506,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.chat-message__reasoning {
|
||||
padding: 14px 16px;
|
||||
border-color: rgba(92, 122, 170, 0.14);
|
||||
background: linear-gradient(180deg, rgba(245, 247, 251, 0.96), rgba(239, 243, 248, 0.98));
|
||||
padding: 2px 0 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-head,
|
||||
@@ -1226,31 +1522,57 @@ onBeforeUnmount(() => {
|
||||
.chat-message__reasoning-head {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #4b596d;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #5a98ff;
|
||||
box-shadow: 0 0 0 0 rgba(90, 152, 255, 0.34);
|
||||
animation: pulse-dot 1.6s ease-in-out infinite;
|
||||
.chat-message__reasoning-title strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
color: #4f76ea;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-icon-svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-toggle {
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #5f728b;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
color: #7b8798;
|
||||
font-size: 18px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-chevron {
|
||||
display: inline-block;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-chevron--collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.chat-message__reasoning-body {
|
||||
margin-left: 7px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid rgba(120, 134, 156, 0.24);
|
||||
}
|
||||
|
||||
.chat-message__markdown {
|
||||
@@ -1272,8 +1594,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.chat-message__markdown--reasoning {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
color: #5b6676;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(p) {
|
||||
@@ -1339,15 +1662,58 @@ onBeforeUnmount(() => {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-pre .hljs) {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table-wrap) {
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
overflow-x: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table) {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table th),
|
||||
.chat-message__markdown :deep(.md-table td) {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table th) {
|
||||
background: rgba(68, 98, 158, 0.08);
|
||||
color: #1f2f47;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table tr:last-child td) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-message__streaming {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 26px;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
min-height: 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-message__streaming--plain {
|
||||
padding-right: 10px;
|
||||
padding: 2px 10px 2px 0;
|
||||
}
|
||||
|
||||
.chat-message__streaming--reasoning {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-message__time,
|
||||
@@ -1429,15 +1795,43 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.assistant-toolbar__pill--select {
|
||||
padding-right: 10px;
|
||||
padding: 0 10px 0 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
.assistant-toolbar__select-label {
|
||||
color: #64758b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
||||
min-height: 30px;
|
||||
padding: 0 7px 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: none;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box:hover :deep(.el-select__wrapper) {
|
||||
border-color: rgba(36, 102, 220, 0.18);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__selected-item) {
|
||||
color: #42526a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__caret) {
|
||||
color: #627593;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
@@ -1514,3 +1908,32 @@ 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>
|
||||
|
||||
@@ -4,42 +4,77 @@ import { computed } from 'vue'
|
||||
import type { TodayEvent } from '@/types/dashboard'
|
||||
import { formatTimeRange } from '@/utils/date'
|
||||
|
||||
interface TimelineSlot {
|
||||
interface BaseSlot {
|
||||
key: string
|
||||
kind: 'event' | 'pause'
|
||||
label: string
|
||||
timeText?: string
|
||||
eventOrder?: number
|
||||
title: string
|
||||
}
|
||||
|
||||
interface EventSlot extends BaseSlot {
|
||||
kind: 'event'
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
interface PauseSlot extends BaseSlot {
|
||||
kind: 'pause'
|
||||
}
|
||||
|
||||
type TimelineSlot = EventSlot | PauseSlot
|
||||
|
||||
interface RenderEventSlot {
|
||||
key: string
|
||||
kind: 'event'
|
||||
timeText: string
|
||||
title: string
|
||||
locationText: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
interface RenderPauseSlot {
|
||||
key: string
|
||||
kind: 'pause'
|
||||
title: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
type RenderSlot = RenderEventSlot | RenderPauseSlot
|
||||
|
||||
const props = defineProps<{
|
||||
events: TodayEvent[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
// 1. 时间轴始终固定为 8 个槽位,顺序不再受当天是否有课影响。
|
||||
// 2. 课程槽位缺数据时显示“无课”,而不是直接消失,避免把后续块位挤乱。
|
||||
// 3. 午休和晚餐是纯占位块,不展示时间文本,只负责占住用户指定的位置。
|
||||
const slotBlueprint: TimelineSlot[] = [
|
||||
{ key: 'slot-1', kind: 'event', label: '上午', timeText: '08:00 - 09:40', eventOrder: 1 },
|
||||
{ key: 'slot-2', kind: 'event', label: '上午', timeText: '10:15 - 11:55', eventOrder: 2 },
|
||||
{ key: 'slot-noon', kind: 'pause', label: '午休' },
|
||||
{ key: 'slot-4', kind: 'event', label: '下午', timeText: '14:00 - 15:40', eventOrder: 4 },
|
||||
// 1. 晚餐块固定放在 7-8 节与 9-10 节之间,作为晚间课程前的过渡占位。
|
||||
// 2. 根据用户最新要求,它要出现在“17:55 结束的课块之后、19:00 黄色块之前”。
|
||||
// 3. 用户要求该块只保留单独卡片,不展示时间文本。
|
||||
{ key: 'slot-dinner', kind: 'pause', label: '晚餐' },
|
||||
{ key: 'slot-5', kind: 'event', label: '下午', timeText: '16:15 - 17:55', eventOrder: 5 },
|
||||
{ key: 'slot-6', kind: 'event', label: '晚间', timeText: '19:00 - 20:40', eventOrder: 6 },
|
||||
{ key: 'slot-7', kind: 'event', label: '晚间', timeText: '20:50 - 22:30', eventOrder: 7 },
|
||||
{ key: 'slot-1', kind: 'event', title: '1-2节', startTime: '08:00', endTime: '09:40' },
|
||||
{ key: 'slot-2', kind: 'event', title: '3-4节', startTime: '10:15', endTime: '11:55' },
|
||||
{ key: 'slot-noon', kind: 'pause', title: '午休' },
|
||||
{ key: 'slot-4', kind: 'event', title: '5-6节', startTime: '14:00', endTime: '15:40' },
|
||||
{ key: 'slot-5', kind: 'event', title: '7-8节', startTime: '16:15', endTime: '17:55' },
|
||||
{ key: 'slot-dinner', kind: 'pause', title: '晚餐' },
|
||||
{ key: 'slot-6', kind: 'event', title: '9-10节', startTime: '19:00', endTime: '20:40' },
|
||||
{ key: 'slot-7', kind: 'event', title: '11-12节', startTime: '20:50', endTime: '22:30' },
|
||||
]
|
||||
|
||||
function buildTimeKey(start?: string | null, end?: string | null) {
|
||||
return `${(start || '').trim()}|${(end || '').trim()}`
|
||||
}
|
||||
|
||||
const eventMap = computed(() => {
|
||||
const map = new Map<number, TodayEvent>()
|
||||
const map = new Map<string, TodayEvent>()
|
||||
for (const event of props.events ?? []) {
|
||||
map.set(event.order, event)
|
||||
map.set(buildTimeKey(event.start_time, event.end_time), event)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function resolveCardTone(event: TodayEvent) {
|
||||
function resolveCardTone(event: TodayEvent | null) {
|
||||
if (!event) {
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
if (event.type === 'course') {
|
||||
return 'course'
|
||||
}
|
||||
@@ -48,19 +83,36 @@ function resolveCardTone(event: TodayEvent) {
|
||||
1: 'sky',
|
||||
2: 'violet',
|
||||
4: 'mint',
|
||||
5: 'amber',
|
||||
5: 'emerald',
|
||||
6: 'amber',
|
||||
7: 'cyan',
|
||||
}
|
||||
|
||||
return orderToneMap[event.order] ?? 'neutral'
|
||||
}
|
||||
|
||||
function resolveSlotEvent(slot: TimelineSlot) {
|
||||
if (typeof slot.eventOrder !== 'number') {
|
||||
return null
|
||||
}
|
||||
return eventMap.value.get(slot.eventOrder) ?? null
|
||||
}
|
||||
const renderSlots = computed<RenderSlot[]>(() =>
|
||||
slotBlueprint.map((slot) => {
|
||||
if (slot.kind === 'pause') {
|
||||
return {
|
||||
key: slot.key,
|
||||
kind: 'pause',
|
||||
title: slot.title,
|
||||
hint: '为中段留出缓冲与恢复时间',
|
||||
}
|
||||
}
|
||||
|
||||
const event = eventMap.value.get(buildTimeKey(slot.startTime, slot.endTime)) ?? null
|
||||
return {
|
||||
key: slot.key,
|
||||
kind: 'event',
|
||||
timeText: formatTimeRange(event?.start_time || slot.startTime, event?.end_time || slot.endTime),
|
||||
title: event?.name || '无课',
|
||||
locationText: event?.location || '休息时间',
|
||||
tone: resolveCardTone(event),
|
||||
}
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -78,36 +130,20 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
</div>
|
||||
|
||||
<div v-else class="timeline-grid">
|
||||
<template v-for="slot in slotBlueprint" :key="slot.key">
|
||||
<article v-if="slot.kind === 'pause'" class="timeline-placeholder timeline-placeholder--pause">
|
||||
<span v-if="slot.timeText" class="timeline-placeholder__time">{{ slot.timeText }}</span>
|
||||
<strong class="timeline-placeholder__title">{{ slot.label }}</strong>
|
||||
<span class="timeline-placeholder__hint">为中段留出缓冲与恢复时间</span>
|
||||
</article>
|
||||
|
||||
<template v-for="slot in renderSlots" :key="slot.key">
|
||||
<article
|
||||
v-else-if="resolveSlotEvent(slot)"
|
||||
v-if="slot.kind === 'event'"
|
||||
class="timeline-event"
|
||||
:class="`timeline-event--${resolveCardTone(resolveSlotEvent(slot)!)}`"
|
||||
:class="`timeline-event--${slot.tone}`"
|
||||
>
|
||||
<span class="timeline-event__time">
|
||||
{{
|
||||
formatTimeRange(
|
||||
resolveSlotEvent(slot)?.start_time,
|
||||
resolveSlotEvent(slot)?.end_time,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<strong class="timeline-event__title">{{ resolveSlotEvent(slot)?.name }}</strong>
|
||||
<span class="timeline-event__location">
|
||||
{{ resolveSlotEvent(slot)?.location || '休息时间' }}
|
||||
</span>
|
||||
<span class="timeline-event__time">{{ slot.timeText }}</span>
|
||||
<strong class="timeline-event__title">{{ slot.title }}</strong>
|
||||
<span class="timeline-event__location">{{ slot.locationText }}</span>
|
||||
</article>
|
||||
|
||||
<article v-else class="timeline-event timeline-event--neutral">
|
||||
<span class="timeline-event__time">{{ slot.timeText }}</span>
|
||||
<strong class="timeline-event__title">无课</strong>
|
||||
<span class="timeline-event__location">休息时间</span>
|
||||
<article v-else class="timeline-placeholder timeline-placeholder--pause">
|
||||
<strong class="timeline-placeholder__title">{{ slot.title }}</strong>
|
||||
<span class="timeline-placeholder__hint">{{ slot.hint }}</span>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
@@ -154,9 +190,9 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
.timeline-grid {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
/* 1. 改为 auto-fit 自适应列数,避免固定列数把左侧主区整体撑宽。 */
|
||||
/* 2. 每张卡片保留可读最小宽度,空间不足时自动换行,而不是出现横向滚动条。 */
|
||||
/* 3. 这样在左右近似二分的布局下,左侧信息板也能保持完整可见。 */
|
||||
/* 1. 使用自适应列数,避免固定列数把左侧主区撑爆。 */
|
||||
/* 2. 但槽位顺序固定,换行只影响视觉换行,不影响时间先后顺序。 */
|
||||
/* 3. 这样无论是否缺课,8 个槽位都会按既定顺序逐个渲染。 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
overflow: visible;
|
||||
@@ -217,6 +253,14 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
background: #1669c1;
|
||||
}
|
||||
|
||||
.timeline-event--sky {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%);
|
||||
}
|
||||
|
||||
.timeline-event--sky::before {
|
||||
background: #c8d6e8;
|
||||
}
|
||||
|
||||
.timeline-event--violet {
|
||||
background: linear-gradient(180deg, #eef0ff 0%, #e6e8ff 100%);
|
||||
}
|
||||
@@ -226,10 +270,18 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
}
|
||||
|
||||
.timeline-event--mint {
|
||||
background: linear-gradient(180deg, #e6f8f1 0%, #def5ec 100%);
|
||||
background: linear-gradient(180deg, #e6f2ff 0%, #dceaff 100%);
|
||||
}
|
||||
|
||||
.timeline-event--mint::before {
|
||||
background: #2f7de1;
|
||||
}
|
||||
|
||||
.timeline-event--emerald {
|
||||
background: linear-gradient(180deg, #e6f8f1 0%, #def5ec 100%);
|
||||
}
|
||||
|
||||
.timeline-event--emerald::before {
|
||||
background: #27b482;
|
||||
}
|
||||
|
||||
@@ -273,12 +325,6 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
background: linear-gradient(180deg, #f5f9ff 0%, #eef4fb 100%);
|
||||
}
|
||||
|
||||
.timeline-placeholder__time {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #4c6c97;
|
||||
}
|
||||
|
||||
.timeline-placeholder__title {
|
||||
font-size: 16px;
|
||||
color: #22324b;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AuthView from '@/views/AuthView.vue'
|
||||
import AssistantView from '@/views/AssistantView.vue'
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -27,6 +28,14 @@ const router = createRouter({
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/assistant',
|
||||
name: 'assistant',
|
||||
component: AssistantView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import 'highlight.js/styles/github.css'
|
||||
|
||||
function escapeHtml(input: string) {
|
||||
return input
|
||||
.replaceAll('&', '&')
|
||||
@@ -7,152 +11,77 @@ function escapeHtml(input: string) {
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function parseInlineMarkdown(input: string) {
|
||||
const inlineCodeBlocks: string[] = []
|
||||
const htmlBreakToken = '@@HTML_BREAK@@'
|
||||
let content = input.replace(/<br\s*\/?>/gi, htmlBreakToken)
|
||||
function renderHighlightedCode(sourceCode: string, language: string) {
|
||||
const normalizedLanguage = language.trim()
|
||||
const safeLanguageClass = normalizedLanguage ? ` language-${escapeHtml(normalizedLanguage)}` : ''
|
||||
|
||||
// 1. 先抽离行内代码,避免代码片段里的 Markdown / HTML 被后续规则误处理。
|
||||
// 2. <br> 只做白名单放行,其它原始 HTML 仍统一转义,避免把模型输出直接注入页面。
|
||||
// 3. 若用户就是想输入普通换行,外层段落逻辑仍会继续按 <br /> 渲染,不受这里影响。
|
||||
content = escapeHtml(content)
|
||||
try {
|
||||
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) {
|
||||
const highlighted = hljs.highlight(sourceCode, {
|
||||
language: normalizedLanguage,
|
||||
ignoreIllegals: true,
|
||||
}).value
|
||||
return `<pre class="md-pre"><code class="md-code hljs${safeLanguageClass}">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
content = content.replace(/`([^`]+)`/g, (_, code: string) => {
|
||||
const token = `@@INLINE_CODE_${inlineCodeBlocks.length}@@`
|
||||
inlineCodeBlocks.push(`<code>${escapeHtml(code.replaceAll(htmlBreakToken, '<br>'))}</code>`)
|
||||
return token
|
||||
})
|
||||
|
||||
content = content.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
(_, label: string, link: string) =>
|
||||
`<a href="${escapeHtml(link)}" target="_blank" rel="noreferrer noopener">${label}</a>`,
|
||||
)
|
||||
|
||||
content = content.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
content = content.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
content = content.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
content = content.replaceAll(htmlBreakToken, '<br />')
|
||||
|
||||
return content.replace(/@@INLINE_CODE_(\d+)@@/g, (_, index: string) => inlineCodeBlocks[Number(index)] ?? '')
|
||||
const highlighted = hljs.highlightAuto(sourceCode).value
|
||||
return `<pre class="md-pre"><code class="md-code hljs">${highlighted}</code></pre>`
|
||||
} catch {
|
||||
const escaped = escapeHtml(sourceCode)
|
||||
return `<pre class="md-pre"><code class="md-code${safeLanguageClass}">${escaped}</code></pre>`
|
||||
}
|
||||
}
|
||||
|
||||
// renderMarkdown 负责把常见 Markdown 文本安全转换为可展示 HTML。
|
||||
const markdownRenderer = new MarkdownIt({
|
||||
// 1. 禁止渲染原始 HTML,避免模型输出被直接注入页面。
|
||||
// 2. 保留换行语义,让对话消息中的软换行更接近聊天阅读习惯。
|
||||
// 3. 开启 linkify,自动识别纯文本链接,减少“写成网址却不可点”的情况。
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
// 1. 统一在渲染阶段做代码高亮,避免在组件层重复处理字符串。
|
||||
// 2. 优先按模型返回的语言标记高亮,语言未知时自动推断。
|
||||
// 3. 高亮异常时自动降级为转义后的纯文本代码块,保证渲染不会中断。
|
||||
highlight(sourceCode: string, language: string) {
|
||||
return renderHighlightedCode(sourceCode, language)
|
||||
},
|
||||
})
|
||||
|
||||
const defaultLinkOpenRenderer =
|
||||
markdownRenderer.renderer.rules.link_open ??
|
||||
((tokens: any[], index: number, options: any, _env: any, self: any) =>
|
||||
self.renderToken(tokens, index, options))
|
||||
|
||||
markdownRenderer.renderer.rules.link_open = (
|
||||
tokens: any[],
|
||||
index: number,
|
||||
options: any,
|
||||
env: any,
|
||||
self: any,
|
||||
) => {
|
||||
const token = tokens[index]
|
||||
|
||||
// 1. 所有外链统一新窗口打开,避免覆盖当前对话页。
|
||||
// 2. 强制附加 rel,降低反向标签页劫持风险。
|
||||
token.attrSet('target', '_blank')
|
||||
token.attrSet('rel', 'noreferrer noopener')
|
||||
|
||||
return defaultLinkOpenRenderer(tokens, index, options, env, self)
|
||||
}
|
||||
|
||||
markdownRenderer.renderer.rules.table_open = () => '<div class="md-table-wrap"><table class="md-table">'
|
||||
markdownRenderer.renderer.rules.table_close = () => '</table></div>'
|
||||
|
||||
// renderMarkdown 负责把聊天消息里的 Markdown 渲染为安全 HTML。
|
||||
// 职责边界:
|
||||
// 1. 负责处理标题、列表、引用、代码块、链接、粗斜体等常见场景。
|
||||
// 2. 不追求完整 CommonMark 兼容,只覆盖聊天消息里最常见的展示需求。
|
||||
// 3. 所有原始文本都会先做 HTML 转义,避免把模型输出直接当成原生 HTML 注入页面。
|
||||
// 1. 负责常见 GFM 语法(包含表格、代码块)渲染,不负责业务字段裁剪与内容截断。
|
||||
// 2. 负责输出可直接插入 v-html 的字符串,不负责 DOM 挂载与样式布局。
|
||||
// 3. 若输入为空,仅返回空串,不抛异常阻断对话主链路。
|
||||
export function renderMarkdown(input: string) {
|
||||
const normalized = (input || '').replace(/\r\n?/g, '\n').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fencedBlocks: string[] = []
|
||||
let source = normalized.replace(/```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```/g, (_, language: string, code: string) => {
|
||||
const token = `@@FENCED_BLOCK_${fencedBlocks.length}@@`
|
||||
const languageClass = language ? ` language-${escapeHtml(language)}` : ''
|
||||
fencedBlocks.push(
|
||||
`<pre class="md-pre"><code class="md-code${languageClass}">${escapeHtml(code.trimEnd())}</code></pre>`,
|
||||
)
|
||||
return token
|
||||
})
|
||||
|
||||
const lines = source.split('\n')
|
||||
const htmlParts: string[] = []
|
||||
let unorderedItems: string[] = []
|
||||
let orderedItems: string[] = []
|
||||
let quoteLines: string[] = []
|
||||
let paragraphLines: string[] = []
|
||||
|
||||
function flushParagraph() {
|
||||
if (paragraphLines.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<p>${parseInlineMarkdown(paragraphLines.join('<br />'))}</p>`)
|
||||
paragraphLines = []
|
||||
}
|
||||
|
||||
function flushUnorderedList() {
|
||||
if (unorderedItems.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<ul>${unorderedItems.map((item) => `<li>${parseInlineMarkdown(item)}</li>`).join('')}</ul>`)
|
||||
unorderedItems = []
|
||||
}
|
||||
|
||||
function flushOrderedList() {
|
||||
if (orderedItems.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<ol>${orderedItems.map((item) => `<li>${parseInlineMarkdown(item)}</li>`).join('')}</ol>`)
|
||||
orderedItems = []
|
||||
}
|
||||
|
||||
function flushBlockquote() {
|
||||
if (quoteLines.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<blockquote>${quoteLines.map((line) => `<p>${parseInlineMarkdown(line)}</p>`).join('')}</blockquote>`)
|
||||
quoteLines = []
|
||||
}
|
||||
|
||||
function flushAllBlocks() {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushOrderedList()
|
||||
flushBlockquote()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
flushAllBlocks()
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/)
|
||||
if (headingMatch) {
|
||||
flushAllBlocks()
|
||||
const level = headingMatch[1].length
|
||||
htmlParts.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/)
|
||||
if (unorderedMatch) {
|
||||
flushParagraph()
|
||||
flushOrderedList()
|
||||
flushBlockquote()
|
||||
unorderedItems.push(unorderedMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/)
|
||||
if (orderedMatch) {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushBlockquote()
|
||||
orderedItems.push(orderedMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const quoteMatch = trimmed.match(/^>\s?(.*)$/)
|
||||
if (quoteMatch) {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushOrderedList()
|
||||
quoteLines.push(quoteMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
paragraphLines.push(trimmed)
|
||||
}
|
||||
|
||||
flushAllBlocks()
|
||||
|
||||
return htmlParts
|
||||
.join('')
|
||||
.replace(/@@FENCED_BLOCK_(\d+)@@/g, (_, index: string) => fencedBlocks[Number(index)] ?? '')
|
||||
return markdownRenderer.render(normalized)
|
||||
}
|
||||
|
||||
136
frontend/src/views/AssistantView.vue
Normal file
136
frontend/src/views/AssistantView.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
|
||||
|
||||
interface PageSwitchItem {
|
||||
key: 'dashboard' | 'assistant'
|
||||
label: string
|
||||
short: string
|
||||
to: '/dashboard' | '/assistant'
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const switchItems: PageSwitchItem[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: '排程',
|
||||
short: '排',
|
||||
to: '/dashboard',
|
||||
},
|
||||
{
|
||||
key: 'assistant',
|
||||
label: '对话',
|
||||
short: 'AI',
|
||||
to: '/assistant',
|
||||
},
|
||||
]
|
||||
|
||||
const activeSwitchKey = computed<PageSwitchItem['key']>(() =>
|
||||
route.path.startsWith('/assistant') ? 'assistant' : 'dashboard',
|
||||
)
|
||||
|
||||
function handlePageSwitch(targetPath: PageSwitchItem['to']) {
|
||||
if (route.path !== targetPath) {
|
||||
router.push(targetPath)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="assistant-view">
|
||||
<section class="assistant-view__layout">
|
||||
<aside class="assistant-view__switch-rail glass-panel">
|
||||
<button
|
||||
v-for="item in switchItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="assistant-view__switch-item"
|
||||
:class="{ 'assistant-view__switch-item--active': activeSwitchKey === item.key }"
|
||||
@click="handlePageSwitch(item.to)"
|
||||
>
|
||||
<span>{{ item.short }}</span>
|
||||
<small>{{ item.label }}</small>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<AssistantPanel class="assistant-view__panel" view-mode="standalone" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assistant-view {
|
||||
height: 100vh;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #f6f8fb 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
.assistant-view__layout {
|
||||
height: calc(100vh - 24px);
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(56px, 0.3fr) minmax(0, 6fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-view__switch-rail {
|
||||
min-height: 0;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: linear-gradient(180deg, rgba(249, 250, 252, 0.95), rgba(243, 247, 252, 0.98));
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
padding: 14px 7px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-view__switch-item {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: #6b7789;
|
||||
padding: 10px 4px 9px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.assistant-view__switch-item span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(86, 101, 126, 0.1);
|
||||
}
|
||||
|
||||
.assistant-view__switch-item small {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.assistant-view__switch-item--active {
|
||||
color: #335fc2;
|
||||
background: linear-gradient(180deg, rgba(88, 126, 224, 0.16), rgba(88, 126, 224, 0.08));
|
||||
}
|
||||
|
||||
.assistant-view__switch-item--active span {
|
||||
background: rgba(58, 95, 184, 0.2);
|
||||
}
|
||||
|
||||
.assistant-view__panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
|
||||
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
|
||||
@@ -13,6 +13,7 @@ import type { TaskItem, TodayEvent } from '@/types/dashboard'
|
||||
import { formatHeaderDate } from '@/utils/date'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const pageLoading = ref(false)
|
||||
@@ -39,13 +40,27 @@ const taskForm = reactive<{
|
||||
deadline_at: null,
|
||||
})
|
||||
|
||||
const sidebarItems = [
|
||||
{ key: 'home', label: '总览', short: '总' },
|
||||
interface SidebarItem {
|
||||
key: 'home' | 'task' | 'calendar' | 'ai'
|
||||
label: string
|
||||
short: string
|
||||
to?: '/dashboard' | '/assistant'
|
||||
}
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
|
||||
{ key: 'task', label: '任务', short: '任' },
|
||||
{ key: 'calendar', label: '日程', short: '程' },
|
||||
{ key: 'ai', label: '助手', short: 'AI' },
|
||||
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
|
||||
]
|
||||
|
||||
const activeSidebarKey = computed<SidebarItem['key']>(() => {
|
||||
if (route.path.startsWith('/assistant')) {
|
||||
return 'ai'
|
||||
}
|
||||
return 'home'
|
||||
})
|
||||
|
||||
const quadrantOrder = [1, 2, 3, 4] as const
|
||||
|
||||
const quadrantMeta: Record<
|
||||
@@ -217,6 +232,20 @@ function handleCourseImportEntry() {
|
||||
ElMessage.info('课表导入入口已预留,下一步我可以继续把导入流程页接出来')
|
||||
}
|
||||
|
||||
function handleSidebarNavigate(item: SidebarItem) {
|
||||
// 1. 已接通路由的入口直接跳转,避免侧栏按钮成为“仅装饰”元素。
|
||||
// 2. 未接通的入口先给出明确提示,防止用户误以为点击失效。
|
||||
// 3. 同路由不重复 push,避免产生无意义导航与日志噪音。
|
||||
if (item.to) {
|
||||
if (route.path !== item.to) {
|
||||
void router.push(item.to)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info(`${item.label} 页面正在开发中`)
|
||||
}
|
||||
|
||||
function clampSidebarWidth(nextWidth: number) {
|
||||
return Math.min(110, Math.max(68, nextWidth))
|
||||
}
|
||||
@@ -320,7 +349,8 @@ onBeforeUnmount(() => {
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="dashboard-sidebar__nav-item"
|
||||
:class="{ 'dashboard-sidebar__nav-item--active': item.key === 'home' }"
|
||||
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
|
||||
@click="handleSidebarNavigate(item)"
|
||||
>
|
||||
<span>{{ item.short }}</span>
|
||||
<small>{{ item.label }}</small>
|
||||
|
||||
Reference in New Issue
Block a user