Version: 0.9.61.dev.260501
后端:
1. 主动调度 graph + session bridge 收口——把 dry-run / select / preview / confirm / rerun 串成受限 graph,新增 active_schedule_sessions 缓存与聊天拦截,ready_preview 后释放回自由聊天
2. 会话与通知链路对齐——notification 统一绑定 conversation_id,action_url 指向 /assistant/{conversation_id},会话不存在改回 404 语义,避免 wrong param type 误导排障
3. estimated_sections 写入与主动调度消费链路补齐——任务创建、quick task 与随口记入口都透传估计节数,主动调度只消费落库值
前端:
4. AssistantPanel 最小适配主动调度预览与失败态——复用主动调度卡片/微调弹窗,补历史加载失败可见提示与跨账号会话拦截
文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
This commit is contained in:
@@ -25,6 +25,152 @@ export interface TimelineConfirmPayload {
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewTrigger {
|
||||
trigger_id: string
|
||||
trigger_type: string
|
||||
source: string
|
||||
target_type: string
|
||||
target_id: number
|
||||
requested_at: string
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewEntry {
|
||||
entry_id: string
|
||||
source_type: string
|
||||
source_id: number
|
||||
title: string
|
||||
start_at?: string
|
||||
end_at?: string
|
||||
week?: number
|
||||
day_of_week?: number
|
||||
section_from?: number
|
||||
section_to?: number
|
||||
status: string
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewVersion {
|
||||
title: string
|
||||
window_start?: string
|
||||
window_end?: string
|
||||
entries: ActiveSchedulePreviewEntry[]
|
||||
summary_lines: string[]
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewSlot {
|
||||
week: number
|
||||
day_of_week: number
|
||||
section: number
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewSlotSpan {
|
||||
start: ActiveSchedulePreviewSlot
|
||||
end: ActiveSchedulePreviewSlot
|
||||
duration_sections: number
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewChangeItem {
|
||||
change_id: string
|
||||
change_type: string
|
||||
target_type: string
|
||||
target_id: number
|
||||
from_slot?: ActiveSchedulePreviewSlot
|
||||
to_slot?: ActiveSchedulePreviewSlotSpan
|
||||
duration_sections: number
|
||||
affected_event_ids: number[]
|
||||
edited_allowed: boolean
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewCandidateTarget {
|
||||
target_type: string
|
||||
target_id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewCandidate {
|
||||
candidate_id: string
|
||||
candidate_type: string
|
||||
title: string
|
||||
summary: string
|
||||
target: ActiveSchedulePreviewCandidateTarget
|
||||
changes: ActiveSchedulePreviewChangeItem[]
|
||||
before_summary: string
|
||||
after_summary: string
|
||||
risk: string
|
||||
score: number
|
||||
validation: Record<string, any>
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface ActiveSchedulePreviewDetail {
|
||||
preview_id: string
|
||||
status: string
|
||||
apply_status: string
|
||||
expires_at: string
|
||||
generated_at: string
|
||||
expired: boolean
|
||||
trigger: ActiveSchedulePreviewTrigger
|
||||
explanation: string
|
||||
notification_summary: string
|
||||
selected_candidate: ActiveSchedulePreviewCandidate
|
||||
candidates: ActiveSchedulePreviewCandidate[]
|
||||
decision: Record<string, any>
|
||||
metrics: Record<string, any>
|
||||
issues: Array<Record<string, any>>
|
||||
context_summary: Record<string, any>
|
||||
before: ActiveSchedulePreviewVersion
|
||||
after: ActiveSchedulePreviewVersion
|
||||
changes: ActiveSchedulePreviewChangeItem[]
|
||||
risk: Record<string, any>
|
||||
base_version: string
|
||||
can_confirm: boolean
|
||||
can_ignore: boolean
|
||||
trace_id: string
|
||||
}
|
||||
|
||||
export interface ActiveScheduleConfirmChange {
|
||||
change_id?: string
|
||||
type: string
|
||||
target_type?: string
|
||||
target_id?: number
|
||||
task_id?: number
|
||||
event_id?: number
|
||||
week?: number
|
||||
day_of_week?: number
|
||||
section_from?: number
|
||||
section_to?: number
|
||||
duration_sections?: number
|
||||
makeup_for_event_id?: number
|
||||
source_event_id?: number
|
||||
slots?: ActiveSchedulePreviewSlot[]
|
||||
edited_allowed?: boolean
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ActiveScheduleConfirmRequest {
|
||||
candidate_id: string
|
||||
action: 'confirm'
|
||||
edited_changes?: ActiveScheduleConfirmChange[]
|
||||
idempotency_key: string
|
||||
}
|
||||
|
||||
export interface ActiveScheduleConfirmResult {
|
||||
preview_id: string
|
||||
apply_id: string
|
||||
apply_status: string
|
||||
candidate_id: string
|
||||
request_hash?: string
|
||||
request_body_hash?: string
|
||||
skipped_changes?: Array<{
|
||||
change_id?: string
|
||||
change_type: string
|
||||
reason: string
|
||||
}>
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface TaskQueryCardTaskItem {
|
||||
id: number
|
||||
title: string
|
||||
@@ -36,12 +182,12 @@ export interface TaskQueryCardTaskItem {
|
||||
|
||||
export interface TaskQueryCardFilter {
|
||||
key:
|
||||
| 'quadrant'
|
||||
| 'keyword'
|
||||
| 'deadline_after'
|
||||
| 'deadline_before'
|
||||
| 'include_completed'
|
||||
| 'sort'
|
||||
| 'quadrant'
|
||||
| 'keyword'
|
||||
| 'deadline_after'
|
||||
| 'deadline_before'
|
||||
| 'include_completed'
|
||||
| 'sort'
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
operator?: 'eq' | 'contains' | 'gte' | 'lt'
|
||||
@@ -68,7 +214,7 @@ export interface TaskRecordCardData {
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export type BusinessCardType = 'task_query' | 'task_record'
|
||||
export type BusinessCardType = 'task_query' | 'task_record' | 'active_schedule_preview'
|
||||
export type TaskRecordSource = 'quick_note' | 'create_task'
|
||||
|
||||
export interface TimelineThinkingSummaryPayload {
|
||||
@@ -86,27 +232,27 @@ export interface TimelineBusinessCardPayload {
|
||||
title?: string
|
||||
summary?: string
|
||||
source?: TaskRecordSource
|
||||
data: TaskQueryCardData | TaskRecordCardData
|
||||
data: TaskQueryCardData | TaskRecordCardData | ActiveSchedulePreviewDetail
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: number
|
||||
seq: number
|
||||
kind:
|
||||
| 'user_text'
|
||||
| 'assistant_text'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'confirm_request'
|
||||
| 'schedule_completed'
|
||||
| 'interrupt'
|
||||
| 'status'
|
||||
| 'business_card'
|
||||
| 'thinking_summary'
|
||||
| 'user_text'
|
||||
| 'assistant_text'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'confirm_request'
|
||||
| 'schedule_completed'
|
||||
| 'interrupt'
|
||||
| 'status'
|
||||
| 'business_card'
|
||||
| 'thinking_summary'
|
||||
role?: 'user' | 'assistant'
|
||||
content?: string
|
||||
payload?: {
|
||||
/** @deprecated 仅供 Debug 页 mock 路径与 legacy 后端兼容;生产页已切换至 thinking_summary 协议。 */
|
||||
/** @deprecated 仅供 Debug/mock 路径兼容;正式后端已经切到 thinking_summary 协议。 */
|
||||
reasoning_content?: string
|
||||
stage?: string
|
||||
block_id?: string
|
||||
@@ -171,18 +317,52 @@ export async function saveScheduleState(conversationId: string, items: PlacedIte
|
||||
export async function applyBatchIntoSchedule(
|
||||
taskClassId: number,
|
||||
items: PlacedItem[],
|
||||
idempotencyKey: string
|
||||
idempotencyKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.put<ApiResponse<void>>('/task-class/apply-batch-into-schedule', {
|
||||
task_class_id: taskClassId,
|
||||
items,
|
||||
}, {
|
||||
headers: {
|
||||
'X-Idempotency-Key': idempotencyKey
|
||||
}
|
||||
})
|
||||
await http.put<ApiResponse<void>>(
|
||||
'/task-class/apply-batch-into-schedule',
|
||||
{
|
||||
task_class_id: taskClassId,
|
||||
items,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-Idempotency-Key': idempotencyKey,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '保存方案失败'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主动调度预览详情。
|
||||
*/
|
||||
export async function getActiveSchedulePreview(previewId: string): Promise<ActiveSchedulePreviewDetail> {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<ActiveSchedulePreviewDetail>>(`/active-schedule/preview/${previewId}`)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '获取主动调度预览失败'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认并应用主动调度预览。
|
||||
*/
|
||||
export async function confirmActiveSchedulePreview(
|
||||
previewId: string,
|
||||
payload: ActiveScheduleConfirmRequest,
|
||||
): Promise<ActiveScheduleConfirmResult> {
|
||||
try {
|
||||
const response = await http.post<ApiResponse<ActiveScheduleConfirmResult>>(
|
||||
`/active-schedule/preview/${previewId}/confirm`,
|
||||
payload,
|
||||
)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '确认主动调度预览失败'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
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'
|
||||
import {
|
||||
saveScheduleState,
|
||||
applyBatchIntoSchedule,
|
||||
confirmActiveSchedulePreview,
|
||||
type ActiveScheduleConfirmChange,
|
||||
type ActiveSchedulePreviewDetail,
|
||||
} from '@/api/schedule_agent'
|
||||
|
||||
const props = defineProps<{
|
||||
previewData: SchedulePreviewData | null
|
||||
visible: boolean
|
||||
previewKind?: 'schedule' | 'active_schedule'
|
||||
activePreviewDetail?: ActiveSchedulePreviewDetail | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -91,6 +99,56 @@ function buildPlacedItems(): PlacedItem[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveItemSlot(item: HybridScheduleEntry) {
|
||||
return {
|
||||
week: item.week,
|
||||
day_of_week: item.day_of_week,
|
||||
section_from: item.section_from,
|
||||
section_to: item.section_to,
|
||||
duration_sections: item.section_to - item.section_from + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActiveChangeItem(change: ActiveSchedulePreviewDetail['changes'][number]) {
|
||||
if (change.target_type === 'task_pool' || change.change_type === 'add_task_pool_to_schedule') {
|
||||
return suggestedItems.value.find(item => item.task_item_id === change.target_id)
|
||||
}
|
||||
return suggestedItems.value.find(item => item.event_id === change.target_id)
|
||||
}
|
||||
|
||||
function buildActiveEditedChanges(): ActiveScheduleConfirmChange[] {
|
||||
if (!props.activePreviewDetail) {
|
||||
return []
|
||||
}
|
||||
|
||||
return props.activePreviewDetail.changes.map((change) => {
|
||||
const currentItem = resolveActiveChangeItem(change)
|
||||
const slot = currentItem ? resolveItemSlot(currentItem) : undefined
|
||||
const fallbackSlot = change.to_slot
|
||||
const week = slot?.week ?? fallbackSlot?.start.week ?? 1
|
||||
const dayOfWeek = slot?.day_of_week ?? fallbackSlot?.start.day_of_week ?? 1
|
||||
const sectionFrom = slot?.section_from ?? fallbackSlot?.start.section ?? 1
|
||||
const sectionTo = slot?.section_to ?? fallbackSlot?.end.section ?? sectionFrom
|
||||
const durationSections = slot?.duration_sections ?? fallbackSlot?.duration_sections ?? Math.max(1, sectionTo - sectionFrom + 1)
|
||||
|
||||
return {
|
||||
change_id: change.change_id,
|
||||
type: change.change_type,
|
||||
target_type: change.target_type,
|
||||
target_id: change.target_id,
|
||||
task_id: change.target_type === 'task_pool' ? change.target_id : undefined,
|
||||
event_id: change.target_type === 'schedule_event' ? change.target_id : undefined,
|
||||
week,
|
||||
day_of_week: dayOfWeek,
|
||||
section_from: sectionFrom,
|
||||
section_to: sectionTo,
|
||||
duration_sections: durationSections,
|
||||
edited_allowed: change.edited_allowed,
|
||||
metadata: change.metadata,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存至 State (Redis)
|
||||
*/
|
||||
@@ -130,33 +188,50 @@ async function handleOfficialSave() {
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
// 按 task_class_id 分组
|
||||
const courseIndex = buildCoursePositionIndex(suggestedItems.value)
|
||||
const groups = new Map<number, PlacedItem[]>()
|
||||
suggestedItems.value.forEach(e => {
|
||||
if (e.type === 'task' && e.status === 'suggested' && e.task_class_id) {
|
||||
if (!groups.has(e.task_class_id)) groups.set(e.task_class_id, [])
|
||||
groups.get(e.task_class_id)!.push({
|
||||
task_item_id: e.task_item_id,
|
||||
week: e.week,
|
||||
day_of_week: e.day_of_week,
|
||||
start_section: e.section_from,
|
||||
end_section: e.section_to,
|
||||
embed_course_event_id: resolveEmbedCourseEventId(e, courseIndex),
|
||||
})
|
||||
if (props.previewKind === 'active_schedule') {
|
||||
const activeDetail = props.activePreviewDetail
|
||||
if (!activeDetail) {
|
||||
throw new Error('主动调度预览数据不完整')
|
||||
}
|
||||
})
|
||||
|
||||
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
|
||||
applyBatchIntoSchedule(classId, groupItems, `${officialSaveIdempotencyKey.value}-${classId}`)
|
||||
)
|
||||
const payload = {
|
||||
candidate_id: activeDetail.selected_candidate.candidate_id,
|
||||
action: 'confirm' as const,
|
||||
edited_changes: buildActiveEditedChanges(),
|
||||
idempotency_key: officialSaveIdempotencyKey.value,
|
||||
}
|
||||
|
||||
await confirmActiveSchedulePreview(activeDetail.preview_id, payload)
|
||||
ElMessage.success('主动调度已确认')
|
||||
} else {
|
||||
// 按 task_class_id 分组
|
||||
const courseIndex = buildCoursePositionIndex(suggestedItems.value)
|
||||
const groups = new Map<number, PlacedItem[]>()
|
||||
suggestedItems.value.forEach(e => {
|
||||
if (e.type === 'task' && e.status === 'suggested' && e.task_class_id) {
|
||||
if (!groups.has(e.task_class_id)) groups.set(e.task_class_id, [])
|
||||
groups.get(e.task_class_id)!.push({
|
||||
task_item_id: e.task_item_id,
|
||||
week: e.week,
|
||||
day_of_week: e.day_of_week,
|
||||
start_section: e.section_from,
|
||||
end_section: e.section_to,
|
||||
embed_course_event_id: resolveEmbedCourseEventId(e, courseIndex),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
|
||||
applyBatchIntoSchedule(classId, groupItems, `${officialSaveIdempotencyKey.value}-${classId}`),
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
ElMessage.success('日程已正式保存到数据库')
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
ElMessage.success('日程已正式保存到数据库')
|
||||
|
||||
// 保存成功后刷新幂等键,虽然通常弹窗会关闭,但这是为了逻辑严密
|
||||
officialSaveIdempotencyKey.value = crypto.randomUUID()
|
||||
|
||||
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -20,10 +20,13 @@ import {
|
||||
import {
|
||||
getSchedulePreview,
|
||||
getConversationTimeline,
|
||||
getActiveSchedulePreview,
|
||||
type TimelineEvent,
|
||||
type TimelineToolPayload,
|
||||
type TimelineConfirmPayload,
|
||||
type ToolView
|
||||
type ToolView,
|
||||
type ActiveSchedulePreviewDetail,
|
||||
type ActiveSchedulePreviewEntry,
|
||||
} from '@/api/schedule_agent'
|
||||
import { refreshToken } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -34,6 +37,7 @@ import type {
|
||||
ConversationContextStats,
|
||||
ConversationListItem,
|
||||
ConversationMeta,
|
||||
HybridScheduleEntry,
|
||||
ThinkingModeType,
|
||||
SchedulePreviewData,
|
||||
} from '@/types/dashboard'
|
||||
@@ -158,6 +162,7 @@ const conversationList = ref<ConversationListItem[]>([])
|
||||
const conversationMetaMap = reactive<Record<string, ConversationMeta>>({})
|
||||
const conversationMessagesMap = reactive<Record<string, AssistantMessage[]>>({})
|
||||
const unavailableHistoryMap = reactive<Record<string, boolean>>({})
|
||||
const conversationHistoryLoadErrorMap = reactive<Record<string, string>>({})
|
||||
const thinkingMessageMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningStartedAtMap = reactive<Record<string, number>>({})
|
||||
@@ -193,6 +198,8 @@ const THINKING_STREAM_FLUSH_THRESHOLD = 100
|
||||
const isFineTuneModalVisible = ref(false)
|
||||
const fineTuneLoading = ref(false)
|
||||
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
||||
const activeFineTuneKind = ref<'schedule' | 'active_schedule'>('schedule')
|
||||
const activeFineTuneActiveDetail = ref<ActiveSchedulePreviewDetail | null>(null)
|
||||
|
||||
// 任务状态叠加层,用于实时同步和交互
|
||||
interface TaskStatusState {
|
||||
@@ -289,27 +296,14 @@ async function hydrateTaskStatuses(conversationId: string) {
|
||||
messages.forEach(msg => {
|
||||
// 同时也扫描消息本身可能附带的 extra(用于 SSE 在线消息)
|
||||
if (msg.extra?.business_card) {
|
||||
const card = msg.extra.business_card
|
||||
if (card.card_type === 'task_query') {
|
||||
const data = card.data as any
|
||||
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
|
||||
} else if (card.card_type === 'task_record') {
|
||||
const data = card.data as any
|
||||
if (data.id) ids.add(data.id)
|
||||
}
|
||||
collectTaskIdsFromBusinessCard(msg.extra.business_card).forEach((id) => ids.add(id))
|
||||
}
|
||||
|
||||
// 扫描历史恢复的卡片事件
|
||||
const cardList = businessCardEventsMap[msg.id]
|
||||
if (cardList) {
|
||||
cardList.forEach(card => {
|
||||
if (card.card_type === 'task_query') {
|
||||
const data = card.data as any
|
||||
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
|
||||
} else if (card.card_type === 'task_record') {
|
||||
const data = card.data as any
|
||||
if (data.id) ids.add(data.id)
|
||||
}
|
||||
collectTaskIdsFromBusinessCard(card).forEach((id) => ids.add(id))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -542,16 +536,22 @@ const selectedConversationSubtitle = computed(() => {
|
||||
return `消息 ${messageCount} 条 · 最近更新 ${formatConversationTime(lastMessageAt)}`
|
||||
})
|
||||
|
||||
const shouldShowHistoryFallback = computed(() => {
|
||||
if (!selectedConversationId.value) {
|
||||
return false
|
||||
const selectedConversationHistoryNotice = computed(() => {
|
||||
const conversationId = selectedConversationId.value
|
||||
if (!conversationId || isDraftConversationId(conversationId)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
unavailableHistoryMap[selectedConversationId.value] === true &&
|
||||
rawSelectedMessages.value.length === 0 &&
|
||||
(selectedConversation.value?.message_count ?? 0) > 0
|
||||
)
|
||||
if (unavailableHistoryMap[conversationId] !== true || rawSelectedMessages.value.length > 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const errorText = (conversationHistoryLoadErrorMap[conversationId] || '').toLowerCase()
|
||||
if (errorText.includes('conversation not found')) {
|
||||
return '当前会话不存在或不属于当前账号,请切换到自己的会话。'
|
||||
}
|
||||
|
||||
return '当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。'
|
||||
})
|
||||
|
||||
const selectedConversationContextStats = computed(() => {
|
||||
@@ -1106,6 +1106,143 @@ function appendBusinessCardEvent(messageId: string, payload: TimelineBusinessCar
|
||||
assistantTimelineLastKindMap[messageId] = 'business_card'
|
||||
}
|
||||
|
||||
function isActiveSchedulePreviewCard(payload: TimelineBusinessCardPayload): payload is TimelineBusinessCardPayload & {
|
||||
card_type: 'active_schedule_preview'
|
||||
data: ActiveSchedulePreviewDetail
|
||||
} {
|
||||
return payload.card_type === 'active_schedule_preview'
|
||||
}
|
||||
|
||||
function isActiveSchedulePreviewDetail(value: unknown): value is ActiveSchedulePreviewDetail {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const detail = value as Partial<ActiveSchedulePreviewDetail>
|
||||
return typeof detail.preview_id === 'string' && detail.preview_id.trim().length > 0
|
||||
}
|
||||
|
||||
function needsActiveSchedulePreviewHydration(detail: ActiveSchedulePreviewDetail | null | undefined) {
|
||||
if (!detail) {
|
||||
return true
|
||||
}
|
||||
return !Array.isArray(detail.changes) || !Array.isArray(detail.before?.entries) || !Array.isArray(detail.after?.entries)
|
||||
}
|
||||
|
||||
function resolveActiveSchedulePreviewSummary(
|
||||
detail: ActiveSchedulePreviewDetail,
|
||||
payload?: Pick<TimelineBusinessCardPayload, 'title' | 'summary'>,
|
||||
) {
|
||||
const candidates = [
|
||||
payload?.summary,
|
||||
payload?.title,
|
||||
detail.notification_summary,
|
||||
detail.explanation,
|
||||
detail.selected_candidate?.summary,
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const text = `${candidate || ''}`.trim()
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return '已生成主动调度建议'
|
||||
}
|
||||
|
||||
function buildActiveScheduleHybridEntry(
|
||||
entry: ActiveSchedulePreviewEntry,
|
||||
status: 'existing' | 'suggested',
|
||||
): HybridScheduleEntry | null {
|
||||
// 1. 当前微调弹窗必须依赖周 / 星期 / 节次坐标渲染棋盘。
|
||||
// 2. 若后端暂未给出完整坐标,则先跳过该条,避免把无效块渲染到错误位置。
|
||||
// 3. 后续若后端补齐更多 entry 语义,再继续扩展这层映射,而不是在 UI 里兜底猜位置。
|
||||
if (!entry.week || !entry.day_of_week || !entry.section_from || !entry.section_to) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCourse = entry.source_type === 'course'
|
||||
return {
|
||||
week: entry.week,
|
||||
day_of_week: entry.day_of_week,
|
||||
section_from: entry.section_from,
|
||||
section_to: entry.section_to,
|
||||
name: entry.title || '未命名事项',
|
||||
type: isCourse ? 'course' : 'task',
|
||||
status,
|
||||
task_item_id: isCourse ? 0 : entry.source_id,
|
||||
task_class_id: 0,
|
||||
event_id: entry.source_id,
|
||||
can_be_embedded: isCourse ? entry.editable : false,
|
||||
block_for_suggested: isCourse,
|
||||
context_tag: entry.source_type,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSchedulePreviewFromActiveDetail(
|
||||
conversationId: string,
|
||||
detail: ActiveSchedulePreviewDetail,
|
||||
payload?: Pick<TimelineBusinessCardPayload, 'title' | 'summary'>,
|
||||
): SchedulePreviewData {
|
||||
// 1. 现有微调弹窗依赖 hybrid_entries,因此这里把主动调度 before/after 轻量翻译为旧预览结构。
|
||||
// 2. before.entries 作为“当前已存在棋盘”,after.entries 中 status=added 的条目作为“待确认建议块”。
|
||||
// 3. 这层只做前端展示适配,不擅自改写后端 preview 语义;真正的 confirm 仍走主动调度专用 API。
|
||||
const existingEntries = (detail.before?.entries || [])
|
||||
.map((entry) => buildActiveScheduleHybridEntry(entry, 'existing'))
|
||||
.filter((entry): entry is HybridScheduleEntry => Boolean(entry))
|
||||
const suggestedEntries = (detail.after?.entries || [])
|
||||
.filter((entry) => entry.status === 'added')
|
||||
.map((entry) => buildActiveScheduleHybridEntry(entry, 'suggested'))
|
||||
.filter((entry): entry is HybridScheduleEntry => Boolean(entry))
|
||||
|
||||
return {
|
||||
conversation_id: conversationId,
|
||||
trace_id: detail.trace_id || '',
|
||||
summary: resolveActiveSchedulePreviewSummary(detail, payload),
|
||||
candidate_plans: [],
|
||||
hybrid_entries: [...existingEntries, ...suggestedEntries],
|
||||
task_class_ids: [],
|
||||
generated_at: detail.generated_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function collectTaskIdsFromBusinessCard(payload: TimelineBusinessCardPayload): number[] {
|
||||
const ids = new Set<number>()
|
||||
|
||||
if (payload.card_type === 'task_query') {
|
||||
(payload.data as TaskQueryCardData).tasks?.forEach((task) => {
|
||||
if (task.id) {
|
||||
ids.add(task.id)
|
||||
}
|
||||
})
|
||||
return Array.from(ids)
|
||||
}
|
||||
|
||||
if (payload.card_type === 'task_record') {
|
||||
const id = (payload.data as TaskRecordCardData).id
|
||||
if (id) {
|
||||
ids.add(id)
|
||||
}
|
||||
return Array.from(ids)
|
||||
}
|
||||
|
||||
if (!isActiveSchedulePreviewDetail(payload.data)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const detail = payload.data
|
||||
detail.changes?.forEach((change) => {
|
||||
if ((change.target_type === 'task_pool' || change.change_type === 'add_task_pool_to_schedule') && change.target_id > 0) {
|
||||
ids.add(change.target_id)
|
||||
}
|
||||
})
|
||||
detail.after?.entries?.forEach((entry) => {
|
||||
if (entry.source_type === 'task_pool' && entry.source_id > 0) {
|
||||
ids.add(entry.source_id)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(ids)
|
||||
}
|
||||
|
||||
function isToolTraceExpanded(eventId: string) {
|
||||
return toolTraceExpandedMap[eventId] === true
|
||||
}
|
||||
@@ -1566,6 +1703,21 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
|
||||
const businessCards = businessCardEventsMap[source.id] || []
|
||||
for (const card of businessCards) {
|
||||
if (isActiveSchedulePreviewCard(card)) {
|
||||
const activeSchedulePreview = card.data
|
||||
blocks.push({
|
||||
id: `${source.id}:active-schedule-card:${(card as any)._seq}`,
|
||||
type: 'schedule_card',
|
||||
seq: (card as any)._seq,
|
||||
schedulePreview: buildSchedulePreviewFromActiveDetail(source.id, activeSchedulePreview, card),
|
||||
schedulePreviewKind: 'active_schedule',
|
||||
activeSchedulePreview,
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
id: `${source.id}:card:${(card as any)._seq}`,
|
||||
type: 'business_card',
|
||||
@@ -1582,6 +1734,7 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
type: 'schedule_card',
|
||||
seq: scheduleResultSeqMap[source.id] || 1000000,
|
||||
schedulePreview: scheduleResultMap[source.id],
|
||||
schedulePreviewKind: 'schedule',
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
@@ -2256,11 +2409,14 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
||||
const events = await getConversationTimeline(conversationId)
|
||||
conversationMessagesMap[conversationId] = rebuildStateFromTimeline(conversationId, events)
|
||||
unavailableHistoryMap[conversationId] = false
|
||||
delete conversationHistoryLoadErrorMap[conversationId]
|
||||
// 时间线恢复后,立即启动任务状态同步(Hydration)
|
||||
void hydrateTaskStatuses(conversationId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load timeline:', error)
|
||||
unavailableHistoryMap[conversationId] = true
|
||||
conversationHistoryLoadErrorMap[conversationId] =
|
||||
error instanceof Error ? error.message : '会话历史加载失败'
|
||||
ensureConversationBucket(conversationId)
|
||||
}
|
||||
}
|
||||
@@ -2417,17 +2573,28 @@ function startNewConversation() {
|
||||
suppressEmptyStateTransition.value = false
|
||||
}
|
||||
|
||||
async function openFineTuneModal(data: SchedulePreviewData) {
|
||||
// 1. 如果点击的是占位卡片(尚未加载详情),则触发实时拉取。
|
||||
async function openFineTuneModal(
|
||||
data: SchedulePreviewData,
|
||||
options: {
|
||||
previewKind?: 'schedule' | 'active_schedule'
|
||||
activePreviewDetail?: ActiveSchedulePreviewDetail | null
|
||||
} = {},
|
||||
) {
|
||||
const previewKind = options.previewKind || 'schedule'
|
||||
activeFineTuneKind.value = previewKind
|
||||
activeFineTuneActiveDetail.value = options.activePreviewDetail ?? null
|
||||
|
||||
// 1. 普通排程预览仍沿用原来的实时拉取逻辑。
|
||||
// 2. 主动调度预览优先复用时间线里携带的 detail;若只有 preview_id,则按需补拉。
|
||||
if ((data as any).is_placeholder) {
|
||||
if (fineTuneLoading.value) return
|
||||
|
||||
|
||||
fineTuneLoading.value = true
|
||||
try {
|
||||
const realData = await getSchedulePreview(selectedConversationId.value)
|
||||
// 成功后覆盖占位符,下次点击无需再查
|
||||
activeFineTuneData.value = realData
|
||||
|
||||
|
||||
// 这里的逻辑主要是为了同步界面上的 card 状态(如果是合并消息,对应的 id 为 dm.id)
|
||||
for (const mid of Object.keys(scheduleResultMap)) {
|
||||
if ((scheduleResultMap[mid] as any).is_placeholder) {
|
||||
@@ -2441,6 +2608,36 @@ async function openFineTuneModal(data: SchedulePreviewData) {
|
||||
} finally {
|
||||
fineTuneLoading.value = false
|
||||
}
|
||||
} else if (previewKind === 'active_schedule') {
|
||||
let activeDetail = activeFineTuneActiveDetail.value
|
||||
if (needsActiveSchedulePreviewHydration(activeDetail) && activeDetail?.preview_id) {
|
||||
if (fineTuneLoading.value) return
|
||||
|
||||
fineTuneLoading.value = true
|
||||
try {
|
||||
activeDetail = await getActiveSchedulePreview(activeDetail.preview_id)
|
||||
activeFineTuneActiveDetail.value = activeDetail
|
||||
} catch (error: any) {
|
||||
console.error('Load active schedule preview failed:', error)
|
||||
ElMessage.warning('主动调度预览正在生成中,请稍候再试...')
|
||||
return
|
||||
} finally {
|
||||
fineTuneLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeDetail) {
|
||||
ElMessage.warning('主动调度预览数据不完整')
|
||||
return
|
||||
}
|
||||
|
||||
activeFineTuneData.value = buildSchedulePreviewFromActiveDetail(
|
||||
selectedConversationId.value,
|
||||
activeDetail,
|
||||
{
|
||||
summary: data.summary,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
activeFineTuneData.value = data
|
||||
}
|
||||
@@ -2450,6 +2647,8 @@ async function openFineTuneModal(data: SchedulePreviewData) {
|
||||
|
||||
function closeFineTuneModal() {
|
||||
isFineTuneModalVisible.value = false
|
||||
activeFineTuneKind.value = 'schedule'
|
||||
activeFineTuneActiveDetail.value = null
|
||||
}
|
||||
|
||||
function handleScheduleSaved() {
|
||||
@@ -2781,16 +2980,7 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
||||
appendBusinessCardEvent(assistantMessage.id, extra.business_card)
|
||||
scheduleScrollMessagesToBottom(true)
|
||||
|
||||
// SSE 在线接收到新卡片时,也尝试同步一次其状态(主要针对立即生成的任务)
|
||||
const card = extra.business_card
|
||||
const ids: number[] = []
|
||||
if (card.card_type === 'task_query') {
|
||||
(card.data as TaskQueryCardData).tasks?.forEach(t => { if (t.id) ids.push(t.id) })
|
||||
} else if (card.card_type === 'task_record') {
|
||||
const id = (card.data as TaskRecordCardData).id
|
||||
if (id) ids.push(id)
|
||||
}
|
||||
|
||||
const ids = collectTaskIdsFromBusinessCard(extra.business_card)
|
||||
if (ids.length > 0) {
|
||||
ids.forEach(id => {
|
||||
if (!taskStatusMap[id]) {
|
||||
@@ -2993,6 +3183,15 @@ async function sendMessageInternal(options: SendMessageOptions = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentConversationId = selectedConversationId.value
|
||||
if (currentConversationId && !isDraftConversationId(currentConversationId)) {
|
||||
const historyErrorText = (conversationHistoryLoadErrorMap[currentConversationId] || '').toLowerCase()
|
||||
if (historyErrorText.includes('conversation not found')) {
|
||||
ElMessage.warning('当前会话不存在或不属于当前账号,请切换到自己的会话后再发送。')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 有 confirm 覆盖层且不是“覆盖层按钮触发”的发送时,阻止误发送。
|
||||
// 2. 覆盖层内确认/拒绝按钮会显式传入 bypass,允许继续发送 confirm_action。
|
||||
if (shouldShowDialogConfirmOverlay.value && !options.bypassConfirmOverlayCheck) {
|
||||
@@ -3305,8 +3504,8 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<transition name="chat-content-fade">
|
||||
<div :key="conversationTransitionKey" class="assistant-messages__inner">
|
||||
<div v-if="shouldShowHistoryFallback" class="assistant-chat__fallback">
|
||||
当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。
|
||||
<div v-if="selectedConversationHistoryNotice" class="assistant-chat__fallback">
|
||||
{{ selectedConversationHistoryNotice }}
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
@@ -3489,7 +3688,10 @@ onBeforeUnmount(() => {
|
||||
<template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
|
||||
<ScheduleResultCard
|
||||
:summary="block.schedulePreview.summary"
|
||||
@click="openFineTuneModal(block.schedulePreview)"
|
||||
@click="openFineTuneModal(block.schedulePreview, {
|
||||
previewKind: block.schedulePreviewKind,
|
||||
activePreviewDetail: block.activeSchedulePreview,
|
||||
})"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -3722,6 +3924,8 @@ onBeforeUnmount(() => {
|
||||
<ScheduleFineTuneModal
|
||||
:visible="isFineTuneModalVisible"
|
||||
:preview-data="activeFineTuneData"
|
||||
:preview-kind="activeFineTuneKind"
|
||||
:active-preview-detail="activeFineTuneActiveDetail"
|
||||
@close="closeFineTuneModal"
|
||||
@saved="handleScheduleSaved"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TimelineBusinessCardPayload, ToolView } from '@/api/schedule_agent'
|
||||
import type { ActiveSchedulePreviewDetail, TimelineBusinessCardPayload, ToolView } from '@/api/schedule_agent'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ConversationListItem,
|
||||
@@ -129,6 +129,8 @@ export interface DisplayAssistantBlock {
|
||||
event?: ToolTraceEvent
|
||||
statusEvent?: StatusTraceEvent
|
||||
schedulePreview?: SchedulePreviewData
|
||||
schedulePreviewKind?: 'schedule' | 'active_schedule'
|
||||
activeSchedulePreview?: ActiveSchedulePreviewDetail
|
||||
businessCard?: TimelineBusinessCardPayload
|
||||
/** 所属的源消息 ID,用于状态查询。 */
|
||||
sourceId?: string
|
||||
|
||||
Reference in New Issue
Block a user