Version: 0.7.6.dev.260325
后端: - ♻️ 将 `taskquery` 模块迁移至 `agent2`,并完成与 `agent2` 业务链路及整体结构的正式接入 前端: - 🧱 已完成基础框架搭建,并完成了登录、注册、主页等页面并对接了对应接口;但整体功能实现仍在完善中
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||
.omc/
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
37
backend/agent2/model/task_priority.go
Normal file
37
backend/agent2/model/task_priority.go
Normal file
@@ -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 "未知优先级"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
81
backend/agent2/node/taskquery_test.go
Normal file
81
backend/agent2/node/taskquery_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
286
backend/agent2/node/taskquery_tool.go
Normal file
286
backend/agent2/node/taskquery_tool.go
Normal file
@@ -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")
|
||||
}
|
||||
40
backend/agent2/node/taskquery_tool_test.go
Normal file
40
backend/agent2/node/taskquery_tool_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
74
backend/agent2/node/tool_common.go
Normal file
74
backend/agent2/node/tool_common.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 返回“指定会话”的排程结构化预览。
|
||||
//
|
||||
// 设计说明:
|
||||
|
||||
@@ -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 中的缓存结构。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
130
backend/service/agentsvc/agent_history.go
Normal file
130
backend/service/agentsvc/agent_history.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SmartFlow</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2262
frontend/package-lock.json
generated
Normal file
2262
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
68
frontend/src/api/agent.ts
Normal file
68
frontend/src/api/agent.ts
Normal file
@@ -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<ApiResponse<ConversationListResponse>>('/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<ApiResponse<ConversationMeta>>('/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<ApiResponse<ConversationHistoryMessage[]>>(conversationHistoryPath, {
|
||||
params: {
|
||||
conversation_id: conversationId,
|
||||
},
|
||||
})
|
||||
return response.data.data ?? []
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '会话消息加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
62
frontend/src/api/auth.ts
Normal file
62
frontend/src/api/auth.ts
Normal file
@@ -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<ApiResponse<TokenPair>>('/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<ApiResponse<RegisterResult>>('/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<ApiResponse<TokenPair>>('/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<PlainResponse>(
|
||||
'/user/logout',
|
||||
{},
|
||||
{
|
||||
skipRefresh: true,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '退出登录失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
134
frontend/src/api/http.ts
Normal file
134
frontend/src/api/http.ts
Normal file
@@ -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<TokenPair> | 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<ApiResponse<TokenPair>>(
|
||||
'/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
|
||||
13
frontend/src/api/schedule.ts
Normal file
13
frontend/src/api/schedule.ts
Normal file
@@ -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<ApiResponse<TodaySchedule[]>>('/schedule/today')
|
||||
return response.data.data ?? []
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '今日日程加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
61
frontend/src/api/task.ts
Normal file
61
frontend/src/api/task.ts
Normal file
@@ -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<ApiResponse<TaskItem[]>>('/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<ApiResponse<TaskCreateResult>>('/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<ApiResponse<TaskMutationResult>>(
|
||||
'/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<ApiResponse<TaskMutationResult>>(
|
||||
'/task/undo-complete',
|
||||
{ task_id: taskId },
|
||||
{
|
||||
headers: {
|
||||
'X-Idempotency-Key': createIdempotencyKey('task-undo'),
|
||||
},
|
||||
},
|
||||
)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '恢复任务失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
1610
frontend/src/components/dashboard/AssistantPanel.vue
Normal file
1610
frontend/src/components/dashboard/AssistantPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
238
frontend/src/components/dashboard/TaskQuadrantCard.vue
Normal file
238
frontend/src/components/dashboard/TaskQuadrantCard.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TaskItem } from '@/types/dashboard'
|
||||
import { formatDeadline } from '@/utils/date'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
caption: string
|
||||
count: number
|
||||
tone: 'danger' | 'primary' | 'warning' | 'slate'
|
||||
tasks: TaskItem[]
|
||||
emptyText: string
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [task: TaskItem]
|
||||
}>()
|
||||
|
||||
const visibleTasks = computed(() => props.tasks.slice(0, 4))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="quadrant-card" :class="`quadrant-card--${tone}`">
|
||||
<header class="quadrant-card__header">
|
||||
<div>
|
||||
<p class="quadrant-card__eyebrow">{{ caption }}</p>
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
<span class="quadrant-card__count">{{ count }}项</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="quadrant-card__skeleton">
|
||||
<div v-for="index in 3" :key="index" class="quadrant-card__skeleton-item" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="visibleTasks.length === 0" class="quadrant-card__empty">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
|
||||
<div v-else class="quadrant-list">
|
||||
<button
|
||||
v-for="task in visibleTasks"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="quadrant-item"
|
||||
:class="{ 'quadrant-item--completed': task.is_completed }"
|
||||
@click="emit('toggle', task)"
|
||||
>
|
||||
<span class="quadrant-item__check">
|
||||
{{ task.is_completed ? '✓' : '' }}
|
||||
</span>
|
||||
<span class="quadrant-item__content">
|
||||
<strong>{{ task.title }}</strong>
|
||||
<small>{{ formatDeadline(task.deadline) }}</small>
|
||||
</span>
|
||||
<span class="quadrant-item__status">
|
||||
{{ task.is_completed ? '已完成' : '待处理' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quadrant-card {
|
||||
border-radius: 28px;
|
||||
padding: 22px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.07);
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.quadrant-card--danger {
|
||||
background: linear-gradient(180deg, #fff1f2 0%, #fff7f7 100%);
|
||||
}
|
||||
|
||||
.quadrant-card--primary {
|
||||
background: linear-gradient(180deg, #eef7ff 0%, #f7fbff 100%);
|
||||
}
|
||||
|
||||
.quadrant-card--warning {
|
||||
background: linear-gradient(180deg, #fff8df 0%, #fffdf1 100%);
|
||||
}
|
||||
|
||||
.quadrant-card--slate {
|
||||
background: linear-gradient(180deg, #f2f5fb 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.quadrant-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.quadrant-card__eyebrow {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(32, 50, 79, 0.72);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quadrant-card h3 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.quadrant-card__count {
|
||||
min-width: 64px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #39506f;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quadrant-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quadrant-item,
|
||||
.quadrant-card__skeleton-item {
|
||||
border-radius: 18px;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.quadrant-item {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(17, 24, 39, 0.06);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.quadrant-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08);
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.quadrant-item--completed {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.quadrant-item__check {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: #f8fafc;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #2c8d57;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.quadrant-item__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quadrant-item__content strong,
|
||||
.quadrant-item__content small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quadrant-item__content strong {
|
||||
font-size: 16px;
|
||||
color: #122033;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quadrant-item--completed .quadrant-item__content strong {
|
||||
color: #96a0af;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.quadrant-item__content small {
|
||||
margin-top: 6px;
|
||||
color: #768396;
|
||||
}
|
||||
|
||||
.quadrant-item__status {
|
||||
font-size: 13px;
|
||||
color: #8090a5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quadrant-card__empty {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8b97a7;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.quadrant-card__skeleton {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quadrant-card__skeleton-item {
|
||||
background: linear-gradient(90deg, rgba(230, 236, 244, 0.8), rgba(245, 248, 252, 1), rgba(230, 236, 244, 0.8));
|
||||
background-size: 200% 100%;
|
||||
animation: quadrant-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes quadrant-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
283
frontend/src/components/dashboard/TodayTimeline.vue
Normal file
283
frontend/src/components/dashboard/TodayTimeline.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TodayEvent } from '@/types/dashboard'
|
||||
import { formatTimeRange } from '@/utils/date'
|
||||
|
||||
interface TimelineSlot {
|
||||
order: number
|
||||
kind: 'event' | 'pause'
|
||||
label: string
|
||||
timeText?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
events: TodayEvent[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const slotBlueprint: TimelineSlot[] = [
|
||||
{ order: 1, kind: 'event', label: '上午' },
|
||||
{ order: 2, kind: 'event', label: '上午' },
|
||||
{ order: 3, kind: 'pause', label: '午休', timeText: '11:55 - 14:00' },
|
||||
{ order: 4, kind: 'event', label: '下午' },
|
||||
{ order: 5, kind: 'event', label: '下午' },
|
||||
{ order: 6, kind: 'pause', label: '晚餐', timeText: '17:55 - 19:00' },
|
||||
{ order: 7, kind: 'event', label: '晚间' },
|
||||
]
|
||||
|
||||
const eventMap = computed(() => {
|
||||
const map = new Map<number, TodayEvent>()
|
||||
for (const event of props.events ?? []) {
|
||||
map.set(event.order, event)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function resolveCardTone(event: TodayEvent) {
|
||||
if (event.type === 'course') {
|
||||
return 'course'
|
||||
}
|
||||
|
||||
const orderToneMap: Record<number, string> = {
|
||||
1: 'sky',
|
||||
2: 'violet',
|
||||
4: 'mint',
|
||||
5: 'amber',
|
||||
7: 'cyan',
|
||||
}
|
||||
|
||||
return orderToneMap[event.order] ?? 'neutral'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="timeline-card glass-panel">
|
||||
<header class="timeline-card__header">
|
||||
<div>
|
||||
<p class="timeline-card__eyebrow">今日总览</p>
|
||||
<h2>今日日程一览</h2>
|
||||
</div>
|
||||
<span class="timeline-card__caption">按时间顺序展示课程与任务安排</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="timeline-skeleton">
|
||||
<div v-for="index in 7" :key="index" class="timeline-skeleton__item" />
|
||||
</div>
|
||||
|
||||
<div v-else class="timeline-grid">
|
||||
<template v-for="slot in slotBlueprint" :key="slot.order">
|
||||
<article
|
||||
v-if="eventMap.has(slot.order)"
|
||||
class="timeline-event"
|
||||
:class="`timeline-event--${resolveCardTone(eventMap.get(slot.order)!)}`"
|
||||
>
|
||||
<span class="timeline-event__time">
|
||||
{{
|
||||
formatTimeRange(
|
||||
eventMap.get(slot.order)?.start_time,
|
||||
eventMap.get(slot.order)?.end_time,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<strong class="timeline-event__title">{{ eventMap.get(slot.order)?.name }}</strong>
|
||||
<span class="timeline-event__location">
|
||||
{{ eventMap.get(slot.order)?.location || '休息时间' }}
|
||||
</span>
|
||||
</article>
|
||||
|
||||
<article v-else class="timeline-placeholder timeline-placeholder--pause">
|
||||
<span class="timeline-placeholder__time">{{ slot.timeText }}</span>
|
||||
<strong class="timeline-placeholder__title">{{ slot.label }}</strong>
|
||||
<span class="timeline-placeholder__hint">为中段留出缓冲与恢复时间</span>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline-card {
|
||||
border-radius: 28px;
|
||||
padding: 22px 22px 20px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
.timeline-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.timeline-card__eyebrow {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #2a6fdf;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.timeline-card h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.timeline-card__caption {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-event,
|
||||
.timeline-placeholder,
|
||||
.timeline-skeleton__item {
|
||||
min-height: 124px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.timeline-event {
|
||||
padding: 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid rgba(17, 24, 39, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-event::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.timeline-event__time {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #295b9b;
|
||||
}
|
||||
|
||||
.timeline-event__title {
|
||||
margin-top: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.timeline-event__location {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: #5f6980;
|
||||
}
|
||||
|
||||
.timeline-event--course {
|
||||
background: linear-gradient(180deg, #ecf4ff 0%, #e4eefc 100%);
|
||||
}
|
||||
|
||||
.timeline-event--course::before {
|
||||
background: #1669c1;
|
||||
}
|
||||
|
||||
.timeline-event--violet {
|
||||
background: linear-gradient(180deg, #eef0ff 0%, #e6e8ff 100%);
|
||||
}
|
||||
|
||||
.timeline-event--violet::before {
|
||||
background: #676cff;
|
||||
}
|
||||
|
||||
.timeline-event--mint {
|
||||
background: linear-gradient(180deg, #e6f8f1 0%, #def5ec 100%);
|
||||
}
|
||||
|
||||
.timeline-event--mint::before {
|
||||
background: #27b482;
|
||||
}
|
||||
|
||||
.timeline-event--amber {
|
||||
background: linear-gradient(180deg, #fff5db 0%, #fff0cb 100%);
|
||||
}
|
||||
|
||||
.timeline-event--amber::before {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.timeline-event--cyan {
|
||||
background: linear-gradient(180deg, #e1f7ff 0%, #d6f2fb 100%);
|
||||
}
|
||||
|
||||
.timeline-event--cyan::before {
|
||||
background: #57b8ea;
|
||||
}
|
||||
|
||||
.timeline-placeholder {
|
||||
border: 1px dashed rgba(120, 144, 171, 0.28);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
color: #8a96a8;
|
||||
}
|
||||
|
||||
.timeline-placeholder--pause {
|
||||
background: linear-gradient(180deg, #f5f9ff 0%, #eef4fb 100%);
|
||||
}
|
||||
|
||||
.timeline-placeholder__time {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #4c6c97;
|
||||
}
|
||||
|
||||
.timeline-placeholder__title {
|
||||
font-size: 16px;
|
||||
color: #22324b;
|
||||
}
|
||||
|
||||
.timeline-placeholder__hint {
|
||||
font-size: 12px;
|
||||
color: #7a889d;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.timeline-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-skeleton__item {
|
||||
background: linear-gradient(90deg, rgba(230, 236, 244, 0.8), rgba(245, 248, 252, 1), rgba(230, 236, 244, 0.8));
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/env.d.ts
vendored
Normal file
1
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
16
frontend/src/main.ts
Normal file
16
frontend/src/main.ts
Normal file
@@ -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')
|
||||
56
frontend/src/router/index.ts
Normal file
56
frontend/src/router/index.ts
Normal file
@@ -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
|
||||
87
frontend/src/stores/auth.ts
Normal file
87
frontend/src/stores/auth.ts
Normal file
@@ -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,
|
||||
}
|
||||
})
|
||||
68
frontend/src/style.css
Normal file
68
frontend/src/style.css
Normal file
@@ -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);
|
||||
}
|
||||
34
frontend/src/types/api.ts
Normal file
34
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface ApiResponse<T> {
|
||||
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
|
||||
}
|
||||
99
frontend/src/types/dashboard.ts
Normal file
99
frontend/src/types/dashboard.ts
Normal file
@@ -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<string, unknown>
|
||||
}
|
||||
71
frontend/src/utils/date.ts
Normal file
71
frontend/src/utils/date.ts
Normal file
@@ -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}`
|
||||
}
|
||||
20
frontend/src/utils/http.ts
Normal file
20
frontend/src/utils/http.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'axios'
|
||||
|
||||
interface ErrorBody {
|
||||
info?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function extractErrorMessage(error: unknown, fallback: string) {
|
||||
if (axios.isAxiosError<ErrorBody>(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
|
||||
}
|
||||
11
frontend/src/utils/idempotency.ts
Normal file
11
frontend/src/utils/idempotency.ts
Normal file
@@ -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)}`
|
||||
}
|
||||
151
frontend/src/utils/markdown.ts
Normal file
151
frontend/src/utils/markdown.ts
Normal file
@@ -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(`<code>${escapeHtml(code)}</code>`)
|
||||
return token
|
||||
})
|
||||
|
||||
content = content.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
(_, label: string, link: string) =>
|
||||
`<a href="${escapeHtml(link)}" target="_blank" rel="noreferrer noopener">${label}</a>`,
|
||||
)
|
||||
|
||||
content = content.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
content = content.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
content = content.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
|
||||
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(
|
||||
`<pre class="md-pre"><code class="md-code${languageClass}">${escapeHtml(code.trimEnd())}</code></pre>`,
|
||||
)
|
||||
return token
|
||||
})
|
||||
|
||||
const lines = source.split('\n')
|
||||
const htmlParts: string[] = []
|
||||
let unorderedItems: string[] = []
|
||||
let orderedItems: string[] = []
|
||||
let quoteLines: string[] = []
|
||||
let paragraphLines: string[] = []
|
||||
|
||||
function flushParagraph() {
|
||||
if (paragraphLines.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<p>${parseInlineMarkdown(paragraphLines.join('<br />'))}</p>`)
|
||||
paragraphLines = []
|
||||
}
|
||||
|
||||
function flushUnorderedList() {
|
||||
if (unorderedItems.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<ul>${unorderedItems.map((item) => `<li>${parseInlineMarkdown(item)}</li>`).join('')}</ul>`)
|
||||
unorderedItems = []
|
||||
}
|
||||
|
||||
function flushOrderedList() {
|
||||
if (orderedItems.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<ol>${orderedItems.map((item) => `<li>${parseInlineMarkdown(item)}</li>`).join('')}</ol>`)
|
||||
orderedItems = []
|
||||
}
|
||||
|
||||
function flushBlockquote() {
|
||||
if (quoteLines.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<blockquote>${quoteLines.map((line) => `<p>${parseInlineMarkdown(line)}</p>`).join('')}</blockquote>`)
|
||||
quoteLines = []
|
||||
}
|
||||
|
||||
function flushAllBlocks() {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushOrderedList()
|
||||
flushBlockquote()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
flushAllBlocks()
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/)
|
||||
if (headingMatch) {
|
||||
flushAllBlocks()
|
||||
const level = headingMatch[1].length
|
||||
htmlParts.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/)
|
||||
if (unorderedMatch) {
|
||||
flushParagraph()
|
||||
flushOrderedList()
|
||||
flushBlockquote()
|
||||
unorderedItems.push(unorderedMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/)
|
||||
if (orderedMatch) {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushBlockquote()
|
||||
orderedItems.push(orderedMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const quoteMatch = trimmed.match(/^>\s?(.*)$/)
|
||||
if (quoteMatch) {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushOrderedList()
|
||||
quoteLines.push(quoteMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
paragraphLines.push(trimmed)
|
||||
}
|
||||
|
||||
flushAllBlocks()
|
||||
|
||||
return htmlParts
|
||||
.join('')
|
||||
.replace(/@@FENCED_BLOCK_(\d+)@@/g, (_, index: string) => fencedBlocks[Number(index)] ?? '')
|
||||
}
|
||||
371
frontend/src/views/AuthView.vue
Normal file
371
frontend/src/views/AuthView.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type PanelName = 'login' | 'register'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const activePanel = ref<PanelName>('login')
|
||||
const loginLoading = ref(false)
|
||||
const registerLoading = ref(false)
|
||||
|
||||
const loginForm = reactive({
|
||||
username: authStore.lastUsername,
|
||||
password: '',
|
||||
})
|
||||
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
phone_number: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const redirectPath = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
|
||||
async function submitLogin() {
|
||||
if (!loginForm.username.trim() || !loginForm.password.trim()) {
|
||||
ElMessage.warning('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loginLoading.value = true
|
||||
try {
|
||||
await authStore.login({
|
||||
username: loginForm.username.trim(),
|
||||
password: loginForm.password,
|
||||
})
|
||||
ElMessage.success('登录成功,欢迎回来')
|
||||
await router.push(redirectPath)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '登录失败')
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRegister() {
|
||||
if (!registerForm.username.trim() || !registerForm.phone_number.trim() || !registerForm.password.trim()) {
|
||||
ElMessage.warning('请先把注册信息填写完整')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^1\d{10}$/.test(registerForm.phone_number.trim())) {
|
||||
ElMessage.warning('请输入正确的 11 位手机号')
|
||||
return
|
||||
}
|
||||
|
||||
if (registerForm.password.length < 6) {
|
||||
ElMessage.warning('密码至少需要 6 位')
|
||||
return
|
||||
}
|
||||
|
||||
if (registerForm.password !== registerForm.confirmPassword) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
registerLoading.value = true
|
||||
try {
|
||||
await authStore.register({
|
||||
username: registerForm.username.trim(),
|
||||
phone_number: registerForm.phone_number.trim(),
|
||||
password: registerForm.password,
|
||||
})
|
||||
loginForm.username = registerForm.username.trim()
|
||||
loginForm.password = ''
|
||||
registerForm.password = ''
|
||||
registerForm.confirmPassword = ''
|
||||
activePanel.value = 'login'
|
||||
ElMessage.success('注册成功,请使用新账号登录')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '注册失败')
|
||||
} finally {
|
||||
registerLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page">
|
||||
<div class="page-shell auth-layout">
|
||||
<section class="auth-brand glass-panel">
|
||||
<div class="auth-brand__badge">SmartFlow</div>
|
||||
<h1>把任务、课程与智能规划放在同一个工作台里。</h1>
|
||||
<p>
|
||||
这一版先把登录链路跑通。后面我们会在这个基础上继续接任务管理、课表总览和智能体排程能力。
|
||||
</p>
|
||||
|
||||
<div class="auth-brand__points">
|
||||
<article>
|
||||
<strong>扁平化界面</strong>
|
||||
<span>去掉多余装饰,把信息层级讲清楚。</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>登录态托管</strong>
|
||||
<span>统一管理 access token,后续接业务页面更轻松。</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>可持续扩展</strong>
|
||||
<span>路由、状态、接口层已经拆开,后面直接加页面即可。</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="auth-card glass-panel">
|
||||
<div class="auth-card__header">
|
||||
<div>
|
||||
<span class="auth-card__eyebrow">欢迎使用</span>
|
||||
<h2>账号入口</h2>
|
||||
</div>
|
||||
<p>先登录,再进入示例工作台。</p>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activePanel" stretch class="auth-tabs">
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form label-position="top" class="auth-form" @submit.prevent="submitLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="auth-submit"
|
||||
:loading="loginLoading"
|
||||
@click="submitLogin"
|
||||
>
|
||||
登录并进入示例页
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form label-position="top" class="auth-form" @submit.prevent="submitRegister">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="例如:losita"
|
||||
size="large"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号">
|
||||
<el-input
|
||||
v-model="registerForm.phone_number"
|
||||
placeholder="请输入 11 位手机号"
|
||||
size="large"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="建议至少 6 位"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码">
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="auth-submit"
|
||||
:loading="registerLoading"
|
||||
@click="submitRegister"
|
||||
>
|
||||
创建账号
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.auth-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(380px, 460px);
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.auth-brand,
|
||||
.auth-card {
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 680px;
|
||||
}
|
||||
|
||||
.auth-brand__badge {
|
||||
width: fit-content;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: #e8f2ff;
|
||||
color: #1f5fbf;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-brand h1 {
|
||||
margin: 24px 0 16px;
|
||||
max-width: 10em;
|
||||
font-size: clamp(36px, 5vw, 56px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.auth-brand p {
|
||||
margin: 0;
|
||||
max-width: 38rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.auth-brand__points {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.auth-brand__points article {
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(17, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.auth-brand__points strong {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.auth-brand__points span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 30px 30px 24px;
|
||||
min-height: 680px;
|
||||
}
|
||||
|
||||
.auth-card__header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.auth-card__header h2 {
|
||||
margin: 8px 0 8px;
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.auth-card__header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-card__eyebrow {
|
||||
color: #1f5fbf;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
--el-color-primary: var(--brand);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.auth-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-brand,
|
||||
.auth-card {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.auth-page {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.auth-brand,
|
||||
.auth-card {
|
||||
padding: 22px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
806
frontend/src/views/DashboardView.vue
Normal file
806
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,806 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
|
||||
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
|
||||
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
|
||||
import { completeTask, createTask, getTasks, undoCompleteTask } from '@/api/task'
|
||||
import { getTodaySchedule } from '@/api/schedule'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { TaskItem, TodayEvent } from '@/types/dashboard'
|
||||
import { formatHeaderDate } from '@/utils/date'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const pageLoading = ref(false)
|
||||
const taskLoading = ref(false)
|
||||
const scheduleLoading = ref(false)
|
||||
const createTaskLoading = ref(false)
|
||||
const logoutLoading = ref(false)
|
||||
const createTaskDialogVisible = ref(false)
|
||||
const dashboardLayoutRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const tasks = ref<TaskItem[]>([])
|
||||
const todayEvents = ref<TodayEvent[]>([])
|
||||
|
||||
const sidebarWidth = ref(78)
|
||||
const assistantWidth = ref(460)
|
||||
|
||||
const taskForm = reactive<{
|
||||
title: string
|
||||
priority_group: number
|
||||
deadline_at: Date | null
|
||||
}>({
|
||||
title: '',
|
||||
priority_group: 2,
|
||||
deadline_at: null,
|
||||
})
|
||||
|
||||
const sidebarItems = [
|
||||
{ key: 'home', label: '总览', short: '总' },
|
||||
{ key: 'task', label: '任务', short: '任' },
|
||||
{ key: 'calendar', label: '日程', short: '程' },
|
||||
{ key: 'ai', label: '助手', short: 'AI' },
|
||||
]
|
||||
|
||||
const quadrantOrder = [1, 2, 3, 4] as const
|
||||
|
||||
const quadrantMeta: Record<
|
||||
(typeof quadrantOrder)[number],
|
||||
{ title: string; caption: string; tone: 'danger' | 'primary' | 'warning' | 'slate'; emptyText: string }
|
||||
> = {
|
||||
1: {
|
||||
title: '重要且紧急',
|
||||
caption: '优先处理',
|
||||
tone: 'danger',
|
||||
emptyText: '暂无需要立刻推进的事项',
|
||||
},
|
||||
2: {
|
||||
title: '重要不紧急',
|
||||
caption: '持续推进',
|
||||
tone: 'primary',
|
||||
emptyText: '这里适合放长期投入的关键任务',
|
||||
},
|
||||
3: {
|
||||
title: '简单不重要',
|
||||
caption: '顺手完成',
|
||||
tone: 'warning',
|
||||
emptyText: '暂无高频但低价值的小任务',
|
||||
},
|
||||
4: {
|
||||
title: '不简单不重要',
|
||||
caption: '谨慎投入',
|
||||
tone: 'slate',
|
||||
emptyText: '这里可以放暂缓事项或后续再评估的任务',
|
||||
},
|
||||
}
|
||||
|
||||
const pageTitleDate = computed(() => formatHeaderDate(new Date()))
|
||||
const greetingName = computed(() => authStore.lastUsername || 'SmartFlow 用户')
|
||||
const layoutStyle = computed(() => ({
|
||||
'--dashboard-sidebar-width': `${sidebarWidth.value}px`,
|
||||
'--dashboard-assistant-width': `${assistantWidth.value}px`,
|
||||
}))
|
||||
|
||||
const groupedTasks = computed(() => {
|
||||
const groups: Record<number, TaskItem[]> = {
|
||||
1: [],
|
||||
2: [],
|
||||
3: [],
|
||||
4: [],
|
||||
}
|
||||
|
||||
for (const task of tasks.value) {
|
||||
if (groups[task.priority_group]) {
|
||||
groups[task.priority_group].push(task)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(groups)) {
|
||||
groups[Number(key)].sort((left, right) => {
|
||||
if (left.is_completed !== right.is_completed) {
|
||||
return left.is_completed ? 1 : -1
|
||||
}
|
||||
return left.id - right.id
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
async function loadTasksData() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
tasks.value = await getTasks()
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? error.message : '任务加载失败')
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScheduleData() {
|
||||
scheduleLoading.value = true
|
||||
try {
|
||||
const schedules = await getTodaySchedule()
|
||||
todayEvents.value = schedules.flatMap((item) => item.events).sort((left, right) => left.order - right.order)
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? error.message : '今日日程加载失败')
|
||||
} finally {
|
||||
scheduleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
pageLoading.value = true
|
||||
await Promise.allSettled([loadTasksData(), loadScheduleData()])
|
||||
pageLoading.value = false
|
||||
}
|
||||
|
||||
async function handleTaskToggle(task: TaskItem) {
|
||||
try {
|
||||
if (task.is_completed) {
|
||||
const result = await undoCompleteTask(task.id)
|
||||
task.is_completed = result.is_completed
|
||||
task.status = result.status
|
||||
ElMessage.success('任务已恢复为未完成')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await completeTask(task.id)
|
||||
task.is_completed = result.is_completed
|
||||
task.status = result.status
|
||||
ElMessage.success(result.already_completed ? '任务已经是完成状态' : '任务已标记为完成')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '任务更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateTaskDialog() {
|
||||
taskForm.title = ''
|
||||
taskForm.priority_group = 2
|
||||
taskForm.deadline_at = null
|
||||
createTaskDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleCreateTask() {
|
||||
if (!taskForm.title.trim()) {
|
||||
ElMessage.warning('请先填写任务标题')
|
||||
return
|
||||
}
|
||||
|
||||
createTaskLoading.value = true
|
||||
try {
|
||||
const created = await createTask({
|
||||
title: taskForm.title.trim(),
|
||||
priority_group: taskForm.priority_group,
|
||||
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
|
||||
})
|
||||
|
||||
tasks.value.unshift({
|
||||
id: created.id,
|
||||
user_id: 0,
|
||||
title: created.title,
|
||||
priority_group: created.priority_group,
|
||||
status: created.status,
|
||||
deadline: created.deadline_at ?? '',
|
||||
is_completed: false,
|
||||
})
|
||||
|
||||
createTaskDialogVisible.value = false
|
||||
ElMessage.success('任务已添加')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '创建任务失败')
|
||||
} finally {
|
||||
createTaskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
logoutLoading.value = true
|
||||
|
||||
try {
|
||||
await authStore.logout()
|
||||
ElMessage.success('已安全退出登录')
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? `${error.message},本地登录态已清除` : '退出接口异常,本地登录态已清除')
|
||||
} finally {
|
||||
logoutLoading.value = false
|
||||
await router.push('/auth')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCourseImportEntry() {
|
||||
ElMessage.info('课表导入入口已预留,下一步我可以继续把导入流程页接出来')
|
||||
}
|
||||
|
||||
function clampSidebarWidth(nextWidth: number) {
|
||||
return Math.min(110, Math.max(68, nextWidth))
|
||||
}
|
||||
|
||||
function clampAssistantWidth(nextWidth: number) {
|
||||
return Math.min(680, Math.max(380, nextWidth))
|
||||
}
|
||||
|
||||
function startResize(type: 'sidebar' | 'assistant', event: PointerEvent) {
|
||||
const layout = dashboardLayoutRef.value
|
||||
if (!layout || window.innerWidth <= 1380) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = layout.getBoundingClientRect()
|
||||
const startX = event.clientX
|
||||
const startSidebarWidth = sidebarWidth.value
|
||||
const startAssistantWidth = assistantWidth.value
|
||||
|
||||
// 1. 拖拽时先记录容器宽度和起始位置,避免每次 move 都重复读布局造成抖动。
|
||||
// 2. 中间主区域需要保留最小宽度,防止用户把左右面板拖到挤爆内容区。
|
||||
// 3. 结束时统一解绑事件,避免指针松开后仍残留拖拽状态。
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX
|
||||
const splitterTotalWidth = 20
|
||||
const minMainWidth = 760
|
||||
|
||||
if (type === 'sidebar') {
|
||||
const nextSidebarWidth = clampSidebarWidth(startSidebarWidth + deltaX)
|
||||
const maxSidebarWidth = rect.width - assistantWidth.value - splitterTotalWidth - minMainWidth
|
||||
sidebarWidth.value = Math.min(nextSidebarWidth, Math.max(68, maxSidebarWidth))
|
||||
return
|
||||
}
|
||||
|
||||
const nextAssistantWidth = clampAssistantWidth(startAssistantWidth - deltaX)
|
||||
const maxAssistantWidth = rect.width - sidebarWidth.value - splitterTotalWidth - minMainWidth
|
||||
assistantWidth.value = Math.min(nextAssistantWidth, Math.max(380, maxAssistantWidth))
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', stopResize)
|
||||
document.body.classList.remove('dashboard-resizing')
|
||||
}
|
||||
|
||||
document.body.classList.add('dashboard-resizing')
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', stopResize)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('dashboard-resizing')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="dashboard-page">
|
||||
<div ref="dashboardLayoutRef" class="dashboard-layout" :style="layoutStyle">
|
||||
<aside class="dashboard-sidebar">
|
||||
<div class="dashboard-sidebar__brand">S</div>
|
||||
<nav class="dashboard-sidebar__nav">
|
||||
<button
|
||||
v-for="item in sidebarItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="dashboard-sidebar__nav-item"
|
||||
:class="{ 'dashboard-sidebar__nav-item--active': item.key === 'home' }"
|
||||
>
|
||||
<span>{{ item.short }}</span>
|
||||
<small>{{ item.label }}</small>
|
||||
</button>
|
||||
</nav>
|
||||
<button type="button" class="dashboard-sidebar__settings">设</button>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
class="dashboard-splitter"
|
||||
role="separator"
|
||||
aria-label="调整侧边导航宽度"
|
||||
@pointerdown.prevent="startResize('sidebar', $event)"
|
||||
>
|
||||
<span class="dashboard-splitter__line" />
|
||||
</div>
|
||||
|
||||
<section class="dashboard-main">
|
||||
<header class="dashboard-topbar glass-panel">
|
||||
<div>
|
||||
<div class="dashboard-topbar__brandline">
|
||||
<strong>AI 智慧日程系统</strong>
|
||||
<span>{{ pageTitleDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-topbar__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dashboard-topbar__logout"
|
||||
:disabled="logoutLoading"
|
||||
@click="handleLogout"
|
||||
>
|
||||
{{ logoutLoading ? '退出中…' : '登出' }}
|
||||
</button>
|
||||
<div class="dashboard-topbar__profile">
|
||||
<strong>{{ greetingName }}</strong>
|
||||
<span>{{ greetingName.slice(0, 1).toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-content page-shell">
|
||||
<TodayTimeline :events="todayEvents" :loading="scheduleLoading || pageLoading" />
|
||||
|
||||
<div class="dashboard-actions">
|
||||
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">
|
||||
添加任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-quadrants">
|
||||
<TaskQuadrantCard
|
||||
v-for="group in quadrantOrder"
|
||||
:key="group"
|
||||
:title="quadrantMeta[group].title"
|
||||
:caption="quadrantMeta[group].caption"
|
||||
:tone="quadrantMeta[group].tone"
|
||||
:empty-text="quadrantMeta[group].emptyText"
|
||||
:count="groupedTasks[group].length"
|
||||
:tasks="groupedTasks[group]"
|
||||
:loading="taskLoading || pageLoading"
|
||||
@toggle="handleTaskToggle"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-import glass-panel">
|
||||
<div class="dashboard-import__content">
|
||||
<p class="dashboard-import__eyebrow">课程导入</p>
|
||||
<h2>导入课表</h2>
|
||||
<p>导入课表后,可以在安排日程时避开上课时间,后续我会继续把导入流程页接完整。</p>
|
||||
<button type="button" class="dashboard-import__button" @click="handleCourseImportEntry">
|
||||
开始导入
|
||||
</button>
|
||||
</div>
|
||||
<div class="dashboard-import__shape">
|
||||
<span class="dashboard-import__shape-ring" />
|
||||
<span class="dashboard-import__shape-core" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="dashboard-splitter"
|
||||
role="separator"
|
||||
aria-label="调整 AI 助手宽度"
|
||||
@pointerdown.prevent="startResize('assistant', $event)"
|
||||
>
|
||||
<span class="dashboard-splitter__line" />
|
||||
</div>
|
||||
|
||||
<AssistantPanel class="dashboard-assistant" />
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="createTaskDialogVisible"
|
||||
title="添加任务"
|
||||
width="460px"
|
||||
align-center
|
||||
class="dashboard-dialog"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="任务标题">
|
||||
<el-input v-model="taskForm.title" maxlength="255" placeholder="例如:完成数据库复习" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="优先级象限">
|
||||
<el-select v-model="taskForm.priority_group" class="dashboard-dialog__select">
|
||||
<el-option :value="1" label="1 - 重要且紧急" />
|
||||
<el-option :value="2" label="2 - 重要不紧急" />
|
||||
<el-option :value="3" label="3 - 简单不重要" />
|
||||
<el-option :value="4" label="4 - 不简单不重要" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="截止时间">
|
||||
<el-date-picker
|
||||
v-model="taskForm.deadline_at"
|
||||
type="datetime"
|
||||
placeholder="可选,不设置也可以"
|
||||
class="dashboard-dialog__select"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="createTaskDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createTaskLoading" @click="handleCreateTask">保存任务</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
height: 100vh;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
--dashboard-sidebar-width: 78px;
|
||||
--dashboard-assistant-width: 460px;
|
||||
height: calc(100vh - 20px);
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--dashboard-sidebar-width)
|
||||
10px
|
||||
minmax(0, 1fr)
|
||||
10px
|
||||
var(--dashboard-assistant-width);
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
height: 100%;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, #165ca8 0%, #104d8f 100%);
|
||||
padding: 16px 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-sidebar__brand,
|
||||
.dashboard-sidebar__settings {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-sidebar__nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.dashboard-sidebar__nav-item {
|
||||
width: 54px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
padding: 10px 8px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-sidebar__nav-item span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-sidebar__nav-item small {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.dashboard-sidebar__nav-item--active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-splitter {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dashboard-splitter__line {
|
||||
width: 4px;
|
||||
height: 64px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(145, 163, 188, 0.24), rgba(88, 124, 177, 0.4), rgba(145, 163, 188, 0.24));
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.dashboard-splitter:hover .dashboard-splitter__line {
|
||||
transform: scaleX(1.15);
|
||||
background: linear-gradient(180deg, rgba(104, 140, 194, 0.34), rgba(42, 108, 214, 0.62), rgba(104, 140, 194, 0.34));
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-topbar {
|
||||
border-radius: 24px;
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-topbar__brandline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dashboard-topbar__brandline strong {
|
||||
font-size: 18px;
|
||||
color: #14233a;
|
||||
}
|
||||
|
||||
.dashboard-topbar__brandline span {
|
||||
color: #677588;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-topbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dashboard-topbar__logout {
|
||||
min-width: 88px;
|
||||
height: 38px;
|
||||
border-radius: 13px;
|
||||
border: 1px solid rgba(28, 98, 205, 0.22);
|
||||
background: #f9fbff;
|
||||
color: #1d63cf;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-topbar__profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-topbar__profile strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-topbar__profile span {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
background: #eef3fb;
|
||||
color: #314156;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.dashboard-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dashboard-actions__primary {
|
||||
height: 42px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
background: linear-gradient(180deg, #246ff1 0%, #1a5dc8 100%);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-quadrants {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dashboard-import {
|
||||
border-radius: 26px;
|
||||
padding: 28px 30px;
|
||||
min-height: 240px;
|
||||
background: linear-gradient(135deg, #0f5ca9 0%, #0b4b89 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-import__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.dashboard-import__eyebrow {
|
||||
margin: 0 0 8px;
|
||||
opacity: 0.76;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-import h2 {
|
||||
margin: 0;
|
||||
font-size: 38px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.dashboard-import p {
|
||||
margin: 12px 0 22px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-import__button {
|
||||
height: 46px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
background: #fff;
|
||||
color: #0d55a0;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-import__shape {
|
||||
position: relative;
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.dashboard-import__shape-ring,
|
||||
.dashboard-import__shape-core {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
border: 14px solid rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.dashboard-import__shape-ring {
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
right: 22px;
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
.dashboard-import__shape-core {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
right: 0;
|
||||
bottom: 2px;
|
||||
}
|
||||
|
||||
.dashboard-assistant {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.dashboard-dialog :deep(.el-dialog) {
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.dashboard-dialog__select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1640px) {
|
||||
.dashboard-layout {
|
||||
--dashboard-assistant-width: 430px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.dashboard-layout {
|
||||
height: calc(100vh - 20px);
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-splitter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-assistant {
|
||||
grid-column: 2;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.dashboard-layout {
|
||||
height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
height: auto;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-sidebar__nav {
|
||||
grid-auto-flow: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-main,
|
||||
.dashboard-assistant {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.dashboard-quadrants {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-import {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dashboard-page {
|
||||
height: auto;
|
||||
padding: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dashboard-topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-topbar__actions,
|
||||
.dashboard-topbar__brandline {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
frontend/tsconfig.app.json
Normal file
24
frontend/tsconfig.app.json
Normal file
@@ -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"]
|
||||
}
|
||||
11
frontend/tsconfig.json
Normal file
11
frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
frontend/tsconfig.node.json
Normal file
17
frontend/tsconfig.node.json
Normal file
@@ -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"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
289
openapi.yaml
Normal file
289
openapi.yaml
Normal file
@@ -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: ""
|
||||
Reference in New Issue
Block a user