Version: 0.9.29.dev.260418
后端:无 前端: 1. AssistantPanel 增加 confirm_request 覆盖层交互(流式确认卡片)并重构发送主链路 - 更新 frontend/src/components/dashboard/AssistantPanel.vue: - SSE payload 新增 extra.kind / extra.confirm 解析,识别 confirm_request 事件 - 新增 Confirm 覆盖层状态(visible / interaction_id / title / summary / manuallyClosed)与拒绝草稿输入 - 新增 sendMessageInternal 统一发送入口,普通发送与 confirm_action 发送共用同一链路;覆盖层打开时阻止普通发送,确认/拒绝按钮走 bypass 通道 - 新增 sendConfirmAction / submitConfirmRejectMessage / handleConfirmRejectInputEnter,支持“确认执行”与“拒绝并附带调整要求” - confirm 事件流分流:confirmOnlyStreamMap / confirmVisiblePrefixMap 控制正文抑制;纯 confirm 占位消息在流结束后从消息列表移除并清理关联状态,避免空白气泡残留 2. 新会话标题同步与会话列表局部动画增强(替代整表刷新) - 新增 ensureConversationMeta(forceReload/syncListItem) 与 syncConversationListItemFromMeta,仅回写当前会话列表项 - 新增 syncNewConversationTitleAfterFirstReply:首轮回复结束后按 0/420/980ms 短重试拉取标题,兼容后端异步标题生成延迟 - 新增会话列表标题 reveal 动画与定时器回收逻辑(conversationListItemRevealMap + timer map),提升新标题更新可见性 3. 输入区确认态 UI 与样式补齐 - 在 composer 区域新增确认卡片视图(标题、摘要、拒绝原因输入框、确认/拒绝/关闭按钮) - 新增 confirm-card 动画、历史项 reveal 动画、移动端适配样式;用户消息容器宽度对齐微调 仓库:无
This commit is contained in:
@@ -39,6 +39,17 @@ interface StreamErrorPayload {
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface StreamConfirmPayload {
|
||||
interaction_id?: string
|
||||
title?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
interface StreamExtraPayload {
|
||||
kind?: string
|
||||
confirm?: StreamConfirmPayload
|
||||
}
|
||||
|
||||
interface StreamEventPayload {
|
||||
choices?: StreamChoicePayload[]
|
||||
delta?: StreamDeltaPayload
|
||||
@@ -46,6 +57,7 @@ interface StreamEventPayload {
|
||||
reasoning_content?: string
|
||||
finish_reason?: string | null
|
||||
error?: StreamErrorPayload
|
||||
extra?: StreamExtraPayload
|
||||
}
|
||||
|
||||
|
||||
@@ -55,11 +67,29 @@ interface ConversationGroup {
|
||||
items: ConversationListItem[]
|
||||
}
|
||||
|
||||
interface ConfirmOverlayState {
|
||||
visible: boolean
|
||||
manuallyClosed: boolean
|
||||
interactionId: string
|
||||
title: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
interface ConversationListItemRevealOptions {
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
interface EnsureConversationMetaOptions {
|
||||
forceReload?: boolean
|
||||
syncListItem?: boolean
|
||||
listItemReveal?: ConversationListItemRevealOptions
|
||||
}
|
||||
|
||||
// 展示用消息:合并连续 assistant 消息后的视图模型
|
||||
interface DisplayMessage {
|
||||
/** 第一条源消息的 id,用作 Vue key */
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
/** 合并后的正文内容 */
|
||||
content: string
|
||||
/** 最后一条源消息的时间 */
|
||||
@@ -104,6 +134,14 @@ const streamAbortController = ref<AbortController | null>(null)
|
||||
const editingUserMessageId = ref('')
|
||||
const editingUserMessageDraft = ref('')
|
||||
const pendingPlanningTaskClassIds = ref<number[]>([])
|
||||
const confirmRejectDraft = ref('')
|
||||
const confirmOverlayState = reactive<ConfirmOverlayState>({
|
||||
visible: false,
|
||||
manuallyClosed: false,
|
||||
interactionId: '',
|
||||
title: '',
|
||||
summary: '',
|
||||
})
|
||||
|
||||
const conversationPage = ref(1)
|
||||
const conversationPageSize = 12
|
||||
@@ -118,9 +156,12 @@ const thinkingMessageMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningStartedAtMap = reactive<Record<string, number>>({})
|
||||
const reasoningDurationMap = reactive<Record<string, number>>({})
|
||||
const confirmOnlyStreamMap = reactive<Record<string, boolean>>({})
|
||||
const confirmVisiblePrefixMap = reactive<Record<string, boolean>>({})
|
||||
const conversationContextStatsMap = reactive<Record<string, ConversationContextStats | null>>({})
|
||||
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
|
||||
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
|
||||
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
|
||||
|
||||
const quickActions = [
|
||||
'帮我梳理今天最重要的三件事',
|
||||
@@ -136,12 +177,14 @@ let messageScrollRaf = 0
|
||||
let messageScrollReleaseRaf = 0
|
||||
let reasoningTicker = 0
|
||||
let historyResizeCleanup: (() => void) | null = null
|
||||
const conversationListItemRevealTimerMap = new Map<string, number>()
|
||||
const reasoningDisplayNow = ref(Date.now())
|
||||
const shouldAutoFollowMessages = ref(true)
|
||||
const messageBottomTolerancePx = 24
|
||||
const isProgrammaticMessageScroll = ref(false)
|
||||
|
||||
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
|
||||
const shouldShowDialogConfirmOverlay = computed(() => confirmOverlayState.visible)
|
||||
|
||||
const assistantBodyStyle = computed(() => {
|
||||
return {
|
||||
@@ -335,6 +378,37 @@ function appendConversationMessage(conversationId: string, message: AssistantMes
|
||||
return appended
|
||||
}
|
||||
|
||||
function removeConversationMessage(conversationId: string, messageId: string) {
|
||||
const bucket = conversationMessagesMap[conversationId]
|
||||
if (!bucket || bucket.length <= 0) {
|
||||
return
|
||||
}
|
||||
const targetIndex = bucket.findIndex((item) => item.id === messageId)
|
||||
if (targetIndex < 0) {
|
||||
return
|
||||
}
|
||||
bucket.splice(targetIndex, 1)
|
||||
}
|
||||
|
||||
function cleanupHiddenAssistantMessageState(messageId: string) {
|
||||
// 1. confirm 事件由卡片消费时,这条 assistant 占位消息会从正文列表移除。
|
||||
// 2. 这里同步清理与消息 ID 绑定的前端状态,避免残留状态影响后续新消息。
|
||||
// 3. 即使某个字段不存在也直接 delete,保证幂等,重复调用不会引入副作用。
|
||||
delete thinkingMessageMap[messageId]
|
||||
delete reasoningCollapsedMap[messageId]
|
||||
delete reasoningStartedAtMap[messageId]
|
||||
delete reasoningDurationMap[messageId]
|
||||
delete confirmOnlyStreamMap[messageId]
|
||||
delete confirmVisiblePrefixMap[messageId]
|
||||
}
|
||||
|
||||
function clearConfirmStreamFlags(messageId: string) {
|
||||
// 1. confirm 分流结束后,清理该条消息的分流标记,避免后续同 ID 复用时误判。
|
||||
// 2. 只清理 confirm 相关标记,不触碰正文展示状态,确保 confirm 前文本仍可见。
|
||||
delete confirmOnlyStreamMap[messageId]
|
||||
delete confirmVisiblePrefixMap[messageId]
|
||||
}
|
||||
|
||||
function createDraftConversationId() {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
@@ -450,6 +524,63 @@ function prependConversationPreview(conversationId: string, previewText: string,
|
||||
]
|
||||
}
|
||||
|
||||
function isConversationListItemRevealing(conversationId: string) {
|
||||
return conversationListItemRevealMap[conversationId] === true
|
||||
}
|
||||
|
||||
function triggerConversationListItemReveal(conversationId: string) {
|
||||
if (!conversationId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 相同会话在短时间内可能被连续更新,这里先清理旧定时器,避免动画状态错乱。
|
||||
// 2. 通过 nextTick 让 class 先完成一次“移除 -> 添加”,确保同一元素可重复触发动画。
|
||||
// 3. 动画结束后回收标记,防止无关渲染继续保留高亮状态。
|
||||
const previousTimer = conversationListItemRevealTimerMap.get(conversationId)
|
||||
if (typeof previousTimer === 'number') {
|
||||
window.clearTimeout(previousTimer)
|
||||
}
|
||||
|
||||
conversationListItemRevealMap[conversationId] = false
|
||||
void nextTick(() => {
|
||||
conversationListItemRevealMap[conversationId] = true
|
||||
const nextTimer = window.setTimeout(() => {
|
||||
delete conversationListItemRevealMap[conversationId]
|
||||
conversationListItemRevealTimerMap.delete(conversationId)
|
||||
}, 460)
|
||||
conversationListItemRevealTimerMap.set(conversationId, nextTimer)
|
||||
})
|
||||
}
|
||||
|
||||
function syncConversationListItemFromMeta(
|
||||
meta: ConversationMeta,
|
||||
options: ConversationListItemRevealOptions = {},
|
||||
) {
|
||||
const targetIndex = conversationList.value.findIndex((item) => item.conversation_id === meta.conversation_id)
|
||||
if (targetIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = conversationList.value[targetIndex]!
|
||||
const nextTitle = meta.has_title && meta.title ? meta.title : current.title
|
||||
const titleChanged = nextTitle !== current.title
|
||||
|
||||
const nextItem: ConversationListItem = {
|
||||
...current,
|
||||
title: nextTitle,
|
||||
has_title: meta.has_title,
|
||||
message_count: meta.message_count,
|
||||
last_message_at: meta.last_message_at ?? current.last_message_at,
|
||||
status: meta.status || current.status,
|
||||
}
|
||||
|
||||
conversationList.value.splice(targetIndex, 1, nextItem)
|
||||
|
||||
if (options.animate && titleChanged) {
|
||||
triggerConversationListItemReveal(meta.conversation_id)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHistoryMessage(message: ConversationHistoryMessage, index: number): AssistantMessage {
|
||||
const id = `${message.id ?? `${message.role}-${index}`}`
|
||||
const reasoningText = typeof message.reasoning_content === 'string' ? message.reasoning_content : ''
|
||||
@@ -1039,14 +1170,31 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConversationMeta(conversationId: string) {
|
||||
if (!conversationId || isDraftConversationId(conversationId) || conversationMetaMap[conversationId]) {
|
||||
async function ensureConversationMeta(
|
||||
conversationId: string,
|
||||
options: EnsureConversationMetaOptions = {},
|
||||
) {
|
||||
const { forceReload = false, syncListItem = false, listItemReveal } = options
|
||||
if (!conversationId || isDraftConversationId(conversationId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 默认复用本地 meta,避免每次发消息都触发重复请求。
|
||||
// 2. 新建会话需要“只更新当前一条标题”时,可通过 syncListItem 将 meta 回写到列表项。
|
||||
// 3. forceReload 仅用于需要强制拉取最新标题/计数的场景,避免改成全列表刷新。
|
||||
if (!forceReload && conversationMetaMap[conversationId]) {
|
||||
if (syncListItem) {
|
||||
syncConversationListItemFromMeta(conversationMetaMap[conversationId], listItemReveal)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = await getConversationMeta(conversationId)
|
||||
upsertConversationMeta(meta)
|
||||
if (syncListItem) {
|
||||
syncConversationListItemFromMeta(meta, listItemReveal)
|
||||
}
|
||||
} catch {
|
||||
// 1. 标题和条数属于增强信息,不应阻塞聊天主链路。
|
||||
// 2. 即使元信息失败,列表里的回退标题仍可保证界面可用。
|
||||
@@ -1054,6 +1202,42 @@ async function ensureConversationMeta(conversationId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// syncNewConversationTitleAfterFirstReply 负责在“新对话首条消息的 SSE 结束后”补齐标题。
|
||||
// 职责边界:
|
||||
// 1. 只处理首轮新会话标题补齐,不参与老会话每轮消息后的标题刷新策略。
|
||||
// 2. 先立即拉一次,再做少量延迟重试,兼容后端“标题异步生成稍晚到达”的场景。
|
||||
// 3. 每次都只回写当前会话对应的列表项,不触发整表刷新,避免界面出现“重置式加载”。
|
||||
async function syncNewConversationTitleAfterFirstReply(conversationId: string) {
|
||||
if (!conversationId || isDraftConversationId(conversationId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const retryDelaysMs = [0, 420, 980]
|
||||
for (const delayMs of retryDelaysMs) {
|
||||
// 1. 首次 0ms 立即请求,尽量在流结束后第一时间拿到真实标题。
|
||||
// 2. 后续延迟重试只做短时补偿,避免长时间轮询造成多余请求。
|
||||
// 3. 单次请求失败由 ensureConversationMeta 内部兜底吞掉,不阻塞聊天主流程。
|
||||
if (delayMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, delayMs)
|
||||
})
|
||||
}
|
||||
|
||||
await ensureConversationMeta(conversationId, {
|
||||
forceReload: true,
|
||||
syncListItem: true,
|
||||
listItemReveal: {
|
||||
animate: true,
|
||||
},
|
||||
})
|
||||
|
||||
const meta = conversationMetaMap[conversationId]
|
||||
if (meta?.has_title && meta.title) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversationContextStats(conversationId: string, forceReload = false) {
|
||||
// 1. draft 会话还没有稳定 chat_id,直接请求只会得到无意义的空结果,因此这里提前短路。
|
||||
// 2. 已经读过且本轮没有强制刷新时复用本地缓存,避免切换同一会话时重复打点接口。
|
||||
@@ -1080,6 +1264,7 @@ async function loadConversationContextStats(conversationId: string, forceReload
|
||||
|
||||
async function selectConversation(conversationId: string) {
|
||||
cancelEditUserMessage()
|
||||
resetConfirmOverlay()
|
||||
selectedConversationId.value = conversationId
|
||||
await Promise.allSettled([
|
||||
loadConversationMessages(conversationId),
|
||||
@@ -1091,6 +1276,7 @@ async function selectConversation(conversationId: string) {
|
||||
|
||||
function startNewConversation() {
|
||||
cancelEditUserMessage()
|
||||
resetConfirmOverlay()
|
||||
selectedConversationId.value = ''
|
||||
messageInput.value = ''
|
||||
activeStreamingMessageId.value = ''
|
||||
@@ -1101,6 +1287,51 @@ function isManualThinkingEnabled(mode: ThinkingModeType) {
|
||||
return mode === 'true'
|
||||
}
|
||||
|
||||
function resetConfirmOverlay() {
|
||||
// 1. 会话切换/新建会话时直接重置确认覆盖层,避免把上一个会话的确认状态误带到当前会话。
|
||||
// 2. interactionId 同时清空,确保下一次收到相同 ID 的事件也能被视为新事件并重新拉起卡片。
|
||||
confirmOverlayState.visible = false
|
||||
confirmOverlayState.manuallyClosed = false
|
||||
confirmOverlayState.interactionId = ''
|
||||
confirmOverlayState.title = ''
|
||||
confirmOverlayState.summary = ''
|
||||
confirmRejectDraft.value = ''
|
||||
}
|
||||
|
||||
function closeConfirmOverlay() {
|
||||
// 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。
|
||||
// 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。
|
||||
confirmOverlayState.visible = false
|
||||
confirmOverlayState.manuallyClosed = true
|
||||
confirmRejectDraft.value = ''
|
||||
}
|
||||
|
||||
function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
|
||||
if (!confirmPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextInteractionId = `${confirmPayload.interaction_id || ''}`.trim()
|
||||
const isNewInteraction = Boolean(nextInteractionId) && nextInteractionId !== confirmOverlayState.interactionId
|
||||
|
||||
// 1. 同一个 interaction 会被多次分片推送;若用户已经手动关闭,则不重复弹出。
|
||||
// 2. 只有拿到新的 interaction_id,才重置手动关闭标记并重新显示覆盖层。
|
||||
if (isNewInteraction) {
|
||||
confirmOverlayState.manuallyClosed = false
|
||||
confirmRejectDraft.value = ''
|
||||
}
|
||||
if (confirmOverlayState.manuallyClosed && !isNewInteraction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextInteractionId) {
|
||||
confirmOverlayState.interactionId = nextInteractionId
|
||||
}
|
||||
confirmOverlayState.title = `${confirmPayload.title || '操作确认'}`.trim() || '操作确认'
|
||||
confirmOverlayState.summary = `${confirmPayload.summary || ''}`.trim()
|
||||
confirmOverlayState.visible = true
|
||||
}
|
||||
|
||||
function buildChatRequestExtra(
|
||||
planningTaskClassIds: number[] = [],
|
||||
): ChatRequestExtra | undefined {
|
||||
@@ -1220,11 +1451,23 @@ 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)
|
||||
}
|
||||
|
||||
const shouldSuppressVisibleDelta = confirmOnlyStreamMap[assistantMessage.id] === true
|
||||
const choice = parsed.choices?.[0]
|
||||
const delta = choice?.delta ?? parsed.delta ?? parsed
|
||||
const finishReason = choice?.finish_reason ?? parsed.finish_reason ?? null
|
||||
|
||||
if (
|
||||
!shouldSuppressVisibleDelta &&
|
||||
typeof delta?.reasoning_content === 'string' &&
|
||||
delta.reasoning_content
|
||||
) {
|
||||
@@ -1237,7 +1480,7 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}`
|
||||
}
|
||||
|
||||
if (typeof delta?.content === 'string' && delta.content) {
|
||||
if (!shouldSuppressVisibleDelta && typeof delta?.content === 'string' && delta.content) {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
// 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。
|
||||
// 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。
|
||||
@@ -1255,7 +1498,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
reasoningCollapsedMap[assistantMessage.id] = true
|
||||
}
|
||||
|
||||
scheduleScrollMessagesToBottom(false)
|
||||
if (!shouldSuppressVisibleDelta) {
|
||||
scheduleScrollMessagesToBottom(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function streamAssistantReply(
|
||||
@@ -1263,7 +1508,7 @@ async function streamAssistantReply(
|
||||
text: string,
|
||||
assistantMessage: AssistantMessage,
|
||||
createdAt: string,
|
||||
refreshPreview: boolean,
|
||||
shouldSyncCurrentConversationMeta: boolean,
|
||||
requestExtra?: ChatRequestExtra,
|
||||
signal?: AbortSignal,
|
||||
) : Promise<string> {
|
||||
@@ -1280,7 +1525,7 @@ async function streamAssistantReply(
|
||||
|
||||
if (actualConversationId !== draftConversationId) {
|
||||
migrateConversationState(draftConversationId, actualConversationId)
|
||||
if (refreshPreview) {
|
||||
if (shouldSyncCurrentConversationMeta) {
|
||||
prependConversationPreview(actualConversationId, text, createdAt)
|
||||
}
|
||||
}
|
||||
@@ -1309,15 +1554,25 @@ async function streamAssistantReply(
|
||||
processSseBlock(buffer, assistantMessage)
|
||||
}
|
||||
|
||||
if (!assistantMessage.content.trim()) {
|
||||
const confirmConsumedByCard = confirmOnlyStreamMap[assistantMessage.id] === true
|
||||
const hasVisiblePrefixBeforeConfirm = confirmVisiblePrefixMap[assistantMessage.id] === true
|
||||
if (confirmConsumedByCard && !hasVisiblePrefixBeforeConfirm) {
|
||||
// 1. confirm_request 属于“卡片专属事件”,正文区域不应再显示这条 assistant 占位消息。
|
||||
// 2. 这里直接移除占位消息,避免出现空白气泡、时间戳残留或兜底文案误导。
|
||||
// 3. 同步清理消息状态映射,防止同一 ID 的历史状态污染下一轮发送。
|
||||
removeConversationMessage(actualConversationId, assistantMessage.id)
|
||||
cleanupHiddenAssistantMessageState(assistantMessage.id)
|
||||
} else if (confirmConsumedByCard) {
|
||||
// confirm 前已有正文/思考,保留前缀内容,仅清理 confirm 分流标记。
|
||||
clearConfirmStreamFlags(assistantMessage.id)
|
||||
} else if (!assistantMessage.content.trim()) {
|
||||
assistantMessage.content = assistantMessage.reasoning?.trim()
|
||||
? '已完成深度思考,但当前响应未返回正文内容。'
|
||||
: '暂未收到回复正文,请稍后重试。'
|
||||
}
|
||||
|
||||
if (refreshPreview) {
|
||||
await loadConversationListData(true)
|
||||
await ensureConversationMeta(actualConversationId)
|
||||
if (shouldSyncCurrentConversationMeta) {
|
||||
await syncNewConversationTitleAfterFirstReply(actualConversationId)
|
||||
}
|
||||
|
||||
return actualConversationId
|
||||
@@ -1335,16 +1590,39 @@ function stopStreaming() {
|
||||
// 1. 先创建用户消息和 assistant 占位消息,让发送动作立即反馈到界面,等待建连过程无感化。
|
||||
// 2. 若当前是新会话,则先使用 draft 会话承接本地状态,等响应头返回真实 conversation_id 后再整体迁移。
|
||||
// 3. 网络错误只中断当前这轮 assistant 占位,不回滚用户已发送的内容,避免“点了发送却像没发出去”。
|
||||
async function sendMessage(preset?: string) {
|
||||
const text = (preset ?? messageInput.value).trim()
|
||||
interface SendMessageOptions {
|
||||
preset?: string
|
||||
requestExtra?: ChatRequestExtra
|
||||
resetPlanningSelectionOnSuccess?: boolean
|
||||
bypassConfirmOverlayCheck?: boolean
|
||||
}
|
||||
|
||||
// sendMessageInternal 负责执行“本地先上屏,再异步接流”的统一发送链路。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 统一承接普通发送和 confirm_action 发送,避免两套发送逻辑分叉后状态不一致。
|
||||
// 2. 只负责前端本轮状态流转,不负责后端 pending 语义判定。
|
||||
// 3. 失败时保留用户已发文本,只补齐占位消息兜底文案,确保交互可感知。
|
||||
async function sendMessageInternal(options: SendMessageOptions = {}) {
|
||||
const text = (options.preset ?? messageInput.value).trim()
|
||||
if (!text || chatLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 有 confirm 覆盖层且不是“覆盖层按钮触发”的发送时,阻止误发送。
|
||||
// 2. 覆盖层内确认/拒绝按钮会显式传入 bypass,允许继续发送 confirm_action。
|
||||
if (shouldShowDialogConfirmOverlay.value && !options.bypassConfirmOverlayCheck) {
|
||||
ElMessage.warning('当前有待确认操作,请先处理确认卡片。')
|
||||
return
|
||||
}
|
||||
|
||||
chatLoading.value = true
|
||||
|
||||
const planningTaskClassIdsForRequest = [...pendingPlanningTaskClassIds.value]
|
||||
// 智能编排不再强制新开对话:直接沿用当前会话,在原地发送编排请求。
|
||||
const planningTaskClassIdsForRequest = options.requestExtra ? [] : [...pendingPlanningTaskClassIds.value]
|
||||
const requestExtra = options.requestExtra ?? buildChatRequestExtra(planningTaskClassIdsForRequest)
|
||||
const resetPlanningSelectionOnSuccess =
|
||||
options.resetPlanningSelectionOnSuccess ?? planningTaskClassIdsForRequest.length > 0
|
||||
const isNewConversationRound = !selectedConversationId.value
|
||||
const draftConversationId = selectedConversationId.value || createDraftConversationId()
|
||||
|
||||
if (!selectedConversationId.value) {
|
||||
@@ -1377,7 +1655,6 @@ async function sendMessage(preset?: string) {
|
||||
prependConversationPreview(draftConversationId, text, now)
|
||||
scheduleScrollMessagesToBottom(false, true)
|
||||
|
||||
// 1. 创建 AbortController:用户点击停止按钮时可通过 controller.abort() 中断 fetch 请求。
|
||||
const controller = new AbortController()
|
||||
streamAbortController.value = controller
|
||||
|
||||
@@ -1387,21 +1664,32 @@ async function sendMessage(preset?: string) {
|
||||
text,
|
||||
assistantMessage,
|
||||
now,
|
||||
true,
|
||||
buildChatRequestExtra(planningTaskClassIdsForRequest),
|
||||
isNewConversationRound,
|
||||
requestExtra,
|
||||
controller.signal,
|
||||
)
|
||||
if (planningTaskClassIdsForRequest.length > 0) {
|
||||
if (resetPlanningSelectionOnSuccess) {
|
||||
pendingPlanningTaskClassIds.value = []
|
||||
}
|
||||
// 流式成功后不重新加载历史:流式数据就是当前会话的权威来源,
|
||||
// 过早 reload 会因 persistVisibleMessage 尚未落库导致 merge 产生重复/丢失。
|
||||
// 历史数据在下次切换会话或刷新页面时自然加载。
|
||||
await Promise.allSettled([
|
||||
loadConversationContextStats(actualConversationId, true),
|
||||
])
|
||||
} catch (error) {
|
||||
// 用户主动中断:不弹出错误提示,只给占位消息补一段中断文案。
|
||||
if (confirmOnlyStreamMap[assistantMessage.id] === true) {
|
||||
// 1. confirm 事件流中断时,正文占位消息同样不应暴露给用户。
|
||||
// 2. 这里按“卡片优先”策略移除占位,并清理消息状态,避免出现半截文本。
|
||||
const hasVisiblePrefixBeforeConfirm = confirmVisiblePrefixMap[assistantMessage.id] === true
|
||||
if (!hasVisiblePrefixBeforeConfirm) {
|
||||
const fallbackConversationId = selectedConversationId.value || draftConversationId
|
||||
removeConversationMessage(fallbackConversationId, assistantMessage.id)
|
||||
cleanupHiddenAssistantMessageState(assistantMessage.id)
|
||||
} else {
|
||||
// confirm 前已有可见内容时,保留前缀文本并只清理分流标记。
|
||||
clearConfirmStreamFlags(assistantMessage.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
if (!assistantMessage.content.trim()) {
|
||||
assistantMessage.content = '本次回复已手动停止。'
|
||||
@@ -1420,6 +1708,51 @@ async function sendMessage(preset?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(preset?: string) {
|
||||
await sendMessageInternal({ preset })
|
||||
}
|
||||
|
||||
async function sendConfirmAction(action: 'accept' | 'reject', rejectMessage?: string) {
|
||||
// 1. confirm 是显式人工动作,先关闭覆盖层再发送,让对话框立即恢复可见。
|
||||
// 2. “拒绝”动作允许带上用户自定义要求,后端可据此进行重规划。
|
||||
// 3. 发送完成后清空输入草稿,避免旧要求残留到下一轮确认卡片。
|
||||
const normalizedRejectMessage = `${rejectMessage || ''}`.trim()
|
||||
const presetMessage =
|
||||
action === 'accept'
|
||||
? '我确认,继续执行。'
|
||||
: normalizedRejectMessage || '先不执行,请重新规划。'
|
||||
|
||||
closeConfirmOverlay()
|
||||
await sendMessageInternal({
|
||||
preset: presetMessage,
|
||||
requestExtra: { confirm_action: action },
|
||||
resetPlanningSelectionOnSuccess: false,
|
||||
bypassConfirmOverlayCheck: true,
|
||||
})
|
||||
confirmRejectDraft.value = ''
|
||||
}
|
||||
|
||||
async function submitConfirmRejectMessage() {
|
||||
const rejectMessage = confirmRejectDraft.value.trim()
|
||||
if (!rejectMessage) {
|
||||
ElMessage.warning('请输入你的调整要求后再发送。')
|
||||
return
|
||||
}
|
||||
|
||||
await sendConfirmAction('reject', rejectMessage)
|
||||
}
|
||||
|
||||
function handleConfirmRejectInputEnter(event: KeyboardEvent) {
|
||||
// 1. 中文输入法上屏期间按回车只用于选词,不应误触发发送。
|
||||
// 2. 仅在“非组合输入 + 单独 Enter”时提交,Shift+Enter 仍可换行。
|
||||
if (event.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void submitConfirmRejectMessage()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedMessages.value.length,
|
||||
() => {
|
||||
@@ -1449,6 +1782,10 @@ onBeforeUnmount(() => {
|
||||
window.clearInterval(reasoningTicker)
|
||||
reasoningTicker = 0
|
||||
}
|
||||
for (const timerId of conversationListItemRevealTimerMap.values()) {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
conversationListItemRevealTimerMap.clear()
|
||||
releaseHistoryResizeListeners()
|
||||
window.removeEventListener('resize', syncHistoryPanelWidthForViewport)
|
||||
})
|
||||
@@ -1527,7 +1864,10 @@ onBeforeUnmount(() => {
|
||||
:key="item.conversation_id"
|
||||
type="button"
|
||||
class="assistant-history__item"
|
||||
:class="{ 'assistant-history__item--active': item.conversation_id === selectedConversationId }"
|
||||
:class="{
|
||||
'assistant-history__item--active': item.conversation_id === selectedConversationId,
|
||||
'assistant-history__item--reveal': isConversationListItemRevealing(item.conversation_id),
|
||||
}"
|
||||
@click="selectConversation(item.conversation_id)"
|
||||
>
|
||||
<span class="assistant-history__item-title">
|
||||
@@ -1740,8 +2080,72 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="_9a2f8e4 assistant-composer-ds">
|
||||
<div class="aaff8b8f">
|
||||
<div class="_9a2f8e4 assistant-composer-ds" :class="{ 'assistant-composer-ds--confirm': shouldShowDialogConfirmOverlay }">
|
||||
<div
|
||||
v-if="shouldShowDialogConfirmOverlay"
|
||||
class="assistant-confirm-composer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="确认操作"
|
||||
>
|
||||
<article class="assistant-confirm-card">
|
||||
<header class="assistant-confirm-card__header">
|
||||
<p class="assistant-confirm-card__eyebrow">待确认操作</p>
|
||||
<h3 class="assistant-confirm-card__title">{{ confirmOverlayState.title || '操作确认' }}</h3>
|
||||
</header>
|
||||
|
||||
<p v-if="confirmOverlayState.summary" class="assistant-confirm-card__summary">
|
||||
{{ confirmOverlayState.summary }}
|
||||
</p>
|
||||
<p class="assistant-confirm-card__hint">
|
||||
该操作需要你的明确确认。点击“关闭卡片”会恢复输入框,不会自动确认。
|
||||
</p>
|
||||
|
||||
<div class="assistant-confirm-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="assistant-confirm-card__button assistant-confirm-card__button--primary"
|
||||
:disabled="chatLoading"
|
||||
@click="sendConfirmAction('accept')"
|
||||
>
|
||||
确认执行
|
||||
</button>
|
||||
|
||||
<div class="assistant-confirm-card__reject-box">
|
||||
<label class="assistant-confirm-card__reject-label" for="confirm-reject-input">
|
||||
拒绝并提出调整要求
|
||||
</label>
|
||||
<textarea
|
||||
id="confirm-reject-input"
|
||||
v-model="confirmRejectDraft"
|
||||
class="assistant-confirm-card__reject-input"
|
||||
rows="3"
|
||||
:disabled="chatLoading"
|
||||
placeholder="例如:先不要执行,把学习任务改到周末晚上,课程日只保留复习。按 Enter 发送,Shift+Enter 换行。"
|
||||
@keydown.enter.exact="handleConfirmRejectInputEnter"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="assistant-confirm-card__button assistant-confirm-card__button--ghost"
|
||||
:disabled="chatLoading || !confirmRejectDraft.trim()"
|
||||
@click="submitConfirmRejectMessage"
|
||||
>
|
||||
发送拒绝要求
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="assistant-confirm-card__button assistant-confirm-card__button--plain"
|
||||
@click="closeConfirmOverlay"
|
||||
>
|
||||
关闭卡片
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="aaff8b8f">
|
||||
<div class="_77cefa5 _9996a53">
|
||||
<div class="_020ab5b">
|
||||
<TaskClassPlanningPicker
|
||||
@@ -2105,6 +2509,25 @@ onBeforeUnmount(() => {
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant-history__item--reveal {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assistant-history__item--reveal::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(66, 112, 234, 0) 0%,
|
||||
rgba(66, 112, 234, 0.2) 42%,
|
||||
rgba(66, 112, 234, 0) 100%
|
||||
);
|
||||
transform: translateX(-118%);
|
||||
animation: history-item-reveal-sweep 420ms ease-out forwards;
|
||||
}
|
||||
|
||||
.assistant-history__item:hover {
|
||||
border-color: rgba(54, 96, 210, 0.18);
|
||||
background: rgba(237, 242, 255, 0.72);
|
||||
@@ -2121,6 +2544,10 @@ onBeforeUnmount(() => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.assistant-history__item--reveal .assistant-history__item-title {
|
||||
animation: history-item-title-fade-in 420ms ease-out;
|
||||
}
|
||||
|
||||
.assistant-history__item-time,
|
||||
.assistant-history__empty,
|
||||
.assistant-history__end {
|
||||
@@ -2279,6 +2706,157 @@ onBeforeUnmount(() => {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-composer-ds--confirm {
|
||||
border-top: 1px solid rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
.assistant-confirm-composer {
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.assistant-confirm-card {
|
||||
width: 100%;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(42, 72, 145, 0.22);
|
||||
background: linear-gradient(180deg, #ffffff, #f6f9ff);
|
||||
box-shadow: 0 14px 28px rgba(22, 37, 74, 0.16);
|
||||
padding: 24px 24px 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
animation: confirm-card-enter 220ms ease-out;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__header {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__eyebrow {
|
||||
margin: 0;
|
||||
color: #4a5f88;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__title {
|
||||
margin: 0;
|
||||
color: #17263d;
|
||||
font-size: 26px;
|
||||
line-height: 1.28;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__summary {
|
||||
margin: 0;
|
||||
color: #22304a;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__hint {
|
||||
margin: 0;
|
||||
color: #5f6f88;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__actions {
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button:disabled {
|
||||
opacity: 0.48;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--primary {
|
||||
background: linear-gradient(180deg, #2f62df, #234ec2);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 18px rgba(35, 78, 194, 0.28);
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--ghost {
|
||||
border-color: rgba(25, 48, 98, 0.22);
|
||||
background: #ffffff;
|
||||
color: #2a3c5f;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--ghost:hover {
|
||||
border-color: rgba(25, 48, 98, 0.34);
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-box {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-label {
|
||||
color: #405173;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-input {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(25, 48, 98, 0.2);
|
||||
background: #ffffff;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #1f2a3f;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-input:focus {
|
||||
border-color: rgba(47, 98, 223, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(47, 98, 223, 0.14);
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-input:disabled {
|
||||
background: #f5f7fb;
|
||||
color: #73819b;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--plain {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: #6a7791;
|
||||
width: auto;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--plain:hover {
|
||||
color: #3d4f74;
|
||||
background: rgba(35, 78, 194, 0.07);
|
||||
}
|
||||
|
||||
.assistant-messages {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -2341,6 +2919,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.chat-message__user-row {
|
||||
width: 100%;
|
||||
max-width: min(92%, 860px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 8px;
|
||||
@@ -2972,6 +3553,11 @@ onBeforeUnmount(() => {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
|
||||
@keyframes confirm-card-enter {
|
||||
0% { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
|
||||
40% { transform: translateY(-4px); opacity: 1; }
|
||||
@@ -2993,6 +3579,26 @@ onBeforeUnmount(() => {
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes history-item-title-fade-in {
|
||||
0% {
|
||||
opacity: 0.42;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes history-item-reveal-sweep {
|
||||
0% {
|
||||
transform: translateX(-118%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(118%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.assistant-body,
|
||||
.assistant-body--collapsed {
|
||||
@@ -3022,6 +3628,19 @@ onBeforeUnmount(() => {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.assistant-confirm-composer {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.assistant-confirm-card {
|
||||
border-radius: 18px;
|
||||
padding: 18px 16px 14px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ec4f5d61 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user