Files
DevFlow/src/components/ai/TaskParkedCard.vue
T
lxy 3818a63522 新增: 待发箱抽屉与对话体验前端批
- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
2026-10-11 07:38:00 +08:00

177 lines
7.2 KiB
Vue

<template>
<!-- 无人值守安全停靠卡(设计 §四):后端非正常终局(六路,stop_kind 词表见 i18n)emit AiTaskParked(已保存现场并复位
generating,非死等),useAiEvents/useAiLifecycleEvents 登记 pendingTaskParked(per-conv Map)
后本卡渲染。复用 HelpRequiredCard 模式:per-conv 守卫(挂起会话===当前展示会话)+ 行为后摘卡。
卡面话术按 U5 异常态公式组织:状态(title+stop_kind 标签)+真实原因(reason)+已保留什么
(preserved 常驻行,前端兜底后端 reason 措辞不齐)+出路(继续/停止归档两枚;cancelled 只显
停止归档,第二条出路由 reason 文案"重发消息可继续"承担)。
操作两枚:继续(ai_continue_loop 重新授权续跑)/
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 -->
<div v-if="parkedActive" class="ai-task-parked">
<div class="ai-task-parked-head">
<span class="ai-task-parked-tag" :class="kindClass">{{ kindLabel }}</span>
<span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span>
</div>
<span class="ai-task-parked-reason">{{ parkedActive.reason }}</span>
<!-- U5 公式「已保留什么」:常驻一行而非依赖后端 reason 措辞,保证任意 stop_kind 公式完整 -->
<span class="ai-task-parked-preserved">{{ t('ai.taskParked.preserved') }}</span>
<div class="ai-task-parked-actions">
<!-- cancelled(审批挂起中被用户主动取消)不显「继续」:用户刚点过停止,首位按钮再诱导续跑与意图相逆 -->
<button v-if="parkedActive.stopKind !== 'cancelled'" class="ai-task-parked-btn" :disabled="busy" @click="handleContinue">{{ t('aiChat.continueLoop') }}</button>
<button class="ai-task-parked-btn ai-task-parked-btn--muted" :disabled="busy" @click="handleStopArchive">{{ t('ai.taskParked.stopArchive') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api'
import { pendingTaskParked } from '../../composables/ai/useAiPendingState'
// toast 经 emit 转父(保持单一 toast 源,与 HelpRequiredCard 一致)
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 当前展示会话的停靠挂起(无则不渲染)。后端停靠已复位 generating,与 HelpRequiredCard 同理
// 不依赖 streaming 判断显隐。Map 键控 per-conv,多会话各自停靠互不覆盖。
const parkedActive = computed(() => {
const id = store.state.activeConversationId
if (!id) return null
return pendingTaskParked.get(id) ?? null
})
// stop_kind 词表(后端契约钉死):命中词表取对应文案与色级,未知值中性兜底(向后兼容后端新增变体)
const KNOWN_KINDS = new Set(['approval_timeout', 'cancelled', 'circuit_breaker', 'stall_breaker', 'max_rounds', 'fatal', 'midstream'])
const kindLabel = computed(() => {
const k = parkedActive.value?.stopKind
if (k && KNOWN_KINDS.has(k)) return t(`ai.taskParked.stopKind.${k}`)
return t('ai.taskParked.unknownKind')
})
// fatal 走 danger 红色(连续失败重入预算耗尽,警示级最高;语义上仍有「继续」路),其余 warning 琥珀
const kindClass = computed(() => (parkedActive.value?.stopKind === 'fatal' ? 'ai-task-parked-tag--fatal' : ''))
// A5(2026-10-05):删 scrollIntoView——卡挂输入区上方(.ai-input-area 内,常驻可见),
// 非消息流;scrollIntoView 找不到有意义的滚动容器≈no-op,且原注释「消息流已长时让按钮
// 可见」与实际挂载点不符。卡片显示态本身由 parkedActive watch 驱动,无需额外滚动。
// 操作进行中防重复点击(继续/停止都是 IPC 往返,连点会重复触发续跑/停止)
const busy = ref(false)
/** 继续:ai_continue_loop 重新授权续跑(预算重计=用户再授权语义);成功即摘卡 */
async function handleContinue(): Promise<void> {
const convId = parkedActive.value?.conversationId
if (!convId || busy.value) return
busy.value = true
try {
await aiApi.continueLoop(convId)
pendingTaskParked.delete(convId)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.continueLoopFailed', { msg }), type: 'error' })
} finally {
busy.value = false
}
}
/** 停止归档:既有 ai_chat_stop 停止路径;停靠态 loop 已复位,此调用确保任何残留终局并落库,
* 成功后摘卡(用户不再续跑)。会话归档沿用侧栏既有入口,此处不代做避免不可逆误触 */
async function handleStopArchive(): Promise<void> {
const convId = parkedActive.value?.conversationId
if (!convId || busy.value) return
busy.value = true
try {
await aiApi.stopChat(convId)
pendingTaskParked.delete(convId)
emit('toast', { msg: t('ai.taskParked.stoppedToast'), type: 'info' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskParked.archiveFailed', { msg }), type: 'error' })
} finally {
busy.value = false
}
}
</script>
<style scoped>
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */
.ai-task-parked {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
border-radius: var(--df-radius);
}
.ai-task-parked-head {
display: flex;
align-items: center;
gap: 6px;
}
.ai-task-parked-tag {
flex-shrink: 0;
padding: 0 6px;
border-radius: calc(var(--df-radius) - 2px);
background: var(--df-warning);
color: #000;
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.ai-task-parked-tag--fatal {
background: var(--df-danger);
color: #fff;
}
.ai-task-parked-title {
font-size: 11.5px;
font-weight: 600;
color: var(--df-warning);
}
.ai-task-parked-reason {
font-size: 11px;
color: var(--df-text-dim);
opacity: 0.9;
word-break: break-word;
white-space: pre-wrap;
}
/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */
.ai-task-parked-preserved {
font-size: 10.5px;
color: var(--df-text-dim);
opacity: 0.8;
}
.ai-task-parked-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.ai-task-parked-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
background: var(--df-warning);
color: #000;
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
}
.ai-task-parked-btn:hover { filter: brightness(1.1); }
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-task-parked-btn--muted {
background: transparent;
color: var(--df-text-dim);
border-color: var(--df-border);
}
.ai-task-parked-btn--muted:hover { color: var(--df-text); }
</style>