- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
177 lines
7.2 KiB
Vue
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>
|