新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩 - boundary_json 随会话落库(V45 可逆迁移) - 六路非正常终局统一安全停靠与托盘通知 - 每五轮进度事件与会话头部进度条 - 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<!-- 无人值守安全停靠卡(设计 §四):后端非正常终局(六路,stop_kind 词表见 i18n)emit AiTaskParked(已保存现场并复位
|
||||
generating,非死等),useAiEvents/useAiLifecycleEvents 登记 pendingTaskParked(per-conv Map)
|
||||
后本卡渲染。复用 HelpRequiredCard 模式:per-conv 守卫(挂起会话===当前展示会话)+ 行为后摘卡。
|
||||
卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/
|
||||
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
|
||||
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 -->
|
||||
<div v-if="parkedActive" ref="cardRef" 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>
|
||||
<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, watch, nextTick } 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' : ''))
|
||||
|
||||
// 卡片根 ref。停靠常发生在多轮迭代后消息流已长,卡显时 scrollIntoView 让操作按钮立即可见
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
watch(parkedActive, async (p) => {
|
||||
if (!p) return
|
||||
await nextTick()
|
||||
cardRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
|
||||
// 操作进行中防重复点击(继续/停止都是 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;
|
||||
}
|
||||
.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>
|
||||
Reference in New Issue
Block a user