Version: 0.9.28.dev.260418

后端:
1. 查任务功能(query_tasks)从旧 Agent 链路迁移为新 execute 工具
- 新增 newAgent/tools/taskquery.go:自包含 TaskQueryToolHandler,零引用旧 agent 包;参数校验(象限1~4、排序白名单、limit上限20)、时间边界解析(四种格式自动补齐)、结构化 JSON 结果
- newAgent/tools/registry.go:DefaultRegistryDeps 新增 TaskQuery 字段;scheduleFreeTools 新增 query_tasks;注册 query_tasks 读工具(无需 confirm,不依赖 ScheduleState)
- newAgent/prompt/execute.go:有 plan / ReAct 两套系统 prompt 执行规则新增 query_tasks 读操作说明,支持按象限、关键词、截止时间筛选排序
- service/agentsvc/agent_task_query.go:queryTasksForAgent 导出为 QueryTasksForTool,供启动层闭包调用;内部调用同步改为 QueryTasksForTool
- cmd/start.go:NewDefaultRegistryWithDeps 注入 TaskQuery 闭包,桥接新工具参数到旧 service 层查询能力,复用已有过滤/排序/紧急度提升逻辑;旧链路全部保留不动
2. order_guard 条件触发——仅日程写操作后走守卫节点
- newAgent/model/common_state.go:新增 HasScheduleWriteOps 标记字段;ResetForNextRun 追加清理
- newAgent/node/execute.go:executeToolCall / executePendingTool 两处写工具执行后,通过 registry.IsWriteTool 判断并置 HasScheduleWriteOps=true
- newAgent/graph/common_graph.go:branchAfterExecute 分支条件新增 HasScheduleWriteOps 判断,非日程操作(query_tasks / quick_note_create / web_search 等)直接 deliver 跳过 order_guard;branchAfterRoughBuild 不变,粗排天然是写操作

前端:
1. 助手面板新增 SSE 流式请求停止按钮
- AssistantPanel.vue:新增 streamAbortController ref 和 stopStreaming 方法;fetchChatStream / streamAssistantReply 透传 AbortSignal;sendMessage 创建 AbortController,catch 区分用户主动中断与异常;流式期间显示红色停止按钮替代发送按钮
2. 象限卡片任务列表取消硬截断,改为滚动查看
- TaskQuadrantCard.vue:visibleTasks 不再 slice(0,4),全部展示;quadrant-list 新增 max-height + overflow-y + 自定义滚动条样式

仓库:无
This commit is contained in:
Losita
2026-04-18 13:32:26 +08:00
parent 4fbf9397d2
commit a5d301ceb9
10 changed files with 498 additions and 26 deletions

View File

@@ -6,6 +6,7 @@ import (
"log"
"time"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/LoveLosita/smartflow/backend/api"
"github.com/LoveLosita/smartflow/backend/dao"
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
@@ -197,6 +198,41 @@ func Start() {
return created.ID, nil
},
},
TaskQuery: newagenttools.TaskQueryDeps{
// 调用目的:桥接新工具参数到旧 service 层查询能力,复用已有的过滤/排序/紧急度提升逻辑。
QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) {
req := agentnode.TaskQueryRequest{
UserID: userID,
Quadrant: params.Quadrant,
SortBy: params.SortBy,
Order: params.Order,
Limit: params.Limit,
IncludeCompleted: params.IncludeCompleted,
Keyword: params.Keyword,
DeadlineBefore: params.DeadlineBefore,
DeadlineAfter: params.DeadlineAfter,
}
records, err := agentService.QueryTasksForTool(ctx, req)
if err != nil {
return nil, err
}
results := make([]newagenttools.TaskQueryResult, 0, len(records))
for _, r := range records {
deadlineStr := ""
if r.DeadlineAt != nil {
deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04")
}
results = append(results, newagenttools.TaskQueryResult{
ID: r.ID,
Title: r.Title,
PriorityGroup: r.PriorityGroup,
IsCompleted: r.IsCompleted,
DeadlineAt: deadlineStr,
})
}
return results, nil
},
},
}))
agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager))

View File

