diff --git a/.gitignore b/.gitignore index 13f1aa3..64918cb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # 2. 依赖管理 (Dependencies) # Go 项目通常不提交 vendor,除非你有特殊需求 /vendor/ +/frontend/node_modules/ # 3. 配置文件与敏感信息 (Security & Configs) # 绝对不要提交包含数据库密码和 Kafka 地址的配置文件 @@ -17,6 +18,7 @@ backend/config.yaml # 4. 临时文件与日志 (Logs & Temp) *.log /tmp/ +/frontend/dist/ # 5. IDE 与系统文件 .idea/ @@ -25,4 +27,4 @@ backend/config.yaml .gocache/ .gomodcache/ .claude/ -.omc/ \ No newline at end of file +.omc/ diff --git a/backend/agent2/graph/quicknote.go b/backend/agent2/graph/quicknote.go index ee5b03b..bbf86eb 100644 --- a/backend/agent2/graph/quicknote.go +++ b/backend/agent2/graph/quicknote.go @@ -12,24 +12,22 @@ import ( ) const ( - // QuickNoteGraphName 是“随口记”图编排的稳定标识。 - // 保留这个名字的目的: - // 1. 让 compile 后的 graph 名称在日志、调试、可视化工具里有固定口径; - // 2. 后续如果接入更多技能图,可以统一按技能名识别。 + // QuickNoteGraphName 是随口记图编排的稳定标识。 + // + // 职责边界: + // 1. 仅用于 graph 编译和链路标识,方便日志与排障统一定位。 + // 2. 不参与意图判断,也不承载任务写库的业务语义。 QuickNoteGraphName = "quick_note" ) -// RunQuickNoteGraph 执行“随口记”图编排。 +// RunQuickNoteGraph 负责执行“随口记 -> 判断 -> 提取 -> 落库 -> 收口”的整条图链路。 // // 职责边界: -// 1. 这里只负责 graph 连线与运行时装配,不负责节点内部业务细节; -// 2. graph 层只挂 node 层对外暴露的方法,不再维护额外 runner 适配层; -// 3. 工具注册、时间基准补齐、compile 参数收口都在这里统一完成。 +// 1. 负责输入兜底、工具装配、节点注册与 graph 运行。 +// 2. 不负责每个节点的具体业务决策,节点内部逻辑由 node 层实现。 +// 3. 返回的 state 表示整条链路的最终状态,供上层继续拼接响应或写日志。 func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInput) (*agentmodel.QuickNoteState, error) { - // 1. 启动前先做硬校验。 - // 1.1 model 为空时无法调模型,直接失败; - // 1.2 state 为空时图无法承载共享上下文,也必须直接拦截; - // 1.3 tool deps 不完整时,后续 persist 节点必然失败,因此这里提前收口。 + // 1. 先校验最基础依赖,避免图已经启动后才发现模型或状态为空。 if input.Model == nil { return nil, errors.New("quick note graph: model is nil") } @@ -40,9 +38,7 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp return nil, err } - // 2. 统一补齐本次请求的时间基准。 - // 2.1 RequestNow 只在整条 quicknote 链路入口确定一次,避免同一次请求里相对时间口径漂移; - // 2.2 RequestNowText 是 prompt 注入用文本,缺失时也在这里统一补齐。 + // 2. 补齐当前请求时间,保证后续提示词、时间解析和落库字段都基于同一时刻。 if input.State.RequestNow.IsZero() { input.State.RequestNow = agentshared.NowToMinute() } @@ -50,9 +46,7 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp input.State.RequestNowText = agentshared.FormatMinute(input.State.RequestNow) } - // 3. 构建工具包并提取“创建任务”工具。 - // 3.1 graph 层只关心“拿到一个可执行工具”,不关心工具内部如何注册; - // 3.2 失败时直接返回,避免把半残依赖继续交给 node 层。 + // 3. 图运行前统一准备工具与节点容器,避免节点内部重复做依赖解析。 toolBundle, err := agentnode.BuildQuickNoteToolBundle(ctx, input.Deps) if err != nil { return nil, err @@ -62,18 +56,13 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp return nil, err } - // 4. 在 node 层创建节点容器。 - // 4.1 这一步就是“请求级依赖注入”的唯一收口点; - // 4.2 graph 后续只认 `nodes.Intent / nodes.Priority / nodes.Persist` 这些方法,不再额外造 runner。 nodes, err := agentnode.NewQuickNoteNodes(input, createTaskTool) if err != nil { return nil, err } - // 5. 创建状态图容器,输入输出统一都是 *QuickNoteState。 + // 4. 主链路保持“意图识别 -> 优先级评估 -> 持久化 -> 退出”,中间通过 branch 决定是否提前结束或重试写库。 graph := compose.NewGraph[*agentmodel.QuickNoteState, *agentmodel.QuickNoteState]() - - // 6. 注册节点。 if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeIntent, compose.InvokableLambda(nodes.Intent)); err != nil { return nil, err } @@ -87,14 +76,9 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp return nil, err } - // 7. 所有请求统一从 intent 节点开始。 if err = graph.AddEdge(compose.START, agentnode.QuickNoteGraphNodeIntent); err != nil { return nil, err } - - // 8. intent 后分支: - // 8.1 命中随口记且时间合法 -> priority; - // 8.2 非随口记,或时间校验失败 -> exit。 if err = graph.AddBranch(agentnode.QuickNoteGraphNodeIntent, compose.NewGraphBranch( nodes.NextAfterIntent, map[string]bool{ @@ -104,22 +88,12 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp )); err != nil { return nil, err } - - // 9. 显式 exit 节点仍然保留。 - // 这样后续若要统一加日志、埋点、收尾逻辑,不需要再改 branch 结构。 if err = graph.AddEdge(agentnode.QuickNoteGraphNodeExit, compose.END); err != nil { return nil, err } - - // 10. priority 后固定进入 persist。 if err = graph.AddEdge(agentnode.QuickNoteGraphNodeRank, agentnode.QuickNoteGraphNodePersist); err != nil { return nil, err } - - // 11. persist 后分支: - // 11.1 已成功写入 -> END; - // 11.2 仍可重试 -> 回到 persist; - // 11.3 重试耗尽 -> END,由 state 中的失败文案兜底。 if err = graph.AddBranch(agentnode.QuickNoteGraphNodePersist, compose.NewGraphBranch( nodes.NextAfterPersist, map[string]bool{ @@ -130,13 +104,12 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp return nil, err } - // 12. 为 persist 重试预留运行步数余量,避免异常状态把图跑成死循环。 + // 5. persist 节点允许有限次重试,因此最大步数要覆盖首次执行与重试回路。 maxSteps := input.State.MaxToolRetry + 10 if maxSteps < 12 { maxSteps = 12 } - // 13. 编译并执行图。 runnable, err := graph.Compile(ctx, compose.WithGraphName(QuickNoteGraphName), compose.WithMaxRunSteps(maxSteps), diff --git a/backend/agent2/graph/taskquery.go b/backend/agent2/graph/taskquery.go index 67067d2..ce49791 100644 --- a/backend/agent2/graph/taskquery.go +++ b/backend/agent2/graph/taskquery.go @@ -1,22 +1,126 @@ package agentgraph -import agentnode "github.com/LoveLosita/smartflow/backend/agent2/node" +import ( + "context" + "errors" + "strings" + "time" -const ( - TaskQueryGraphName = "task_query" - - TaskQueryNodePlan = "task_query.plan" - TaskQueryNodeTool = "task_query.tool.query" - TaskQueryNodeReflect = "task_query.reflect" - TaskQueryNodeReply = "task_query.reply" + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + agentnode "github.com/LoveLosita/smartflow/backend/agent2/node" + "github.com/cloudwego/eino/compose" ) -// TaskQueryGraph 是“随口问任务”图编排骨架。 -type TaskQueryGraph struct { - Nodes *agentnode.TaskQueryNodes -} +const ( + // TaskQueryGraphName 是任务查询图编排的稳定标识。 + // + // 职责边界: + // 1. 仅用于 graph 编译、日志和排障时标识当前链路。 + // 2. 不承载路由判断,也不负责描述具体业务含义。 + TaskQueryGraphName = "task_query" +) -// NewTaskQueryGraph 创建任务查询图骨架。 -func NewTaskQueryGraph(nodes *agentnode.TaskQueryNodes) *TaskQueryGraph { - return &TaskQueryGraph{Nodes: nodes} +// RunTaskQueryGraph 负责串起任务查询图,并返回最终给用户的回复文本。 +// +// 职责边界: +// 1. 负责做图运行前的依赖校验、默认值补齐、节点装配与 graph 编译执行。 +// 2. 不负责实现单个节点的业务细节,这些逻辑由 node 层承接。 +// 3. 返回值中的 string 是最终可直接透传给上层的回复;error 仅表示链路级失败。 +func RunTaskQueryGraph(ctx context.Context, input agentnode.TaskQueryGraphRunInput) (string, error) { + // 1. 先拦住空模型、空状态和依赖缺失,避免 graph 运行到一半才出现不可恢复错误。 + if input.Model == nil { + return "", errors.New("task query graph: model is nil") + } + if input.State == nil { + return "", errors.New("task query graph: state is nil") + } + if err := input.Deps.Validate(); err != nil { + return "", err + } + + // 2. 请求时间缺失时补齐当前时间,保证后续时间锚定与提示词上下文稳定。 + if strings.TrimSpace(input.State.RequestNowText) == "" { + input.State.RequestNowText = time.Now().In(time.Local).Format("2006-01-02 15:04") + } + + // 3. 先准备工具,再构造节点容器;这样 graph 中每个节点都能拿到已校验好的依赖。 + toolBundle, err := agentnode.BuildTaskQueryToolBundle(ctx, input.Deps) + if err != nil { + return "", err + } + queryTool, err := agentnode.GetTaskQueryInvokableToolByName(toolBundle, agentnode.ToolNameTaskQueryTasks) + if err != nil { + return "", err + } + nodes, err := agentnode.NewTaskQueryNodes(input, queryTool) + if err != nil { + return "", err + } + + // 4. 注册节点与边,保持“计划 -> 归一化 -> 时间锚定 -> 查询 -> 反思”的单向主链。 + graph := compose.NewGraph[*agentmodel.TaskQueryState, *agentmodel.TaskQueryState]() + if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil { + return "", err + } + if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeQuadrant, compose.InvokableLambda(nodes.NormalizeQuadrant)); err != nil { + return "", err + } + if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeTimeAnchor, compose.InvokableLambda(nodes.AnchorTime)); err != nil { + return "", err + } + if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeQuery, compose.InvokableLambda(nodes.Query)); err != nil { + return "", err + } + if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeReflect, compose.InvokableLambda(nodes.Reflect)); err != nil { + return "", err + } + if err = graph.AddEdge(compose.START, agentnode.TaskQueryGraphNodePlan); err != nil { + return "", err + } + if err = graph.AddEdge(agentnode.TaskQueryGraphNodePlan, agentnode.TaskQueryGraphNodeQuadrant); err != nil { + return "", err + } + if err = graph.AddEdge(agentnode.TaskQueryGraphNodeQuadrant, agentnode.TaskQueryGraphNodeTimeAnchor); err != nil { + return "", err + } + if err = graph.AddEdge(agentnode.TaskQueryGraphNodeTimeAnchor, agentnode.TaskQueryGraphNodeQuery); err != nil { + return "", err + } + if err = graph.AddEdge(agentnode.TaskQueryGraphNodeQuery, agentnode.TaskQueryGraphNodeReflect); err != nil { + return "", err + } + if err = graph.AddBranch(agentnode.TaskQueryGraphNodeReflect, compose.NewGraphBranch(nodes.NextAfterReflect, map[string]bool{ + agentnode.TaskQueryGraphNodeQuery: true, + compose.END: true, + })); err != nil { + return "", err + } + + // 5. 反思节点支持按配置重试,因此最大步数需要覆盖“首次查询 + 多轮回看”的上限。 + maxSteps := 24 + input.State.MaxReflectRetry*4 + if maxSteps < 24 { + maxSteps = 24 + } + runnable, err := graph.Compile(ctx, + compose.WithGraphName(TaskQueryGraphName), + compose.WithMaxRunSteps(maxSteps), + compose.WithNodeTriggerMode(compose.AnyPredecessor), + ) + if err != nil { + return "", err + } + finalState, err := runnable.Invoke(ctx, input.State) + if err != nil { + return "", err + } + if finalState == nil { + return "", errors.New("task query graph: final state is nil") + } + + // 6. 最终回复为空时给一个稳定兜底,避免上层拿到空字符串后再次拼接出异常文案。 + reply := strings.TrimSpace(finalState.FinalReply) + if reply == "" { + reply = "我这边暂时没整理出稳定结果,你可以换一个更具体的筛选条件再试一次。" + } + return reply, nil } diff --git a/backend/agent2/llm/taskquery.go b/backend/agent2/llm/taskquery.go index a095ca7..c2cac21 100644 --- a/backend/agent2/llm/taskquery.go +++ b/backend/agent2/llm/taskquery.go @@ -1,19 +1,83 @@ package agentllm -// TaskQueryPlanOutput 是“随口问任务”聚合规划的模型契约草案。 +import ( + "context" + + agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt" + "github.com/cloudwego/eino-ext/components/model/ark" +) + +// TaskQueryPlanOutput 描述计划节点返回的结构化查询方案。 +// +// 职责边界: +// 1. 只承接模型输出,不在这里做合法性校验。 +// 2. 字段为空或非法时,由 node 层继续归一化与兜底。 type TaskQueryPlanOutput struct { - Intent string `json:"intent"` - Quadrants []int `json:"quadrants"` - SortBy string `json:"sort_by"` - Limit int `json:"limit"` - TimeRange string `json:"time_range"` - NeedBroadening bool `json:"need_broadening"` - Keywords []string `json:"keywords"` + UserGoal string `json:"user_goal"` + Quadrants []int `json:"quadrants"` + SortBy string `json:"sort_by"` + Order string `json:"order"` + Limit int `json:"limit"` + IncludeCompleted *bool `json:"include_completed"` + Keyword string `json:"keyword"` + DeadlineBefore string `json:"deadline_before"` + DeadlineAfter string `json:"deadline_after"` } -// TaskQueryReflectOutput 是查询结果反思节点的模型契约草案。 -type TaskQueryReflectOutput struct { - Satisfied bool `json:"satisfied"` - NeedRetry bool `json:"need_retry"` - RetrySuggestion string `json:"retry_suggestion"` +// TaskQueryRetryPatch 描述反思节点允许回写的计划补丁。 +// +// 输入输出语义: +// 1. 指针字段为 nil 表示“不改这个字段”。 +// 2. 非 nil 但值为空字符串,表示显式清空该条件。 +type TaskQueryRetryPatch struct { + Quadrants *[]int `json:"quadrants,omitempty"` + SortBy *string `json:"sort_by,omitempty"` + Order *string `json:"order,omitempty"` + Limit *int `json:"limit,omitempty"` + IncludeCompleted *bool `json:"include_completed,omitempty"` + Keyword *string `json:"keyword,omitempty"` + DeadlineBefore *string `json:"deadline_before,omitempty"` + DeadlineAfter *string `json:"deadline_after,omitempty"` +} + +// TaskQueryReflectOutput 描述反思节点对本轮查询结果的判定。 +// +// 输入输出语义: +// 1. Satisfied=true 表示当前结果可直接收口。 +// 2. NeedRetry=true 表示建议再跑一轮,但真正是否重试由 node 层结合次数上限决定。 +// 3. Reply 是可直接给用户的候选文案,允许为空。 +type TaskQueryReflectOutput struct { + Satisfied bool `json:"satisfied"` + NeedRetry bool `json:"need_retry"` + Reason string `json:"reason"` + Reply string `json:"reply"` + RetryPatch TaskQueryRetryPatch `json:"retry_patch"` +} + +// PlanTaskQuery 负责调用模型,把自然语言查询规划成结构化检索参数。 +// +// 职责边界: +// 1. 只负责模型调用与 JSON 解析。 +// 2. 不负责结果兜底、限流裁剪或时间归一化。 +func PlanTaskQuery(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*TaskQueryPlanOutput, error) { + parsed, _, err := CallArkJSON[TaskQueryPlanOutput](ctx, chatModel, agentprompt.TaskQueryPlanPrompt, agentprompt.BuildTaskQueryPlanUserPrompt(nowText, userInput), ArkCallOptions{ + Temperature: 0, + MaxTokens: 260, + Thinking: ThinkingModeDisabled, + }) + return parsed, err +} + +// ReflectTaskQuery 负责让模型判断当前查询结果是否满足用户意图。 +// +// 职责边界: +// 1. 只负责反思提示词调用与结构化解析。 +// 2. 不负责实际执行重试,也不负责拼接最终兜底回复。 +func ReflectTaskQuery(ctx context.Context, chatModel *ark.ChatModel, prompt string) (*TaskQueryReflectOutput, error) { + parsed, _, err := CallArkJSON[TaskQueryReflectOutput](ctx, chatModel, agentprompt.TaskQueryReflectPrompt, prompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: 380, + Thinking: ThinkingModeDisabled, + }) + return parsed, err } diff --git a/backend/agent2/model/quicknote.go b/backend/agent2/model/quicknote.go index e7cd7d5..0fc8b5c 100644 --- a/backend/agent2/model/quicknote.go +++ b/backend/agent2/model/quicknote.go @@ -7,89 +7,56 @@ import ( ) const ( - // QuickNoteDatetimeMinuteLayout 是“随口记”链路内部统一的分钟级时间格式。 - // 说明: - // 1) 用于把“当前时间基准”传给模型,避免模型在相对时间推断时出现秒级抖动。 - // 2) 用于日志和调试,读起来比 RFC3339 更直观。 + // QuickNoteDatetimeMinuteLayout 是随口记链路统一使用的分钟级时间格式。 QuickNoteDatetimeMinuteLayout = "2006-01-02 15:04" - - // QuickNoteTimezoneName 是随口记链路默认业务时区。 - // 这里固定为东八区,避免容器运行在 UTC 时把“明天/今晚”解释偏移到错误日期。 + // QuickNoteTimezoneName 是随口记时间解析与展示优先使用的时区。 QuickNoteTimezoneName = "Asia/Shanghai" - // QuickNotePriorityImportantUrgent 对应四象限里的“重要且紧急”。 - QuickNotePriorityImportantUrgent = 1 - // QuickNotePriorityImportantNotUrgent 对应“重要不紧急”。 - QuickNotePriorityImportantNotUrgent = 2 - // QuickNotePrioritySimpleNotImportant 对应“简单不重要”。 - QuickNotePrioritySimpleNotImportant = 3 - // QuickNotePriorityComplexNotImportant 对应“不简单不重要”。 - QuickNotePriorityComplexNotImportant = 4 + QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent + QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent + QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant + QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant ) -// IsValidTaskPriority 判断优先级是否合法。 -func IsValidTaskPriority(priority int) bool { - return priority >= QuickNotePriorityImportantUrgent && priority <= QuickNotePriorityComplexNotImportant -} - -// PriorityLabelCN 把优先级数值转换为中文标签,便于拼接给用户的自然语言回复。 -func PriorityLabelCN(priority int) string { - switch priority { - case QuickNotePriorityImportantUrgent: - return "重要且紧急" - case QuickNotePriorityImportantNotUrgent: - return "重要不紧急" - case QuickNotePrioritySimpleNotImportant: - return "简单不重要" - case QuickNotePriorityComplexNotImportant: - return "不简单不重要" - default: - return "未知优先级" - } -} - -// QuickNoteState 是“AI随口记”链路在 graph 节点间传递的统一状态容器。 +// QuickNoteState 是随口记图在节点间流转的完整状态。 +// +// 职责边界: +// 1. 负责保存意图识别、任务提取、工具重试和最终回复所需状态。 +// 2. 不负责图编排,也不直接映射数据库任务实体。 type QuickNoteState struct { TraceID string UserID int ConversationID string - - // RequestNow 记录“请求进入随口记链路时”的时间基准(分钟级)。 - RequestNow time.Time - // RequestNowText 是 RequestNow 的字符串形式,主要用于 prompt 注入。 + RequestNow time.Time RequestNowText string - - UserInput string + UserInput string IsQuickNoteIntent bool IntentJudgeReason string - ExtractedTitle string - ExtractedDeadline *time.Time - ExtractedDeadlineText string - // ExtractedUrgencyThreshold 表示“进入紧急象限的分界时间”。 + ExtractedTitle string + ExtractedDeadline *time.Time + ExtractedDeadlineText string ExtractedUrgencyThreshold *time.Time ExtractedPriority int - // ExtractedBanter 是聚合规划阶段生成的“轻松跟进句”。 - ExtractedBanter string - // PlannedBySingleCall 标记本次是否走了“单请求聚合规划”快路径。 - PlannedBySingleCall bool - - ExtractedPriorityReason string - // DeadlineValidationError 记录时间校验失败原因。 - DeadlineValidationError string + ExtractedBanter string + PlannedBySingleCall bool + ExtractedPriorityReason string + DeadlineValidationError string ToolAttemptCount int MaxToolRetry int LastToolError string - - PersistedTaskID int - Persisted bool - - AssistantReply string + PersistedTaskID int + Persisted bool + AssistantReply string } -// NewQuickNoteState 创建随口记状态对象并初始化默认重试次数。 +// NewQuickNoteState 负责创建随口记图的初始状态。 +// +// 输入输出语义: +// 1. RequestNow 与 RequestNowText 会在创建时同步写入,保证整条链路共用同一时间基准。 +// 2. MaxToolRetry 默认给 3,避免上层未配置时完全失去重试能力。 func NewQuickNoteState(traceID string, userID int, conversationID, userInput string) *QuickNoteState { requestNow := agentshared.NowToMinute() return &QuickNoteState{ @@ -103,18 +70,30 @@ func NewQuickNoteState(traceID string, userID int, conversationID, userInput str } } -// CanRetryTool 判断当前是否还能继续重试工具调用。 +// CanRetryTool 返回当前是否还允许再次调用持久化工具。 +// +// 输入输出语义: +// 1. true 表示“尚未达到最大重试次数”,调用方仍可继续重试。 +// 2. false 表示必须收口,避免无限重试。 func (s *QuickNoteState) CanRetryTool() bool { return s.ToolAttemptCount < s.MaxToolRetry } -// RecordToolError 记录一次工具调用失败。 +// RecordToolError 记录一次工具失败,并推进重试计数。 +// +// 职责边界: +// 1. 只更新与工具失败相关的状态。 +// 2. 不决定是否继续重试,是否重试由节点分支逻辑判断。 func (s *QuickNoteState) RecordToolError(errMsg string) { s.ToolAttemptCount++ s.LastToolError = errMsg } -// RecordToolSuccess 记录一次工具调用成功。 +// RecordToolSuccess 记录一次工具成功结果。 +// +// 输入输出语义: +// 1. taskID 必须是持久化后的真实任务 ID。 +// 2. 成功后会清空 LastToolError,表示当前链路已进入稳定态。 func (s *QuickNoteState) RecordToolSuccess(taskID int) { s.ToolAttemptCount++ s.PersistedTaskID = taskID diff --git a/backend/agent2/model/task_priority.go b/backend/agent2/model/task_priority.go new file mode 100644 index 0000000..fc40802 --- /dev/null +++ b/backend/agent2/model/task_priority.go @@ -0,0 +1,37 @@ +package agentmodel + +const ( + TaskPriorityImportantUrgent = 1 + TaskPriorityImportantNotUrgent = 2 + TaskPrioritySimpleNotImportant = 3 + TaskPriorityComplexNotImportant = 4 +) + +// IsValidTaskPriority 用于校验任务优先级是否合法。 +// +// 职责边界: +// 1. 只负责判断 priority 是否落在系统支持的 1~4 范围内。 +// 2. 不负责把自然语言映射成优先级,也不负责做业务兜底推断。 +func IsValidTaskPriority(priority int) bool { + return priority >= TaskPriorityImportantUrgent && priority <= TaskPriorityComplexNotImportant +} + +// PriorityLabelCN 返回任务优先级对应的中文标签。 +// +// 职责边界: +// 1. 只负责“优先级枚举 -> 中文展示文案”的稳定映射。 +// 2. 不负责国际化、多语言切换或业务规则解释。 +func PriorityLabelCN(priority int) string { + switch priority { + case TaskPriorityImportantUrgent: + return "重要且紧急" + case TaskPriorityImportantNotUrgent: + return "重要不紧急" + case TaskPrioritySimpleNotImportant: + return "简单不重要" + case TaskPriorityComplexNotImportant: + return "复杂不重要" + default: + return "未知优先级" + } +} diff --git a/backend/agent2/model/taskquery.go b/backend/agent2/model/taskquery.go index 7748999..77fa164 100644 --- a/backend/agent2/model/taskquery.go +++ b/backend/agent2/model/taskquery.go @@ -1,11 +1,87 @@ package agentmodel -// TaskQueryState 是“任务查询”skill 的运行时状态骨架。 -type TaskQueryState struct { - UserInput string - RequestNowText string - NeedRetry bool - RetryCount int - MaxReflectRetry int - FinalReply string +import "time" + +const ( + // DefaultTaskQueryLimit 是任务查询默认返回条数。 + DefaultTaskQueryLimit = 5 + // MaxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制模型输出范围。 + MaxTaskQueryLimit = 20 + // DefaultTaskQueryReflectRetry 是任务查询反思节点的默认重试次数。 + DefaultTaskQueryReflectRetry = 2 +) + +// TaskQueryItem 是任务查询链路最终展示给模型和用户的轻量任务视图。 +// +// 职责边界: +// 1. 只承载展示和反思所需字段,避免把底层数据库结构直接暴露给图层。 +// 2. 不负责描述完整任务实体,也不负责持久化。 +type TaskQueryItem struct { + ID int `json:"id"` + Title string `json:"title"` + PriorityGroup int `json:"priority_group"` + PriorityLabel string `json:"priority_label"` + IsCompleted bool `json:"is_completed"` + DeadlineAt string `json:"deadline_at,omitempty"` + UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"` +} + +// TaskQueryPlan 是计划节点产出的内部查询方案。 +// +// 输入输出语义: +// 1. DeadlineBeforeText / DeadlineAfterText 保留原始文本,便于继续透传给工具和日志。 +// 2. DeadlineBefore / DeadlineAfter 是归一化后的时间对象,仅供执行期使用。 +// 3. IncludeCompleted=true 表示允许把已完成任务纳入候选集。 +type TaskQueryPlan struct { + Quadrants []int + SortBy string + Order string + Limit int + + IncludeCompleted bool + Keyword string + DeadlineBeforeText string + DeadlineAfterText string + DeadlineBefore *time.Time + DeadlineAfter *time.Time +} + +// TaskQueryState 是任务查询图在各节点之间流转的完整状态。 +// +// 职责边界: +// 1. 负责保存用户输入、结构化计划、工具结果和反思过程状态。 +// 2. 不负责图编排本身,也不直接绑定外部数据库实体。 +type TaskQueryState struct { + UserMessage string + RequestNowText string + UserGoal string + Plan TaskQueryPlan + ExplicitLimit int + + LastQueryItems []TaskQueryItem + LastQueryTotal int + AutoBroadenApplied bool + RetryCount int + MaxReflectRetry int + NeedRetry bool + ReflectReason string + FinalReply string +} + +// NewTaskQueryState 负责创建任务查询图的初始状态。 +// +// 输入输出语义: +// 1. maxReflectRetry <= 0 时会自动回退到默认值,避免上层遗漏配置导致无法重试。 +// 2. 返回的状态对象已初始化空切片,可直接进入 graph 执行。 +func NewTaskQueryState(userMessage, requestNowText string, maxReflectRetry int) *TaskQueryState { + if maxReflectRetry <= 0 { + maxReflectRetry = DefaultTaskQueryReflectRetry + } + return &TaskQueryState{ + UserMessage: userMessage, + RequestNowText: requestNowText, + MaxReflectRetry: maxReflectRetry, + LastQueryItems: make([]TaskQueryItem, 0), + AutoBroadenApplied: false, + } } diff --git a/backend/agent2/node/quicknote.go b/backend/agent2/node/quicknote.go index edced05..7b79aa7 100644 --- a/backend/agent2/node/quicknote.go +++ b/backend/agent2/node/quicknote.go @@ -11,33 +11,21 @@ import ( ) const ( - // QuickNoteGraphNodeIntent 是随口记图里的“意图识别”节点名。 - // 这里把节点名下沉到 node 层,是为了让: - // 1. 节点自己的分支方法可以直接返回目标节点名; - // 2. graph 层只负责连线,不需要反向暴露常量给 node 层; - // 3. 后续若节点改名,只需要在这里统一收口。 + // QuickNoteGraphNodeIntent 是随口记图中的“意图识别”节点名。 QuickNoteGraphNodeIntent = "quick_note_intent" - // QuickNoteGraphNodeRank 是随口记图里的“优先级评估”节点名。 + // QuickNoteGraphNodeRank 是随口记图中的“优先级评估”节点名。 QuickNoteGraphNodeRank = "quick_note_priority" - // QuickNoteGraphNodePersist 是随口记图里的“持久化写库”节点名。 + // QuickNoteGraphNodePersist 是随口记图中的“持久化写库”节点名。 QuickNoteGraphNodePersist = "quick_note_persist" - // QuickNoteGraphNodeExit 是随口记图里的“提前退出”节点名。 + // QuickNoteGraphNodeExit 是随口记图中的“提前退出”节点名。 QuickNoteGraphNodeExit = "quick_note_exit" ) -// QuickNoteGraphRunInput 描述一次“随口记图运行”所需的请求级依赖。 +// QuickNoteGraphRunInput 描述一次随口记图运行所需的请求级依赖。 // // 职责边界: -// 1. Model:当前请求实际使用的聊天模型; -// 2. State:本次图运行共享的状态对象; -// 3. Deps:工具层依赖,例如解析 user_id、执行写库; -// 4. SkipIntentVerification:若上游路由已高置信命中,可跳过二次意图判断; -// 5. EmitStage:向外层推送阶段消息的可选回调。 -// -// 不负责什么: -// 1. 不负责真正的 graph 连线; -// 2. 不负责工具注册与提取; -// 3. 不负责节点内部业务流转。 +// 1. 负责把模型、初始状态、工具依赖和阶段回调打包给 graph 层。 +// 2. 不负责做依赖校验,校验逻辑由 graph/node 构造阶段处理。 type QuickNoteGraphRunInput struct { Model *ark.ChatModel State *agentmodel.QuickNoteState @@ -46,29 +34,22 @@ type QuickNoteGraphRunInput struct { EmitStage func(stage, detail string) } -// QuickNoteNodes 是“随口记”节点容器。 -// -// 设计目的: -// 1. 把“请求级依赖”收口到 node 层,而不是继续堆在 graph 层; -// 2. 让 graph 层直接挂 `nodes.Intent / nodes.Priority / nodes.Persist` 这些方法; -// 3. 这样 graph 文件就只负责画图,不再负责依赖转接。 +// QuickNoteNodes 是随口记图的节点容器。 // // 职责边界: -// 1. 负责提供可直接挂载到 graph 的节点方法; -// 2. 负责在节点执行时读取本次请求的 input / tool / stage emitter; -// 3. 不负责 graph 编译与运行,也不负责 service 层收尾持久化。 +// 1. 负责承接节点运行时依赖,并向 graph 暴露可直接挂载的方法。 +// 2. 不负责 graph 编译,也不负责 service 层接口接线。 type QuickNoteNodes struct { input QuickNoteGraphRunInput createTaskTool tool.InvokableTool emitStage func(stage, detail string) } -// NewQuickNoteNodes 创建随口记节点容器。 +// NewQuickNoteNodes 负责构造随口记节点容器。 // -// 说明: -// 1. 这里做的是“节点依赖注入”,不是 graph 连线; -// 2. emitStage 允许为空,内部会补成 no-op,避免节点里反复判空; -// 3. createTaskTool 为 persist 节点的硬依赖,缺失时直接报错,避免跑到写库节点再失败。 +// 输入输出语义: +// 1. createTaskTool 不能为空,否则 persist 节点无法落库。 +// 2. EmitStage 为空时会回退到空实现,避免节点内部到处判空。 func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool) (*QuickNoteNodes, error) { if createTaskTool == nil { return nil, errors.New("quick note nodes: createTaskTool is nil") @@ -86,18 +67,22 @@ func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.Invokab }, nil } -// Exit 是图里的显式退出节点。 +// Exit 是图中的显式退出节点。 // // 职责边界: -// 1. 只负责把当前 state 原样透传到 END; -// 2. 不负责追加业务逻辑; -// 3. 保留这个节点,是为了后续若要补统一埋点、日志、收尾逻辑时有稳定挂载点。 +// 1. 仅作为图收口占位,保持状态原样透传。 +// 2. 不做额外业务处理,避免退出节点再引入副作用。 func (n *QuickNoteNodes) Exit(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { _ = ctx return st, nil } -// NextAfterIntent 负责根据意图识别结果决定 intent 后的分支走向。 +// NextAfterIntent 根据意图识别结果决定 intent 节点后的分支走向。 +// +// 步骤说明: +// 1. 非随口记意图时直接退出,避免误把普通聊天写成任务。 +// 2. 截止时间校验失败时同样直接退出,让上层优先把错误提示给用户。 +// 3. 只有意图成立且时间合法,才进入优先级评估节点。 func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { _ = ctx if st == nil || !st.IsQuickNoteIntent { @@ -107,10 +92,14 @@ func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.Qui return QuickNoteGraphNodeExit, nil } return QuickNoteGraphNodeRank, nil - } -// NextAfterPersist 负责根据持久化结果决定 persist 后的分支走向。 +// NextAfterPersist 根据持久化结果决定 persist 节点后的分支走向。 +// +// 输入输出语义: +// 1. Persisted=true 表示已经成功写库,可以直接结束。 +// 2. Persisted=false 且 CanRetryTool()=true 表示继续重试写库。 +// 3. 重试用尽后会补齐兜底回复,再结束链路,避免用户拿到空响应。 func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { _ = ctx if st == nil { @@ -123,9 +112,6 @@ func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.Qu return QuickNoteGraphNodePersist, nil } if st.AssistantReply == "" { - // 1. 重试次数耗尽且上游没有明确失败文案时,在这里补一条兜底回复; - // 2. 这样可以保证图结束后 service 层一定能拿到稳定可展示的失败信息; - // 3. 不在 graph 层处理,是因为这属于节点业务状态修正。 st.AssistantReply = "抱歉,我已经重试了多次,还是没能成功记录这条任务,请稍后再试。" } return compose.END, nil diff --git a/backend/agent2/node/quicknote_tool.go b/backend/agent2/node/quicknote_tool.go index 6b27941..2f427b8 100644 --- a/backend/agent2/node/quicknote_tool.go +++ b/backend/agent2/node/quicknote_tool.go @@ -17,16 +17,13 @@ import ( ) const ( - // ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的标准名称。 - // 该名称会直接暴露给大模型,因此建议保持稳定,避免后续提示词和历史上下文失配。 + // ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的稳定名称。 ToolNameQuickNoteCreateTask = "quick_note_create_task" - // ToolDescQuickNoteCreateTask 是工具的简要职责说明。 + // ToolDescQuickNoteCreateTask 是给大模型看的工具职责说明。 ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级" ) var ( - // quickNoteDeadlineLayouts 是“绝对时间”白名单格式。 - // 只要命中任意一个 layout,就会被归一化为分钟级时间并进入写库流程。 quickNoteDeadlineLayouts = []string{ time.RFC3339, "2006-01-02T15:04", @@ -46,9 +43,6 @@ var ( "2006.01.02": {}, } - // 正则区: - // 1) 用于解析明确时间表达; - // 2) 用于“是否存在时间线索”的判定(即使格式错误,也会触发校验失败而非静默忽略)。 quickNoteClockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[::]\s*(\d{1,2})`) quickNoteClockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`) quickNoteYMDRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`) @@ -61,48 +55,38 @@ var ( } ) -// QuickNoteToolDeps 描述“随口记工具包”需要的外部依赖。 -// 这里采用函数注入的方式,避免 agent 包和 service/dao 强耦合,后续更容易演进为 mock 测试或多实现切换。 +// QuickNoteToolDeps 描述随口记工具所需的外部依赖。 type QuickNoteToolDeps struct { - // ResolveUserID 从上下文中解析当前登录用户 ID。 ResolveUserID func(ctx context.Context) (int, error) - // CreateTask 执行真实写库动作。 - CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error) + CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error) } func (d QuickNoteToolDeps) Validate() error { - // 1. ResolveUserID 为空会导致工具无法绑定当前用户,必须提前失败。 if d.ResolveUserID == nil { return errors.New("quick note tool deps: ResolveUserID is nil") } - // 2. CreateTask 为空说明没有真实写库实现,工具无法完成核心职责。 if d.CreateTask == nil { return errors.New("quick note tool deps: CreateTask is nil") } return nil } -// QuickNoteToolBundle 是随口记工具集合的打包结果。 -// - Tools: 给 ToolsNode 使用 -// - ToolInfos: 给 ChatModel 绑定工具 schema 使用 -// 两者分开返回,可以适配你后面用 chain、graph、react 的不同挂载姿势。 +// QuickNoteToolBundle 是随口记工具集合。 type QuickNoteToolBundle struct { Tools []tool.BaseTool ToolInfos []*schema.ToolInfo } -// QuickNoteCreateTaskRequest 是工具层到业务层的内部请求结构。 -// 与模型输入解耦,避免模型字段变化直接影响业务签名。 +// QuickNoteCreateTaskRequest 是工具层传给业务层的内部请求。 type QuickNoteCreateTaskRequest struct { - UserID int - Title string - PriorityGroup int - DeadlineAt *time.Time - // UrgencyThresholdAt 是“进入紧急象限”的分界时间,允许为空。 + UserID int + Title string + PriorityGroup int + DeadlineAt *time.Time UrgencyThresholdAt *time.Time } -// QuickNoteCreateTaskResult 是业务层返回给工具层的结构化结果。 +// QuickNoteCreateTaskResult 是业务层回给工具层的结构化结果。 type QuickNoteCreateTaskResult struct { TaskID int Title string @@ -111,21 +95,18 @@ type QuickNoteCreateTaskResult struct { UrgencyThresholdAt *time.Time } -// QuickNoteCreateTaskToolInput 是提供给大模型的工具参数定义。 -// 注意:user_id 不对模型暴露,统一从鉴权上下文提取,避免越权写入。 +// QuickNoteCreateTaskToolInput 是暴露给模型的工具入参。 type QuickNoteCreateTaskToolInput struct { Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"` - // PriorityGroup 使用 1~4,和后端 tasks.priority 保持一致。 - PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4不简单不重要)"` - // DeadlineAt 支持绝对时间与常见相对时间(如明天/后天/下周一/今晚),内部会归一化为绝对时间。 + // PriorityGroup 与 tasks.priority 保持一致,取值 1~4。 + PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4复杂不重要)"` + // DeadlineAt 支持绝对时间与常见中文相对时间。 DeadlineAt string `json:"deadline_at,omitempty" jsonschema:"description=可选截止时间,支持RFC3339、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd HH:mm 以及常见中文相对时间"` - // UrgencyThresholdAt 表示“何时从不紧急象限自动平移到紧急象限”。 - // 允许为空;非空时会走同样的时间解析与合法性校验。 + // UrgencyThresholdAt 表示何时自动进入紧急象限。 UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间,支持与deadline_at相同格式"` } -// QuickNoteCreateTaskToolOutput 是返回给大模型的工具结果。 -// 该结构可直接给模型用于“向用户解释已记录到哪个优先级”。 +// QuickNoteCreateTaskToolOutput 是返回给模型的结构化结果。 type QuickNoteCreateTaskToolOutput struct { TaskID int `json:"task_id"` Title string `json:"title"` @@ -135,26 +116,20 @@ type QuickNoteCreateTaskToolOutput struct { Message string `json:"message"` } -// BuildQuickNoteToolBundle 构建“AI随口记”工具包。 -// 这是 agent 目录给上层编排层(chain/graph/react)提供的统一入口。 +// BuildQuickNoteToolBundle 构建随口记工具包。 func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) { - // 1. 启动期做依赖校验,尽早暴露 wiring 问题,避免运行时才 panic。 if err := deps.Validate(); err != nil { return nil, err } - // 2. 通过 InferTool 把 Go 函数声明成“模型可调用工具”。 - // 该闭包函数是工具的真实执行体,后续所有参数校验都在这里兜底。 createTaskTool, err := toolutils.InferTool( ToolNameQuickNoteCreateTask, ToolDescQuickNoteCreateTask, func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) { - // 2.1 防御式检查:工具调用参数不能为 nil。 if input == nil { return nil, errors.New("工具参数不能为空") } - // 2.2 标题与优先级是写库硬条件,必须先校验。 title := strings.TrimSpace(input.Title) if title == "" { return nil, errors.New("title 不能为空") @@ -163,9 +138,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup) } - // 这里对 deadline_at 做“强校验”: - // - 空值允许(代表没有截止时间); - // - 非空但无法解析直接报错,避免把有问题的时间静默写成 NULL。 deadline, err := parseOptionalDeadline(input.DeadlineAt) if err != nil { return nil, err @@ -175,7 +147,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui return nil, err } - // 2.3 user_id 一律来自鉴权上下文,不信任模型侧入参,防止越权写别人的任务。 userID, err := deps.ResolveUserID(ctx) if err != nil { return nil, fmt.Errorf("解析用户身份失败: %w", err) @@ -184,7 +155,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui return nil, fmt.Errorf("非法 user_id=%d", userID) } - // 2.4 走业务层写库。 result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{ UserID: userID, Title: title, @@ -199,18 +169,15 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui return nil, errors.New("写入任务后返回结果异常") } - // 2.5 结果归一化:优先使用业务层返回值,其次回退到入参,保证输出稳定可读。 finalTitle := title if strings.TrimSpace(result.Title) != "" { finalTitle = strings.TrimSpace(result.Title) } - finalPriority := input.PriorityGroup if agentmodel.IsValidTaskPriority(result.PriorityGroup) { finalPriority = result.PriorityGroup } - // 2.6 截止时间输出统一为 RFC3339,便于跨系统传输与调试。 deadlineStr := "" if result.DeadlineAt != nil { deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339) @@ -218,7 +185,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339) } - // 2.7 组装给模型的结构化结果,包含可直接面向用户的 message 草稿。 return &QuickNoteCreateTaskToolOutput{ TaskID: result.TaskID, Title: finalTitle, @@ -233,7 +199,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui return nil, fmt.Errorf("构建随口记工具失败: %w", err) } - // 3. Tools 给执行节点使用,ToolInfos 给模型注册 schema 使用,二者都要返回。 tools := []tool.BaseTool{createTaskTool} infos, err := collectToolInfos(ctx, tools) if err != nil { @@ -246,57 +211,26 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui }, nil } -func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) { - // 按工具列表顺序提取 ToolInfo,确保“tools[idx] <-> infos[idx]”一一对应。 - infos := make([]*schema.ToolInfo, 0, len(tools)) - for _, t := range tools { - info, err := t.Info(ctx) - if err != nil { - return nil, fmt.Errorf("读取工具信息失败: %w", err) - } - infos = append(infos, info) - } - return infos, nil -} - // GetInvokableToolByName 通过工具名提取可执行工具实例。 func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) { if bundle == nil { return nil, errors.New("tool bundle is nil") } - if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 { - return nil, errors.New("tool bundle is empty") - } - for idx, info := range bundle.ToolInfos { - if info == nil || info.Name != name { - continue - } - invokable, ok := bundle.Tools[idx].(tool.InvokableTool) - if !ok { - return nil, fmt.Errorf("tool %s is not invokable", name) - } - return invokable, nil - } - return nil, fmt.Errorf("tool %s not found", name) + return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name) } // parseOptionalDeadline 解析工具输入中的可选截止时间。 -// 该入口用于“工具参数强校验”:只要调用方给了非空 deadline_at,就必须能被解析。 func parseOptionalDeadline(raw string) (*time.Time, error) { - // 1. 先做标点与空白归一化,避免中文输入噪声影响解析。 value := normalizeDeadlineInput(raw) if value == "" { - // 2. 空字符串合法,表示任务无截止时间。 return nil, nil } - // 3. 统一按“严格模式”解析:给了时间就必须成功解析。 deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute()) if err != nil { return nil, err } if deadline == nil { - // 4. 区分“无时间线索”和“有线索但不支持”,返回更准确错误信息。 if !hasHint { return nil, fmt.Errorf("deadline_at 格式不支持: %s", value) } @@ -306,9 +240,7 @@ func parseOptionalDeadline(raw string) (*time.Time, error) { } // parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。 -// 该函数保持“严格模式”:非空字符串无法解析时会直接返回 error。 func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) { - // 场景:模型已给出 deadline_at,需要基于同一 requestNow 再次硬校验。 value := normalizeDeadlineInput(raw) if value == "" { return nil, nil @@ -325,12 +257,7 @@ func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) } // parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。 -// 返回值说明: -// - deadline != nil:成功解析出时间; -// - hasHint=false 且 err=nil:文本里没有明显时间线索,应视为“用户没给时间”; -// - hasHint=true 且 err!=nil:用户给了时间但格式非法,应提示用户修正,不应落库。 func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) { - // 场景:解析用户原始句子时,允许“没给时间”,但不允许“给了错误时间却静默通过”。 value := normalizeDeadlineInput(raw) if value == "" { return nil, false, nil @@ -339,10 +266,8 @@ func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, deadline, hasHint, err := parseOptionalDeadlineFromText(value, now) if err != nil { if hasHint { - // 有时间线索 + 解析失败:上层应明确提示用户改时间格式。 return nil, true, err } - // 无明显时间线索:按“未提供时间”处理。 return nil, false, nil } if deadline == nil { @@ -354,49 +279,36 @@ func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, return deadline, true, nil } -// parseOptionalDeadlineFromText 是内部通用解析器。 -// 解析顺序: -// 1) 绝对时间(明确年月日时分); -// 2) 相对时间(明天/下周一/今晚); -// 3) 若识别到时间线索但仍失败,返回 hasHint=true + error,交给上层决定是否拦截。 +// parseOptionalDeadlineFromText 是内部通用时间解析器。 func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) { if strings.TrimSpace(value) == "" { return nil, false, nil } - // 1. 统一时区与时间基准,保证相对时间可重复计算。 loc := quickNoteLocation() now = now.In(loc) hasHint := hasDeadlineHint(value) - // 2. 先尝试绝对时间(优先级更高,歧义更小)。 if abs, ok := tryParseAbsoluteDeadline(value, loc); ok { return abs, true, nil } - - // 3. 再尝试相对时间(明天/下周一/今晚)。 if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized { if err != nil { return nil, true, err } return rel, true, nil } - - // 4. 到这里仍失败时,根据 hasHint 决定返回“软失败”还是“硬失败”。 if hasHint { return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value) } return nil, false, nil } -// normalizeDeadlineInput 把中文标点和空白先归一化,降低格式解析的噪声。 func normalizeDeadlineInput(raw string) string { - // 先 trim,避免纯空格输入影响后续逻辑。 trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } - // 将中文标点统一成英文形态,降低正则和 layout 解析复杂度。 replacer := strings.NewReplacer( ":", ":", ",", ",", @@ -406,12 +318,7 @@ func normalizeDeadlineInput(raw string) string { return strings.TrimSpace(replacer.Replace(trimmed)) } -// hasDeadlineHint 判断文本里是否存在“时间相关线索”。 -// 该函数的意义是区分两种情况: -// 1) 用户根本没给时间(允许 deadline 为空); -// 2) 用户给了时间但写错(必须提示修正,不能静默写 NULL)。 func hasDeadlineHint(value string) bool { - // 1. 先用结构化正则快速判断(时间格式、日期格式、周几格式)。 if quickNoteClockHMRegex.MatchString(value) || quickNoteClockCNRegex.MatchString(value) || quickNoteYMDRegex.MatchString(value) || @@ -420,7 +327,6 @@ func hasDeadlineHint(value string) bool { quickNoteWeekdayRegex.MatchString(value) { return true } - // 2. 再用词元判断“明天/今晚”等语义线索。 for _, token := range quickNoteRelativeTokens { if strings.Contains(value, token) { return true @@ -429,51 +335,40 @@ func hasDeadlineHint(value string) bool { return false } -// tryParseAbsoluteDeadline 尝试按绝对时间格式解析。 -// 若只提供日期(无时分),默认归一到当天 23:59,表示“当日截止”。 func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) { - // 逐个 layout 尝试,命中即返回。 for _, layout := range quickNoteDeadlineLayouts { var ( - t time.Time - err error + parsed time.Time + err error ) if layout == time.RFC3339 { - t, err = time.Parse(layout, value) + parsed, err = time.Parse(layout, value) if err == nil { - t = t.In(loc) + parsed = parsed.In(loc) } } else { - t, err = time.ParseInLocation(layout, value, loc) + parsed, err = time.ParseInLocation(layout, value, loc) } if err != nil { continue } - // Date-only 输入(例如 2026-03-20)默认补到 23:59。 if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly { - t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 0, 0, loc) + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc) } else { - // 非 date-only 则统一清零秒级,保持分钟粒度一致。 - t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc) + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc) } - return &t, true + return &parsed, true } return nil, false } -// tryParseRelativeDeadline 尝试解析“相对时间 + 可选时刻”。 -// 例子: -// - 明天交报告(默认 23:59) -// - 下周一上午9点开会(解析为下周一 09:00) func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) { - // 1. 先确定“哪一天”。 baseDate, recognized := inferBaseDate(value, now, loc) if !recognized { return nil, false, nil } - // 2. 再解析“几点几分”,若缺失则按语义默认时刻兜底。 hour, minute, hasExplicitClock, err := extractClock(value) if err != nil { return nil, true, err @@ -486,14 +381,7 @@ func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) ( return &deadline, true, nil } -// inferBaseDate 负责先确定“哪一天”。 -// 解析优先级: -// 1) 明确年月日; -// 2) 月日(自动推断年份); -// 3) 周几表达(本周/下周); -// 4) 明天/后天/今晚等相对词。 func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) { - // 1) yyyy年MM月dd日 if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 { year, _ := strconv.Atoi(matched[1]) month, _ := strconv.Atoi(matched[2]) @@ -503,7 +391,6 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, } } - // 2) MM月dd日(自动推断年份:若今年已过则滚到明年) if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 { month, _ := strconv.Atoi(matched[1]) day, _ := strconv.Atoi(matched[2]) @@ -522,7 +409,6 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, return candidate, true } - // 3) 本周/下周 + 周几 if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 { prefix := matched[1] target, ok := toWeekday(matched[2]) @@ -531,7 +417,6 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, } } - // 4) 今天/明天/后天/大后天/昨天等相对词 today := startOfDay(now) switch { case strings.Contains(value, "大后天"): @@ -549,17 +434,11 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, } } -// extractClock 从文本提取时刻(时/分)。 -// 支持: -// - 24h 表达:18:30 -// - 中文表达:3点、3点半、3点20分 func extractClock(value string) (int, int, bool, error) { - // hour/minute 最终会用于 time.Date,需要先做范围约束。 hour := 0 minute := 0 hasClock := false - // 1) 24 小时制:18:30 if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 { h, errH := strconv.Atoi(matched[1]) m, errM := strconv.Atoi(matched[2]) @@ -570,7 +449,6 @@ func extractClock(value string) (int, int, bool, error) { minute = m hasClock = true } else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 { - // 2) 中文时刻:3点 / 3点半 / 3点20分 h, errH := strconv.Atoi(matched[1]) if errH != nil { return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) @@ -592,11 +470,9 @@ func extractClock(value string) (int, int, bool, error) { } if !hasClock { - // 没有显式时刻并不是错误,交给默认时刻策略处理。 return 0, 0, false, nil } - // 3) 根据“下午/晚上/中午/凌晨”等语义修正 12/24 小时制。 if isPMHint(value) && hour < 12 { hour += 12 } @@ -613,9 +489,7 @@ func extractClock(value string) (int, int, bool, error) { return hour, minute, true, nil } -// defaultClockByHint 当文本只给了“日期/相对日”但没给具体时刻时,按语义兜底。 func defaultClockByHint(value string) (int, int) { - // 没有明确时刻时按中文语义设置一个“可解释的默认值”。 switch { case strings.Contains(value, "凌晨"): return 1, 0 @@ -628,29 +502,24 @@ func defaultClockByHint(value string) (int, int) { case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"): return 20, 0 default: - // 只给了日期没有具体时刻时,默认当天结束前。 return 23, 59 } } func isPMHint(value string) bool { - // 下午/晚上/傍晚通常应映射到 12:00 之后。 return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") } func isNoonHint(value string) bool { - // “中午 1 点”这类表达通常是 13:00 而非 01:00。 return strings.Contains(value, "中午") } func startOfDay(t time.Time) time.Time { - // 保留原时区,只把时分秒归零。 loc := t.Location() return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) } func isValidDate(year, month, day int) bool { - // 先做快速范围筛,再用 time.Date 回填校验闰月闰年和越界日期。 if month < 1 || month > 12 || day < 1 || day > 31 { return false } @@ -659,7 +528,6 @@ func isValidDate(year, month, day int) bool { } func toWeekday(chinese string) (time.Weekday, bool) { - // 把中文周几映射到 Go 的 Weekday 枚举。 switch chinese { case "一": return time.Monday, true @@ -680,16 +548,13 @@ func toWeekday(chinese string) (time.Weekday, bool) { } } -// resolveWeekdayDate 根据“本周/下周 + 周几”换算目标日期。 func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time { - // 1. 先定位本周周一。 today := startOfDay(now) weekdayOffset := (int(today.Weekday()) + 6) % 7 weekStart := today.AddDate(0, 0, -weekdayOffset) targetOffset := (int(target) + 6) % 7 candidateThisWeek := weekStart.AddDate(0, 0, targetOffset) - // 2. 再根据“本周/下周/无前缀”选择最终日期。 switch { case strings.HasPrefix(prefix, "下"): return candidateThisWeek.AddDate(0, 0, 7) @@ -703,7 +568,6 @@ func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time. } } -// quickNoteLocation 返回随口记链路使用的业务时区。 func quickNoteLocation() *time.Location { loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName) if err != nil { @@ -712,12 +576,10 @@ func quickNoteLocation() *time.Location { return loc } -// quickNoteNowToMinute 返回当前时间并截断到分钟级。 func quickNoteNowToMinute() time.Time { return agentshared.NowToMinute() } -// formatQuickNoteTimeToMinute 将时间格式化为分钟级字符串。 func formatQuickNoteTimeToMinute(t time.Time) string { return agentshared.FormatMinute(t.In(quickNoteLocation())) } diff --git a/backend/agent2/node/taskquery.go b/backend/agent2/node/taskquery.go index f3dd841..88cffa5 100644 --- a/backend/agent2/node/taskquery.go +++ b/backend/agent2/node/taskquery.go @@ -1,25 +1,729 @@ package agentnode import ( + "context" + "encoding/json" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt" agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream" + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" ) -// TaskQueryNodeDeps 描述“随口问任务”节点层的公共依赖。 -type TaskQueryNodeDeps struct { - LLM *agentllm.Client - StageEmitter agentstream.StageEmitter -} +const ( + TaskQueryGraphNodePlan = "task_query.plan" + TaskQueryGraphNodeQuadrant = "task_query.quadrant" + TaskQueryGraphNodeTimeAnchor = "task_query.time_anchor" + TaskQueryGraphNodeQuery = "task_query.query" + TaskQueryGraphNodeReflect = "task_query.reflect" +) -// TaskQueryNodes 是“随口问任务”节点逻辑容器。 -type TaskQueryNodes struct { - deps TaskQueryNodeDeps -} - -// NewTaskQueryNodes 创建任务查询节点容器。 -func NewTaskQueryNodes(deps TaskQueryNodeDeps) *TaskQueryNodes { - if deps.StageEmitter == nil { - deps.StageEmitter = agentstream.NoopStageEmitter() +var ( + explicitLimitPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\btop\s*(\d{1,2})\b`), + regexp.MustCompile(`前\s*(\d{1,2})\s*(个|条|项)?`), + regexp.MustCompile(`(\d{1,2})\s*(个|条|项)?\s*任务`), + regexp.MustCompile(`给我\s*(\d{1,2})\s*(个|条|项)?`), } - return &TaskQueryNodes{deps: deps} + chineseDigitMap = map[rune]int{ + '一': 1, + '二': 2, + '两': 2, + '三': 3, + '四': 4, + '五': 5, + '六': 6, + '七': 7, + '八': 8, + '九': 9, + '十': 10, + } +) + +// TaskQueryGraphRunInput 描述一次任务查询图运行需要的依赖。 +type TaskQueryGraphRunInput struct { + Model *ark.ChatModel + State *agentmodel.TaskQueryState + Deps TaskQueryToolDeps + EmitStage func(stage, detail string) +} + +// TaskQueryNodes 是任务查询图的节点容器。 +// +// 职责边界: +// 1. 负责承接请求级依赖,并向 graph 暴露可直接挂载的方法。 +// 2. 不负责 graph 编译、service 接线和持久化。 +type TaskQueryNodes struct { + input TaskQueryGraphRunInput + queryTool tool.InvokableTool + emitStage agentstream.StageEmitter +} + +func NewTaskQueryNodes(input TaskQueryGraphRunInput, queryTool tool.InvokableTool) (*TaskQueryNodes, error) { + if input.Model == nil { + return nil, fmt.Errorf("task query nodes: model is nil") + } + if input.State == nil { + return nil, fmt.Errorf("task query nodes: state is nil") + } + if err := input.Deps.Validate(); err != nil { + return nil, err + } + if queryTool == nil { + return nil, fmt.Errorf("task query nodes: queryTool is nil") + } + return &TaskQueryNodes{ + input: input, + queryTool: queryTool, + emitStage: agentstream.WrapStageEmitter(input.EmitStage), + }, nil +} + +// Plan 负责把用户原话规划成结构化查询计划。 +func (n *TaskQueryNodes) Plan(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { + if st == nil { + return nil, fmt.Errorf("task query graph: nil state in plan node") + } + + n.emitStage("task_query.plan.generating", "正在一次性规划查询范围、排序和时间条件。") + planned, err := agentllm.PlanTaskQuery(ctx, n.input.Model, st.RequestNowText, st.UserMessage) + if err != nil || planned == nil { + st.UserGoal = "查询任务" + st.Plan = defaultTaskQueryPlan() + return st, nil + } + + st.UserGoal = strings.TrimSpace(planned.UserGoal) + if st.UserGoal == "" { + st.UserGoal = "查询任务" + } + st.Plan = normalizeTaskQueryPlan(*planned) + + // 1. 若用户原话里明确指定了返回条数,则以后端识别结果为准。 + // 2. 这样可以避免规划模型漏掉数量要求,或后续反思 patch 意外改写 limit。 + if explicitLimit, found := extractExplicitLimitFromUser(st.UserMessage); found { + st.ExplicitLimit = explicitLimit + st.Plan.Limit = explicitLimit + } + return st, nil +} + +// NormalizeQuadrant 负责把象限参数去重并统一成稳定顺序。 +func (n *TaskQueryNodes) NormalizeQuadrant(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { + _ = ctx + if st == nil { + return nil, fmt.Errorf("task query graph: nil state in quadrant node") + } + + n.emitStage("task_query.quadrant.routing", "正在归一化象限筛选范围。") + st.Plan.Quadrants = normalizeQuadrants(st.Plan.Quadrants) + return st, nil +} + +// AnchorTime 负责把时间文本边界解析成可执行时间对象。 +func (n *TaskQueryNodes) AnchorTime(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { + _ = ctx + if st == nil { + return nil, fmt.Errorf("task query graph: nil state in time anchor node") + } + + n.emitStage("task_query.time.anchoring", "正在锁定时间过滤边界。") + applyTimeAnchorOnPlan(&st.Plan) + return st, nil +} + +// Query 负责真正调用工具查询任务。 +func (n *TaskQueryNodes) Query(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { + if st == nil { + return nil, fmt.Errorf("task query graph: nil state in query node") + } + + n.emitStage("task_query.tool.querying", "正在查询任务数据。") + items, err := n.executePlanByTool(ctx, st.Plan) + if err != nil { + st.LastQueryItems = make([]agentmodel.TaskQueryItem, 0) + st.LastQueryTotal = 0 + st.ReflectReason = "查询工具执行失败" + return st, nil + } + + st.LastQueryItems = items + st.LastQueryTotal = len(items) + + // 1. 如果首轮为空且还没自动放宽过,则做一次可解释的自动放宽。 + // 2. 放宽范围仅限关键词、完成状态、时间边界,不主动改象限与 limit,避免语义漂移。 + if st.LastQueryTotal == 0 && !st.AutoBroadenApplied { + broadenedPlan, changed := autoBroadenPlan(st.Plan) + if changed { + st.AutoBroadenApplied = true + st.Plan = broadenedPlan + n.emitStage("task_query.tool.broadened", "首次查询为空,已自动放宽条件再试一次。") + retryItems, retryErr := n.executePlanByTool(ctx, st.Plan) + if retryErr == nil { + st.LastQueryItems = retryItems + st.LastQueryTotal = len(retryItems) + } + } + } + + return st, nil +} + +// Reflect 负责判断当前结果是否满足用户诉求,并决定是否重试。 +func (n *TaskQueryNodes) Reflect(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { + if st == nil { + return nil, fmt.Errorf("task query graph: nil state in reflect node") + } + + n.emitStage("task_query.reflecting", "正在判断结果是否贴合你的需求。") + reflectPrompt := agentprompt.BuildTaskQueryReflectUserPrompt( + st.RequestNowText, + st.UserMessage, + st.UserGoal, + summarizeTaskQueryPlan(st.Plan), + st.RetryCount, + st.MaxReflectRetry, + summarizeTaskQueryItems(st.LastQueryItems, 6), + ) + reflectResult, err := agentllm.ReflectTaskQuery(ctx, n.input.Model, reflectPrompt) + if err != nil || reflectResult == nil { + st.NeedRetry = false + st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems) + return st, nil + } + + st.ReflectReason = strings.TrimSpace(reflectResult.Reason) + + if reflectResult.Satisfied { + st.NeedRetry = false + st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply)) + return st, nil + } + + if reflectResult.NeedRetry && st.RetryCount < st.MaxReflectRetry { + st.Plan = applyRetryPatch(st.Plan, reflectResult.RetryPatch, st.ExplicitLimit) + st.RetryCount++ + st.NeedRetry = true + if reply := strings.TrimSpace(reflectResult.Reply); reply != "" { + st.FinalReply = reply + } + return st, nil + } + + st.NeedRetry = false + st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply)) + return st, nil +} + +func (n *TaskQueryNodes) NextAfterReflect(ctx context.Context, st *agentmodel.TaskQueryState) (string, error) { + _ = ctx + if st != nil && st.NeedRetry { + return TaskQueryGraphNodeQuery, nil + } + return compose.END, nil +} + +func (n *TaskQueryNodes) executePlanByTool(ctx context.Context, plan agentmodel.TaskQueryPlan) ([]agentmodel.TaskQueryItem, error) { + if n.queryTool == nil { + return nil, fmt.Errorf("task query tool is nil") + } + + merged := make([]agentmodel.TaskQueryItem, 0, plan.Limit) + seen := make(map[int]struct{}, plan.Limit*2) + + runOne := func(quadrant *int) error { + input := TaskQueryToolInput{ + Quadrant: quadrant, + SortBy: plan.SortBy, + Order: plan.Order, + Limit: plan.Limit, + Keyword: plan.Keyword, + DeadlineBefore: plan.DeadlineBeforeText, + DeadlineAfter: plan.DeadlineAfterText, + } + includeCompleted := plan.IncludeCompleted + input.IncludeCompleted = &includeCompleted + + rawInput, err := json.Marshal(input) + if err != nil { + return err + } + rawOutput, err := n.queryTool.InvokableRun(ctx, string(rawInput)) + if err != nil { + return err + } + parsed, err := agentllm.ParseJSONObject[TaskQueryToolOutput](rawOutput) + if err != nil { + return err + } + + for _, item := range parsed.Items { + if _, exists := seen[item.ID]; exists { + continue + } + seen[item.ID] = struct{}{} + merged = append(merged, item) + } + return nil + } + + if len(plan.Quadrants) == 0 { + if err := runOne(nil); err != nil { + return nil, err + } + } else { + for _, quadrant := range plan.Quadrants { + q := quadrant + if err := runOne(&q); err != nil { + return nil, err + } + } + } + + sortTaskQueryItems(merged, plan) + if len(merged) > plan.Limit { + merged = merged[:plan.Limit] + } + return merged, nil +} + +func defaultTaskQueryPlan() agentmodel.TaskQueryPlan { + return agentmodel.TaskQueryPlan{ + SortBy: "deadline", + Order: "asc", + Limit: agentmodel.DefaultTaskQueryLimit, + IncludeCompleted: false, + } +} + +func normalizeTaskQueryPlan(raw agentllm.TaskQueryPlanOutput) agentmodel.TaskQueryPlan { + plan := defaultTaskQueryPlan() + plan.Quadrants = normalizeQuadrants(raw.Quadrants) + + if sortBy := strings.ToLower(strings.TrimSpace(raw.SortBy)); sortBy == "deadline" || sortBy == "priority" || sortBy == "id" { + plan.SortBy = sortBy + } + if order := strings.ToLower(strings.TrimSpace(raw.Order)); order == "asc" || order == "desc" { + plan.Order = order + } + if raw.Limit > 0 { + plan.Limit = raw.Limit + } + if plan.Limit > agentmodel.MaxTaskQueryLimit { + plan.Limit = agentmodel.MaxTaskQueryLimit + } + if plan.Limit <= 0 { + plan.Limit = agentmodel.DefaultTaskQueryLimit + } + if raw.IncludeCompleted != nil { + plan.IncludeCompleted = *raw.IncludeCompleted + } + plan.Keyword = strings.TrimSpace(raw.Keyword) + plan.DeadlineBeforeText = strings.TrimSpace(raw.DeadlineBefore) + plan.DeadlineAfterText = strings.TrimSpace(raw.DeadlineAfter) + applyTimeAnchorOnPlan(&plan) + return plan +} + +func normalizeQuadrants(quadrants []int) []int { + if len(quadrants) == 0 { + return nil + } + + seen := make(map[int]struct{}, len(quadrants)) + result := make([]int, 0, len(quadrants)) + for _, quadrant := range quadrants { + if quadrant < 1 || quadrant > 4 { + continue + } + if _, exists := seen[quadrant]; exists { + continue + } + seen[quadrant] = struct{}{} + result = append(result, quadrant) + } + + sort.Ints(result) + if len(result) == 0 || len(result) == 4 { + return nil + } + return result +} + +func applyTimeAnchorOnPlan(plan *agentmodel.TaskQueryPlan) { + if plan == nil { + return + } + + before, errBefore := parseTaskQueryBoundaryTime(plan.DeadlineBeforeText, true) + after, errAfter := parseTaskQueryBoundaryTime(plan.DeadlineAfterText, false) + + if errBefore != nil { + plan.DeadlineBefore = nil + plan.DeadlineBeforeText = "" + } else { + plan.DeadlineBefore = before + } + if errAfter != nil { + plan.DeadlineAfter = nil + plan.DeadlineAfterText = "" + } else { + plan.DeadlineAfter = after + } + + if plan.DeadlineBefore != nil && plan.DeadlineAfter != nil && plan.DeadlineAfter.After(*plan.DeadlineBefore) { + plan.DeadlineBefore = nil + plan.DeadlineAfter = nil + plan.DeadlineBeforeText = "" + plan.DeadlineAfterText = "" + } +} + +func autoBroadenPlan(plan agentmodel.TaskQueryPlan) (agentmodel.TaskQueryPlan, bool) { + broadened := plan + changed := false + + if strings.TrimSpace(broadened.Keyword) != "" { + broadened.Keyword = "" + changed = true + } + if !broadened.IncludeCompleted { + broadened.IncludeCompleted = true + changed = true + } + if broadened.DeadlineBefore != nil || broadened.DeadlineAfter != nil || broadened.DeadlineBeforeText != "" || broadened.DeadlineAfterText != "" { + broadened.DeadlineBefore = nil + broadened.DeadlineAfter = nil + broadened.DeadlineBeforeText = "" + broadened.DeadlineAfterText = "" + changed = true + } + return broadened, changed +} + +func applyRetryPatch(plan agentmodel.TaskQueryPlan, patch agentllm.TaskQueryRetryPatch, explicitLimit int) agentmodel.TaskQueryPlan { + next := plan + changed := false + + if patch.Quadrants != nil { + next.Quadrants = normalizeQuadrants(*patch.Quadrants) + changed = true + } + if patch.SortBy != nil { + sortBy := strings.ToLower(strings.TrimSpace(*patch.SortBy)) + if sortBy == "deadline" || sortBy == "priority" || sortBy == "id" { + next.SortBy = sortBy + changed = true + } + } + if patch.Order != nil { + order := strings.ToLower(strings.TrimSpace(*patch.Order)) + if order == "asc" || order == "desc" { + next.Order = order + changed = true + } + } + if patch.Limit != nil && explicitLimit <= 0 { + limit := *patch.Limit + if limit <= 0 { + limit = agentmodel.DefaultTaskQueryLimit + } + if limit > agentmodel.MaxTaskQueryLimit { + limit = agentmodel.MaxTaskQueryLimit + } + next.Limit = limit + changed = true + } + if patch.IncludeCompleted != nil { + next.IncludeCompleted = *patch.IncludeCompleted + changed = true + } + if patch.Keyword != nil { + next.Keyword = strings.TrimSpace(*patch.Keyword) + changed = true + } + if patch.DeadlineBefore != nil { + next.DeadlineBeforeText = strings.TrimSpace(*patch.DeadlineBefore) + changed = true + } + if patch.DeadlineAfter != nil { + next.DeadlineAfterText = strings.TrimSpace(*patch.DeadlineAfter) + changed = true + } + + if changed { + applyTimeAnchorOnPlan(&next) + } + if explicitLimit > 0 { + next.Limit = explicitLimit + } + return next +} + +func summarizeTaskQueryPlan(plan agentmodel.TaskQueryPlan) string { + quadrants := "全部象限" + if len(plan.Quadrants) > 0 { + parts := make([]string, 0, len(plan.Quadrants)) + for _, quadrant := range plan.Quadrants { + parts = append(parts, strconv.Itoa(quadrant)) + } + quadrants = strings.Join(parts, ",") + } + return fmt.Sprintf( + "quadrants=%s sort=%s/%s limit=%d include_completed=%t keyword=%s before=%s after=%s", + quadrants, + plan.SortBy, + plan.Order, + plan.Limit, + plan.IncludeCompleted, + emptyToDash(plan.Keyword), + emptyToDash(plan.DeadlineBeforeText), + emptyToDash(plan.DeadlineAfterText), + ) +} + +func summarizeTaskQueryItems(items []agentmodel.TaskQueryItem, max int) string { + if len(items) == 0 { + return "无结果" + } + if max <= 0 { + max = 5 + } + if len(items) > max { + items = items[:max] + } + + lines := make([]string, 0, len(items)) + for _, item := range items { + lines = append(lines, fmt.Sprintf( + "- #%d %s | 象限=%d | 完成=%t | 截止=%s", + item.ID, + item.Title, + item.PriorityGroup, + item.IsCompleted, + emptyToDash(item.DeadlineAt), + )) + } + return strings.Join(lines, "\n") +} + +func buildTaskQueryFallbackReply(items []agentmodel.TaskQueryItem) string { + if len(items) == 0 { + return "我这边暂时没找到匹配的任务。你可以再补一句,比如“按截止时间最早的前 3 个”或“只看简单不重要”。" + } + + preview := items + if len(preview) > 3 { + preview = preview[:3] + } + lines := make([]string, 0, len(preview)) + for _, item := range preview { + lines = append(lines, fmt.Sprintf("%s(%s)", item.Title, item.PriorityLabel)) + } + return fmt.Sprintf("我先给你筛到这些:%s。要不要我再按“更紧急”或“更简单”继续细化?", strings.Join(lines, "、")) +} + +func buildTaskQueryFinalReply(items []agentmodel.TaskQueryItem, plan agentmodel.TaskQueryPlan, llmReply string) string { + if len(items) == 0 { + base := buildTaskQueryFallbackReply(items) + if strings.TrimSpace(llmReply) == "" { + return base + } + return strings.TrimSpace(llmReply) + "\n" + base + } + + desired := plan.Limit + if desired <= 0 { + desired = agentmodel.DefaultTaskQueryLimit + } + if desired > agentmodel.MaxTaskQueryLimit { + desired = agentmodel.MaxTaskQueryLimit + } + + showCount := desired + if len(items) < showCount { + showCount = len(items) + } + + preview := items[:showCount] + lines := make([]string, 0, len(preview)) + for idx, item := range preview { + deadline := strings.TrimSpace(item.DeadlineAt) + if deadline == "" { + deadline = "无明确截止时间" + } + status := "未完成" + if item.IsCompleted { + status = "已完成" + } + lines = append(lines, fmt.Sprintf( + "%d. %s(%s,%s,截止:%s)", + idx+1, + item.Title, + item.PriorityLabel, + status, + deadline, + )) + } + + header := fmt.Sprintf("给你整理了 %d 条任务:", showCount) + if lead := extractSafeReplyLead(llmReply); lead != "" { + header = lead + "\n" + header + } + + reply := header + "\n" + strings.Join(lines, "\n") + if len(items) > showCount { + reply += fmt.Sprintf("\n另外还有 %d 条匹配任务,要不要我继续往下列?", len(items)-showCount) + } + return reply +} + +func extractSafeReplyLead(llmReply string) string { + text := strings.TrimSpace(llmReply) + if text == "" { + return "" + } + + lower := strings.ToLower(text) + if strings.Contains(text, "\n") || + strings.Contains(text, "#") || + strings.Contains(lower, "1.") || + strings.Contains(text, "1、") || + strings.Contains(text, "以下是") { + return "" + } + if len([]rune(text)) > 30 { + return "" + } + return text +} + +func sortTaskQueryItems(items []agentmodel.TaskQueryItem, plan agentmodel.TaskQueryPlan) { + if len(items) <= 1 { + return + } + + sortBy := strings.ToLower(strings.TrimSpace(plan.SortBy)) + order := strings.ToLower(strings.TrimSpace(plan.Order)) + if order != "desc" { + order = "asc" + } + + sort.SliceStable(items, func(i, j int) bool { + left := items[i] + right := items[j] + + switch sortBy { + case "priority": + if left.PriorityGroup != right.PriorityGroup { + if order == "desc" { + return left.PriorityGroup > right.PriorityGroup + } + return left.PriorityGroup < right.PriorityGroup + } + return left.ID > right.ID + case "id": + if order == "desc" { + return left.ID > right.ID + } + return left.ID < right.ID + default: + leftTime, leftOK := parseTaskQueryItemDeadline(left.DeadlineAt) + rightTime, rightOK := parseTaskQueryItemDeadline(right.DeadlineAt) + if leftOK && rightOK { + if !leftTime.Equal(rightTime) { + if order == "desc" { + return leftTime.After(rightTime) + } + return leftTime.Before(rightTime) + } + return left.ID > right.ID + } + if leftOK && !rightOK { + return true + } + if !leftOK && rightOK { + return false + } + return left.ID > right.ID + } + }) +} + +func parseTaskQueryItemDeadline(raw string) (time.Time, bool) { + text := strings.TrimSpace(raw) + if text == "" { + return time.Time{}, false + } + parsed, err := time.ParseInLocation("2006-01-02 15:04", text, time.Local) + if err != nil { + return time.Time{}, false + } + return parsed, true +} + +func emptyToDash(text string) string { + if strings.TrimSpace(text) == "" { + return "-" + } + return strings.TrimSpace(text) +} + +// extractExplicitLimitFromUser 从用户原话里提取显式条数要求。 +// +// 步骤说明: +// 1. 先识别阿拉伯数字表达,例如“前3个”“给我5条”“top 10”。 +// 2. 再识别中文数字表达,例如“前五个”“来三个”。 +// 3. 最终统一约束到 1~20 范围内。 +func extractExplicitLimitFromUser(userMessage string) (int, bool) { + text := strings.TrimSpace(userMessage) + if text == "" { + return 0, false + } + + for _, pattern := range explicitLimitPatterns { + matched := pattern.FindStringSubmatch(text) + if len(matched) < 2 { + continue + } + number, err := strconv.Atoi(strings.TrimSpace(matched[1])) + if err != nil { + continue + } + return normalizeExplicitLimit(number) + } + + for _, prefix := range []string{"前", "来", "给我"} { + for digit, number := range chineseDigitMap { + token := prefix + string(digit) + if strings.Contains(text, token) { + return normalizeExplicitLimit(number) + } + for _, suffix := range []string{"个", "条", "项"} { + if strings.Contains(text, token+suffix) { + return normalizeExplicitLimit(number) + } + } + } + } + return 0, false +} + +func normalizeExplicitLimit(number int) (int, bool) { + if number <= 0 { + return 0, false + } + if number > agentmodel.MaxTaskQueryLimit { + number = agentmodel.MaxTaskQueryLimit + } + return number, true } diff --git a/backend/agent2/node/taskquery_test.go b/backend/agent2/node/taskquery_test.go new file mode 100644 index 0000000..77d0835 --- /dev/null +++ b/backend/agent2/node/taskquery_test.go @@ -0,0 +1,81 @@ +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) + } +} diff --git a/backend/agent2/node/taskquery_tool.go b/backend/agent2/node/taskquery_tool.go new file mode 100644 index 0000000..e0ef140 --- /dev/null +++ b/backend/agent2/node/taskquery_tool.go @@ -0,0 +1,286 @@ +package agentnode + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + "github.com/cloudwego/eino/components/tool" + toolutils "github.com/cloudwego/eino/components/tool/utils" + "github.com/cloudwego/eino/schema" +) + +const ( + ToolNameTaskQueryTasks = "query_tasks" + ToolDescTaskQueryTasks = "按象限、关键词、截止时间筛选并排序任务,返回结构化任务列表" +) + +var taskQueryTimeLayouts = []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", +} + +// TaskQueryToolDeps 描述任务查询工具依赖的外部查询能力。 +// +// 职责边界: +// 1. QueryTasks 负责读取真实任务数据。 +// 2. 工具层只负责参数校验、归一化和结构化输出,不直接耦合 DAO。 +type TaskQueryToolDeps struct { + QueryTasks func(ctx context.Context, req TaskQueryRequest) ([]TaskQueryTaskRecord, error) +} + +// Validate 负责校验任务查询工具依赖是否齐全。 +func (d TaskQueryToolDeps) Validate() error { + if d.QueryTasks == nil { + return errors.New("task query tool deps: QueryTasks is nil") + } + return nil +} + +// TaskQueryToolBundle 同时返回工具实例和工具元信息。 +// +// 职责边界: +// 1. Tools 给执行节点使用。 +// 2. ToolInfos 给模型注册 schema 使用。 +type TaskQueryToolBundle struct { + Tools []tool.BaseTool + ToolInfos []*schema.ToolInfo +} + +// TaskQueryRequest 是工具层传给业务层的内部查询请求。 +type TaskQueryRequest struct { + UserID int + Quadrant *int + SortBy string + Order string + Limit int + IncludeCompleted bool + Keyword string + DeadlineBefore *time.Time + DeadlineAfter *time.Time +} + +// TaskQueryTaskRecord 是业务层返回给工具层的任务记录。 +type TaskQueryTaskRecord struct { + ID int + Title string + PriorityGroup int + IsCompleted bool + DeadlineAt *time.Time + UrgencyThresholdAt *time.Time +} + +// TaskQueryToolInput 是暴露给大模型的工具入参。 +type TaskQueryToolInput struct { + Quadrant *int `json:"quadrant,omitempty" jsonschema:"description=可选象限(1~4)"` + SortBy string `json:"sort_by,omitempty" jsonschema:"description=排序字段(deadline|priority|id)"` + Order string `json:"order,omitempty" jsonschema:"description=排序方向(asc|desc)"` + Limit int `json:"limit,omitempty" jsonschema:"description=返回条数,默认5,上限20"` + IncludeCompleted *bool `json:"include_completed,omitempty" jsonschema:"description=是否包含已完成任务,默认false"` + Keyword string `json:"keyword,omitempty" jsonschema:"description=可选标题关键词,模糊匹配"` + DeadlineBefore string `json:"deadline_before,omitempty" jsonschema:"description=可选截止时间上界,支持RFC3339或yyyy-MM-dd HH:mm"` + DeadlineAfter string `json:"deadline_after,omitempty" jsonschema:"description=可选截止时间下界,支持RFC3339或yyyy-MM-dd HH:mm"` +} + +// TaskQueryToolOutput 是返回给模型的结构化结果。 +type TaskQueryToolOutput struct { + Total int `json:"total"` + Items []agentmodel.TaskQueryItem `json:"items"` +} + +// BuildTaskQueryToolBundle 负责构建任务查询工具包。 +// +// 步骤说明: +// 1. 先校验依赖是否完整,避免生成一个运行时必定失败的工具。 +// 2. 再把输入归一化成内部请求,调用业务查询函数拿到真实数据。 +// 3. 最后把业务记录转换成统一的轻量任务视图,供模型和反思节点复用。 +func BuildTaskQueryToolBundle(ctx context.Context, deps TaskQueryToolDeps) (*TaskQueryToolBundle, error) { + if err := deps.Validate(); err != nil { + return nil, err + } + + queryTool, err := toolutils.InferTool( + ToolNameTaskQueryTasks, + ToolDescTaskQueryTasks, + func(ctx context.Context, input *TaskQueryToolInput) (*TaskQueryToolOutput, error) { + req, err := normalizeTaskQueryToolInput(input) + if err != nil { + return nil, err + } + + records, err := deps.QueryTasks(ctx, req) + if err != nil { + return nil, err + } + + items := make([]agentmodel.TaskQueryItem, 0, len(records)) + for _, record := range records { + items = append(items, agentmodel.TaskQueryItem{ + ID: record.ID, + Title: record.Title, + PriorityGroup: record.PriorityGroup, + PriorityLabel: agentmodel.PriorityLabelCN(record.PriorityGroup), + IsCompleted: record.IsCompleted, + DeadlineAt: formatTaskQueryTime(record.DeadlineAt), + UrgencyThresholdAt: formatTaskQueryTime(record.UrgencyThresholdAt), + }) + } + + return &TaskQueryToolOutput{ + Total: len(items), + Items: items, + }, nil + }, + ) + if err != nil { + return nil, fmt.Errorf("构建任务查询工具失败: %w", err) + } + + tools := []tool.BaseTool{queryTool} + infos, err := collectToolInfos(ctx, tools) + if err != nil { + return nil, err + } + return &TaskQueryToolBundle{ + Tools: tools, + ToolInfos: infos, + }, nil +} + +// GetTaskQueryInvokableToolByName 按工具名提取可执行工具。 +func GetTaskQueryInvokableToolByName(bundle *TaskQueryToolBundle, name string) (tool.InvokableTool, error) { + if bundle == nil { + return nil, errors.New("task query tool bundle is nil") + } + return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name) +} + +// normalizeTaskQueryToolInput 负责参数默认值回填与合法性校验。 +// +// 步骤说明: +// 1. 先准备默认值,保证空参数也能执行一次合理查询。 +// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。 +// 3. 若上下界冲突,则直接返回错误,避免查出必为空的结果。 +func normalizeTaskQueryToolInput(input *TaskQueryToolInput) (TaskQueryRequest, error) { + req := TaskQueryRequest{ + SortBy: "deadline", + Order: "asc", + Limit: agentmodel.DefaultTaskQueryLimit, + IncludeCompleted: false, + } + if input == nil { + return req, nil + } + + if input.Quadrant != nil { + if *input.Quadrant < 1 || *input.Quadrant > 4 { + return TaskQueryRequest{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", *input.Quadrant) + } + quadrant := *input.Quadrant + req.Quadrant = &quadrant + } + + if sortBy := strings.ToLower(strings.TrimSpace(input.SortBy)); sortBy != "" { + req.SortBy = sortBy + } + switch req.SortBy { + case "deadline", "priority", "id": + default: + return TaskQueryRequest{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", req.SortBy) + } + + if order := strings.ToLower(strings.TrimSpace(input.Order)); order != "" { + req.Order = order + } + switch req.Order { + case "asc", "desc": + default: + return TaskQueryRequest{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", req.Order) + } + + if input.Limit > 0 { + req.Limit = input.Limit + } + if req.Limit > agentmodel.MaxTaskQueryLimit { + req.Limit = agentmodel.MaxTaskQueryLimit + } + if req.Limit <= 0 { + req.Limit = agentmodel.DefaultTaskQueryLimit + } + + if input.IncludeCompleted != nil { + req.IncludeCompleted = *input.IncludeCompleted + } + req.Keyword = strings.TrimSpace(input.Keyword) + + before, err := parseTaskQueryBoundaryTime(input.DeadlineBefore, true) + if err != nil { + return TaskQueryRequest{}, err + } + after, err := parseTaskQueryBoundaryTime(input.DeadlineAfter, false) + if err != nil { + return TaskQueryRequest{}, err + } + req.DeadlineBefore = before + req.DeadlineAfter = after + if req.DeadlineBefore != nil && req.DeadlineAfter != nil && req.DeadlineAfter.After(*req.DeadlineBefore) { + return TaskQueryRequest{}, errors.New("deadline_after 不能晚于 deadline_before") + } + + return req, nil +} + +// parseTaskQueryBoundaryTime 解析截止时间上下界。 +// +// 职责边界: +// 1. isUpper=true 时,纯日期补到当天 23:59:59。 +// 2. isUpper=false 时,纯日期补到当天 00:00:00。 +// 3. 不支持的格式直接返回错误,由调用方决定是否回退。 +func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) { + text := strings.TrimSpace(raw) + if text == "" { + return nil, nil + } + + loc := time.Local + for _, layout := range taskQueryTimeLayouts { + var ( + parsed time.Time + err error + ) + if layout == time.RFC3339 { + parsed, err = time.Parse(layout, text) + if err == nil { + parsed = parsed.In(loc) + } + } else { + parsed, err = time.ParseInLocation(layout, text, loc) + } + if err != nil { + continue + } + + if layout == "2006-01-02" { + if isUpper { + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc) + } else { + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc) + } + } + return &parsed, nil + } + return nil, fmt.Errorf("时间格式不支持: %s", text) +} + +// formatTaskQueryTime 负责把内部时间格式化为给模型展示的分钟级文本。 +func formatTaskQueryTime(value *time.Time) string { + if value == nil { + return "" + } + return value.In(time.Local).Format("2006-01-02 15:04") +} diff --git a/backend/agent2/node/taskquery_tool_test.go b/backend/agent2/node/taskquery_tool_test.go new file mode 100644 index 0000000..97dfed1 --- /dev/null +++ b/backend/agent2/node/taskquery_tool_test.go @@ -0,0 +1,40 @@ +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) + } +} diff --git a/backend/agent2/node/tool_common.go b/backend/agent2/node/tool_common.go new file mode 100644 index 0000000..969ca76 --- /dev/null +++ b/backend/agent2/node/tool_common.go @@ -0,0 +1,74 @@ +package agentnode + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" +) + +// collectToolInfos 负责批量提取工具元信息,供模型注册与工具索引复用。 +// +// 职责边界: +// 1. 只负责调用 tool.Info 并聚合返回结果。 +// 2. 不负责校验工具是否可执行,也不负责按名称检索工具。 +func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) { + infos := make([]*schema.ToolInfo, 0, len(tools)) + for _, currentTool := range tools { + info, err := currentTool.Info(ctx) + if err != nil { + return nil, fmt.Errorf("读取工具信息失败: %w", err) + } + infos = append(infos, info) + } + return infos, nil +} + +// buildInvokableToolMap 负责把工具列表转换成“工具名 -> 可执行工具”的索引表。 +// +// 步骤说明: +// 1. 先校验 tools 与 infos 是否一一对应,避免后续按下标取值时出现错配。 +// 2. 再校验每个工具都带有合法名字,并且确实实现了 InvokableTool 接口。 +// 3. 任一步失败都立即返回错误,避免 graph 在运行期拿到半残缺的工具集合。 +func buildInvokableToolMap(tools []tool.BaseTool, infos []*schema.ToolInfo) (map[string]tool.InvokableTool, error) { + if len(tools) == 0 || len(infos) == 0 { + return nil, errors.New("tool bundle is empty") + } + if len(tools) != len(infos) { + return nil, errors.New("tool bundle mismatch") + } + + result := make(map[string]tool.InvokableTool, len(tools)) + for idx, currentTool := range tools { + info := infos[idx] + if info == nil || strings.TrimSpace(info.Name) == "" { + return nil, errors.New("tool info is invalid") + } + invokable, ok := currentTool.(tool.InvokableTool) + if !ok { + return nil, fmt.Errorf("tool %s is not invokable", info.Name) + } + result[info.Name] = invokable + } + return result, nil +} + +// getInvokableToolByName 负责从工具集合中提取指定名称的可执行工具。 +// +// 职责边界: +// 1. 负责复用统一索引逻辑,避免各业务链路重复写名称查找代码。 +// 2. 不负责兜底选择其他工具;未命中时直接返回错误,由上层决定如何处理。 +func getInvokableToolByName(tools []tool.BaseTool, infos []*schema.ToolInfo, name string) (tool.InvokableTool, error) { + invokableMap, err := buildInvokableToolMap(tools, infos) + if err != nil { + return nil, err + } + invokable, ok := invokableMap[name] + if !ok { + return nil, fmt.Errorf("tool %s not found", name) + } + return invokable, nil +} diff --git a/backend/agent2/prompt/taskquery.go b/backend/agent2/prompt/taskquery.go index 00d8504..ff9c504 100644 --- a/backend/agent2/prompt/taskquery.go +++ b/backend/agent2/prompt/taskquery.go @@ -5,19 +5,75 @@ import ( "strings" ) -const taskQuerySystemPrompt = ` -你是 SmartFlow 的“任务查询规划助手”。 -你不直接回答最终结果,而是先判断用户想查哪一类任务、需要怎样排序、以及是否需要时间范围约束。 +const TaskQueryPlanPrompt = `你是 SmartFlow 的任务查询规划器。请根据用户原话,输出结构化查询计划 JSON,供后端直接执行。 +只允许输出 JSON,不要输出解释、代码块或多余文字。 -当前文件先保留 prompt 归档位置与基本职责说明。 -` - -// BuildTaskQuerySystemPrompt 返回任务查询系统提示词骨架。 -func BuildTaskQuerySystemPrompt() string { - return strings.TrimSpace(taskQuerySystemPrompt) +输出字段: +{ + "user_goal": "一句话总结用户诉求", + "quadrants": [1,2,3,4], + "sort_by": "deadline|priority|id", + "order": "asc|desc", + "limit": 1-20, + "include_completed": false, + "keyword": "可选关键词,或空字符串", + "deadline_before": "yyyy-MM-dd HH:mm 或空字符串", + "deadline_after": "yyyy-MM-dd HH:mm 或空字符串" } -// BuildTaskQueryUserPrompt 构造任务查询用户提示词骨架。 -func BuildTaskQueryUserPrompt(nowText, userInput string) string { - return fmt.Sprintf("当前时间(北京时间,精确到分钟):%s\n用户请求:%s", strings.TrimSpace(nowText), strings.TrimSpace(userInput)) +规则: +1. quadrants 为空数组表示“全部象限”。 +2. 用户未提排序时,默认 sort_by=deadline 且 order=asc。 +3. 用户未提数量时,limit 默认 5。 +4. 时间字段必须输出绝对时间或空字符串,不要输出“明天”“下周一”这类相对时间。 +5. 如果用户语义更偏向“我还有什么要做”“看看待办”,优先考虑 1、2 象限;如果 1、2 象限为空,再考虑 3、4 象限。 +6. 如果用户语义更偏向“来点事做做”“给我点轻松的任务”,优先考虑 3、4 象限。 +7. 允许多选象限。` + +const TaskQueryReflectPrompt = `你是 SmartFlow 的任务查询结果审阅器。你会看到:用户原话、当前查询计划、查询结果摘要、当前重试次数。 +请只输出 JSON,不要输出解释、代码块或多余文字。 + +输出字段: +{ + "satisfied": true, + "need_retry": false, + "reason": "一句话原因", + "reply": "可直接给用户看的中文回复", + "retry_patch": { + "quadrants": [1,2,3,4], + "sort_by": "deadline|priority|id", + "order": "asc|desc", + "limit": 1-20, + "include_completed": true, + "keyword": "可选关键词,或空字符串", + "deadline_before": "yyyy-MM-dd HH:mm 或空字符串", + "deadline_after": "yyyy-MM-dd HH:mm 或空字符串" + } +} + +规则: +1. 如果当前结果已经满足用户诉求,返回 satisfied=true 且 need_retry=false。 +2. 如果当前结果不满足,但仍值得再查一次,返回 need_retry=true,并尽量只给最小必要 patch。 +3. 如果不建议再试,返回 need_retry=false,并在 reply 里说明当前最接近的结果。 +4. reply 应该是自然中文,不要输出表格。` + +func BuildTaskQueryPlanUserPrompt(nowText, userInput string) string { + return fmt.Sprintf( + "当前时间(北京时间,精确到分钟):%s\n用户输入:%s\n\n请输出任务查询计划 JSON。", + strings.TrimSpace(nowText), + strings.TrimSpace(userInput), + ) +} + +func BuildTaskQueryReflectUserPrompt(nowText, userInput, userGoal, planSummary string, retryCount, maxRetry int, resultSummary string) string { + return fmt.Sprintf( + "当前时间:%s\n用户原话:%s\n用户目标:%s\n当前查询计划:%s\n当前重试:%d/%d\n查询结果摘要:\n%s", + strings.TrimSpace(nowText), + strings.TrimSpace(userInput), + strings.TrimSpace(userGoal), + strings.TrimSpace(planSummary), + retryCount, + maxRetry, + strings.TrimSpace(resultSummary), + ) } diff --git a/backend/api/agent.go b/backend/api/agent.go index 7fe6366..d8a99fc 100644 --- a/backend/api/agent.go +++ b/backend/api/agent.go @@ -165,6 +165,19 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) { pageSize = parsedPageSize } + // 2.3 limit 是 page_size 的懒加载别名: + // 2.3.1 前端若显式传 limit,则以 limit 为准,避免前端再做字段转换; + // 2.3.2 若 limit 非法同样直接返回 400,避免把脏参数下沉到 service; + // 2.3.3 若未传 limit,则继续沿用历史 page_size 行为,保持老前端兼容。 + if rawLimit := strings.TrimSpace(c.Query("limit")); rawLimit != "" { + parsedLimit, err := strconv.Atoi(rawLimit) + if err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + pageSize = parsedLimit + } + // 3. status 过滤器可选,最终合法性由 service 层统一校验。 status := strings.TrimSpace(c.Query("status")) @@ -181,6 +194,42 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) { c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) } +// GetConversationHistory 返回指定会话的聊天历史记录。 +// +// 设计说明: +// 1) 该接口只读历史,不负责改写 Redis/DB 中的会话状态; +// 2) 读取顺序复用现有服务层能力:先校验归属,再查 Redis,未命中再回源 DB; +// 3) 会话不存在时统一返回 400,避免前端把无效会话误判成系统故障。 +func (api *AgentHandler) GetConversationHistory(c *gin.Context) { + // 1. 参数校验:conversation_id 必填。 + conversationID := strings.TrimSpace(c.Query("conversation_id")) + if conversationID == "" { + c.JSON(http.StatusBadRequest, respond.MissingParam) + return + } + + // 2. 从鉴权上下文取当前用户 ID,确保查询范围只落在“本人会话”内。 + userID := c.GetInt("user_id") + + // 3. 设置短超时,避免缓存抖动或慢查询长期占用连接。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + // 4. 调 service 查询聊天历史。 + history, err := api.svc.GetConversationHistory(ctx, userID, conversationID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + respond.DealWithError(c, err) + return + } + + // 5. 返回统一响应结构。 + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, history)) +} + // GetSchedulePlanPreview 返回“指定会话”的排程结构化预览。 // // 设计说明: diff --git a/backend/model/agent.go b/backend/model/agent.go index 49112f7..e3223ed 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -78,10 +78,25 @@ type GetConversationListResponse struct { List []GetConversationListItem `json:"list"` Page int `json:"page"` PageSize int `json:"page_size"` + Limit int `json:"limit"` Total int64 `json:"total"` HasMore bool `json:"has_more"` } +// GetConversationHistoryItem 是“按会话读取聊天历史”接口的单条消息响应。 +// +// 职责边界: +// 1. role/content:承载前端渲染消息气泡所需的核心字段; +// 2. id/created_at:仅在回源 DB 时可稳定提供,命中 Redis 时允许为空; +// 3. reasoning_content:兼容模型推理内容,缓存命中时可直接透传。 +type GetConversationHistoryItem struct { + ID int `json:"id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt *time.Time `json:"created_at,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` +} + // SchedulePlanPreviewCache 是“排程预览”在 Redis 中的缓存结构。 // // 职责边界: diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 2ef48a8..51bea17 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -93,6 +93,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d agentGroup.POST("/chat", middleware.TokenQuotaGuard(cache, userRepo), handlers.AgentHandler.ChatAgent) agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta) agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList) + agentGroup.GET("/conversation-history", handlers.AgentHandler.GetConversationHistory) agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview) } } diff --git a/backend/service/agentsvc/agent_history.go b/backend/service/agentsvc/agent_history.go new file mode 100644 index 0000000..fccbccf --- /dev/null +++ b/backend/service/agentsvc/agent_history.go @@ -0,0 +1,130 @@ +package agentsvc + +import ( + "context" + "log" + "strings" + + "github.com/LoveLosita/smartflow/backend/conv" + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/pkg" + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/cloudwego/eino/schema" + "gorm.io/gorm" +) + +// GetConversationHistory 返回指定会话的聊天历史。 +// +// 职责边界: +// 1. 负责会话 ID 归一化、会话归属校验,以及“先 Redis、后 DB”的读取编排; +// 2. 负责把缓存消息 / DB 记录统一转换为 API 响应 DTO; +// 3. 不负责补写会话标题,也不负责修改聊天主链路的缓存写入策略。 +func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, chatID string) ([]model.GetConversationHistoryItem, error) { + normalizedChatID := strings.TrimSpace(chatID) + if normalizedChatID == "" { + return nil, respond.MissingParam + } + + // 1. 先做归属校验: + // 1.1 Redis 历史缓存只按 chat_id 分桶,不能单靠缓存判断用户归属; + // 1.2 因此先查会话是否属于当前用户,避免命中别人会话缓存时产生越权读取; + // 1.3 若会话不存在,统一返回 gorm.ErrRecordNotFound,交由 API 层映射为参数错误。 + exists, err := s.repo.IfChatExists(ctx, userID, normalizedChatID) + if err != nil { + return nil, err + } + if !exists { + return nil, gorm.ErrRecordNotFound + } + + // 2. 优先读 Redis: + // 2.1 命中时直接返回,复用当前聊天主链路维护的最近消息窗口; + // 2.2 失败策略:缓存读取异常只记日志并继续回源 DB,避免缓存抖动导致接口不可用; + // 2.3 注意:缓存消息不包含稳定的 DB 主键与创建时间,因此这些字段允许为空。 + if s.agentCache != nil { + history, cacheErr := s.agentCache.GetHistory(ctx, normalizedChatID) + if cacheErr != nil { + log.Printf("读取会话历史缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr) + } else if history != nil { + return buildConversationHistoryItemsFromCache(history), nil + } + } + + // 3. Redis 未命中时回源 DB: + // 3.1 复用现有 GetUserChatHistories 读取最近 N 条历史,保证查询链路和主聊天链路口径一致; + // 3.2 失败时直接上抛,由 API 层统一处理; + // 3.3 成功后若缓存可用,则顺手回填 Redis,降低后续冷启动成本。 + histories, err := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), normalizedChatID) + if err != nil { + return nil, err + } + + if s.agentCache != nil { + if setErr := s.agentCache.BackfillHistory(ctx, normalizedChatID, conv.ToEinoMessages(histories)); setErr != nil { + log.Printf("回填会话历史缓存失败 chat_id=%s: %v", normalizedChatID, setErr) + } + } + + return buildConversationHistoryItemsFromDB(histories), nil +} + +// buildConversationHistoryItemsFromCache 把 Redis 中的 Eino 消息转换为接口响应。 +// +// 职责边界: +// 1. 只做字段映射,不做权限校验或排序调整; +// 2. 不补 created_at/id,因为当前缓存模型不承载这两个字段; +// 3. role 统一输出为 user / assistant / system,避免前端再感知 schema.RoleType。 +func buildConversationHistoryItemsFromCache(messages []*schema.Message) []model.GetConversationHistoryItem { + items := make([]model.GetConversationHistoryItem, 0, len(messages)) + for _, msg := range messages { + if msg == nil { + continue + } + items = append(items, model.GetConversationHistoryItem{ + Role: normalizeConversationHistoryRole(string(msg.Role)), + Content: strings.TrimSpace(msg.Content), + ReasoningContent: strings.TrimSpace(msg.ReasoningContent), + }) + } + return items +} + +// buildConversationHistoryItemsFromDB 把数据库聊天记录转换为接口响应。 +// +// 职责边界: +// 1. 只透传 DB 已有字段,不尝试补算 reasoning_content; +// 2. message_content / role 为空时兜底为空串与 system,避免空指针影响接口; +// 3. 保持 DAO 返回的时间正序,前端可直接渲染。 +func buildConversationHistoryItemsFromDB(histories []model.ChatHistory) []model.GetConversationHistoryItem { + items := make([]model.GetConversationHistoryItem, 0, len(histories)) + for _, history := range histories { + content := "" + if history.MessageContent != nil { + content = strings.TrimSpace(*history.MessageContent) + } + + role := "system" + if history.Role != nil { + role = normalizeConversationHistoryRole(*history.Role) + } + + items = append(items, model.GetConversationHistoryItem{ + ID: history.ID, + Role: role, + Content: content, + CreatedAt: history.CreatedAt, + }) + } + return items +} + +func normalizeConversationHistoryRole(role string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "user": + return "user" + case "assistant": + return "assistant" + default: + return "system" + } +} diff --git a/backend/service/agentsvc/agent_meta.go b/backend/service/agentsvc/agent_meta.go index 6ba60b6..fde61f9 100644 --- a/backend/service/agentsvc/agent_meta.go +++ b/backend/service/agentsvc/agent_meta.go @@ -122,6 +122,7 @@ func (s *AgentService) GetConversationList(ctx context.Context, userID, page, pa List: items, Page: normalizedPage, PageSize: normalizedPageSize, + Limit: normalizedPageSize, Total: total, HasMore: hasMore, }, nil diff --git a/backend/service/agentsvc/agent_task_query.go b/backend/service/agentsvc/agent_task_query.go index d1d660f..bdfd595 100644 --- a/backend/service/agentsvc/agent_task_query.go +++ b/backend/service/agentsvc/agent_task_query.go @@ -7,18 +7,14 @@ import ( "strings" "time" - "github.com/LoveLosita/smartflow/backend/agent/taskquery" + 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/model" "github.com/LoveLosita/smartflow/backend/respond" "github.com/cloudwego/eino-ext/components/model/ark" ) -// runTaskQueryFlow 执行“任务查询”分支。 -// -// 职责边界: -// 1. 负责把本次请求接入 taskquery 执行器; -// 2. 负责把 user_id 注入工具依赖,确保模型无法越权查他人任务; -// 3. 不负责聊天持久化(由 AgentChat 主流程统一收口)。 func (s *AgentService) runTaskQueryFlow( ctx context.Context, selectedModel *ark.ChatModel, @@ -26,7 +22,6 @@ func (s *AgentService) runTaskQueryFlow( userID int, emitStage func(stage, detail string), ) (string, error) { - // 1. 依赖预检:任务查询必须依赖 taskRepo + model。 if s == nil || s.taskRepo == nil { return "", errors.New("task query service dependency is not ready") } @@ -34,19 +29,14 @@ func (s *AgentService) runTaskQueryFlow( return "", errors.New("task query model is nil") } - // 2. 构建执行输入并启动 tool-calling。 - // 2.1 RequestNow 仅用于 prompt 辅助,不参与数据库过滤。 requestNow := time.Now().In(time.Local).Format("2006-01-02 15:04") - return taskquery.RunTaskQueryGraph(ctx, taskquery.QueryGraphRunInput{ - Model: selectedModel, - UserMessage: userMessage, - RequestNowText: requestNow, - MaxReflectRetry: 2, - EmitStage: emitStage, - Deps: taskquery.TaskQueryToolDeps{ - QueryTasks: func(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) { - // 2.2 调用目的:在工具层做完参数校验后,这里把 user_id 强制注入,再执行真实查询。 - // 这样可以保证模型永远只能查当前登录用户的数据。 + state := agentmodel.NewTaskQueryState(strings.TrimSpace(userMessage), requestNow, agentmodel.DefaultTaskQueryReflectRetry) + return agentgraph.RunTaskQueryGraph(ctx, agentnode.TaskQueryGraphRunInput{ + Model: selectedModel, + State: state, + EmitStage: emitStage, + Deps: agentnode.TaskQueryToolDeps{ + QueryTasks: func(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) { req.UserID = userID return s.queryTasksForAgent(ctx, req) }, @@ -54,16 +44,8 @@ func (s *AgentService) runTaskQueryFlow( }) } -// queryTasksForAgent 在 Agent 任务查询场景下读取并筛选任务。 -// -// 职责边界: -// 1. 负责“读取原始任务 + 读时优先级派生 + 条件筛选 + 排序 + 截断”; -// 2. 不负责写库,不触发 outbox(只读查询链路); -// 3. 返回的是工具层结构,不直接暴露 DAO 模型给上层。 -func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) { +func (s *AgentService) queryTasksForAgent(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) { _ = ctx - - // 1. 基础参数校验。 if req.UserID <= 0 { return nil, errors.New("invalid user_id in task query") } @@ -71,20 +53,14 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas return nil, errors.New("task repository is nil") } - // 2. 读取用户全部任务。 - // 2.1 当前 TaskDAO 读取接口无 context 参数,这里保持最小侵入复用既有能力; - // 2.2 若用户任务为空,返回空切片而不是 error,方便模型自然回复“暂无任务”。 tasks, err := s.taskRepo.GetTasksByUserID(req.UserID) if err != nil { if errors.Is(err, respond.UserTasksEmpty) { - return make([]taskquery.TaskRecord, 0), nil + return make([]agentnode.TaskQueryTaskRecord, 0), nil } return nil, err } - // 3. 读时派生 + 条件筛选: - // 3.1 先按“紧急分界线”做内存派生,保证查询视图与主业务口径一致; - // 3.2 再应用 include_completed/quadrant/keyword/deadline 条件。 now := time.Now() filtered := make([]model.Task, 0, len(tasks)) for _, originalTask := range tasks { @@ -96,18 +72,14 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas filtered = append(filtered, currentTask) } - // 4. 排序与截断: - // 4.1 排序字段/方向已经在工具层校验过,这里按约定执行; - // 4.2 limit 截断只发生在排序之后,保证“前 N 条”语义正确。 sortTasksForQuery(filtered, req) if req.Limit > 0 && len(filtered) > req.Limit { filtered = filtered[:req.Limit] } - // 5. 映射成工具输出结构。 - records := make([]taskquery.TaskRecord, 0, len(filtered)) + records := make([]agentnode.TaskQueryTaskRecord, 0, len(filtered)) for _, task := range filtered { - records = append(records, taskquery.TaskRecord{ + records = append(records, agentnode.TaskQueryTaskRecord{ ID: task.ID, Title: task.Title, PriorityGroup: task.Priority, @@ -119,24 +91,13 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas return records, nil } -// applyReadTimeUrgencyPromotion 复用“读时紧急性派生”口径(内存态)。 -// -// 规则: -// 1. 已完成任务不派生; -// 2. 未到紧急分界线不派生; -// 3. 到线后仅做 2->1、4->3 的象限平移; -// 4. 只改内存对象,不改数据库。 func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) { - if task == nil { - return - } - if task.IsCompleted || task.UrgencyThresholdAt == nil { + if task == nil || task.IsCompleted || task.UrgencyThresholdAt == nil { return } if task.UrgencyThresholdAt.After(now) { return } - switch task.Priority { case 2: task.Priority = 1 @@ -145,29 +106,17 @@ func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) { } } -// taskMatchesQueryFilter 判断任务是否满足查询条件。 -func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) bool { - // 1. include_completed=false 时默认过滤掉已完成任务。 +func taskMatchesQueryFilter(task model.Task, req agentnode.TaskQueryRequest) bool { if !req.IncludeCompleted && task.IsCompleted { return false } - - // 2. quadrant 过滤:只保留指定象限。 if req.Quadrant != nil && task.Priority != *req.Quadrant { return false } - - // 3. keyword 过滤:对标题做大小写不敏感包含匹配。 keyword := strings.TrimSpace(req.Keyword) - if keyword != "" { - if !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) { - return false - } + if keyword != "" && !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) { + return false } - - // 4. deadline 区间过滤: - // 4.1 只要设置了上下界,deadline_at 为空的任务默认不匹配; - // 4.2 区间边界为闭区间(>= after 且 <= before)。 if req.DeadlineAfter != nil { if task.DeadlineAt == nil || task.DeadlineAt.Before(*req.DeadlineAfter) { return false @@ -181,17 +130,10 @@ func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) boo return true } -// sortTasksForQuery 按查询条件排序任务。 -// -// 排序策略: -// 1. sort_by=deadline:按截止时间排,deadline 为空的任务统一放末尾; -// 2. sort_by=priority:按象限数值排(1 最紧急),同优先级再按 id 倒序; -// 3. sort_by=id:按 id 排(可近似“新旧顺序”)。 -func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) { +func sortTasksForQuery(tasks []model.Task, req agentnode.TaskQueryRequest) { if len(tasks) <= 1 { return } - order := strings.ToLower(strings.TrimSpace(req.Order)) if order != "desc" { order = "asc" @@ -204,7 +146,6 @@ func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) { sort.SliceStable(tasks, func(i, j int) bool { left := tasks[i] right := tasks[j] - switch sortBy { case "priority": if left.Priority != right.Priority { @@ -213,42 +154,31 @@ func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) { } return left.Priority < right.Priority } - // 同优先级时按 id 倒序,保证排序稳定且更接近“最近创建在前”。 return left.ID > right.ID case "id": if order == "desc" { return left.ID > right.ID } return left.ID < right.ID - default: // deadline + default: if less, decided := compareDeadline(left.DeadlineAt, right.DeadlineAt, order); decided { return less } - // 截止时间相同或都为空时,回退 id 倒序保证稳定性。 return left.ID > right.ID } }) } -// compareDeadline 比较两个可选截止时间。 -// -// 返回语义: -// 1. less:left 是否应排在 right 前; -// 2. decided:本次比较是否已能得出顺序;false 表示需要上层继续用次级键比较。 func compareDeadline(left, right *time.Time, order string) (less bool, decided bool) { - // 1. 都为空:本次不决策,交给次级键。 if left == nil && right == nil { return false, false } - // 2. 只有一边为空:为空的一侧统一放末尾。 if left == nil && right != nil { return false, true } if left != nil && right == nil { return true, true } - - // 3. 两边都不为空:按 order 做时间比较。 if left.Equal(*right) { return false, false } diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..716360d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + SmartFlow + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9300ac7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2262 @@ +{ + "name": "smartflow-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smartflow-frontend", + "version": "0.1.0", + "dependencies": { + "@vue/shared": "^3.5.0", + "axios": "^1.8.0", + "element-plus": "^2.9.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom/node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "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", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/language-core/node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/language-core/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/language-core/node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/language-core/node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/language-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/language-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vueuse/core/node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/element-plus": { + "version": "2.13.6", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/form-data/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data/node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/form-data/node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data/node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data/node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data/node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data/node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data/node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "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" + }, + "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" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/vite/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/vite/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/vite/node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue/node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/vue/node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/vue/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/vue/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/vue/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/vue/node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/vue/node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/vue/node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/vue/node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/vue/node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/vue/node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/vue/node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/vue/node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/vue/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/vue/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/vue/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/vue/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vue/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/vue/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/vue/node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vue/node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index e69de29..0ddae61 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "smartflow-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vue/shared": "^3.5.0", + "axios": "^1.8.0", + "element-plus": "^2.9.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e69de29..98240ae 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/agent.ts b/frontend/src/api/agent.ts new file mode 100644 index 0000000..904aa9b --- /dev/null +++ b/frontend/src/api/agent.ts @@ -0,0 +1,68 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' +import type { ConversationListResponse, ConversationMeta } from '@/types/dashboard' +import { extractErrorMessage } from '@/utils/http' + +const conversationHistoryPath = '/agent/conversation-history' + +export interface ConversationHistoryMessage { + id?: string | number + role: 'user' | 'assistant' | 'system' + content: string + created_at?: string | null + reasoning_content?: string +} + +export interface ConversationListQuery { + page?: number + pageSize?: number + status?: 'active' | 'archived' +} + +// getConversationList 负责按 openapi 约定读取会话列表分页。 +// 职责边界: +// 1. 负责把前端分页参数映射为后端要求的 page/limit。 +// 2. 不负责前端滚动懒加载时的合并、去重和选中逻辑。 +// 3. 接口失败时统一抛出中文错误,便于页面层直接提示。 +export async function getConversationList(options: ConversationListQuery = {}) { + const { page = 1, pageSize = 20, status = 'active' } = options + + try { + const response = await http.get>('/agent/conversation-list', { + params: { + page, + limit: pageSize, + status, + }, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '会话列表加载失败,请稍后重试')) + } +} + +export async function getConversationMeta(conversationId: string) { + try { + const response = await http.get>('/agent/conversation-meta', { + params: { + conversation_id: conversationId, + }, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '会话信息加载失败,请稍后重试')) + } +} + +export async function getConversationHistory(conversationId: string) { + try { + const response = await http.get>(conversationHistoryPath, { + params: { + conversation_id: conversationId, + }, + }) + return response.data.data ?? [] + } catch (error) { + throw new Error(extractErrorMessage(error, '会话消息加载失败,请稍后重试')) + } +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..7bdc917 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,62 @@ +import http from '@/api/http' +import { extractErrorMessage } from '@/utils/http' +import type { + ApiResponse, + LoginPayload, + PlainResponse, + RefreshTokenPayload, + RegisterPayload, + RegisterResult, + TokenPair, +} from '@/types/api' + +export async function login(payload: LoginPayload) { + try { + const response = await http.post>('/user/login', payload, { + skipAuth: true, + skipRefresh: true, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '登录失败,请稍后重试')) + } +} + +export async function register(payload: RegisterPayload) { + try { + const response = await http.post>('/user/register', payload, { + skipAuth: true, + skipRefresh: true, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '注册失败,请稍后重试')) + } +} + +export async function refreshToken(payload: RefreshTokenPayload) { + try { + const response = await http.post>('/user/refresh-token', payload, { + skipAuth: true, + skipRefresh: true, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '登录状态已失效,请重新登录')) + } +} + +export async function logout() { + try { + const response = await http.post( + '/user/logout', + {}, + { + skipRefresh: true, + }, + ) + return response.data + } catch (error) { + throw new Error(extractErrorMessage(error, '退出登录失败,请稍后重试')) + } +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..7ad5960 --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,134 @@ +import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' + +import router from '@/router' +import { useAuthStore } from '@/stores/auth' +import type { ApiResponse, TokenPair } from '@/types/api' + +declare module 'axios' { + interface AxiosRequestConfig { + skipAuth?: boolean + skipRefresh?: boolean + _retry?: boolean + } + + interface InternalAxiosRequestConfig { + skipAuth?: boolean + skipRefresh?: boolean + _retry?: boolean + } +} + +const http = axios.create({ + baseURL: '/api/v1', + timeout: 12000, +}) + +const refreshHttp = axios.create({ + baseURL: '/api/v1', + timeout: 12000, +}) + +let refreshPromise: Promise | null = null + +function getAuthStore() { + return useAuthStore() +} + +async function redirectToAuth() { + if (router.currentRoute.value.name !== 'auth') { + await router.push('/auth') + } +} + +// refreshAccessToken 负责把多个并发 401 合并成一次 refresh 请求。 +// 职责边界: +// 1. 负责读取当前 refresh token,并向后端换发新的 token 对。 +// 2. 不负责决定“哪些请求应该触发 refresh”;这一层由响应拦截器判断。 +// 3. 失败时会清理本地会话并跳回登录页,避免页面继续带着坏 token 重试。 +async function refreshAccessToken() { + if (refreshPromise) { + return refreshPromise + } + + const authStore = getAuthStore() + const currentRefreshToken = authStore.refreshToken.trim() + if (!currentRefreshToken) { + authStore.clearSession() + await redirectToAuth() + throw new Error('缺少 refresh token,请重新登录') + } + + refreshPromise = refreshHttp + .post>( + '/user/refresh-token', + { + old_refresh_token: currentRefreshToken, + }, + { + skipAuth: true, + skipRefresh: true, + }, + ) + .then((response) => { + const tokens = response.data.data + authStore.applyTokenPair(tokens) + return tokens + }) + .catch(async (error) => { + authStore.clearSession() + await redirectToAuth() + throw error + }) + .finally(() => { + refreshPromise = null + }) + + return refreshPromise +} + +http.interceptors.request.use((config) => { + const authStore = getAuthStore() + + // 1. 默认所有业务请求都自动带 access token。 + // 2. 登录/注册/刷新这类公开接口通过 skipAuth 显式跳过,避免误带过期 token。 + if (!config.skipAuth && authStore.accessToken) { + config.headers.Authorization = `Bearer ${authStore.accessToken}` + } + + return config +}) + +http.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const authStore = getAuthStore() + const originalRequest = error.config as InternalAxiosRequestConfig | undefined + + // 1. 只对“带请求配置的 401”尝试自动续签。 + // 2. 已经重试过、显式禁用 refresh 的请求,直接走失败兜底,避免死循环。 + if ( + error.response?.status !== 401 || + !originalRequest || + originalRequest.skipRefresh || + originalRequest._retry + ) { + if (error.response?.status === 401 && !originalRequest?.skipRefresh) { + authStore.clearSession() + await redirectToAuth() + } + return Promise.reject(error) + } + + originalRequest._retry = true + + try { + const tokens = await refreshAccessToken() + originalRequest.headers.Authorization = `Bearer ${tokens.access_token}` + return http(originalRequest) + } catch (refreshError) { + return Promise.reject(refreshError) + } + }, +) + +export default http diff --git a/frontend/src/api/schedule.ts b/frontend/src/api/schedule.ts new file mode 100644 index 0000000..6f01305 --- /dev/null +++ b/frontend/src/api/schedule.ts @@ -0,0 +1,13 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' +import type { TodaySchedule } from '@/types/dashboard' +import { extractErrorMessage } from '@/utils/http' + +export async function getTodaySchedule() { + try { + const response = await http.get>('/schedule/today') + return response.data.data ?? [] + } catch (error) { + throw new Error(extractErrorMessage(error, '今日日程加载失败,请稍后重试')) + } +} diff --git a/frontend/src/api/task.ts b/frontend/src/api/task.ts new file mode 100644 index 0000000..6cda41f --- /dev/null +++ b/frontend/src/api/task.ts @@ -0,0 +1,61 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' +import type { TaskCreatePayload, TaskCreateResult, TaskItem, TaskMutationResult } from '@/types/dashboard' +import { createIdempotencyKey } from '@/utils/idempotency' +import { extractErrorMessage } from '@/utils/http' + +export async function getTasks() { + try { + const response = await http.get>('/task/get') + return response.data.data ?? [] + } catch (error) { + throw new Error(extractErrorMessage(error, '任务加载失败,请稍后重试')) + } +} + +export async function createTask(payload: TaskCreatePayload) { + try { + const response = await http.post>('/task/create', payload, { + headers: { + 'X-Idempotency-Key': createIdempotencyKey('task-create'), + }, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '创建任务失败,请稍后重试')) + } +} + +export async function completeTask(taskId: number) { + try { + const response = await http.put>( + '/task/complete', + { task_id: taskId }, + { + headers: { + 'X-Idempotency-Key': createIdempotencyKey('task-complete'), + }, + }, + ) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '更新任务状态失败,请稍后重试')) + } +} + +export async function undoCompleteTask(taskId: number) { + try { + const response = await http.put>( + '/task/undo-complete', + { task_id: taskId }, + { + headers: { + 'X-Idempotency-Key': createIdempotencyKey('task-undo'), + }, + }, + ) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '恢复任务失败,请稍后重试')) + } +} diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue new file mode 100644 index 0000000..948d6b0 --- /dev/null +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -0,0 +1,1610 @@ + + +