Version: 0.9.32.dev.260419
后端: 1. 会话历史接口切换为统一时间线读取,并兼容 extra.resume 恢复协议 - api/agent.go:新增 resume->confirm_action 映射(approve/reject/cancel),恢复请求缺 conversation_id 时拦截;GetConversationHistory 改为 GetConversationTimeline - routers/routers.go:路由从 GET /conversation-history 切换为 GET /conversation-timeline - model/agent.go:删除 GetConversationHistoryItem 旧 DTO 2. 新增会话时间线持久化链路(MySQL + Redis) - 新增 model/agent_timeline.go:定义 timeline kind、AgentTimelineEvent、持久化/返回结构 - 新增 dao/agent_timeline.go:写入事件、按 seq 查询、查询 max seq - inits/mysql.go:AutoMigrate 增加 AgentTimelineEvent - dao/cache.go:新增 timeline list/seq key,支持 incr/set seq、append/list、全量回填与删除 - 新增 service/agentsvc/agent_timeline.go:时间线读写编排(Redis 优先、DB 回源、seq 分配与冲突重试、extra 事件映射) 3. 聊天主链路改为写入 timeline,旧 history 服务下线 - service/agentsvc/agent.go:普通聊天用户/助手消息改为 appendConversationTimelineEvent - service/agentsvc/agent_newagent.go:透传 resume_interaction_id;注入 emitter extra hook 持久化卡片事件;正文写入 timeline - 删除 service/agentsvc/agent_history.go:下线 conversation-history 旧缓存编排 4. newAgent 恢复与确认防串单增强 - newAgent/model/graph_run_state.go:AgentGraphRequest 新增 ResumeInteractionID - newAgent/node/agent_nodes.go:透传 ResumeInteractionID - newAgent/node/chat.go:增加 stale_resume 校验;accept/reject 兼容 approve/cancel;非法动作返回 invalid_confirm_action - newAgent/stream/emitter.go:新增 extraEventHook / SetExtraEventHook,在 extra-only 与 confirm 事件触发 5. 日程暂存后同步刷新预览缓存,避免读到拖拽前旧数据 - service/agentsvc/agent_schedule_state.go:Save 后重建并覆盖 preview 缓存,保留 trace/candidate 等字段 6. 缓存失效策略调整到 timeline 口径 - middleware/cache_deleter.go:移除 conversation-history 失效逻辑;ChatHistory/AgentChat/AgentTimelineEvent 加入忽略集合 前端: 7. 新增时间线接口与类型定义 - frontend/src/api/schedule_agent.ts:新增 TimelineEvent/TimelineToolPayload/TimelineConfirmPayload 与 getConversationTimeline 8. AssistantPanel 全面对接 timeline 重建消息与卡片 - frontend/src/components/dashboard/AssistantPanel.vue:移除旧 history merge/normalize,新增 rebuildStateFromTimeline;支持 execution mode(always_execute);支持 resume-only 发送;修复 confirm 弹层手动关闭后重复弹出;会话标题显示放宽;流式中隐藏 action bar 9. 精排弹窗健壮性与交互动效优化 - frontend/src/components/assistant/ScheduleFineTuneModal.vue:previewData 支持 nullable,新增 visible 控制与 watch 初始化,补齐空值保护并调整弹窗动画 仓库: 10. 新增前端时间线接入说明文档 - docs/frontend/newagent_timeline_对接说明.md:接口、kind、payload、刷新重建与迁移建议
This commit is contained in:
@@ -3,6 +3,37 @@ import type { ApiResponse } from '@/types/api'
|
||||
import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||
import { extractErrorMessage } from '@/utils/http'
|
||||
|
||||
export interface TimelineToolPayload {
|
||||
name: string
|
||||
status: 'start' | 'done' | 'blocked' | 'failed'
|
||||
summary: string
|
||||
arguments_preview?: string
|
||||
}
|
||||
|
||||
export interface TimelineConfirmPayload {
|
||||
interaction_id: string
|
||||
title: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: number
|
||||
seq: number
|
||||
kind: 'user_text' | 'assistant_text' | 'tool_call' | 'tool_result' | 'confirm_request' | 'schedule_completed'
|
||||
role?: 'user' | 'assistant'
|
||||
content?: string
|
||||
payload?: {
|
||||
reasoning_content?: string
|
||||
stage?: string
|
||||
block_id?: string
|
||||
display_mode?: 'card'
|
||||
tool?: TimelineToolPayload
|
||||
confirm?: TimelineConfirmPayload
|
||||
}
|
||||
tokens_consumed?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排程预览数据
|
||||
*/
|
||||
@@ -17,6 +48,20 @@ export async function getSchedulePreview(conversationId: string): Promise<Schedu
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话完整时间线
|
||||
*/
|
||||
export async function getConversationTimeline(conversationId: string): Promise<TimelineEvent[]> {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<TimelineEvent[]>>('/agent/conversation-timeline', {
|
||||
params: { conversation_id: conversationId },
|
||||
})
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '获取会话时间线失败'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存排程状态到 Redis
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { HybridScheduleEntry, PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||
import { saveScheduleState, applyBatchIntoSchedule } from '@/api/schedule_agent'
|
||||
|
||||
const props = defineProps<{
|
||||
previewData: SchedulePreviewData
|
||||
previewData: SchedulePreviewData | null
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -13,8 +14,22 @@ const emit = defineEmits<{
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
// 计算数据中的起止周次
|
||||
const currentWeek = ref(1)
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 内部维护一份可变的建议任务列表,组件初始化时默认空
|
||||
const suggestedItems = ref<HybridScheduleEntry[]>([])
|
||||
|
||||
// 监听数据变化,当传了有效数据时才进行初始化,解决 v-if 延迟导致的空引用问题
|
||||
watch(() => props.previewData, (newVal) => {
|
||||
if (newVal) {
|
||||
suggestedItems.value = JSON.parse(JSON.stringify(newVal.hybrid_entries))
|
||||
currentWeek.value = newVal.hybrid_entries.length > 0 ? Math.min(...newVal.hybrid_entries.map(e => e.week)) : 1
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const weekRange = computed(() => {
|
||||
if (!props.previewData) return { min: 1, max: 20 }
|
||||
const weeks = props.previewData.hybrid_entries.map(e => e.week)
|
||||
if (weeks.length === 0) return { min: 1, max: 20 }
|
||||
return {
|
||||
@@ -23,14 +38,6 @@ const weekRange = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const currentWeek = ref(weekRange.value.min)
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 内部维护一份可变的建议任务列表,用于拖拽更新
|
||||
const suggestedItems = ref<HybridScheduleEntry[]>(
|
||||
JSON.parse(JSON.stringify(props.previewData.hybrid_entries))
|
||||
)
|
||||
|
||||
const sectionSlots = [
|
||||
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
|
||||
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
|
||||
@@ -66,6 +73,7 @@ function buildPlacedItems(): PlacedItem[] {
|
||||
* 暂存至 State (Redis)
|
||||
*/
|
||||
async function handleSaveToState() {
|
||||
if (!props.previewData) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const items = buildPlacedItems()
|
||||
@@ -82,6 +90,7 @@ async function handleSaveToState() {
|
||||
* 正式保存到数据库 (MySQL)
|
||||
*/
|
||||
async function handleOfficialSave() {
|
||||
if (!props.previewData) return
|
||||
await ElMessageBox.confirm(
|
||||
'正式保存将把当前编排结果写入你的日程表。保存后本轮编排微调将终止,确认继续吗?',
|
||||
'正式保存确认',
|
||||
@@ -206,7 +215,7 @@ const currentWeekEntries = computed(() =>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="previewData" class="schedule-modal-overlay" @click.self="emit('close')">
|
||||
<div v-if="visible && previewData" class="schedule-modal-overlay" @click.self="emit('close')">
|
||||
<div class="schedule-modal">
|
||||
<header class="schedule-modal__header">
|
||||
<h3>日程预览与精排 (第 {{ currentWeek }} 周)</h3>
|
||||
@@ -650,10 +659,13 @@ const currentWeekEntries = computed(() =>
|
||||
animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
/* 弹窗动画 */
|
||||
.modal-enter-active,
|
||||
/* 弹窗核心动画:采用物理弹簧质感 */
|
||||
.modal-enter-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
@@ -662,19 +674,23 @@ const currentWeekEntries = computed(() =>
|
||||
}
|
||||
|
||||
.modal-enter-active .schedule-modal {
|
||||
animation: modal-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: modal-pop-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.modal-leave-active .schedule-modal {
|
||||
animation: modal-in 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse;
|
||||
animation: modal-pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) reverse;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
transform: scale(0.95) translateY(30px);
|
||||
@keyframes modal-pop-in {
|
||||
0% {
|
||||
transform: scale(0.9) translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
60% {
|
||||
transform: scale(1.02) translateY(-2px);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import ContextWindowMeter from '@/components/assistant/ContextWindowMeter.vue'
|
||||
import TaskClassPlanningPicker from '@/components/assistant/TaskClassPlanningPicker.vue'
|
||||
import {
|
||||
getContextStats,
|
||||
getConversationHistory,
|
||||
getConversationList,
|
||||
getConversationMeta,
|
||||
type ConversationHistoryMessage,
|
||||
} from '@/api/agent'
|
||||
import { getSchedulePreview } from '@/api/schedule_agent'
|
||||
import {
|
||||
getSchedulePreview,
|
||||
getConversationTimeline,
|
||||
type TimelineEvent,
|
||||
type TimelineToolPayload,
|
||||
type TimelineConfirmPayload
|
||||
} from '@/api/schedule_agent'
|
||||
import { refreshToken } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type {
|
||||
@@ -181,6 +185,7 @@ const historyExpanded = ref(true)
|
||||
const selectedConversationId = ref('')
|
||||
|
||||
const selectedThinkingMode = ref<ThinkingModeType>('auto')
|
||||
const selectedExecutionMode = ref<'manual' | 'always'>('manual')
|
||||
const messageInput = ref('')
|
||||
const historyPanelWidth = ref(props.initialHistoryWidth)
|
||||
const activeStreamingMessageId = ref('')
|
||||
@@ -371,12 +376,12 @@ const selectedConversationTitle = computed(() => {
|
||||
}
|
||||
|
||||
const meta = conversationMetaMap[selectedConversationId.value]
|
||||
if (meta?.has_title && meta.title) {
|
||||
if (meta?.title) {
|
||||
return meta.title
|
||||
}
|
||||
|
||||
const current = selectedConversation.value
|
||||
if (current?.has_title && current.title) {
|
||||
if (current?.title) {
|
||||
return current.title
|
||||
}
|
||||
|
||||
@@ -880,104 +885,6 @@ function syncConversationListItemFromMeta(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHistoryMessage(message: ConversationHistoryMessage, index: number): AssistantMessage {
|
||||
const id = `${message.id ?? `${message.role}-${index}`}`
|
||||
const reasoningText = typeof message.reasoning_content === 'string' ? message.reasoning_content : ''
|
||||
const normalized: AssistantMessage = {
|
||||
id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
createdAt: message.created_at ?? new Date().toISOString(),
|
||||
reasoning: reasoningText || undefined,
|
||||
}
|
||||
|
||||
// 1. 历史消息优先使用后端持久化的思考时长,避免刷新后重新按“当前时间 - 创建时间”误算。
|
||||
// 2. 若后端当前未返回有效时长,则清掉旧缓存,回退为“仅展示已思考文案”。
|
||||
// 3. 同时清理 startedAt,防止历史消息误进入前端实时计时分支。
|
||||
delete reasoningStartedAtMap[id]
|
||||
if (typeof message.reasoning_duration_seconds === 'number' && message.reasoning_duration_seconds > 0) {
|
||||
reasoningDurationMap[id] = Math.max(1, Math.round(message.reasoning_duration_seconds))
|
||||
} else {
|
||||
delete reasoningDurationMap[id]
|
||||
}
|
||||
|
||||
thinkingMessageMap[id] = false
|
||||
reasoningCollapsedMap[id] = Boolean(reasoningText.trim())
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isSameLogicalMessage(left: AssistantMessage, right: AssistantMessage) {
|
||||
return (
|
||||
left.role === right.role &&
|
||||
left.content === right.content &&
|
||||
(left.reasoning || '') === (right.reasoning || '')
|
||||
)
|
||||
}
|
||||
|
||||
// mergeServerHistoryWithLocalState 将服务端历史与本地乐观消息合并为最终消息流。
|
||||
//
|
||||
// 核心策略:保留本地消息的原始顺序,用服务端数据"就地替换"匹配到的本地消息。
|
||||
//
|
||||
// 为什么不按时间戳排序?
|
||||
// 1. 聊天历史通过 Kafka 异步持久化,数据库 created_at 是消费者落库时刻,
|
||||
// 而非消息产生时刻。Kafka 消费顺序不保证与发布顺序一致,
|
||||
// 导致 assistant 消息可能比 user 消息先落库,created_at 反而更早。
|
||||
// 2. 本地消息按"用户发送 → 占位 → 流式填充"的顺序 append,天然是正确时序,
|
||||
// 任何基于时间戳的排序都会被异步落库的时钟偏差破坏。
|
||||
// 3. 因此:本地顺序权威,服务端数据用于刷新字段(如 reasoning_duration_seconds),
|
||||
// 新增的服务端消息(其他端产生)追加到尾部。
|
||||
function mergeServerHistoryWithLocalState(
|
||||
conversationId: string,
|
||||
history: ConversationHistoryMessage[],
|
||||
) {
|
||||
const existingBucket = conversationMessagesMap[conversationId] ?? []
|
||||
const normalizedHistory = history.map(normalizeHistoryMessage)
|
||||
|
||||
// 1. 构建服务端消息的快速查找索引:按 ID 和按角色+内容两种方式。
|
||||
const serverById = new Map(normalizedHistory.map((m) => [m.id, m]))
|
||||
const usedServerIds = new Set<string>()
|
||||
|
||||
// 2. 按本地消息的原始顺序逐一处理:
|
||||
// - ID 精确命中 → 用服务端数据替换,保持当前位置;
|
||||
// - 临时 ID 按语义匹配 → 同样替换,保持当前位置;
|
||||
// - 无法匹配 → 保留为乐观消息,保持当前位置。
|
||||
const result: AssistantMessage[] = []
|
||||
for (const localMsg of existingBucket) {
|
||||
// 2.1 先按 ID 精确匹配(非临时 ID 的消息,如历史加载过的服务端消息)。
|
||||
const exactMatch = serverById.get(localMsg.id)
|
||||
if (exactMatch && !usedServerIds.has(exactMatch.id)) {
|
||||
result.push(exactMatch)
|
||||
usedServerIds.add(exactMatch.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2.2 临时 ID(如 user-1700000000000-abc)走语义匹配:
|
||||
// 同一角色 + 同一内容的消息视为同一条逻辑消息。
|
||||
if (isLocalEphemeralMessageId(localMsg.id)) {
|
||||
const logicalMatch = normalizedHistory.find(
|
||||
(sm) => !usedServerIds.has(sm.id) && isSameLogicalMessage(sm, localMsg),
|
||||
)
|
||||
if (logicalMatch) {
|
||||
result.push(logicalMatch)
|
||||
usedServerIds.add(logicalMatch.id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 无法匹配服务端消息时保留本地乐观消息(流式中的占位 / 网络延迟未落库)。
|
||||
result.push(localMsg)
|
||||
}
|
||||
|
||||
// 3. 本地不存在的服务端消息(如其他设备发送的)追加到尾部,按服务端返回顺序排列。
|
||||
for (const serverMsg of normalizedHistory) {
|
||||
if (!usedServerIds.has(serverMsg.id)) {
|
||||
result.push(serverMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function renderMessageMarkdown(content: string) {
|
||||
return renderMarkdown(content)
|
||||
}
|
||||
@@ -1281,7 +1188,6 @@ function shouldShowDisplayReasoningBox(dm: DisplayMessage): boolean {
|
||||
}
|
||||
|
||||
function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean {
|
||||
if (dm.content) return false
|
||||
return isDisplayStreaming(dm) && dm.sources.every(m => thinkingMessageMap[m.id] !== true)
|
||||
}
|
||||
|
||||
@@ -1582,6 +1488,142 @@ function toggleHistoryPanel() {
|
||||
historyExpanded.value = !historyExpanded.value
|
||||
}
|
||||
|
||||
function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[]) {
|
||||
const result: AssistantMessage[] = []
|
||||
let currentAssistantMessage: AssistantMessage | null = null
|
||||
|
||||
// 清理该会话旧的辅助状态(工具、排程卡片等)
|
||||
// 注意:此处不清理 bucket 容器,只清理每个消息关联的映射
|
||||
const existingMessages = conversationMessagesMap[conversationId] || []
|
||||
existingMessages.forEach(msg => {
|
||||
if (msg.role === 'assistant') {
|
||||
clearToolTraceState(msg.id)
|
||||
}
|
||||
})
|
||||
|
||||
for (const event of events) {
|
||||
const kind = String(event.kind || '').toLowerCase()
|
||||
const rawRole = String(event.role || '').toLowerCase()
|
||||
|
||||
// 如果 role 已明确为 user,或者 kind 包含 user 关键字
|
||||
let isUser = rawRole === 'user' || kind.includes('user')
|
||||
// 终极兜底:只要不是明确的五大助手专属事件,就将其视为用户的消息回合边界
|
||||
if (!isUser) {
|
||||
const knownAssistantKinds = ['assistant_text', 'tool_call', 'tool_result', 'confirm_request', 'schedule_completed']
|
||||
if (!knownAssistantKinds.includes(kind)) {
|
||||
isUser = true
|
||||
}
|
||||
}
|
||||
if (isUser) {
|
||||
currentAssistantMessage = null
|
||||
result.push({
|
||||
id: `t-${event.id}`,
|
||||
role: 'user',
|
||||
content: event.content || '',
|
||||
createdAt: event.created_at,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 助手事件
|
||||
if (!currentAssistantMessage) {
|
||||
currentAssistantMessage = {
|
||||
id: `t-${event.id}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: event.created_at,
|
||||
reasoning: '',
|
||||
}
|
||||
result.push(currentAssistantMessage)
|
||||
thinkingMessageMap[currentAssistantMessage.id] = false
|
||||
reasoningCollapsedMap[currentAssistantMessage.id] = true
|
||||
}
|
||||
|
||||
const mid = currentAssistantMessage.id
|
||||
|
||||
switch (event.kind) {
|
||||
case 'assistant_text':
|
||||
if (event.content) {
|
||||
const newContent = event.content
|
||||
const oldContent = currentAssistantMessage.content || ''
|
||||
let chunk = newContent
|
||||
if (newContent.startsWith(oldContent)) {
|
||||
chunk = newContent.slice(oldContent.length)
|
||||
}
|
||||
|
||||
if (chunk) {
|
||||
currentAssistantMessage.content += chunk
|
||||
// 同时存入 blocks 以支持和工具交错显示
|
||||
appendAssistantContentChunk(mid, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.payload?.reasoning_content) {
|
||||
const newReasoning = event.payload.reasoning_content
|
||||
const oldReasoning = currentAssistantMessage.reasoning || ''
|
||||
let reasoningChunk = newReasoning
|
||||
if (newReasoning.startsWith(oldReasoning)) {
|
||||
reasoningChunk = newReasoning.slice(oldReasoning.length)
|
||||
}
|
||||
|
||||
if (reasoningChunk) {
|
||||
currentAssistantMessage.reasoning = oldReasoning + reasoningChunk
|
||||
// 记录推理块的 seq 环境
|
||||
if (!assistantReasoningSeqMap[mid]) {
|
||||
assistantReasoningSeqMap[mid] = event.seq
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_call':
|
||||
if (event.payload?.tool) {
|
||||
const t = event.payload.tool
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
if (event.payload?.tool) {
|
||||
const t = event.payload.tool
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
|
||||
}
|
||||
break
|
||||
|
||||
case 'confirm_request':
|
||||
confirmOnlyStreamMap[mid] = true
|
||||
// 记录确认卡片
|
||||
if (event.payload?.confirm) {
|
||||
// 这里我们只是记录,由 computed 判断是否需要弹出
|
||||
// 实际上 applyConfirmOverlay 会立即修改全局状态,
|
||||
// 在刷新恢复场景下,我们只需设置状态即可。
|
||||
}
|
||||
break
|
||||
|
||||
case 'schedule_completed':
|
||||
// 标记该消息需要排程卡片
|
||||
// 详情通过 schedule_completed 事件触发的 getSchedulePreview 异步填充
|
||||
void (async () => {
|
||||
try {
|
||||
const preview = await getSchedulePreview(conversationId)
|
||||
scheduleResultMap[mid] = preview
|
||||
} catch {
|
||||
// 吞掉,可能是过期的预览
|
||||
}
|
||||
})()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊逻辑:如果最后一条是 confirm_request,则激活弹出层
|
||||
const lastEvent = events[events.length - 1]
|
||||
if (lastEvent?.kind === 'confirm_request' && lastEvent.payload?.confirm) {
|
||||
applyConfirmOverlay(lastEvent.payload.confirm)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadConversationMessages(conversationId: string, forceReload = false) {
|
||||
if (!conversationId) {
|
||||
return
|
||||
@@ -1592,10 +1634,11 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await getConversationHistory(conversationId)
|
||||
conversationMessagesMap[conversationId] = mergeServerHistoryWithLocalState(conversationId, history)
|
||||
const events = await getConversationTimeline(conversationId)
|
||||
conversationMessagesMap[conversationId] = rebuildStateFromTimeline(conversationId, events)
|
||||
unavailableHistoryMap[conversationId] = false
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Failed to load timeline:', error)
|
||||
unavailableHistoryMap[conversationId] = true
|
||||
ensureConversationBucket(conversationId)
|
||||
}
|
||||
@@ -1757,11 +1800,13 @@ async function sendConfirmAction(action: 'approve' | 'reject' | 'cancel') {
|
||||
const interactionId = confirmOverlayState.interactionId
|
||||
if (!interactionId) return
|
||||
|
||||
// 1. 立即关闭覆盖层,避免用户重复点击。
|
||||
// 2. 构造 resume 特殊载荷,复用 sendMessageInternal 发送到聊天接口。
|
||||
// 1. 立即关闭覆盖层,并标记为“已手动处理”。
|
||||
// 这样在同一轮流式响应中,若后端重复推送相同的 interactionId,也不会再误拉起层。
|
||||
confirmOverlayState.visible = false
|
||||
confirmOverlayState.manuallyClosed = true
|
||||
const actionText = action === 'approve' ? '确认执行' : (action === 'reject' ? '拒绝执行' : '取消操作')
|
||||
await sendMessageInternal({
|
||||
preset: '',
|
||||
preset: actionText,
|
||||
bypassConfirmOverlayCheck: true,
|
||||
requestExtra: {
|
||||
resume: {
|
||||
@@ -1781,6 +1826,7 @@ async function submitConfirmRejectMessage() {
|
||||
if (!interactionId) return
|
||||
|
||||
confirmOverlayState.visible = false
|
||||
confirmOverlayState.manuallyClosed = true
|
||||
await sendMessageInternal({
|
||||
preset: text,
|
||||
bypassConfirmOverlayCheck: true,
|
||||
@@ -1829,14 +1875,19 @@ function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
|
||||
function buildChatRequestExtra(
|
||||
planningTaskClassIds: number[] = [],
|
||||
): ChatRequestExtra | undefined {
|
||||
// retry 机制已整体下线,这里只负责把智能编排所需的 task_class_ids 透传给后端。
|
||||
if (planningTaskClassIds.length <= 0) {
|
||||
return undefined
|
||||
const extra: ChatRequestExtra = {}
|
||||
|
||||
// 1. 任务类别过滤:将智能编排所需的 task_class_ids 透传给后端。
|
||||
if (planningTaskClassIds.length > 0) {
|
||||
extra.task_class_ids = [...planningTaskClassIds]
|
||||
}
|
||||
|
||||
return {
|
||||
task_class_ids: [...planningTaskClassIds],
|
||||
// 2. 执行模式控制:若开启“自动执行”,则透传 always_execute 标志,跳过工具调用确认逻辑。
|
||||
if (selectedExecutionMode.value === 'always') {
|
||||
extra.always_execute = true
|
||||
}
|
||||
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
function handlePlanningSelectionApplied(taskClassIds: number[]) {
|
||||
@@ -2185,7 +2236,8 @@ interface SendMessageOptions {
|
||||
// 3. 失败时保留用户已发文本,只补齐占位消息兜底文案,确保交互可感知。
|
||||
async function sendMessageInternal(options: SendMessageOptions = {}) {
|
||||
const text = (options.preset ?? messageInput.value).trim()
|
||||
if (!text || chatLoading.value) {
|
||||
const isResume = Boolean(options.requestExtra?.resume)
|
||||
if ((!text && !isResume) || chatLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2410,7 +2462,7 @@ onBeforeUnmount(() => {
|
||||
@click="selectConversation(item.conversation_id)"
|
||||
>
|
||||
<span class="assistant-history__item-title">
|
||||
{{ item.has_title && item.title ? item.title : '未命名会话' }}
|
||||
{{ item.title || '未命名会话' }}
|
||||
</span>
|
||||
<small v-if="historyExpanded" class="assistant-history__item-time">
|
||||
{{ formatConversationTime(item.last_message_at || item.created_at) }}
|
||||
@@ -2654,7 +2706,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<div v-if="dm.content" class="chat-message__action-bar">
|
||||
<div v-if="dm.content && !isDisplayStreaming(dm)" class="chat-message__action-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="chat-message__icon-button"
|
||||
@@ -2789,6 +2841,21 @@ onBeforeUnmount(() => {
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="assistant-toolbar__pill assistant-toolbar__pill--select assistant-toolbar__pill--execution-mode">
|
||||
<span class="assistant-toolbar__select-label">模式</span>
|
||||
<el-select
|
||||
v-model="selectedExecutionMode"
|
||||
class="assistant-toolbar__select-box assistant-toolbar__select-box--execution"
|
||||
size="small"
|
||||
popper-class="assistant-thinking-select-panel"
|
||||
placement="top-start"
|
||||
:teleported="true"
|
||||
>
|
||||
<el-option value="manual" label="手动确认" />
|
||||
<el-option value="always" label="自动执行" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
|
||||
<ContextWindowMeter
|
||||
class="assistant-toolbar__context-meter"
|
||||
@@ -2856,7 +2923,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 日程排程方案精排弹窗 -->
|
||||
<ScheduleFineTuneModal
|
||||
v-if="isFineTuneModalVisible && activeFineTuneData"
|
||||
:visible="isFineTuneModalVisible"
|
||||
:preview-data="activeFineTuneData"
|
||||
@close="closeFineTuneModal"
|
||||
@saved="handleScheduleSaved"
|
||||
@@ -3433,7 +3500,8 @@ onBeforeUnmount(() => {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.assistant-toolbar__pill--ds-thinking {
|
||||
.assistant-toolbar__pill--ds-thinking,
|
||||
.assistant-toolbar__pill--execution-mode {
|
||||
height: 32px;
|
||||
padding: 0 4px 0 10px;
|
||||
background: #f1f5f9;
|
||||
@@ -3446,7 +3514,8 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.assistant-toolbar__pill--ds-thinking:hover {
|
||||
.assistant-toolbar__pill--ds-thinking:hover,
|
||||
.assistant-toolbar__pill--execution-mode:hover {
|
||||
background: #eef2f6;
|
||||
}
|
||||
|
||||
@@ -3456,8 +3525,12 @@ onBeforeUnmount(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box {
|
||||
width: 64px;
|
||||
.assistant-toolbar__select-box--thinking {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box--execution {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
||||
@@ -4039,8 +4112,12 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box {
|
||||
width: 68px;
|
||||
.assistant-toolbar__select-box--thinking {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box--execution {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
||||
|
||||
@@ -260,13 +260,19 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createTaskDialogVisible" title="添加任务" width="460px" align-center class="dashboard-dialog">
|
||||
<el-dialog
|
||||
v-model="createTaskDialogVisible"
|
||||
title="添加新任务"
|
||||
width="440px"
|
||||
align-center
|
||||
class="dashboard-dialog premium-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-select v-model="taskForm.priority_group" class="dashboard-dialog__select" popper-class="premium-select-popper" placement="bottom-start">
|
||||
<el-option :value="1" label="1 - 重要且紧急" />
|
||||
<el-option :value="2" label="2 - 重要不紧急" />
|
||||
<el-option :value="3" label="3 - 简单不重要" />
|
||||
@@ -274,12 +280,22 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
||||
</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-date-picker
|
||||
v-model="taskForm.deadline_at"
|
||||
type="datetime"
|
||||
placeholder="可选"
|
||||
class="dashboard-dialog__select"
|
||||
popper-class="premium-select-popper"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createTaskDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createTaskLoading" @click="handleCreateTask">保存任务</el-button>
|
||||
<div class="premium-dialog__footer">
|
||||
<button class="premium-btn premium-btn--ghost" @click="createTaskDialogVisible = false">取消</button>
|
||||
<button class="premium-btn premium-btn--primary" :disabled="createTaskLoading" @click="handleCreateTask">
|
||||
{{ createTaskLoading ? '保存中...' : '确认添加' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -365,4 +381,196 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
||||
.dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; }
|
||||
.dashboard-import__shape-ring { position: absolute; inset: 0; border: 40px solid #3b82f6; border-radius: 50%; }
|
||||
.dashboard-import__shape-core { position: absolute; inset: 80px; background: #3b82f6; border-radius: 50%; }
|
||||
|
||||
/* --- Premium Dialog Styles --- */
|
||||
:global(.premium-dialog) {
|
||||
border-radius: 20px !important;
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-dialog__header) {
|
||||
padding: 24px 28px 12px !important;
|
||||
margin-right: 0 !important;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-dialog__title) {
|
||||
font-size: 18px !important;
|
||||
font-weight: 800 !important;
|
||||
color: #0f172a !important;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-dialog__body) {
|
||||
padding: 12px 28px 20px !important;
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-dialog__footer) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.premium-dialog__footer {
|
||||
padding: 16px 28px 24px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.premium-btn {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.premium-btn--primary {
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.premium-btn--primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.premium-btn--ghost {
|
||||
background: #ffffff;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.premium-btn--ghost:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* 弹出动画覆写 */
|
||||
:global(.dialog-fade-enter-active .premium-dialog) {
|
||||
animation: premium-dialog-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes premium-dialog-pop {
|
||||
0% { opacity: 0; transform: scale(0.92) translateY(20px); }
|
||||
60% { opacity: 1; transform: scale(1.02) translateY(-2px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
:global(.el-overlay) {
|
||||
backdrop-filter: blur(6px);
|
||||
background: rgba(15, 23, 42, 0.35) !important;
|
||||
}
|
||||
|
||||
/* 表单美化 */
|
||||
:global(.premium-dialog .el-form-item__label) {
|
||||
font-weight: 700 !important;
|
||||
color: #475569 !important;
|
||||
font-size: 13px !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-input__wrapper),
|
||||
:global(.premium-dialog .el-select__wrapper) {
|
||||
background-color: #f8fafc !important;
|
||||
box-shadow: 0 0 0 1px #e2e8f0 inset !important;
|
||||
border-radius: 10px !important;
|
||||
padding: 4px 12px !important;
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-input__wrapper.is-focus),
|
||||
:global(.premium-dialog .el-select__wrapper.is-focused) {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) inset !important;
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
:global(.premium-dialog .el-dialog__headerbtn) {
|
||||
top: 20px !important;
|
||||
right: 20px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #f1f5f9 !important;
|
||||
transition: all 0.2s !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-dialog__headerbtn:hover) {
|
||||
background: #e2e8f0 !important;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global(.premium-dialog .el-dialog__headerbtn .el-dialog__close) {
|
||||
color: #64748b !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 800 !important;
|
||||
}
|
||||
|
||||
/* 统一输入框高度与背景 */
|
||||
:global(.premium-dialog .el-input__inner),
|
||||
:global(.premium-dialog .el-select .el-input__inner) {
|
||||
height: 38px !important;
|
||||
color: #0f172a !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* --- 下拉菜单扁平化 --- */
|
||||
:global(.premium-select-popper) {
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||
box-shadow: 0 12px 30px -5px rgba(0, 0, 0, 0.12) !important;
|
||||
background: #ffffff !important;
|
||||
overflow: hidden !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
:global(.premium-select-popper .el-select-dropdown__list) {
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
:global(.premium-select-popper .el-select-dropdown__item) {
|
||||
border-radius: 10px !important;
|
||||
height: 38px !important;
|
||||
line-height: 38px !important;
|
||||
margin-bottom: 2px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #475569 !important;
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
:global(.premium-select-popper .el-select-dropdown__item.is-selected) {
|
||||
background: #eff6ff !important;
|
||||
color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
:global(.premium-select-popper .el-select-dropdown__item:hover) {
|
||||
background: #f1f5f9 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
:global(.premium-select-popper .el-popper__arrow) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 时间选择器特定深度覆盖 */
|
||||
:global(.premium-select-popper.el-picker-popper) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:global(.premium-select-popper .el-picker-panel) {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user