@@ -298,7 +298,7 @@ func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (s
// 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver会绕过 Execute 内的 Exhaust 写入,
// 导致 deliver 收口和后续预览落盘语义不一致。
if flowState.Phase == newagentmodel.PhaseDone {
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder {
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder && flowState.HasScheduleWriteOps {
return NodeOrderGuard, nil
}
return NodeDeliver, nil

View File

@@ -109,6 +109,9 @@ type CommonState struct {
// SuggestedOrderBaseline 保存"本轮 execute 启动前"的 suggested 任务相对顺序基线。
// OrderGuard 节点会基于该基线判断微调是否破坏顺序约束。
SuggestedOrderBaseline []int `json:"suggested_order_baseline,omitempty"`
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
// 调用目的graph 分支函数据此判断是否需要走 order_guard非日程操作跳过守卫。
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
// ExecuteThinking 由 Chat 路由决策传入,表示 Execute 节点是否应开启深度思考。
// 预埋字段,当前阶段 Execute 节点可自行决定是否读取。
@@ -218,6 +221,7 @@ func (s *CommonState) ResetForNextRun() {
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
s.AllowReorder = false
s.HasScheduleWriteOps = false
s.SuggestedOrderBaseline = nil
s.ClearTerminalOutcome()
}

View File

@@ -1452,6 +1452,11 @@ func executeToolCall(
// 3. 以标准 assistant+tool 消息对写回历史,避免消息链断裂。
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
// 3.1 标记本轮执行过日程写工具graph 分支据此决定是否走 order_guard。
if registry.IsWriteTool(toolName) {
flowState.HasScheduleWriteOps = true
}
// 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。
//
// 步骤化说明:
@@ -1563,6 +1568,11 @@ func executePendingTool(
// 5. 将工具调用和结果写回历史,维持标准 tool_call 配对格式。
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
// 5.1 标记本轮执行过日程写工具graph 分支据此决定是否走 order_guard。
if registry.IsWriteTool(pending.ToolName) {
flowState.HasScheduleWriteOps = true
}
// 5. 写工具实时预览confirm accept 后真实执行写工具时,立即刷新一次预览缓存。
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)

View File

@@ -40,10 +40,11 @@ const executeSystemPromptWithPlan = `
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. quick_note_create记录任务/提醒若信息足够action=continue + tool_call并显式填写 priority_group若信息不足且无法可靠推断action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成调用成功后可继续done/next_plan/continue处理其他任务但不要为 quick_note_create 本身补充说明。
5. 缺关键上下文且无法通过工具补齐action=ask_user
6. 仅当当前步骤完成时输出 action=next_plan并在 goal_check 对照 done_when 给出证据
7. 仅当整体任务完成时输出 action=done并在 goal_check 总结完成证据。
8. 流程应正式终止时输出 action=abort。`
5. query_tasks查看/筛选任务列表读操作action=continue + tool_call。用于回答"我有什么任务""最近有什么急事"等问题,支持按象限、关键词、截止时间范围筛选和排序
6. 缺关键上下文且无法通过工具补齐action=ask_user
7. 仅当当前步骤完成时输出 action=next_plan,并在 goal_check 对照 done_when 给出证据。
8. 仅当整体任务完成时输出 action=done并在 goal_check 总结完成证据。
9. 流程应正式终止时输出 action=abort。`
const executeSystemPromptReAct = `
你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
@@ -82,9 +83,10 @@ const executeSystemPromptReAct = `
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. quick_note_create记录任务/提醒若信息足够action=continue + tool_call并显式填写 priority_group若信息不足且无法可靠推断action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成调用成功后可继续done/next_plan/continue处理其他任务但不要为 quick_note_create 本身补充说明。
5. 缺关键上下文且无法通过工具补齐action=ask_user
6. 任务完成action=done并在 goal_check 总结完成证据
7. 流程应正式终止action=abort。`
5. query_tasks查看/筛选任务列表读操作action=continue + tool_call。用于回答"我有什么任务""最近有什么急事"等问题,支持按象限、关键词、截止时间范围筛选和排序
6. 缺关键上下文且无法通过工具补齐action=ask_user
7. 任务完成action=done并在 goal_check 总结完成证据。
8. 流程应正式终止action=abort。`
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
func BuildExecuteSystemPrompt() string {

View File

@@ -34,6 +34,9 @@ type DefaultRegistryDeps struct {
// QuickNote 随口记工具依赖。CreateTask 为 nil 时 quick_note_create 返回错误提示,不阻断主流程。
QuickNote QuickNoteDeps
// TaskQuery 任务查询工具依赖。QueryTasks 为 nil 时 query_tasks 不注册,不影响其他工具。
TaskQuery TaskQueryDeps
}
// ToolRegistry 管理工具注册、查找与执行。
@@ -127,6 +130,7 @@ var writeTools = map[string]bool{
var scheduleFreeTools = map[string]bool{
"quick_note_create": true,
"query_tasks": true,
"web_search": true,
"web_fetch": true,
}
@@ -340,6 +344,18 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
)
}
// --- 任务查询读工具 ---
// 调用目的:将"帮我看看有什么任务""最近有什么急事"等查询请求直接查库返回结构化结果,无需 ScheduleState。
// 不加入 writeTools查询是只读操作不需要 confirm 节点二次确认。
if deps.TaskQuery.QueryTasks != nil {
taskQueryHandler := NewTaskQueryToolHandler(deps.TaskQuery)
r.Register("query_tasks",
"按象限、关键词、截止时间筛选并排序任务列表,返回结构化结果。所有参数均为可选。",
`{"name":"query_tasks","parameters":{"quadrant":{"type":"int","description":"可选象限筛选(1~4)"},"keyword":{"type":"string","description":"可选标题关键词,模糊匹配"},"deadline_before":{"type":"string","description":"可选截止时间上界,支持 yyyy-MM-dd HH:mm 或 yyyy-MM-dd"},"deadline_after":{"type":"string","description":"可选截止时间下界,支持 yyyy-MM-dd HH:mm 或 yyyy-MM-dd"},"sort_by":{"type":"string","description":"排序字段(deadline|priority|id)默认deadline"},"order":{"type":"string","description":"排序方向(asc|desc)默认asc"},"limit":{"type":"int","description":"返回条数默认5上限20"},"include_completed":{"type":"bool","description":"是否包含已完成任务默认false"}}}`,
taskQueryHandler,
)
}
// --- Web 搜索读工具 ---
// 1. provider 为 nil 时 handler 返回"暂未启用"的 observation不会阻断主流程
// 2. 两个工具均为读操作,走 action=continue + tool_call 模式。

View File

@@ -0,0 +1,320 @@
package newagenttools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// ==================== 常量 ====================
const (
// defaultTaskQueryLimit 是任务查询默认返回条数。
defaultTaskQueryLimit = 5
// maxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制 LLM 输出范围。
maxTaskQueryLimit = 20
)
// ==================== 优先级中文映射 ====================
// taskQueryPriorityLabelCN 将象限编号转为中文标签。
//
// 职责边界:
// 1. 只负责 1~4 的合法映射,超出范围返回"未知"。
// 2. 不依赖旧链路 agentmodel.PriorityLabelCN保持新工具自包含。
func taskQueryPriorityLabelCN(priority int) string {
switch priority {
case 1:
return "重要且紧急"
case 2:
return "重要不紧急"
case 3:
return "简单不重要"
case 4:
return "复杂不重要"
default:
return "未知"
}
}
// ==================== 类型定义 ====================
// TaskQueryDeps 描述任务查询工具所需的外部依赖。
//
// 职责边界:
// 1. QueryTasks 负责真正查库,工具层不直接依赖 DAO
// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
type TaskQueryDeps struct {
// QueryTasks 将解析后的查询参数传入业务层,返回匹配的任务列表。
// 调用目的:解耦工具层与 DAO 层,方便测试和替换。
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
}
// TaskQueryParams 描述任务查询工具传给业务层的内部查询参数。
//
// 输入输出语义:
// 1. 所有筛选条件均为可选Quadrant 为 nil 表示不限象限。
// 2. 时间边界为 nil 表示不限时间范围。
type TaskQueryParams struct {
Quadrant *int
SortBy string // deadline | priority | id
Order string // asc | desc
Limit int
IncludeCompleted bool
Keyword string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskQueryResult 描述任务查询工具返回给 LLM 的轻量任务视图。
//
// 职责边界:
// 1. 只承载展示所需字段,避免暴露底层数据库结构。
// 2. JSON 序列化后直接作为工具 observation 返回给 LLM。
type TaskQueryResult 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"`
}
// ==================== 时间解析 ====================
// taskQueryTimeLayouts 支持的时间格式列表,按优先级尝试解析。
var taskQueryTimeLayouts = []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
}
// 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
}
// 1. 纯日期格式需要根据上下界补齐时分秒,保证时间区间语义正确。
// 2. 若用户输入"2026-04-20"作为上界,意图是"截止到那天结束"
// 所以补 23:59:59作为下界则补 00:00:00。
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")
}
// ==================== 工具 Handler ====================
// NewTaskQueryToolHandler 创建 query_tasks 工具的 handler 闭包。
//
// 职责边界:
// 1. 负责参数校验、时间解析、调 deps 查库、组装返回;
// 2. 不负责 LLM 交互和会话管理。
// 3. state 参数忽略——任务查询不需要 ScheduleState已注册到 scheduleFreeTools。
func NewTaskQueryToolHandler(deps TaskQueryDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
// 1. 提取 _user_id由 execute 节点在调用前注入)。
userID := 0
if uid, ok := args["_user_id"].(int); ok {
userID = uid
}
if userID <= 0 {
return "工具调用失败:无法识别用户身份。"
}
// 2. 提取并校验查询参数。
params, err := extractTaskQueryParams(args)
if err != nil {
return fmt.Sprintf("工具调用失败:%s", err)
}
// 3. 调用依赖查库。
results, err := deps.QueryTasks(context.Background(), userID, params)
if err != nil {
return fmt.Sprintf("工具调用失败:查询任务时出错(%s。", err)
}
// 4. 为每条结果填充优先级中文标签。
for i := range results {
results[i].PriorityLabel = taskQueryPriorityLabelCN(results[i].PriorityGroup)
}
// 5. 返回结构化 JSON。
if len(results) == 0 {
return `{"total":0,"items":[],"message":"当前没有匹配的任务。"}`
}
output := struct {
Total int `json:"total"`
Items []TaskQueryResult `json:"items"`
Message string `json:"message"`
}{
Total: len(results),
Items: results,
Message: fmt.Sprintf("找到 %d 条匹配任务。", len(results)),
}
jsonBytes, marshalErr := json.Marshal(output)
if marshalErr != nil {
// JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
return fmt.Sprintf("找到 %d 条匹配任务。", len(results))
}
return string(jsonBytes)
}
}
// extractTaskQueryParams 从 args 提取并校验任务查询参数。
//
// 步骤说明:
// 1. 先准备默认值,保证空参数也能执行一次合理查询。
// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。
// 3. 若上下界冲突,则直接返回错误。
func extractTaskQueryParams(args map[string]any) (TaskQueryParams, error) {
params := TaskQueryParams{
SortBy: "deadline",
Order: "asc",
Limit: defaultTaskQueryLimit,
IncludeCompleted: false,
}
// 2.1 象限1~4超出范围拒绝。
if v, ok := args["quadrant"]; ok {
switch val := v.(type) {
case float64:
q := int(val)
if q < 1 || q > 4 {
return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", q)
}
params.Quadrant = &q
case int:
if val < 1 || val > 4 {
return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", val)
}
params.Quadrant = &val
}
}
// 2.2 排序字段:仅支持 deadline/priority/id。
if v, ok := args["sort_by"].(string); ok {
sortBy := strings.ToLower(strings.TrimSpace(v))
if sortBy != "" {
switch sortBy {
case "deadline", "priority", "id":
params.SortBy = sortBy
default:
return TaskQueryParams{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", sortBy)
}
}
}
// 2.3 排序方向:仅支持 asc/desc。
if v, ok := args["order"].(string); ok {
order := strings.ToLower(strings.TrimSpace(v))
if order != "" {
switch order {
case "asc", "desc":
params.Order = order
default:
return TaskQueryParams{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", order)
}
}
}
// 2.4 条数:默认 5上限 20。
if v, ok := args["limit"]; ok {
switch val := v.(type) {
case float64:
params.Limit = int(val)
case int:
params.Limit = val
}
}
if params.Limit <= 0 {
params.Limit = defaultTaskQueryLimit
}
if params.Limit > maxTaskQueryLimit {
params.Limit = maxTaskQueryLimit
}
// 2.5 是否包含已完成任务。
if v, ok := args["include_completed"]; ok {
switch val := v.(type) {
case bool:
params.IncludeCompleted = val
}
}
// 2.6 关键词。
if v, ok := args["keyword"].(string); ok {
params.Keyword = strings.TrimSpace(v)
}
// 2.7 时间边界解析,解析失败直接报错,避免查出无意义的结果。
beforeRaw, _ := args["deadline_before"].(string)
before, err := parseTaskQueryBoundaryTime(beforeRaw, true)
if err != nil {
return TaskQueryParams{}, fmt.Errorf("deadline_before 格式错误: %s", err)
}
params.DeadlineBefore = before
afterRaw, _ := args["deadline_after"].(string)
after, err := parseTaskQueryBoundaryTime(afterRaw, false)
if err != nil {
return TaskQueryParams{}, fmt.Errorf("deadline_after 格式错误: %s", err)
}
params.DeadlineAfter = after
// 2.8 时间区间合法性校验:下界不能晚于上界。
if params.DeadlineBefore != nil && params.DeadlineAfter != nil &&
params.DeadlineAfter.After(*params.DeadlineBefore) {
return TaskQueryParams{}, fmt.Errorf("deadline_after 不能晚于 deadline_before")
}
return params, nil
}

View File

@@ -38,13 +38,13 @@ func (s *AgentService) runTaskQueryFlow(
Deps: agentnode.TaskQueryToolDeps{
QueryTasks: func(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
req.UserID = userID
return s.queryTasksForAgent(ctx, req)
return s.QueryTasksForTool(ctx, req)
},
},
})
}
func (s *AgentService) queryTasksForAgent(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
_ = ctx
if req.UserID <= 0 {
return nil, errors.New("invalid user_id in task query")

View File

@@ -99,6 +99,8 @@ const selectedThinkingMode = ref<ThinkingModeType>('auto')
const messageInput = ref('')
const historyPanelWidth = ref(props.initialHistoryWidth)
const activeStreamingMessageId = ref('')
// 流式请求的 AbortController发送时创建流结束或用户点击停止时 abort。
const streamAbortController = ref<AbortController | null>(null)
const editingUserMessageId = ref('')
const editingUserMessageDraft = ref('')
const pendingPlanningTaskClassIds = ref<number[]>([])
@@ -1124,7 +1126,7 @@ function handlePlanningSelectionApplied(taskClassIds: number[]) {
// 1. 只负责把请求发出去并返回原始 Response不在这里解析 SSE 数据。
// 2. 401 时优先尝试用 refresh token 换新 access token并只重试一次避免死循环。
// 3. 若最终仍未通过鉴权,则清空本地登录态,让页面统一回到重新登录的安全状态。
async function fetchChatStream(body: ChatStreamRequest, attempt = 0): Promise<Response> {
async function fetchChatStream(body: ChatStreamRequest, signal?: AbortSignal, attempt = 0): Promise<Response> {
const response = await fetch('/api/v1/agent/chat', {
method: 'POST',
headers: {
@@ -1132,6 +1134,7 @@ async function fetchChatStream(body: ChatStreamRequest, attempt = 0): Promise<Re
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify(body),
signal,
})
if (response.status === 401 && attempt === 0 && authStore.refreshToken) {
@@ -1139,7 +1142,7 @@ async function fetchChatStream(body: ChatStreamRequest, attempt = 0): Promise<Re
old_refresh_token: authStore.refreshToken,
})
authStore.applyTokenPair(tokens)
return fetchChatStream(body, attempt + 1)
return fetchChatStream(body, signal, attempt + 1)
}
if (response.status === 401) {
@@ -1262,6 +1265,7 @@ async function streamAssistantReply(
createdAt: string,
refreshPreview: boolean,
requestExtra?: ChatRequestExtra,
signal?: AbortSignal,
) : Promise<string> {
const response = await fetchChatStream({
conversation_id: isDraftConversationId(draftConversationId) ? undefined : draftConversationId,
@@ -1269,7 +1273,7 @@ async function streamAssistantReply(
model: 'worker',
thinking: selectedThinkingMode.value,
extra: requestExtra,
})
}, signal)
const responseConversationId = response.headers.get('X-Conversation-ID')?.trim()
const actualConversationId = responseConversationId || draftConversationId
@@ -1319,7 +1323,14 @@ async function streamAssistantReply(
return actualConversationId
}
// sendMessage 负责执行“本地先上屏,再异步接流”的发送链路
// stopStreaming 负责中断正在进行的 SSE 流式请求
// 职责边界:只调用 AbortController.abort(),不修改 chatLoading 等状态——
// 这些状态由 sendMessage 的 finally 块统一清理,避免多处重置导致状态不一致。
function stopStreaming() {
streamAbortController.value?.abort()
}
// sendMessage 负责执行”本地先上屏,再异步接流”的发送链路。
// 职责边界:
// 1. 先创建用户消息和 assistant 占位消息,让发送动作立即反馈到界面,等待建连过程无感化。
// 2. 若当前是新会话,则先使用 draft 会话承接本地状态,等响应头返回真实 conversation_id 后再整体迁移。
@@ -1333,12 +1344,10 @@ async function sendMessage(preset?: string) {
chatLoading.value = true
const planningTaskClassIdsForRequest = [...pendingPlanningTaskClassIds.value]
const shouldStartFreshPlanningConversation = planningTaskClassIdsForRequest.length > 0
const draftConversationId = shouldStartFreshPlanningConversation
? createDraftConversationId()
: (selectedConversationId.value || createDraftConversationId())
// 智能编排不再强制新开对话:直接沿用当前会话,在原地发送编排请求。
const draftConversationId = selectedConversationId.value || createDraftConversationId()
if (!selectedConversationId.value || shouldStartFreshPlanningConversation) {
if (!selectedConversationId.value) {
selectedConversationId.value = draftConversationId
}
ensureConversationBucket(draftConversationId)
@@ -1368,6 +1377,10 @@ async function sendMessage(preset?: string) {
prependConversationPreview(draftConversationId, text, now)
scheduleScrollMessagesToBottom(false, true)
// 1. 创建 AbortController用户点击停止按钮时可通过 controller.abort() 中断 fetch 请求。
const controller = new AbortController()
streamAbortController.value = controller
try {
const actualConversationId = await streamAssistantReply(
draftConversationId,
@@ -1376,6 +1389,7 @@ async function sendMessage(preset?: string) {
now,
true,
buildChatRequestExtra(planningTaskClassIdsForRequest),
controller.signal,
)
if (planningTaskClassIdsForRequest.length > 0) {
pendingPlanningTaskClassIds.value = []
@@ -1387,12 +1401,20 @@ async function sendMessage(preset?: string) {
loadConversationContextStats(actualConversationId, true),
])
} catch (error) {
if (!assistantMessage.content.trim()) {
assistantMessage.content = '本次回复已中断,请稍后重试。'
// 用户主动中断:不弹出错误提示,只给占位消息补一段中断文案。
if (controller.signal.aborted) {
if (!assistantMessage.content.trim()) {
assistantMessage.content = '本次回复已手动停止。'
}
} else {
if (!assistantMessage.content.trim()) {
assistantMessage.content = '本次回复已中断,请稍后重试。'
}
ElMessage.error(error instanceof Error ? error.message : '发送消息失败,请稍后重试')
}
reasoningCollapsedMap[assistantMessage.id] = false
ElMessage.error(error instanceof Error ? error.message : '发送消息失败,请稍后重试')
} finally {
streamAbortController.value = null
activeStreamingMessageId.value = ''
chatLoading.value = false
}
@@ -1779,12 +1801,29 @@ onBeforeUnmount(() => {
>
</label>
<!-- 流式期间显示停止按钮其余时刻显示发送按钮 -->
<button
v-if="chatLoading"
type="button"
class="_7436101 bcc55ca1 _52c986b ds-icon-button ds-icon-button--l ds-icon-button--sizing-container"
aria-label="停止生成"
@click="stopStreaming()"
>
<div class="ds-icon-button__hover-bg" />
<div class="ds-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4.88C2 3.68009 2 3.08013 2.30557 2.65954C2.40426 2.52371 2.52371 2.40426 2.65954 2.30557C3.08013 2 3.68009 2 4.88 2H11.12C12.3199 2 12.9199 2 13.3405 2.30557C13.4763 2.40426 13.5957 2.52371 13.6944 2.65954C14 3.08013 14 3.68009 14 4.88V11.12C14 12.3199 14 12.9199 13.6944 13.3405C13.5957 13.4763 13.4763 13.5957 13.3405 13.6944C12.9199 14 12.3199 14 11.12 14H4.88C3.68009 14 3.08013 14 2.65954 13.6944C2.52371 13.5957 2.40426 13.4763 2.30557 13.3405C2 12.9199 2 12.3199 2 11.12V4.88Z" fill="currentColor" />
</svg>
</div>
<div class="ds-focus-ring" style="--ds-focus-ring-offset: -2px;" />
</button>
<button
v-else
type="button"
class="_7436101 bcc55ca1 ds-icon-button ds-icon-button--l ds-icon-button--sizing-container"
:class="{ 'ds-icon-button--disabled': chatLoading || !messageInput.trim() }"
:disabled="chatLoading || !messageInput.trim()"
:aria-disabled="chatLoading || !messageInput.trim()"
:class="{ 'ds-icon-button--disabled': !messageInput.trim() }"
:disabled="!messageInput.trim()"
:aria-disabled="!messageInput.trim()"
@click="sendMessage()"
>
<div class="ds-icon-button__hover-bg" />
@@ -2318,6 +2357,7 @@ onBeforeUnmount(() => {
.chat-message__assistant-flow {
max-width: min(92%, 860px);
margin: 0 auto;
display: grid;
gap: 12px;
}
@@ -2655,6 +2695,10 @@ onBeforeUnmount(() => {
.assistant-actions {
flex-wrap: wrap;
gap: 8px;
/* grid item 需要 width:100% 撑满,再由 max-width 限宽 + margin 居中 */
width: 100%;
max-width: min(92%, calc(860px + 44px));
margin: 0 auto;
padding: 0 22px 12px;
}
@@ -2687,6 +2731,10 @@ onBeforeUnmount(() => {
.assistant-composer-ds {
--dsw-alias-brand-text: #3357c2;
--dsw-alias-label-primary: #1f2430;
/* grid item 需要 width:100% 撑满,再由 max-width 限宽 + margin 居中 */
width: 100%;
max-width: min(92%, calc(860px + 44px));
margin: 0 auto;
padding: 8px 22px 18px;
border-top: 1px solid rgba(16, 24, 40, 0.05);
}
@@ -2882,6 +2930,18 @@ onBeforeUnmount(() => {
border-color: #244ce0;
}
/* 停止按钮流式期间替代发送按钮hover 态加深底色提示可点击 */
._7436101.bcc55ca1._52c986b {
color: #ffffff;
background: #dc2626;
border-color: #dc2626;
}
._7436101.bcc55ca1._52c986b:hover {
background: #b91c1c;
border-color: #b91c1c;
}
.ds-icon-button--disabled {
opacity: 0.45;
cursor: not-allowed;

View File

@@ -18,7 +18,8 @@ const emit = defineEmits<{
toggle: [task: TaskItem]
}>()
const visibleTasks = computed(() => props.tasks.slice(0, 4))
// 不再硬截断,全部展示;超出的部分通过 quadrant-list 的 max-height + overflow-y 滚动查看。
const visibleTasks = computed(() => props.tasks)
</script>
<template>
@@ -125,6 +126,29 @@ const visibleTasks = computed(() => props.tasks.slice(0, 4))
.quadrant-list {
display: grid;
gap: 12px;
/* 卡片 header 约 70px列表区域最多约 320px约 4 条可见),超出部分滚动 */
max-height: 320px;
overflow-y: auto;
/* 滚动条样式轨道透明滑块圆角淡色hover 加深 */
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.32) transparent;
}
.quadrant-list::-webkit-scrollbar {
width: 5px;
}
.quadrant-list::-webkit-scrollbar-track {
background: transparent;
}
.quadrant-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(148, 163, 184, 0.32);
}
.quadrant-list::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.52);
}
.quadrant-item,