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 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/TaskQuadrantCard.vue b/frontend/src/components/dashboard/TaskQuadrantCard.vue
new file mode 100644
index 0000000..dcd17d0
--- /dev/null
+++ b/frontend/src/components/dashboard/TaskQuadrantCard.vue
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+ {{ emptyText }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/dashboard/TodayTimeline.vue b/frontend/src/components/dashboard/TodayTimeline.vue
new file mode 100644
index 0000000..6fdb50d
--- /dev/null
+++ b/frontend/src/components/dashboard/TodayTimeline.vue
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ formatTimeRange(
+ eventMap.get(slot.order)?.start_time,
+ eventMap.get(slot.order)?.end_time,
+ )
+ }}
+
+ {{ eventMap.get(slot.order)?.name }}
+
+ {{ eventMap.get(slot.order)?.location || '休息时间' }}
+
+
+
+
+ {{ slot.timeText }}
+ {{ slot.label }}
+ 为中段留出缓冲与恢复时间
+
+
+
+
+
+
+
diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend/src/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/src/main.js b/frontend/src/main.js
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..ea836ed
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,16 @@
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import { createApp } from 'vue'
+
+import App from './App.vue'
+import router from './router'
+import './style.css'
+
+const app = createApp(App)
+
+app.use(createPinia())
+app.use(router)
+app.use(ElementPlus)
+
+app.mount('#app')
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
new file mode 100644
index 0000000..7e975bc
--- /dev/null
+++ b/frontend/src/router/index.ts
@@ -0,0 +1,56 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+import { useAuthStore } from '@/stores/auth'
+import AuthView from '@/views/AuthView.vue'
+import DashboardView from '@/views/DashboardView.vue'
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ {
+ path: '/',
+ redirect: '/dashboard',
+ },
+ {
+ path: '/auth',
+ name: 'auth',
+ component: AuthView,
+ meta: {
+ guestOnly: true,
+ },
+ },
+ {
+ path: '/dashboard',
+ name: 'dashboard',
+ component: DashboardView,
+ meta: {
+ requiresAuth: true,
+ },
+ },
+ ],
+})
+
+router.beforeEach((to) => {
+ const authStore = useAuthStore()
+
+ // 1. 进入受保护页面前,必须先确认 access token 是否存在。
+ // 2. 当前阶段只做“是否登录”的前端兜底,不在这里做 token 过期解析。
+ if (to.meta.requiresAuth && !authStore.isAuthenticated) {
+ return {
+ name: 'auth',
+ query: {
+ redirect: to.fullPath,
+ },
+ }
+ }
+
+ if (to.meta.guestOnly && authStore.isAuthenticated) {
+ return {
+ name: 'dashboard',
+ }
+ }
+
+ return true
+})
+
+export default router
diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts
new file mode 100644
index 0000000..c38cafa
--- /dev/null
+++ b/frontend/src/stores/auth.ts
@@ -0,0 +1,87 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+
+import type { LoginPayload, RegisterPayload, TokenPair } from '@/types/api'
+import { login as loginApi, logout as logoutApi, register as registerApi } from '@/api/auth'
+
+const ACCESS_TOKEN_KEY = 'smartflow_access_token'
+const REFRESH_TOKEN_KEY = 'smartflow_refresh_token'
+const LAST_USERNAME_KEY = 'smartflow_last_username'
+
+export const useAuthStore = defineStore('auth', () => {
+ const accessToken = ref(localStorage.getItem(ACCESS_TOKEN_KEY) ?? '')
+ const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) ?? '')
+ const lastUsername = ref(localStorage.getItem(LAST_USERNAME_KEY) ?? '')
+
+ const isAuthenticated = computed(() => accessToken.value.trim().length > 0)
+
+ // applyTokenPair 只负责把后端返回的新 token 对落到内存和本地存储。
+ // 职责边界:
+ // 1. 负责登录成功、刷新成功后的统一持久化。
+ // 2. 不负责调用接口,避免把“网络失败”和“本地状态写入”耦合在一起。
+ // 3. 返回值为空;调用方若需要错误处理,应在接口层完成。
+ function applyTokenPair(tokens: TokenPair) {
+ accessToken.value = tokens.access_token
+ refreshToken.value = tokens.refresh_token
+ localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token)
+ localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
+ }
+
+ function persistLastUsername(username: string) {
+ lastUsername.value = username
+ localStorage.setItem(LAST_USERNAME_KEY, username)
+ }
+
+ // clearSession 只清本地登录态,不调用后端接口。
+ // 职责边界:
+ // 1. 负责在 refresh 失败、401 兜底、主动退出后统一清理本地状态。
+ // 2. 不负责页面跳转;跳转由调用方按场景决定,避免 store 硬绑定 UI。
+ // 3. lastUsername 保留,用于下次登录时回填用户名,减少重复输入。
+ function clearSession() {
+ accessToken.value = ''
+ refreshToken.value = ''
+ localStorage.removeItem(ACCESS_TOKEN_KEY)
+ localStorage.removeItem(REFRESH_TOKEN_KEY)
+ }
+
+ async function login(payload: LoginPayload) {
+ const tokens = await loginApi(payload)
+ applyTokenPair(tokens)
+ persistLastUsername(payload.username)
+ return tokens
+ }
+
+ async function register(payload: RegisterPayload) {
+ const result = await registerApi(payload)
+ persistLastUsername(payload.username)
+ return result
+ }
+
+ // logout 负责“尽力通知后端 + 一定清理本地状态”。
+ // 职责边界:
+ // 1. 先请求后端注销当前 access token,让对应 jti 进入黑名单。
+ // 2. 不保证后端一定成功;即使接口失败,也必须清理本地状态,避免前端假在线。
+ // 3. 返回值语义:接口成功时返回后端响应;失败时向上抛错,但本地状态已被清空。
+ async function logout() {
+ try {
+ const result = await logoutApi()
+ clearSession()
+ return result
+ } catch (error) {
+ clearSession()
+ throw error
+ }
+ }
+
+ return {
+ accessToken,
+ refreshToken,
+ lastUsername,
+ isAuthenticated,
+ applyTokenPair,
+ clearSession,
+ login,
+ register,
+ logout,
+ }
+})
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 0000000..4a09ab3
--- /dev/null
+++ b/frontend/src/style.css
@@ -0,0 +1,68 @@
+:root {
+ color-scheme: light;
+ font-family:
+ "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
+ sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+ color: #1f2937;
+ background:
+ radial-gradient(circle at top left, rgba(119, 198, 255, 0.18), transparent 28%),
+ radial-gradient(circle at right center, rgba(89, 208, 160, 0.16), transparent 24%),
+ linear-gradient(180deg, #f6f8fb 0%, #edf2f7 100%);
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ --page-max-width: 1200px;
+ --surface: rgba(255, 255, 255, 0.92);
+ --surface-strong: #ffffff;
+ --surface-muted: #f5f7fb;
+ --line-soft: rgba(15, 23, 42, 0.08);
+ --text-main: #111827;
+ --text-secondary: #5b6475;
+ --brand: #2684ff;
+ --brand-strong: #1d6fe0;
+ --success: #11a36a;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --shadow-soft: 0 18px 45px rgba(31, 41, 55, 0.08);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ min-height: 100%;
+ margin: 0;
+}
+
+body {
+ min-height: 100vh;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.page-shell {
+ width: min(var(--page-max-width), calc(100vw - 32px));
+ margin: 0 auto;
+}
+
+.glass-panel {
+ background: var(--surface);
+ border: 1px solid var(--line-soft);
+ box-shadow: var(--shadow-soft);
+ backdrop-filter: blur(10px);
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
new file mode 100644
index 0000000..140ff75
--- /dev/null
+++ b/frontend/src/types/api.ts
@@ -0,0 +1,34 @@
+export interface ApiResponse {
+ status: string
+ info: string
+ data: T
+}
+
+export interface PlainResponse {
+ status: string
+ info: string
+}
+
+export interface TokenPair {
+ access_token: string
+ refresh_token: string
+}
+
+export interface LoginPayload {
+ username: string
+ password: string
+}
+
+export interface RegisterPayload {
+ username: string
+ phone_number: string
+ password: string
+}
+
+export interface RegisterResult {
+ id: number
+}
+
+export interface RefreshTokenPayload {
+ old_refresh_token: string
+}
diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts
new file mode 100644
index 0000000..e7c926e
--- /dev/null
+++ b/frontend/src/types/dashboard.ts
@@ -0,0 +1,99 @@
+export interface TaskItem {
+ id: number
+ user_id: number
+ title: string
+ priority_group: number
+ status: string
+ deadline: string
+ is_completed: boolean
+}
+
+export interface TaskCreatePayload {
+ title: string
+ priority_group: number
+ deadline_at?: string | null
+}
+
+export interface TaskCreateResult {
+ id: number
+ title: string
+ priority_group: number
+ deadline_at?: string | null
+ status: string
+ created_at: string
+}
+
+export interface TaskMutationResult {
+ task_id: number
+ is_completed: boolean
+ already_completed?: boolean
+ status: string
+}
+
+export interface TaskBrief {
+ id: number
+ name: string
+ type: string
+}
+
+export interface TodayEvent {
+ id: number
+ order: number
+ name: string
+ start_time: string
+ end_time: string
+ location: string
+ type: string
+ span: number
+ embedded_task_info?: TaskBrief
+}
+
+export interface TodaySchedule {
+ day_of_week: number
+ week: number
+ events: TodayEvent[]
+}
+
+export interface ConversationListItem {
+ conversation_id: string
+ title: string
+ has_title: boolean
+ message_count: number
+ last_message_at?: string | null
+ status: string
+ created_at?: string | null
+}
+
+export interface ConversationListResponse {
+ list: ConversationListItem[]
+ page: number
+ page_size: number
+ limit: number
+ total: number
+ has_more: boolean
+}
+
+export interface ConversationMeta {
+ conversation_id: string
+ title: string
+ has_title: boolean
+ message_count: number
+ last_message_at?: string | null
+ status: string
+}
+
+export interface AssistantMessage {
+ id: string
+ role: 'user' | 'assistant' | 'system'
+ content: string
+ createdAt: string
+ reasoning?: string
+}
+
+export interface ChatStreamRequest {
+ conversation_id?: string
+ message: string
+ model?: string
+ thinking?: boolean
+ extra?: Record
+}
diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts
new file mode 100644
index 0000000..7ba9a85
--- /dev/null
+++ b/frontend/src/utils/date.ts
@@ -0,0 +1,71 @@
+const weekdayMap = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
+
+function toDate(value?: string | null) {
+ if (!value) {
+ return null
+ }
+
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+export function formatHeaderDate(date = new Date()) {
+ const year = date.getFullYear()
+ const month = `${date.getMonth() + 1}`.padStart(2, '0')
+ const day = `${date.getDate()}`.padStart(2, '0')
+ return `${year}年${month}月${day}日 ${weekdayMap[date.getDay()]}`
+}
+
+export function formatTimeRange(start?: string | null, end?: string | null) {
+ if (!start || !end) {
+ return '待安排'
+ }
+ return `${start} - ${end}`
+}
+
+export function formatDeadline(deadline?: string | null) {
+ const date = toDate(deadline)
+ if (!date) {
+ return '未设置截止时间'
+ }
+
+ const month = `${date.getMonth() + 1}`.padStart(2, '0')
+ const day = `${date.getDate()}`.padStart(2, '0')
+ const hour = `${date.getHours()}`.padStart(2, '0')
+ const minute = `${date.getMinutes()}`.padStart(2, '0')
+ return `${month}-${day} ${hour}:${minute}`
+}
+
+export function formatConversationTime(value?: string | null) {
+ const date = toDate(value)
+ if (!date) {
+ return '刚刚'
+ }
+
+ const now = new Date()
+ const sameDay =
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth() &&
+ date.getDate() === now.getDate()
+
+ const hour = `${date.getHours()}`.padStart(2, '0')
+ const minute = `${date.getMinutes()}`.padStart(2, '0')
+ if (sameDay) {
+ return `${hour}:${minute}`
+ }
+
+ const month = `${date.getMonth() + 1}`.padStart(2, '0')
+ const day = `${date.getDate()}`.padStart(2, '0')
+ return `${month}-${day} ${hour}:${minute}`
+}
+
+export function formatMessageTime(value?: string | null) {
+ const date = toDate(value)
+ if (!date) {
+ return ''
+ }
+
+ const hour = `${date.getHours()}`.padStart(2, '0')
+ const minute = `${date.getMinutes()}`.padStart(2, '0')
+ return `${hour}:${minute}`
+}
diff --git a/frontend/src/utils/http.ts b/frontend/src/utils/http.ts
new file mode 100644
index 0000000..97a19e4
--- /dev/null
+++ b/frontend/src/utils/http.ts
@@ -0,0 +1,20 @@
+import axios from 'axios'
+
+interface ErrorBody {
+ info?: string
+ message?: string
+}
+
+export function extractErrorMessage(error: unknown, fallback: string) {
+ if (axios.isAxiosError(error)) {
+ const responseInfo = error.response?.data?.info
+ const responseMessage = error.response?.data?.message
+ return responseInfo || responseMessage || error.message || fallback
+ }
+
+ if (error instanceof Error) {
+ return error.message
+ }
+
+ return fallback
+}
diff --git a/frontend/src/utils/idempotency.ts b/frontend/src/utils/idempotency.ts
new file mode 100644
index 0000000..a2132a3
--- /dev/null
+++ b/frontend/src/utils/idempotency.ts
@@ -0,0 +1,11 @@
+// createIdempotencyKey 负责为写接口生成幂等键。
+// 职责边界:
+// 1. 只负责生成前端唯一键,不负责持久化或重试策略。
+// 2. 优先使用浏览器原生 randomUUID,缺失时退回时间戳方案。
+export function createIdempotencyKey(prefix = 'smartflow') {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return `${prefix}-${crypto.randomUUID()}`
+ }
+
+ return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
+}
diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts
new file mode 100644
index 0000000..a339a0f
--- /dev/null
+++ b/frontend/src/utils/markdown.ts
@@ -0,0 +1,151 @@
+function escapeHtml(input: string) {
+ return input
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+function parseInlineMarkdown(input: string) {
+ const inlineCodeBlocks: string[] = []
+ let content = escapeHtml(input)
+
+ content = content.replace(/`([^`]+)`/g, (_, code: string) => {
+ const token = `@@INLINE_CODE_${inlineCodeBlocks.length}@@`
+ inlineCodeBlocks.push(`${escapeHtml(code)}`)
+ return token
+ })
+
+ content = content.replace(
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
+ (_, label: string, link: string) =>
+ `${label}`,
+ )
+
+ content = content.replace(/\*\*([^*]+)\*\*/g, '$1')
+ content = content.replace(/\*([^*]+)\*/g, '$1')
+ content = content.replace(/~~([^~]+)~~/g, '$1')
+
+ return content.replace(/@@INLINE_CODE_(\d+)@@/g, (_, index: string) => inlineCodeBlocks[Number(index)] ?? '')
+}
+
+// renderMarkdown 负责把常见 Markdown 文本安全转换为可展示 HTML。
+// 职责边界:
+// 1. 负责处理标题、列表、引用、代码块、链接、粗斜体等常见场景。
+// 2. 不追求完整 CommonMark 兼容,只覆盖聊天消息里最常见的展示需求。
+// 3. 所有原始文本都会先做 HTML 转义,避免把模型输出直接当成原生 HTML 注入页面。
+export function renderMarkdown(input: string) {
+ const normalized = (input || '').replace(/\r\n?/g, '\n').trim()
+ if (!normalized) {
+ return ''
+ }
+
+ const fencedBlocks: string[] = []
+ let source = normalized.replace(/```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```/g, (_, language: string, code: string) => {
+ const token = `@@FENCED_BLOCK_${fencedBlocks.length}@@`
+ const languageClass = language ? ` language-${escapeHtml(language)}` : ''
+ fencedBlocks.push(
+ `${escapeHtml(code.trimEnd())}
`,
+ )
+ return token
+ })
+
+ const lines = source.split('\n')
+ const htmlParts: string[] = []
+ let unorderedItems: string[] = []
+ let orderedItems: string[] = []
+ let quoteLines: string[] = []
+ let paragraphLines: string[] = []
+
+ function flushParagraph() {
+ if (paragraphLines.length === 0) {
+ return
+ }
+ htmlParts.push(`${parseInlineMarkdown(paragraphLines.join('
'))}
`)
+ paragraphLines = []
+ }
+
+ function flushUnorderedList() {
+ if (unorderedItems.length === 0) {
+ return
+ }
+ htmlParts.push(`${unorderedItems.map((item) => `- ${parseInlineMarkdown(item)}
`).join('')}
`)
+ unorderedItems = []
+ }
+
+ function flushOrderedList() {
+ if (orderedItems.length === 0) {
+ return
+ }
+ htmlParts.push(`${orderedItems.map((item) => `- ${parseInlineMarkdown(item)}
`).join('')}
`)
+ orderedItems = []
+ }
+
+ function flushBlockquote() {
+ if (quoteLines.length === 0) {
+ return
+ }
+ htmlParts.push(`${quoteLines.map((line) => `${parseInlineMarkdown(line)}
`).join('')}
`)
+ quoteLines = []
+ }
+
+ function flushAllBlocks() {
+ flushParagraph()
+ flushUnorderedList()
+ flushOrderedList()
+ flushBlockquote()
+ }
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+
+ if (!trimmed) {
+ flushAllBlocks()
+ continue
+ }
+
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/)
+ if (headingMatch) {
+ flushAllBlocks()
+ const level = headingMatch[1].length
+ htmlParts.push(`${parseInlineMarkdown(headingMatch[2])}`)
+ continue
+ }
+
+ const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/)
+ if (unorderedMatch) {
+ flushParagraph()
+ flushOrderedList()
+ flushBlockquote()
+ unorderedItems.push(unorderedMatch[1])
+ continue
+ }
+
+ const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/)
+ if (orderedMatch) {
+ flushParagraph()
+ flushUnorderedList()
+ flushBlockquote()
+ orderedItems.push(orderedMatch[1])
+ continue
+ }
+
+ const quoteMatch = trimmed.match(/^>\s?(.*)$/)
+ if (quoteMatch) {
+ flushParagraph()
+ flushUnorderedList()
+ flushOrderedList()
+ quoteLines.push(quoteMatch[1])
+ continue
+ }
+
+ paragraphLines.push(trimmed)
+ }
+
+ flushAllBlocks()
+
+ return htmlParts
+ .join('')
+ .replace(/@@FENCED_BLOCK_(\d+)@@/g, (_, index: string) => fencedBlocks[Number(index)] ?? '')
+}
diff --git a/frontend/src/views/AuthView.vue b/frontend/src/views/AuthView.vue
new file mode 100644
index 0000000..ab23045
--- /dev/null
+++ b/frontend/src/views/AuthView.vue
@@ -0,0 +1,371 @@
+
+
+
+
+
+
+ SmartFlow
+ 把任务、课程与智能规划放在同一个工作台里。
+
+ 这一版先把登录链路跑通。后面我们会在这个基础上继续接任务管理、课表总览和智能体排程能力。
+
+
+
+
+ 扁平化界面
+ 去掉多余装饰,把信息层级讲清楚。
+
+
+ 登录态托管
+ 统一管理 access token,后续接业务页面更轻松。
+
+
+ 可持续扩展
+ 路由、状态、接口层已经拆开,后面直接加页面即可。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 登录并进入示例页
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 创建账号
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue
new file mode 100644
index 0000000..7162f35
--- /dev/null
+++ b/frontend/src/views/DashboardView.vue
@@ -0,0 +1,806 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
课程导入
+
导入课表
+
导入课表后,可以在安排日程时避开上课时间,后续我会继续把导入流程页接完整。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存任务
+
+
+
+
+
+
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..1283252
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": true,
+ "types": ["vite/client"],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..ea9d0cd
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..f4cdb01
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..4a76a46
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,25 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vite'
+
+// 1. 开发环境把 /api 代理到本地 Go 服务,避免前端先处理跨域。
+// 2. 这里只负责联调体验,不负责生产环境网关配置。
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ server: {
+ port: 5173,
+ host: '0.0.0.0',
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:8080',
+ changeOrigin: true,
+ },
+ },
+ },
+})
diff --git a/openapi.yaml b/openapi.yaml
new file mode 100644
index 0000000..1e08cfb
--- /dev/null
+++ b/openapi.yaml
@@ -0,0 +1,289 @@
+openapi: 3.0.3
+info:
+ title: SmartFlow Agent Query API
+ version: 0.8.0
+ description: Agent 会话列表与聊天历史查询接口。
+servers:
+ - url: http://localhost:8080
+paths:
+ /api/v1/agent/conversation-list:
+ get:
+ tags:
+ - Agent
+ summary: 获取 Agent 会话列表
+ description: |
+ 获取当前登录用户的 Agent 会话列表。
+ 支持历史分页参数 `page/page_size`,并兼容懒加载参数 `limit`。
+ 当同时传入 `page_size` 与 `limit` 时,以 `limit` 为准。
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: page
+ in: query
+ description: 页码,默认 1。
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ example: 1
+ - name: page_size
+ in: query
+ description: 历史分页页大小,默认 20。
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ example: 20
+ - name: limit
+ in: query
+ description: 懒加载条数,作为 `page_size` 的别名使用。
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ example: 20
+ - name: status
+ in: query
+ description: 会话状态过滤,仅支持 `active` 或 `archived`。
+ required: false
+ schema:
+ type: string
+ enum:
+ - active
+ - archived
+ example: active
+ responses:
+ '200':
+ description: 查询成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ConversationListEnvelope'
+ examples:
+ success:
+ value:
+ status: "10000"
+ info: success
+ data:
+ list:
+ - conversation_id: 8df59142-29a2-4bf6-85b6-c5e3f4e9cb89
+ title: 明天课程与任务安排
+ has_title: true
+ message_count: 14
+ last_message_at: "2026-03-24T22:18:45+08:00"
+ status: active
+ created_at: "2026-03-24T20:01:12+08:00"
+ page: 1
+ page_size: 20
+ limit: 20
+ total: 1
+ has_more: false
+ '400':
+ description: 参数错误或会话状态非法
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: 未登录或 token 无效
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ /api/v1/agent/conversation-history:
+ get:
+ tags:
+ - Agent
+ summary: 获取 Agent 会话聊天历史
+ description: |
+ 获取指定会话的聊天历史。
+ 服务端会先校验会话是否属于当前用户,再优先读取 Redis,
+ Redis 未命中时回源数据库,并把结果回填到 Redis。
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: conversation_id
+ in: query
+ description: 会话 ID。
+ required: true
+ schema:
+ type: string
+ format: uuid
+ example: 8df59142-29a2-4bf6-85b6-c5e3f4e9cb89
+ responses:
+ '200':
+ description: 查询成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ConversationHistoryEnvelope'
+ examples:
+ success:
+ value:
+ status: "10000"
+ info: success
+ data:
+ - id: 101
+ role: user
+ content: 帮我看下明天最重要的任务
+ created_at: "2026-03-24T22:15:01+08:00"
+ - id: 102
+ role: assistant
+ content: 明天优先处理数据库联调和接口验收。
+ created_at: "2026-03-24T22:15:06+08:00"
+ '400':
+ description: 缺少参数或会话不存在
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: 未登录或 token 无效
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ schemas:
+ ErrorResponse:
+ type: object
+ required:
+ - status
+ - info
+ properties:
+ status:
+ type: string
+ example: "40005"
+ info:
+ type: string
+ example: wrong param type
+ ConversationListEnvelope:
+ type: object
+ required:
+ - status
+ - info
+ - data
+ properties:
+ status:
+ type: string
+ example: "10000"
+ info:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/ConversationListResponse'
+ ConversationListResponse:
+ type: object
+ required:
+ - list
+ - page
+ - page_size
+ - limit
+ - total
+ - has_more
+ properties:
+ list:
+ type: array
+ items:
+ $ref: '#/components/schemas/ConversationListItem'
+ page:
+ type: integer
+ example: 1
+ page_size:
+ type: integer
+ example: 20
+ limit:
+ type: integer
+ description: 当前实际使用的懒加载条数。
+ example: 20
+ total:
+ type: integer
+ format: int64
+ example: 56
+ has_more:
+ type: boolean
+ example: true
+ ConversationListItem:
+ type: object
+ required:
+ - conversation_id
+ - title
+ - has_title
+ - message_count
+ - status
+ properties:
+ conversation_id:
+ type: string
+ format: uuid
+ title:
+ type: string
+ example: 明天课程与任务安排
+ has_title:
+ type: boolean
+ example: true
+ message_count:
+ type: integer
+ example: 14
+ last_message_at:
+ type: string
+ format: date-time
+ nullable: true
+ status:
+ type: string
+ example: active
+ created_at:
+ type: string
+ format: date-time
+ nullable: true
+ ConversationHistoryEnvelope:
+ type: object
+ required:
+ - status
+ - info
+ - data
+ properties:
+ status:
+ type: string
+ example: "10000"
+ info:
+ type: string
+ example: success
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ConversationHistoryItem'
+ ConversationHistoryItem:
+ type: object
+ required:
+ - role
+ - content
+ properties:
+ id:
+ type: integer
+ description: 数据库主键;命中 Redis 时可能为空。
+ example: 102
+ role:
+ type: string
+ enum:
+ - user
+ - assistant
+ - system
+ example: assistant
+ content:
+ type: string
+ example: 明天优先处理数据库联调和接口验收。
+ created_at:
+ type: string
+ format: date-time
+ nullable: true
+ reasoning_content:
+ type: string
+ description: 推理内容;命中数据库历史时通常为空。
+ example: ""