新增: 无人值守P0任务边界全链

- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
lxy
2026-10-04 16:27:30 +08:00
parent 8396849d04
commit b2d44f85e9
29 changed files with 2773 additions and 142 deletions
+170
View File
@@ -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>