diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index ae10451..1af57a8 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -1368,15 +1368,16 @@ func executeToolCall( return fmt.Errorf("工具调用缺少工具名称") } - // 推送工具调用状态,让前端知道当前在做什么。 - if err := emitter.EmitStatus( + // 推送工具调用开始事件(结构化)。 + if err := emitter.EmitToolCallStart( executeStatusBlockID, executeStageName, - "tool_call", - fmt.Sprintf("正在调用工具:%s", toolName), + toolName, + buildToolCallStartSummary(toolName, toolCall.Arguments), + buildToolArgumentsPreviewCN(toolCall.Arguments), false, ); err != nil { - return fmt.Errorf("工具调用状态推送失败: %w", err) + return fmt.Errorf("工具调用开始事件推送失败: %w", err) } // 1. 校验依赖。 @@ -1417,11 +1418,13 @@ func executeToolCall( toolName, flowState.AllowReorder, ) - _ = emitter.EmitStatus( + _ = emitter.EmitToolCallResult( executeStatusBlockID, executeStageName, - "tool_blocked", + toolName, + "blocked", blockedResult, + buildToolArgumentsPreviewCN(toolCall.Arguments), false, ) appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult) @@ -1448,6 +1451,15 @@ func executeToolCall( afterDigest, flattenForLog(result), ) + _ = emitter.EmitToolCallResult( + executeStatusBlockID, + executeStageName, + toolName, + resolveToolEventResultStatus(result), + buildToolEventResultSummary(result), + buildToolArgumentsPreviewCN(toolCall.Arguments), + false, + ) // 3. 以标准 assistant+tool 消息对写回历史,避免消息链断裂。 appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result) @@ -1511,15 +1523,16 @@ func executePendingTool( return fmt.Errorf("解析工具参数失败: %w", err) } - // 2. 推送状态。 - if err := emitter.EmitStatus( + // 2. 推送工具调用开始事件(结构化)。 + if err := emitter.EmitToolCallStart( executeStatusBlockID, executeStageName, - "tool_call", - fmt.Sprintf("正在执行工具:%s", pending.ToolName), + pending.ToolName, + buildToolCallStartSummary(pending.ToolName, args), + buildToolArgumentsPreviewCN(args), false, ); err != nil { - return fmt.Errorf("工具调用状态推送失败: %w", err) + return fmt.Errorf("工具调用开始事件推送失败: %w", err) } // 3. 校验依赖:写工具必须持有有效的日程状态。 @@ -1531,11 +1544,13 @@ func executePendingTool( // 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。 if shouldBlockMinContextSwitch(flowState, pending.ToolName) { blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。" - _ = emitter.EmitStatus( + _ = emitter.EmitToolCallResult( executeStatusBlockID, executeStageName, - "tool_blocked", + pending.ToolName, + "blocked", blockedResult, + buildToolArgumentsPreviewCN(args), false, ) appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult) @@ -1564,6 +1579,15 @@ func executePendingTool( afterDigest, flattenForLog(result), ) + _ = emitter.EmitToolCallResult( + executeStatusBlockID, + executeStageName, + pending.ToolName, + resolveToolEventResultStatus(result), + buildToolEventResultSummary(result), + buildToolArgumentsPreviewCN(args), + false, + ) // 5. 将工具调用和结果写回历史,维持标准 tool_call 配对格式。 appendToolCallResultHistory(conversationContext, pending.ToolName, args, result) @@ -1717,3 +1741,338 @@ func flattenForLog(text string) string { text = strings.ReplaceAll(text, "\r", " ") return strings.TrimSpace(text) } + +// resolveToolEventResultStatus 将工具返回文本映射为前端可识别的结果状态。 +// +// 职责边界: +// 1. 只做轻量字符串规则判断,不做业务语义推理; +// 2. 默认归类为 done,只有明显失败关键字才判定 failed; +// 3. blocked 场景在调用侧显式传入,不由这里推断。 +func resolveToolEventResultStatus(result string) string { + normalized := strings.TrimSpace(result) + if normalized == "" { + return "done" + } + if strings.Contains(normalized, "失败") { + return "failed" + } + lower := strings.ToLower(normalized) + if strings.Contains(lower, "error") || strings.Contains(lower, "failed") { + return "failed" + } + return "done" +} + +// buildToolEventResultSummary 生成用于前端工具行的结果摘要。 +// +// 职责边界: +// 1. 优先从 JSON 结果提炼中文结论,避免前端直接看到原始字段; +// 2. 提炼失败时回退到“压平 + 截断”,保证仍有可读摘要; +// 3. 空结果给出固定兜底文案,避免前端出现空白行。 +func buildToolEventResultSummary(result string) string { + flat := flattenForLog(result) + if flat == "" { + return "工具已执行完成。" + } + + // 1. 工具很多返回 JSON;直接截断 JSON 会把字段名原样暴露到前端,阅读体验差。 + // 2. 这里优先做结构化提炼,转成一句中文结论(成功/失败/关键结果)。 + // 3. 仅在无法提炼时才回退到原文截断,保证不会丢失可读信息。 + if summary, ok := tryExtractToolResultSummaryCN(flat); ok { + return summary + } + + runes := []rune(flat) + if len(runes) <= 48 { + return flat + } + return string(runes[:48]) + "..." +} + +func tryExtractToolResultSummaryCN(raw string) (string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", false + } + + var payload map[string]any + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return "", false + } + + toolRaw := strings.TrimSpace(readStringAnyFromMap(payload, "tool")) + toolName := resolveToolDisplayNameCN(toolRaw) + + if errText := strings.TrimSpace(readStringAnyFromMap(payload, "error", "err")); errText != "" { + return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, errText)), true + } + + if success, exists := payload["success"]; exists { + if ok, isBool := success.(bool); isBool && !ok { + reason := strings.TrimSpace(readStringAnyFromMap(payload, "reason", "message")) + if reason != "" { + return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, reason)), true + } + return truncateToolSummaryCN(fmt.Sprintf("%s执行失败。", toolName)), true + } + } + + if message := strings.TrimSpace(readStringAnyFromMap(payload, "result", "message", "reason")); message != "" { + return truncateToolSummaryCN(message), true + } + + pending, hasPending := readIntAnyFromMap(payload, "pending_count") + completed, hasCompleted := readIntAnyFromMap(payload, "completed_count") + if hasPending || hasCompleted { + skipped, _ := readIntAnyFromMap(payload, "skipped_count") + return fmt.Sprintf("队列状态:待处理 %d,已完成 %d,已跳过 %d。", pending, completed, skipped), true + } + + if hasHead, exists := payload["has_head"]; exists { + if b, isBool := hasHead.(bool); isBool { + if b { + return "已取到当前队首任务。", true + } + return "当前队列没有可处理任务。", true + } + } + + if _, ok := payload["slot_candidates"]; ok { + if total, exists := readIntAnyFromMap(payload, "total"); exists { + return fmt.Sprintf("已找到 %d 个可用时段。", total), true + } + } + + if toolRaw != "" { + return fmt.Sprintf("已完成「%s」。", toolName), true + } + + return "", false +} + +func truncateToolSummaryCN(text string) string { + runes := []rune(strings.TrimSpace(text)) + if len(runes) <= 48 { + return string(runes) + } + return string(runes[:48]) + "..." +} + +// buildToolCallStartSummary 生成“工具开始调用”的中文摘要。 +// +// 职责边界: +// 1. 摘要面向前端展示,避免直接暴露参数字段名; +// 2. 只做轻量信息拼接,不做业务语义推断; +// 3. 仅展示少量关键参数,避免消息过长抢占正文注意力。 +func buildToolCallStartSummary(toolName string, args map[string]any) string { + displayName := resolveToolDisplayNameCN(toolName) + argSummary := buildToolArgumentsPreviewCN(args) + if argSummary == "" { + return fmt.Sprintf("已调用工具:%s。", displayName) + } + return fmt.Sprintf("已调用工具:%s(%s)。", displayName, argSummary) +} + +// buildToolArgumentsPreviewCN 把工具参数转换为中文可读摘要。 +// +// 职责边界: +// 1. 只输出白名单字段的中文标签,避免把原始参数键直接透出给前端; +// 2. 默认最多展示 2 组参数,防止工具行过长; +// 3. 无可展示参数时返回空字符串,由上层决定是否展示。 +func buildToolArgumentsPreviewCN(args map[string]any) string { + if len(args) <= 0 { + return "" + } + + type argPair struct { + Key string + Label string + } + + orderedPairs := []argPair{ + {Key: "title", Label: "任务标题"}, + {Key: "task_name", Label: "任务名称"}, + {Key: "deadline_at", Label: "截止时间"}, + {Key: "new_day", Label: "目标天"}, + {Key: "new_slot_start", Label: "目标开始节次"}, + {Key: "day", Label: "天"}, + {Key: "day_start", Label: "起始天"}, + {Key: "day_end", Label: "结束天"}, + {Key: "day_scope", Label: "日期范围"}, + {Key: "day_of_week", Label: "星期"}, + {Key: "week", Label: "周"}, + {Key: "week_from", Label: "起始周"}, + {Key: "week_to", Label: "结束周"}, + {Key: "week_filter", Label: "周筛选"}, + {Key: "slot_start", Label: "开始节次"}, + {Key: "slot_end", Label: "结束节次"}, + {Key: "slot_type", Label: "时段类型"}, + {Key: "slot_types", Label: "时段类型"}, + {Key: "task_id", Label: "任务编号"}, + {Key: "task_ids", Label: "任务列表"}, + {Key: "task_item_id", Label: "任务条目"}, + {Key: "task_item_ids", Label: "任务条目列表"}, + {Key: "query", Label: "搜索词"}, + {Key: "keyword", Label: "关键词"}, + {Key: "top_k", Label: "返回数量"}, + {Key: "url", Label: "链接"}, + {Key: "reason", Label: "原因"}, + {Key: "limit", Label: "数量"}, + } + + items := make([]string, 0, 2) + for _, pair := range orderedPairs { + rawValue, exists := args[pair.Key] + if !exists { + continue + } + valueText := formatToolArgValueByKeyCN(pair.Key, rawValue) + if valueText == "" { + continue + } + items = append(items, fmt.Sprintf("%s:%s", pair.Label, valueText)) + if len(items) >= 2 { + break + } + } + + return strings.Join(items, ",") +} + +// resolveToolDisplayNameCN 返回工具中文展示名。 +func resolveToolDisplayNameCN(toolName string) string { + name := strings.TrimSpace(toolName) + if name == "" { + return "未知工具" + } + + displayNameMap := map[string]string{ + "get_overview": "查看总览", + "query_range": "查询时段详情", + "queue_status": "查看任务队列", + "queue_pop_head": "获取队首任务", + "queue_apply_head_move": "调整队首任务时段", + "queue_skip_head": "跳过队首任务", + "query_target_tasks": "查询目标任务", + "query_available_slots": "查询可用时间段", + "get_task_info": "查看任务详情", + "quick_note_create": "创建提醒任务", + "query_tasks": "查询任务列表", + "web_search": "网页搜索", + "web_fetch": "网页抓取", + "move": "移动任务", + "place": "放置任务", + "swap": "交换任务", + "batch_move": "批量移动任务", + "spread_even": "均匀分散任务", + "min_context_switch": "减少上下文切换", + "unplace": "移除任务安排", + } + + if label, ok := displayNameMap[name]; ok { + return label + } + return name +} + +func formatToolArgValueByKeyCN(key string, value any) string { + switch key { + case "day_scope": + scope := strings.ToLower(strings.TrimSpace(formatToolArgValueCN(value))) + switch scope { + case "workday": + return "工作日" + case "weekend": + return "周末" + case "all": + return "全部日期" + default: + return scope + } + case "day_of_week": + weekdays := parseAnyToIntSlice(value) + if len(weekdays) <= 0 { + return formatToolArgValueCN(value) + } + labels := make([]string, 0, len(weekdays)) + for _, day := range weekdays { + labels = append(labels, fmt.Sprintf("周%d", day)) + if len(labels) >= 4 { + break + } + } + return strings.Join(labels, "、") + case "task_ids", "task_item_ids", "week_filter": + values := parseAnyToIntSlice(value) + if len(values) <= 0 { + return formatToolArgValueCN(value) + } + items := make([]string, 0, len(values)) + for _, current := range values { + items = append(items, strconv.Itoa(current)) + if len(items) >= 4 { + break + } + } + return strings.Join(items, "、") + case "url": + return truncateToolSummaryCN(formatToolArgValueCN(value)) + case "reason", "title", "task_name", "query", "keyword": + return truncateToolSummaryCN(formatToolArgValueCN(value)) + default: + return formatToolArgValueCN(value) + } +} + +// formatToolArgValueCN 把参数值格式化为中文可读字符串。 +func formatToolArgValueCN(value any) string { + switch v := value.(type) { + case string: + text := strings.TrimSpace(v) + if text == "" { + return "" + } + return text + case int: + return strconv.Itoa(v) + case int8: + return strconv.Itoa(int(v)) + case int16: + return strconv.Itoa(int(v)) + case int32: + return strconv.Itoa(int(v)) + case int64: + return strconv.Itoa(int(v)) + case float32: + return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32)) + case float64: + return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64)) + case bool: + if v { + return "是" + } + return "否" + case []any: + values := make([]string, 0, len(v)) + for _, item := range v { + text := formatToolArgValueCN(item) + if text == "" { + continue + } + values = append(values, text) + if len(values) >= 3 { + break + } + } + return strings.Join(values, "、") + default: + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" || text == "" || text == "map[]" { + return "" + } + return text + } +} diff --git a/backend/newAgent/stream/emitter.go b/backend/newAgent/stream/emitter.go index 95a6ab4..4edaa2f 100644 --- a/backend/newAgent/stream/emitter.go +++ b/backend/newAgent/stream/emitter.go @@ -191,70 +191,55 @@ func (e *ChunkEmitter) EmitPseudoAssistantText(ctx context.Context, blockID, sta // EmitStatus 输出一条阶段状态事件。 // -// 当前兼容策略: -// 1. extra 用 status 表达结构化语义; -// 2. reasoning_content 里同时放一份可读降级文本,保证旧前端也能看到。 +// 协议约束: +// 1. 状态事件只通过 extra 传递,不再写入 reasoning_content; +// 2. includeRole 保留是为了兼容旧签名,当前结构化事件路径不依赖 role。 func (e *ChunkEmitter) EmitStatus(blockID, stage, code, summary string, includeRole bool) error { if e == nil || e.emit == nil { return nil } - - text := buildStageReasoningText(stage, summary) - payload, err := ToOpenAIReasoningChunkWithExtra( - e.RequestID, - e.ModelName, - e.Created, - text, - includeRole, - NewStatusExtra(blockID, stage, code, summary), - ) - if err != nil { - return err - } - if payload == "" { - return nil - } - return e.emit(payload) + _ = includeRole + return e.emitExtraOnly(NewStatusExtra(blockID, stage, code, summary)) } // EmitToolCallStart 输出一次工具调用开始事件。 +// +// 协议约束: +// 1. 工具调用开始事件只走 extra.tool,不回写 reasoning_content; +// 2. includeRole 保留是为了兼容旧签名,当前结构化事件路径不依赖 role。 func (e *ChunkEmitter) EmitToolCallStart(blockID, stage, toolName, summary, argumentsPreview string, includeRole bool) error { if e == nil || e.emit == nil { return nil } - - text := buildToolCallReasoningText(toolName, summary, argumentsPreview) - payload, err := ToOpenAIReasoningChunkWithExtra( - e.RequestID, - e.ModelName, - e.Created, - text, - includeRole, - NewToolCallExtra(blockID, stage, toolName, "start", summary, argumentsPreview), - ) - if err != nil { - return err - } - if payload == "" { - return nil - } - return e.emit(payload) + _ = includeRole + return e.emitExtraOnly(NewToolCallExtra(blockID, stage, toolName, "start", summary, argumentsPreview)) } // EmitToolCallResult 输出一次工具调用结果事件。 -func (e *ChunkEmitter) EmitToolCallResult(blockID, stage, toolName, summary, argumentsPreview string, includeRole bool) error { +// +// 协议约束: +// 1. status 由调用方明确传入(如 done/blocked/failed); +// 2. 结果事件只走 extra.tool,不回写 reasoning_content。 +func (e *ChunkEmitter) EmitToolCallResult(blockID, stage, toolName, status, summary, argumentsPreview string, includeRole bool) error { if e == nil || e.emit == nil { return nil } + _ = includeRole + return e.emitExtraOnly(NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview)) +} - text := buildToolResultReasoningText(toolName, summary) - payload, err := ToOpenAIReasoningChunkWithExtra( +// emitExtraOnly 仅输出结构化 extra 事件,不附带 content/reasoning。 +func (e *ChunkEmitter) emitExtraOnly(extra *OpenAIChunkExtra) error { + if e == nil || e.emit == nil { + return nil + } + payload, err := ToOpenAIStreamWithExtra( + nil, e.RequestID, e.ModelName, e.Created, - text, - includeRole, - NewToolResultExtra(blockID, stage, toolName, "done", summary, argumentsPreview), + false, + extra, ) if err != nil { return err diff --git a/docs/功能决策记录/Agent流式协议_前后端对齐_决策记录.md b/docs/功能决策记录/Agent流式协议_前后端对齐_决策记录.md new file mode 100644 index 0000000..7bf7e04 --- /dev/null +++ b/docs/功能决策记录/Agent流式协议_前后端对齐_决策记录.md @@ -0,0 +1,219 @@ +# Agent 流式协议前后端对齐 决策记录 + +## 1. 基本信息 +- 记录编号:FDR-009 +- 功能名称:Agent Chat SSE 协议前后端对齐(去伪思考化) +- 记录日期:2026-04-18 +- 决策状态:提议(评审后执行) +- 负责人:SmartFlow 团队 +- 关联需求 / Issue: + - 工具调用信息前端可视化(折叠式、通俗文案) + - 降低“深度思考”误导(当前为伪装块) + +## 2. 背景与问题 +- 业务背景: + - 目前聊天页已经在做流式展示、确认卡片、会话管理; + - 计划将“工具调用过程”以专属样式内联展示。 +- 现状问题: + 1. 后端阶段状态/工具状态,会通过 `reasoning_content` 回传给前端,形成“看起来像深度思考”的内容; + 2. 前端目前只消费 `extra.kind=confirm_request`,其他 `extra` 结构化事件并未真正用于渲染; + 3. 用户感知层面会误以为模型正在“深度思考”,但其中大量内容其实是流程状态(非真实思考)。 +- 不做此决策的后果: + 1. 前端即使先移除“深度思考框”,也会连同状态可见性一起丢失; + 2. 前后端协议继续漂移,工具可视化落地会重复返工; + 3. 后续“真流式 speak”与“工具事件流”会相互干扰,排查困难。 + +## 3. 决策目标 +- 目标 1:统一 SSE 协议语义边界:`reasoning_content` 仅承载真实思考文本,不再承载阶段/工具状态文案。 +- 目标 2:以 `extra.kind` 作为结构化事件主通道,前端据此渲染工具/状态专属 UI。 +- 目标 3:明确迁移顺序:先后端协议就位,再前端切换展示,最后清理兼容层。 +- 非目标(本次不解决): + - 不在本轮实现“每一次 speak 都改成 chat 节点流式消息头”; + - 不在本轮重构全部历史会话数据存储格式。 + +## 4. 备选方案 +### 方案 A:先改前端,先隐藏深度思考区 +- 描述:前端先把思考区关闭或默认不展示,后端暂不改。 +- 优点: + - 见效快,UI 即刻变“干净”。 +- 缺点: + - 只是遮罩,不是协议治理; + - 状态可见性与思考内容耦合,后续仍要返工。 +- 复杂度 / 成本:低(短期)/ 高(长期返工) + +### 方案 B:先改后端协议,再改前端渲染(采纳) +- 描述:后端先把状态/工具事件改为结构化主通道,前端再切换消费逻辑与样式。 +- 优点: + - 语义边界清晰,长期维护成本低; + - 前端可直接做“折叠式工具行”,不再依赖伪思考文本; + - 可通过双写/开关平滑迁移,风险可控。 +- 缺点: + - 首轮需要前后端并行协作与联调。 +- 复杂度 / 成本:中 + +### 方案 C:前后端同一轮同时硬切 +- 描述:单次发布同时切后端协议和前端展示,不保留兼容层。 +- 优点: + - 路径最短,代码最“干净”。 +- 缺点: + - 回归风险高,灰度与回滚空间小; + - 一旦线上混部或缓存命中旧逻辑,容易出现空白块/重复块。 +- 复杂度 / 成本:中(开发)/ 高(上线风险) + +## 5. 最终决策 +- 采纳方案:方案 B(先后端协议,再前端渲染,最后清理兼容层)。 +- 关键理由: + 1. 先解决协议语义,再做视觉层改造,避免 UI 层“治标不治本”; + 2. 与“工具调用可视化”目标一致,能直接对接折叠式工具行; + 3. 具备可灰度、可回滚的工程路径。 + +## 6. 影响范围 +- 涉及模块: + - 后端:`backend/newAgent/stream`、`backend/newAgent/node` + - 前端:`frontend/src/components/dashboard/AssistantPanel.vue` +- 数据与存储影响: + - 无数据库结构变更; + - 仅影响实时 SSE 事件解释方式。 +- 接口 / 协议影响: + - `POST /api/v1/agent/chat` 的 SSE chunk 语义调整(见第 7/8 节)。 +- 监控与日志影响: + - 需新增“事件类型计数”与“前端解析命中率”观测。 + +## 7. 前后端接口现状(AS-IS) +### 7.1 SSE 外层协议(当前) +- 后端已定义 OpenAI 兼容壳 + `extra` 扩展,代码位置: + - `backend/newAgent/stream/openai.go` + - `backend/newAgent/stream/emitter.go` +- `extra.kind` 已有枚举:`status`、`tool_call`、`tool_result`、`confirm_request` 等。 + +### 7.2 当前关键现象 +1. 后端 `EmitStatus` 会把阶段文案同时写入: + - `extra.kind=status` + - `choices[0].delta.reasoning_content`(降级文本) +2. 执行节点多数工具过程仍通过 `EmitStatus(code=tool_call/tool_blocked)` 推送; +3. 前端 `processSseBlock` 当前只显式处理: + - `parsed.extra?.kind === 'confirm_request'` +4. 结果:大量状态文案作为“reasoning”显示,形成伪“深度思考”体验。 + +### 7.3 当前事件样例(简化) +```json +{ + "choices": [ + { + "delta": { + "reasoning_content": "阶段:execute\n正在调用工具:queue_status" + } + } + ], + "extra": { + "kind": "status", + "status": { + "code": "tool_call", + "summary": "正在调用工具:queue_status" + } + } +} +``` + +## 8. 目标协议(TO-BE) +### 8.1 总体原则 +1. `reasoning_content`:只承载真实模型思考文本; +2. `extra.kind`:承载流程状态和工具事件(前端主消费通道); +3. 前端渲染:默认不把状态事件塞入“深度思考区”,而是进入工具/状态专属样式。 + +### 8.2 目标事件约定 +| 事件类型 | `extra.kind` | 前端默认表现 | 是否进入 reasoning 区 | +|---|---|---|---| +| 阶段状态 | `status` | 轻量状态行 / 提示条 | 否 | +| 工具调用开始 | `tool_call` | 折叠工具行(默认摘要) | 否 | +| 工具调用结果 | `tool_result` | 更新同一工具行状态与详情 | 否 | +| 待确认 | `confirm_request` | 确认卡片 | 否 | +| 真思考 | `reasoning_text` | 思考区(可折叠) | 是 | +| 正文输出 | `assistant_text` | 正文区 | 否 | + +### 8.3 目标工具事件样例(简化) +```json +{ + "choices": [], + "extra": { + "kind": "tool_call", + "stage": "execute", + "tool": { + "name": "queue_status", + "status": "start", + "summary": "已调用工具:查看任务队列", + "arguments_preview": "默认参数" + } + } +} +``` + +```json +{ + "choices": [], + "extra": { + "kind": "tool_result", + "stage": "execute", + "tool": { + "name": "queue_status", + "status": "done", + "summary": "待处理 3 项,已完成 1 项" + } + } +} +``` + +## 9. 实施计划(先后端、再前端) +### 里程碑 1:后端协议对齐(先做) +- 目标:状态/工具事件走结构化主通道,不再依赖伪 reasoning 文本。 +- 开工清单(后端): + 1. `execute` 工具链路改用 `EmitToolCallStart/EmitToolCallResult`; + 2. `EmitStatus` 增加策略开关:支持“仅 extra,不回写 reasoning_content”模式; + 3. `tool_blocked` 统一归类到工具事件(或结构化 status),避免文本拼接歧义; + 4. 输出契约补充到 `backend/newAgent/ARCHITECTURE.md`。 + +### 里程碑 2:前端事件消费切换 +- 目标:前端按 `extra.kind` 渲染,不再把流程状态当“深度思考”。 +- 开工清单(前端): + 1. `processSseBlock` 增加 `status/tool_call/tool_result` 解析; + 2. 新增“折叠式工具行”状态机(默认摘要、展开详情); + 3. 深度思考区默认只接收真 `reasoning_text`; + 4. 确认卡片逻辑保持兼容。 + +### 里程碑 3:兼容层收敛 +- 目标:确认稳定后去除伪思考降级写法。 +- 开工清单: + 1. 关闭后端状态写入 `reasoning_content`; + 2. 清理前端旧降级路径; + 3. 补齐回归用例与文档。 + +## 10. 风险与应对 +- 风险 1:前端未及时消费 `extra.kind`,导致状态缺失。 + - 应对策略:后端先双写一段窗口期(可配置开关),灰度切换。 +- 风险 2:工具事件顺序乱序导致 UI 折叠状态错位。 + - 应对策略:使用 `block_id + tool.name + stage` 做关联键,按到达顺序幂等更新。 +- 风险 3:历史会话与新会话混合展示不一致。 + - 应对策略:仅对新流式消息生效,历史消息维持只读展示。 + +## 11. 验证与回滚 +- 验证方式: + 1. 后端单测:事件序列与 payload 结构校验; + 2. 前端联调:`tool_call -> tool_result -> summary` 顺序回放; + 3. 手工场景:普通问答 / 工具调用 / confirm / tool_blocked。 +- 成功判定标准: + 1. 状态类文本不再出现在“深度思考区”; + 2. 工具事件能稳定渲染为折叠行; + 3. confirm 卡片行为不回归。 +- 回滚方案: + - 后端切回“状态写 reasoning_content + 旧前端渲染”兼容模式; + - 前端保留旧路径开关,必要时回退版本。 + +## 12. 后续计划 +- 后续优化项 1:把 speak 也统一到更细粒度真流式事件头(下一轮决策)。 +- 后续优化项 2:工具详情文案模板化(通俗中文 + 可本地化)。 +- 后续优化项 3:工具事件接入埋点(点击展开率、阅读停留、错误率)。 + +## 13. 复盘结论(上线后补充) +- 实际效果:待补充 +- 与预期偏差:待补充 +- 后续是否需要二次决策:待补充 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 98240ae..a3ab082 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,3 +1,52 @@ + + + + diff --git a/frontend/src/components/common/MainSidebar.vue b/frontend/src/components/common/MainSidebar.vue new file mode 100644 index 0000000..5b1d170 --- /dev/null +++ b/frontend/src/components/common/MainSidebar.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 3ae7234..b903885 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -45,8 +45,24 @@ interface StreamConfirmPayload { summary?: string } +interface StreamStatusExtraPayload { + code?: string + summary?: string +} + +interface StreamToolExtraPayload { + name?: string + status?: string + summary?: string + arguments_preview?: string +} + interface StreamExtraPayload { kind?: string + block_id?: string + stage?: string + status?: StreamStatusExtraPayload + tool?: StreamToolExtraPayload confirm?: StreamConfirmPayload } @@ -60,6 +76,25 @@ interface StreamEventPayload { extra?: StreamExtraPayload } +type ToolTraceState = 'called' | 'completed' | 'create' | 'blocked' + +interface ToolTraceEvent { + id: string + seq: number + state: ToolTraceState + summary: string + detail?: string + toolName?: string +} + +interface StatusTraceEvent { + id: string + seq: number + code: string + stage: string + summary: string +} + interface ConversationGroup { key: string @@ -102,6 +137,21 @@ interface DisplayMessage { merged: boolean } +interface DisplayAssistantBlock { + id: string + type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' + seq: number + text?: string + event?: ToolTraceEvent + statusEvent?: StatusTraceEvent +} + +interface AssistantContentBlock { + id: string + seq: number + text: string +} + const props = withDefaults( defineProps<{ initialHistoryWidth?: number @@ -119,7 +169,7 @@ const assistantBodyRef = ref(null) const messageViewportRef = ref(null) const historyContentRef = ref(null) -const conversationLoading = ref(false) +const conversationLoading = ref(true) const conversationLoadingMore = ref(false) const chatLoading = ref(false) const historyExpanded = ref(true) @@ -158,6 +208,12 @@ const reasoningStartedAtMap = reactive>({}) const reasoningDurationMap = reactive>({}) const confirmOnlyStreamMap = reactive>({}) const confirmVisiblePrefixMap = reactive>({}) +const toolTraceEventsMap = reactive>({}) +const statusTraceEventsMap = reactive>({}) +const toolTraceExpandedMap = reactive>({}) +const assistantReasoningSeqMap = reactive>({}) +const assistantContentBlocksMap = reactive>({}) +const assistantTimelineLastKindMap = reactive>({}) const conversationContextStatsMap = reactive>({}) const conversationContextStatsLoadingMap = reactive>({}) const conversationContextStatsReadyMap = reactive>({}) @@ -178,6 +234,7 @@ let messageScrollReleaseRaf = 0 let reasoningTicker = 0 let historyResizeCleanup: (() => void) | null = null const conversationListItemRevealTimerMap = new Map() +let assistantTimelineSeq = 0 const reasoningDisplayNow = ref(Date.now()) const shouldAutoFollowMessages = ref(true) const messageBottomTolerancePx = 24 @@ -378,6 +435,237 @@ function appendConversationMessage(conversationId: string, message: AssistantMes return appended } +function ensureToolTraceBucket(messageId: string) { + if (!toolTraceEventsMap[messageId]) { + toolTraceEventsMap[messageId] = [] + } +} + +function ensureStatusTraceBucket(messageId: string) { + if (!statusTraceEventsMap[messageId]) { + statusTraceEventsMap[messageId] = [] + } +} + +function ensureAssistantContentBucket(messageId: string) { + if (!assistantContentBlocksMap[messageId]) { + assistantContentBlocksMap[messageId] = [] + } +} + +function nextAssistantTimelineSeq() { + assistantTimelineSeq += 1 + return assistantTimelineSeq +} + +function clearToolTraceState(messageId: string) { + delete toolTraceEventsMap[messageId] + delete statusTraceEventsMap[messageId] + delete assistantReasoningSeqMap[messageId] + delete assistantContentBlocksMap[messageId] + delete assistantTimelineLastKindMap[messageId] + for (const key of Object.keys(toolTraceExpandedMap)) { + if (key.startsWith(`${messageId}:tool:`)) { + delete toolTraceExpandedMap[key] + } + } +} + +function appendToolTraceEvent( + messageId: string, + state: ToolTraceState, + summary: string, + detail = '', + toolName = '', +) { + const normalizedSummary = summary.trim() + if (!normalizedSummary) { + return + } + + ensureToolTraceBucket(messageId) + const eventSeq = nextAssistantTimelineSeq() + const eventId = `${messageId}:tool:${eventSeq}` + + toolTraceEventsMap[messageId].push({ + id: eventId, + seq: eventSeq, + state, + summary: normalizedSummary, + detail: detail.trim() || undefined, + toolName: toolName.trim() || undefined, + }) + assistantTimelineLastKindMap[messageId] = 'tool' +} + +function appendStatusTraceEvent( + messageId: string, + code: string, + summary: string, + stage = '', +) { + const normalizedSummary = summary.trim() + if (!normalizedSummary) { + return + } + + ensureStatusTraceBucket(messageId) + + // 1. 状态事件可能在同一轮里被重复推送(如重试/补偿分片)。 + // 2. 这里按“同 code + 同摘要 + 同 stage”做相邻去重,避免前端刷出重复提示行。 + // 3. 仅做相邻去重,不做全局去重,保留真实阶段演进顺序。 + const statusEvents = statusTraceEventsMap[messageId] + const last = statusEvents[statusEvents.length - 1] + if (last && last.code === code && last.summary === normalizedSummary && last.stage === stage) { + return + } + + const eventSeq = nextAssistantTimelineSeq() + statusEvents.push({ + id: `${messageId}:status:${eventSeq}`, + seq: eventSeq, + code: code.trim(), + stage: stage.trim(), + summary: normalizedSummary, + }) + assistantTimelineLastKindMap[messageId] = 'status' +} + +function appendAssistantContentChunk(messageId: string, chunk: string) { + if (!chunk) { + return + } + ensureAssistantContentBucket(messageId) + const blocks = assistantContentBlocksMap[messageId] + const lastKind = assistantTimelineLastKindMap[messageId] + + if (lastKind === 'content' && blocks.length > 0) { + blocks[blocks.length - 1]!.text += chunk + return + } + + const seq = nextAssistantTimelineSeq() + blocks.push({ + id: `${messageId}:content:${seq}`, + seq, + text: chunk, + }) + assistantTimelineLastKindMap[messageId] = 'content' +} + +function mapToolEventState(rawStatus?: string): ToolTraceState { + const normalized = `${rawStatus || ''}`.trim().toLowerCase() + if (normalized === 'start' || normalized === 'calling' || normalized === 'called') { + return 'called' + } + if (normalized === 'create' || normalized === 'created') { + return 'create' + } + if (normalized === 'blocked') { + return 'blocked' + } + if (normalized === 'failed' || normalized === 'error') { + return 'blocked' + } + return 'completed' +} + +function normalizeToolSummary(extra: StreamToolExtraPayload): string { + const summary = `${extra.summary || ''}`.trim() + if (summary) { + return summary + } + const toolName = `${extra.name || ''}`.trim() + if (!toolName) { + return '工具事件' + } + return `已调用工具:${toolName}` +} + +function buildToolDetail(extra: StreamToolExtraPayload): string { + const argsPreview = `${extra.arguments_preview || ''}`.trim() + if (!argsPreview || argsPreview === '{}') { + return '' + } + return argsPreview +} + +function normalizeStatusCode(rawCode?: string) { + const code = `${rawCode || ''}`.trim().toLowerCase() + if (!code) { + return 'status' + } + return code +} + +function mapStatusCodeLabel(code: string) { + const labelMap: Record = { + accepted: '请求已接收', + planning: '正在规划', + resumed: '继续处理中', + confirmed: '确认后继续执行', + rejected: '已取消并重新规划', + executing: '正在执行', + plan_confirm: '等待计划确认', + tool_confirm: '等待操作确认', + ask_user: '等待补充信息', + confirm: '等待用户确认', + interrupted: '会话已中断', + summarizing: '正在生成总结', + done: '流程已结束', + rough_building: '正在生成初始排课方案', + rough_build_failed: '初始排课失败', + rough_build_done: '初始排课已完成', + rough_build_done_no_refine: '初始排课已完成', + order_guard_initialized: '已记录顺序基线', + order_guard_passed: '顺序校验通过', + order_guard_restored: '顺序已自动恢复', + order_guard_restore_skipped: '顺序恢复已跳过', + context_compact_start: '正在压缩上下文', + context_compact_done: '上下文压缩完成', + plan_auto_confirmed: '计划已自动确认', + } + return labelMap[code] || '状态已更新' +} + +function buildStatusSummary(extra: StreamExtraPayload): string { + const summary = `${extra.status?.summary || ''}`.trim() + if (summary) { + return summary + } + return mapStatusCodeLabel(normalizeStatusCode(extra.status?.code)) +} + +function isLegacyToolStatusCode(code: string) { + return code === 'tool_call' || code === 'tool_result' || code === 'tool_blocked' +} + +function mapLegacyToolStatusToState(code: string): ToolTraceState { + if (code === 'tool_call') { + return 'called' + } + if (code === 'tool_blocked') { + return 'blocked' + } + return 'completed' +} + +function shouldSkipStatusEvent(code: string, stage = '') { + // confirm_request 已有专属卡片,避免重复显示同语义状态行。 + if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) { + return true + } + return false +} + +function isToolTraceExpanded(eventId: string) { + return toolTraceExpandedMap[eventId] === true +} + +function toggleToolTraceExpanded(eventId: string) { + toolTraceExpandedMap[eventId] = !toolTraceExpandedMap[eventId] +} + function removeConversationMessage(conversationId: string, messageId: string) { const bucket = conversationMessagesMap[conversationId] if (!bucket || bucket.length <= 0) { @@ -388,6 +676,7 @@ function removeConversationMessage(conversationId: string, messageId: string) { return } bucket.splice(targetIndex, 1) + clearToolTraceState(messageId) } function cleanupHiddenAssistantMessageState(messageId: string) { @@ -400,6 +689,7 @@ function cleanupHiddenAssistantMessageState(messageId: string) { delete reasoningDurationMap[messageId] delete confirmOnlyStreamMap[messageId] delete confirmVisiblePrefixMap[messageId] + clearToolTraceState(messageId) } function clearConfirmStreamFlags(messageId: string) { @@ -845,6 +1135,124 @@ function isDisplayStreaming(dm: DisplayMessage): boolean { return dm.sources.some(m => m.id === activeStreamingMessageId.value) } +function getDisplayReasoningSeq(dm: DisplayMessage) { + const seqList: number[] = [] + for (const source of dm.sources) { + const seq = assistantReasoningSeqMap[source.id] + if (typeof seq === 'number' && seq > 0) { + seqList.push(seq) + } + } + if (seqList.length > 0) { + return Math.min(...seqList) + } + if (dm.reasoning?.trim()) { + return 10 + } + return -1 +} + +function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] { + if (dm.role !== 'assistant') { + return [] + } + + const blocks: DisplayAssistantBlock[] = [] + let fallbackSeq = -100000 + let hasContentBlock = false + + for (const source of dm.sources) { + const sourceEvents = (toolTraceEventsMap[source.id] || []).slice().sort((left, right) => left.seq - right.seq) + for (const event of sourceEvents) { + blocks.push({ + id: event.id, + type: 'tool', + seq: event.seq, + event, + }) + } + + const statusEvents = (statusTraceEventsMap[source.id] || []).slice().sort((left, right) => left.seq - right.seq) + for (const statusEvent of statusEvents) { + blocks.push({ + id: statusEvent.id, + type: 'status', + seq: statusEvent.seq, + statusEvent, + }) + } + + const contentBlocks = assistantContentBlocksMap[source.id] || [] + if (contentBlocks.length > 0) { + hasContentBlock = true + for (const contentBlock of contentBlocks) { + blocks.push({ + id: contentBlock.id, + type: 'content', + seq: contentBlock.seq, + text: contentBlock.text, + }) + } + continue + } + + if (source.content) { + hasContentBlock = true + fallbackSeq += 1 + blocks.push({ + id: `${source.id}:content:fallback`, + type: 'content', + seq: fallbackSeq, + text: source.content, + }) + } + } + + if (shouldShowDisplayReasoningBox(dm)) { + const reasoningSeq = getDisplayReasoningSeq(dm) + blocks.push({ + id: `${dm.id}:reasoning`, + type: 'reasoning', + seq: reasoningSeq > 0 ? reasoningSeq : 10, + text: dm.reasoning, + }) + } + + if (!hasContentBlock && dm.content) { + fallbackSeq += 1 + blocks.push({ + id: `${dm.id}:content`, + type: 'content', + seq: fallbackSeq, + text: dm.content, + }) + } + + if (shouldShowDisplayAnsweringIndicator(dm)) { + const maxSeq = blocks.length > 0 ? Math.max(...blocks.map((item) => item.seq)) : 0 + blocks.push({ + id: `${dm.id}:content-indicator`, + type: 'content_indicator', + seq: maxSeq + 1, + }) + } + + return blocks.sort((left, right) => left.seq - right.seq) +} + +function getToolTraceStateLabel(state: ToolTraceState): string { + if (state === 'called') { + return '已调用' + } + if (state === 'create') { + return '已创建' + } + if (state === 'blocked') { + return '已拦截' + } + return '已完成' +} + function shouldShowDisplayReasoningBox(dm: DisplayMessage): boolean { if (dm.role !== 'assistant') return false return dm.sources.some(m => @@ -994,11 +1402,15 @@ async function loadConversationListData(reset = false) { } try { - const result = await getConversationList({ - page: conversationPage.value, - pageSize: conversationPageSize, - status: 'active', - }) + const minTimer = new Promise((resolve) => setTimeout(resolve, 800)) + const [result] = await Promise.all([ + getConversationList({ + page: conversationPage.value, + pageSize: conversationPageSize, + status: 'active', + }), + reset ? minTimer : Promise.resolve(), + ]) if (reset) { conversationList.value = conversationList.value.filter((item) => isDraftConversationId(item.conversation_id)) @@ -1408,6 +1820,80 @@ function prepareAssistantMessageForStreaming(message: AssistantMessage, createdA reasoningCollapsedMap[message.id] = false delete reasoningStartedAtMap[message.id] delete reasoningDurationMap[message.id] + clearToolTraceState(message.id) + toolTraceEventsMap[message.id] = [] + statusTraceEventsMap[message.id] = [] + assistantContentBlocksMap[message.id] = [] + assistantTimelineLastKindMap[message.id] = 'other' +} + +function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistantMessage: AssistantMessage) { + if (!extra?.kind) { + return + } + + if (extra.kind === 'confirm_request') { + // 1. 记录“confirm 到来前是否已存在可见正文/思考”。 + // 2. 若已有可见前缀,后续流结束时只隐藏 confirm 相关部分,不删除整条消息。 + if (assistantMessage.content.trim() || `${assistantMessage.reasoning || ''}`.trim()) { + confirmVisiblePrefixMap[assistantMessage.id] = true + } + confirmOnlyStreamMap[assistantMessage.id] = true + applyConfirmOverlay(extra.confirm) + return + } + + if (extra.kind === 'tool_call' && extra.tool) { + appendToolTraceEvent( + assistantMessage.id, + mapToolEventState(extra.tool.status || 'start'), + normalizeToolSummary(extra.tool), + buildToolDetail(extra.tool), + `${extra.tool.name || ''}`, + ) + return + } + + if (extra.kind === 'tool_result' && extra.tool) { + appendToolTraceEvent( + assistantMessage.id, + mapToolEventState(extra.tool.status || 'done'), + normalizeToolSummary(extra.tool), + buildToolDetail(extra.tool), + `${extra.tool.name || ''}`, + ) + return + } + + if (extra.kind === 'status' && extra.status) { + // 1. status 是固定节点(rough_build/order_guard/compact 等)的主通道,需要进入时间线。 + // 2. 兼容老协议:若 status.code 仍是 tool_*,归并到工具事件,避免重复两条。 + // 3. 非工具状态统一转为“节点状态行”,和正文按 seq 自然穿插。 + const code = normalizeStatusCode(extra.status.code) + if (isLegacyToolStatusCode(code)) { + appendToolTraceEvent( + assistantMessage.id, + mapLegacyToolStatusToState(code), + `${extra.status.summary || '工具事件'}`.trim() || '工具事件', + ) + return + } + if (!shouldSkipStatusEvent(code, `${extra.stage || ''}`.trim())) { + appendStatusTraceEvent( + assistantMessage.id, + code, + buildStatusSummary(extra), + `${extra.stage || ''}`, + ) + } + } +} + +function shouldSuppressReasoningDeltaByExtraKind(kind?: string) { + if (!kind) { + return false + } + return kind === 'status' || kind === 'tool_call' || kind === 'tool_result' } // processSseBlock 负责解析单个 SSE block,并把增量内容落到当前 assistant message 上。 @@ -1451,23 +1937,17 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) { throw new Error(parsed.error.message) } - if (parsed.extra?.kind === 'confirm_request') { - // 1. 记录“confirm 到来前是否已存在可见正文/思考”。 - // 2. 若已有可见前缀,后续流结束时只隐藏 confirm 相关部分,不删除整条消息。 - if (assistantMessage.content.trim() || `${assistantMessage.reasoning || ''}`.trim()) { - confirmVisiblePrefixMap[assistantMessage.id] = true - } - confirmOnlyStreamMap[assistantMessage.id] = true - applyConfirmOverlay(parsed.extra.confirm) - } + handleStreamExtraEvent(parsed.extra, assistantMessage) const shouldSuppressVisibleDelta = confirmOnlyStreamMap[assistantMessage.id] === true + const shouldSuppressReasoningByExtraKind = shouldSuppressReasoningDeltaByExtraKind(parsed.extra?.kind) const choice = parsed.choices?.[0] const delta = choice?.delta ?? parsed.delta ?? parsed const finishReason = choice?.finish_reason ?? parsed.finish_reason ?? null if ( !shouldSuppressVisibleDelta && + !shouldSuppressReasoningByExtraKind && typeof delta?.reasoning_content === 'string' && delta.reasoning_content ) { @@ -1477,10 +1957,15 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) { markReasoningStart(assistantMessage) thinkingMessageMap[assistantMessage.id] = true } + if (!assistantReasoningSeqMap[assistantMessage.id]) { + assistantReasoningSeqMap[assistantMessage.id] = nextAssistantTimelineSeq() + } + assistantTimelineLastKindMap[assistantMessage.id] = 'reasoning' assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}` } if (!shouldSuppressVisibleDelta && typeof delta?.content === 'string' && delta.content) { + appendAssistantContentChunk(assistantMessage.id, delta.content) if (isThinkingMessage(assistantMessage)) { // 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。 // 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。 @@ -1793,7 +2278,7 @@ onBeforeUnmount(() => { @@ -203,7 +205,7 @@ const renderSlots = computed(() => .timeline-skeleton__item { min-width: 0; min-height: 124px; - border-radius: 20px; + border-radius: 14px; } .timeline-event { @@ -211,7 +213,7 @@ const renderSlots = computed(() => display: flex; flex-direction: column; justify-content: space-between; - border: 1px solid rgba(17, 24, 39, 0.06); + border: 1px solid transparent; position: relative; overflow: hidden; } @@ -222,107 +224,142 @@ const renderSlots = computed(() => left: 0; top: 0; bottom: 0; - width: 5px; - opacity: 0.92; + width: 4px; } .timeline-event__time { font-size: 12px; font-weight: 700; - color: #295b9b; + color: #64748b; } .timeline-event__title { margin-top: 12px; font-size: 15px; line-height: 1.35; - color: #172033; + color: #0f172a; } .timeline-event__location { margin-top: 14px; font-size: 12px; - color: #5f6980; + color: #64748b; } .timeline-event--course { - background: linear-gradient(180deg, #ecf4ff 0%, #e4eefc 100%); + background: #eff6ff; } .timeline-event--course::before { - background: #1669c1; + background: #3b82f6; +} + +.timeline-event--course .timeline-event__title, +.timeline-event--course .timeline-event__time { + color: #1d4ed8; } .timeline-event--sky { - background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%); + background: #f0f9ff; } .timeline-event--sky::before { - background: #c8d6e8; + background: #0ea5e9; +} + +.timeline-event--sky .timeline-event__title, +.timeline-event--sky .timeline-event__time { + color: #0369a1; } .timeline-event--violet { - background: linear-gradient(180deg, #eef0ff 0%, #e6e8ff 100%); + background: #f5f3ff; } .timeline-event--violet::before { - background: #676cff; + background: #8b5cf6; +} + +.timeline-event--violet .timeline-event__title, +.timeline-event--violet .timeline-event__time { + color: #6d28d9; } .timeline-event--mint { - background: linear-gradient(180deg, #e6f2ff 0%, #dceaff 100%); + background: #ecfdf5; } .timeline-event--mint::before { - background: #2f7de1; + background: #10b981; +} + +.timeline-event--mint .timeline-event__title, +.timeline-event--mint .timeline-event__time { + color: #047857; } .timeline-event--emerald { - background: linear-gradient(180deg, #e6f8f1 0%, #def5ec 100%); + background: #dcfce7; } .timeline-event--emerald::before { - background: #27b482; + background: #22c55e; +} + +.timeline-event--emerald .timeline-event__title, +.timeline-event--emerald .timeline-event__time { + color: #15803d; } .timeline-event--amber { - background: linear-gradient(180deg, #fff5db 0%, #fff0cb 100%); + background: #fffbeb; } .timeline-event--amber::before { background: #f59e0b; } +.timeline-event--amber .timeline-event__title, +.timeline-event--amber .timeline-event__time { + color: #b45309; +} + .timeline-event--cyan { - background: linear-gradient(180deg, #e1f7ff 0%, #d6f2fb 100%); + background: #cffafe; } .timeline-event--cyan::before { - background: #57b8ea; + background: #06b6d4; +} + +.timeline-event--cyan .timeline-event__title, +.timeline-event--cyan .timeline-event__time { + color: #0e7490; } .timeline-event--neutral { - background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%); + background: #f8fafc; + border-color: rgba(15, 23, 42, 0.05); } .timeline-event--neutral::before { - background: #c8d6e8; + background: #94a3b8; } .timeline-placeholder { - border: 1px dashed rgba(120, 144, 171, 0.28); - background: rgba(255, 255, 255, 0.55); + border: 1px dashed rgba(15, 23, 42, 0.15); + background: #ffffff; display: grid; align-content: center; justify-items: center; gap: 8px; padding: 14px 12px; text-align: center; - color: #8a96a8; + color: #64748b; } .timeline-placeholder--pause { - background: linear-gradient(180deg, #f5f9ff 0%, #eef4fb 100%); + background: #f8fafc; } .timeline-placeholder__title { @@ -359,6 +396,20 @@ const renderSlots = computed(() => } } +.fade-switch-enter-active, +.fade-switch-leave-active { + transition: opacity 0.25s ease, transform 0.25s ease; +} + +.fade-switch-enter-from, +.fade-switch-leave-to { + opacity: 0; + transform: translateY(4px); +} +.fade-switch-leave-to { + transform: translateY(-4px); +} + @media (max-width: 1320px) { .timeline-grid, .timeline-skeleton { diff --git a/frontend/src/components/schedule/TaskClassSidebar.vue b/frontend/src/components/schedule/TaskClassSidebar.vue index 7b92c5d..be1302e 100644 --- a/frontend/src/components/schedule/TaskClassSidebar.vue +++ b/frontend/src/components/schedule/TaskClassSidebar.vue @@ -68,9 +68,6 @@ function resolveDetailPanelStyle(items: TaskClassDetail['items']) { ) const finalHeight = Math.min(preferredHeight, maxHeightByItemCount, maxHeightByContainer) - // 1. 条目少时让卡片自然长高,避免只有两三条时还出现大块留白。 - // 2. 条目超过“当前屏幕可安全展示的最大条数”后,立即锁住高度并进入内部滚动。 - // 3. 这样像 8 条 task_item 这类中等长度列表会稳定触发滚动,不会再因为估算过大而失效。 return { maxHeight: `${finalHeight}px`, } @@ -171,39 +168,41 @@ watch( -
-
正在载入任务块…
+ +
+
正在载入任务块…
-
-
- {{ item.order }} - {{ item.content }} - +
- {{ formatEmbeddedTime(item.embedded_time) }} - - + {{ item.order }} + {{ item.content }} + + {{ formatEmbeddedTime(item.embedded_time) }} + + +
-
+