新增: 无人值守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
+33
View File
@@ -71,6 +71,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
@@ -161,6 +165,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
@@ -215,6 +223,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
@@ -279,6 +291,9 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
import { convPendingApprovals } from '../../composables/ai/aiShared'
// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪;
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
import { getConvBoundary } from '../../composables/ai/useTaskBoundary'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast'
@@ -508,6 +523,11 @@ function pendingCountOf(convId: string): number {
return pendingCountByConv.value.get(convId) ?? 0
}
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
function hasBoundary(convId: string): boolean {
return getConvBoundary(convId) !== null
}
// ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed<Record<string, string>>(() => ({
today: t('aiChat.today'),
@@ -842,6 +862,19 @@ body.ai-sidebar-resizing * {
text-align: center;
cursor: help;
}
/* 无人值守边界徽标(盾形,accent 色系与「已授权」语义一致;纯标记不计数) */
.ai-conv-boundary-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
color: var(--df-accent);
cursor: help;
}
.ai-conv-item-actions {
display: none;
align-items: center;
+530
View File
@@ -0,0 +1,530 @@
<template>
<!-- 无人值守边界配置卡(设计 §2.2):发起时一次性授权,存续期生效,随会话同亡。
默认收起(现状零变化);展开后对当前活跃会话 ai_task_boundary_set 一次拍板。
折叠行在有边界时只读回显摘要(数据源镜像,拉取见下);任务 generating 中锁编辑
(中途改边界=边跑边扩权,设计明令禁止)。不引入对话框,最小折叠卡。 -->
<div class="ai-boundary">
<button type="button" class="ai-boundary-toggle" @click="toggleOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span class="ai-boundary-toggle-label">{{ t('ai.taskBoundary.toggle') }}</span>
<span v-if="current" class="ai-boundary-echo">{{ echoSummary }}</span>
</button>
<div v-if="open" class="ai-boundary-body">
<div v-if="locked" class="ai-boundary-locked">{{ t('ai.taskBoundary.lockedGenerating') }}</div>
<!-- 拉取失败 ≠ 无边界:失败态禁「一次授权」(防默认空表单覆盖原授权),保留表单现状供重试 -->
<div v-if="loadFailed" class="ai-boundary-loadfail">
<span>{{ t('ai.taskBoundary.loadFailed') }}</span>
<button type="button" class="ai-boundary-mini-btn" @click="retryHydrate">{{ t('ai.taskBoundary.retryLoad') }}</button>
</div>
<div class="ai-boundary-row">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.riskCeiling') }}</span>
<label class="ai-boundary-radio" :class="{ 'is-checked': risk === 'low' }">
<input v-model="risk" type="radio" value="low" :disabled="locked" />
<span>{{ t('ai.taskBoundary.riskLow') }}</span>
</label>
<label class="ai-boundary-radio" :class="{ 'is-checked': risk === 'medium' }">
<input v-model="risk" type="radio" value="medium" :disabled="locked" />
<span>{{ t('ai.taskBoundary.riskMedium') }}</span>
</label>
</div>
<div class="ai-boundary-row ai-boundary-row--col">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.dirs') }}</span>
<div v-if="dirs.length" class="ai-boundary-dirs">
<span v-for="d in dirs" :key="d" class="ai-boundary-dir">
<span class="ai-boundary-dir-path" :title="d">{{ d }}</span>
<button type="button" class="ai-boundary-dir-x" :disabled="locked" @click="removeDir(d)">×</button>
</span>
</div>
<span v-else class="ai-boundary-dim">{{ t('ai.taskBoundary.dirEmpty') }}</span>
<div class="ai-boundary-dir-add">
<input
v-model="dirInput"
class="ai-boundary-input"
type="text"
:placeholder="t('ai.taskBoundary.dirPlaceholder')"
:disabled="locked"
@keydown="onDirInputKeydown"
/>
<button type="button" class="ai-boundary-mini-btn" :disabled="locked || !dirInput.trim()" @click="addDir">{{ t('ai.taskBoundary.dirAdd') }}</button>
</div>
<!-- A2: 坏输入就近红字(非法字符/相对路径),不弹窗;存在性不阻断(白名单目录可尚不存在) -->
<span v-if="dirError" class="ai-boundary-dir-err">{{ dirError === 'illegal' ? t('ai.taskBoundary.dirErrIllegal') : t('ai.taskBoundary.dirErrRelative') }}</span>
</div>
<div class="ai-boundary-row">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.forbidDestructive') }}</span>
</div>
<button type="button" class="ai-boundary-budget-toggle" @click="budgetOpen = !budgetOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': budgetOpen }" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
{{ t('ai.taskBoundary.budgetToggle') }}
</button>
<div v-if="budgetOpen" class="ai-boundary-budget">
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetTokens') }}</span>
<input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<span class="ai-boundary-dim">{{ t('ai.taskBoundary.budgetHint') }}</span>
</div>
<div class="ai-boundary-actions">
<button type="button" class="ai-boundary-confirm" :disabled="locked || loadFailed" @click="confirmSet">{{ t('ai.taskBoundary.confirmSet') }}</button>
<!-- A3: 清除=收权(安全向),generating 中保持可用,与字段编辑锁分离;有 confirm 二次确认 -->
<button v-if="current" type="button" class="ai-boundary-clear" @click="clearBoundary">{{ t('ai.taskBoundary.clearBoundary') }}</button>
</div>
</div>
<!-- 清除边界二次确认(运行中收权=后续工具回到逐次审批,须用户拍板;useConfirm 每实例独立 ref) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="t('ai.taskBoundary.clearBoundary')" @result="answerConfirm" />
</div>
</template>
<script setup lang="ts">
// 无人值守边界配置卡:字段宁少勿预留(设计 §2.2)。目录白名单用简化交互(文本添加+删除),
// 不复刻设置页完整目录选择器;预算三项折叠,空=不限(不传 budget)。
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { fetchConvBoundary, getConvBoundary, saveConvBoundary, isConvBoundaryFetchFailed } from '../../composables/ai/useTaskBoundary'
import { useConfirm } from '../../composables/useConfirm'
import ConfirmDialog from '../ConfirmDialog.vue'
import type { TaskBoundary } from '../../api/types'
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' | 'success' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 清除边界二次确认(A3);useConfirm 每调用独立 ref,不与 AiChat 的实例串台
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 收起为默认态(未展开 = 现状逐次审批,零行为变化)
const open = ref(false)
const risk = ref<'low' | 'medium'>('low')
const dirs = ref<string[]>([])
const dirInput = ref('')
// A2: 目录输入词法校验结果(空串=无错;illegal=含非法字符;relative=非绝对路径)
const dirError = ref<'' | 'illegal' | 'relative'>('')
// A1: 边界拉取失败态(≠ 无边界;禁「一次授权」防默认空表单覆盖原授权)
const loadFailed = ref(false)
const budgetOpen = ref(false)
const rounds = ref('')
const tokens = ref('')
const wallSecs = ref('')
const activeId = computed(() => store.state.activeConversationId)
// 生成中锁编辑:防「边跑边扩权」。isGenerating 派生自 convStates reactive Map,computed 内可追踪
const locked = computed(() => store.isGenerating(activeId.value))
// 当前会话已授权边界(镜像;折叠行回显摘要数据源)
const current = computed(() => getConvBoundary(activeId.value))
function resetForm(): void {
risk.value = 'low'
dirs.value = []
dirInput.value = ''
dirError.value = ''
budgetOpen.value = false
rounds.value = ''
tokens.value = ''
wallSecs.value = ''
}
/** 从镜像回填表单(展开或切会话时调);镜像 miss 先拉一次再回填。
* A1: 拉取失败与「确认无边界」分流——失败态置 loadFailed 禁授权,表单保留现状不清空 */
async function hydrate(): Promise<void> {
const id = activeId.value
if (!id) {
resetForm()
loadFailed.value = false
return
}
await fetchConvBoundary(id)
if (activeId.value !== id) return
if (isConvBoundaryFetchFailed(id)) {
loadFailed.value = true
return
}
loadFailed.value = false
const b = getConvBoundary(id)
if (!b) {
resetForm()
return
}
// 单选只提供 low/medium(后端把 high 钳到 medium);万一存量 high 回显按 medium 呈现
risk.value = b.risk_ceiling === 'medium' || b.risk_ceiling === 'high' ? 'medium' : 'low'
dirs.value = [...b.dir_allowlist]
rounds.value = b.budget ? String(b.budget.max_rounds) : ''
tokens.value = b.budget ? String(b.budget.max_tokens) : ''
wallSecs.value = b.budget ? String(b.budget.wall_secs) : ''
budgetOpen.value = !!b.budget
}
/** 失败态重试(错误条按钮):重走拉取+回填 */
function retryHydrate(): void {
void hydrate()
}
function toggleOpen(): void {
open.value = !open.value
if (open.value) void hydrate()
}
// 切会话:预拉边界喂侧栏徽标镜像;展开态走 hydrate(内含拉取)重填表单,防上一会话表单串台
watch(activeId, (id) => {
if (!id) return
if (open.value) void hydrate()
else void fetchConvBoundary(id)
})
onMounted(() => {
if (activeId.value) void fetchConvBoundary(activeId.value)
})
/** 目录输入词法校验(A2,只做本地词法;存在性不阻断——白名单目录可尚不存在,后端
* 运行期 canonicalize 本就是 best-effort)。非法字符:Windows 保留集(冒号仅盘符位合法);
* 相对路径:须盘符/UNC/POSIX 根开头。返回空串=合法 */
function validateDirInput(d: string): '' | 'illegal' | 'relative' {
const body = /^[a-zA-Z]:/.test(d) ? d.slice(2) : d
if (/[<>:"|?*]/.test(body) || /[\u0000-\u001f]/.test(d)) return 'illegal'
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(d) || d.startsWith('\\\\') || d.startsWith('/')
if (!isAbsolute) return 'relative'
return ''
}
function addDir(): void {
const d = dirInput.value.trim()
if (!d) return
const err = validateDirInput(d)
if (err) {
dirError.value = err
return
}
dirError.value = ''
if (!dirs.value.includes(d)) dirs.value.push(d)
dirInput.value = ''
}
// 输入变化即清红字(改一个字就该有机会过下一轮校验,红字只描述当前输入的上一态)
watch(dirInput, () => {
if (dirError.value) dirError.value = ''
})
/** 目录输入回车即添加;IME 组词中的回车属选词非提交,放行给输入法 */
function onDirInputKeydown(e: KeyboardEvent): void {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
addDir()
}
}
function removeDir(d: string): void {
dirs.value = dirs.value.filter(x => x !== d)
}
/** 预算三项解析:全空=undefined(不限);任一非空但非正整数视为无效返回 null */
function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: number } | undefined | null {
const fields = [
{ v: rounds.value.trim(), min: 1 },
{ v: tokens.value.trim(), min: 1 },
{ v: wallSecs.value.trim(), min: 1 },
]
if (fields.every(f => !f.v)) return undefined
const nums: number[] = []
for (const f of fields) {
if (!f.v) {
nums.push(0)
continue
}
const n = Number(f.v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return null
nums.push(n)
}
return { max_rounds: nums[0], max_tokens: nums[1], wall_secs: nums[2] }
}
/** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行 */
async function confirmSet(): Promise<void> {
const id = activeId.value
if (!id || locked.value || loadFailed.value) return
const budget = parseBudget()
if (budget === null) {
emit('toast', { msg: t('ai.taskBoundary.budgetInvalid'), type: 'warning' })
return
}
const boundary: TaskBoundary = {
risk_ceiling: risk.value,
dir_allowlist: dirs.value.slice(),
forbid_destructive: true,
budget: budget ?? null,
}
try {
await saveConvBoundary(id, boundary)
open.value = false
emit('toast', { msg: t('ai.taskBoundary.setOk'), type: 'success' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
}
}
/** 清除边界:set null,回退逐次审批(接管转人工语义),可随时重新授权。
* A3: 收权是安全向,不受 generating 编辑锁限制(任务跑偏时可立即收回授权,唯一出路
* 不该是停掉整个任务);因运行中收权影响后续工具审批方式,加 confirm 二次确认 */
async function clearBoundary(): Promise<void> {
const id = activeId.value
if (!id) return
if (!await confirmDialog(t('ai.taskBoundary.clearConfirm'))) return
try {
await saveConvBoundary(id, null)
resetForm()
emit('toast', { msg: t('ai.taskBoundary.clearOk'), type: 'info' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
}
}
// 折叠行只读回显摘要:「已授权 · 风险上限 中 · 2 个目录 · 预算不限」
const echoSummary = computed(() => {
const b = current.value
if (!b) return ''
const riskWord = b.risk_ceiling === 'low'
? t('ai.taskBoundary.riskLow')
: b.risk_ceiling === 'medium'
? t('ai.taskBoundary.riskMedium')
: t('ai.agentTrace.riskHigh') // 存量 high 边界回显(单选已不产出 high,兜底转 i18n 不直出裸串)
const parts = [
t('ai.taskBoundary.echoRisk', { risk: riskWord }),
t('ai.taskBoundary.echoDirs', { n: b.dir_allowlist.length }),
b.budget
? t('ai.taskBoundary.echoBudget', { rounds: b.budget.max_rounds, tokens: b.budget.max_tokens, secs: b.budget.wall_secs })
: t('ai.taskBoundary.echoNoBudget'),
]
return parts.join(' · ')
})
</script>
<style scoped>
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */
.ai-boundary {
margin-bottom: 4px;
font-size: 11px;
color: var(--df-text-dim);
}
.ai-boundary-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 4px;
border: none;
background: none;
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
border-radius: var(--df-radius-sm, 4px);
max-width: 100%;
}
.ai-boundary-toggle:hover { color: var(--df-text); }
.ai-boundary-chevron {
transition: transform 0.15s var(--df-ease);
flex-shrink: 0;
}
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
.ai-boundary-toggle-label { font-weight: 600; }
.ai-boundary-echo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-accent);
opacity: 0.85;
}
.ai-boundary-body {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 7px;
padding: 8px;
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
border-radius: var(--df-radius);
}
.ai-boundary-locked {
font-size: 10.5px;
color: var(--df-warning);
}
.ai-boundary-loadfail {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
color: var(--df-danger);
}
.ai-boundary-dir-err {
font-size: 10.5px;
color: var(--df-danger);
}
.ai-boundary-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ai-boundary-row--col {
flex-direction: column;
align-items: stretch;
gap: 5px;
}
.ai-boundary-label {
font-weight: 600;
color: var(--df-text);
}
.ai-boundary-radio {
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
color: var(--df-text-dim);
}
.ai-boundary-radio.is-checked { color: var(--df-accent); }
.ai-boundary-radio input { accent-color: var(--df-accent); margin: 0; }
.ai-boundary-radio:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
.ai-boundary-dirs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ai-boundary-dir {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: calc(var(--df-radius) - 2px);
font-size: 10.5px;
color: var(--df-text);
max-width: 100%;
}
.ai-boundary-dir-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.ai-boundary-dir-x {
border: none;
background: none;
color: var(--df-text-dim);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0 2px;
}
.ai-boundary-dir-x:hover:not(:disabled) { color: var(--df-danger); }
.ai-boundary-dir-x:disabled { cursor: not-allowed; opacity: 0.5; }
.ai-boundary-dir-add {
display: flex;
gap: 4px;
}
.ai-boundary-input {
flex: 1;
min-width: 0;
font-size: 11px;
padding: 2px 6px;
background: var(--df-bg-card);
color: var(--df-text);
border: 0.5px solid var(--df-border);
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); }
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-input--num { flex: 0 0 90px; }
.ai-boundary-mini-btn {
border: 0.5px solid var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
flex-shrink: 0;
}
.ai-boundary-mini-btn:hover:not(:disabled) { color: var(--df-text); }
.ai-boundary-mini-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.ai-boundary-budget-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: none;
padding: 0;
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
align-self: flex-start;
}
.ai-boundary-budget-toggle:hover { color: var(--df-text); }
.ai-boundary-budget {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ai-boundary-field {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--df-text-dim);
white-space: nowrap;
}
.ai-boundary-dim {
font-size: 10px;
opacity: 0.7;
}
.ai-boundary-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ai-boundary-confirm {
border: none;
background: var(--df-accent);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
transition: background 0.15s var(--df-ease);
}
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); }
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-clear {
border: 0.5px solid var(--df-border);
background: transparent;
color: var(--df-text-dim);
font-size: 11px;
padding: 4px 10px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
}
.ai-boundary-clear:hover:not(:disabled) { color: var(--df-danger); border-color: var(--df-danger); }
.ai-boundary-clear:disabled { cursor: not-allowed; opacity: 0.5; }
</style>
+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>
+80 -1
View File
@@ -119,6 +119,20 @@
<div class="ai-bottom-tools" ref="goalsRef">
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
<div class="ai-bottom-tools-right">
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。 -->
<div v-if="taskProgress" class="ai-task-progress">
<span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span
v-if="taskProgress.maxRounds > 0"
class="ai-task-progress-bar"
:title="$t('ai.taskProgress.barHint', { round: taskProgress.round, max: taskProgress.maxRounds })"
>
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
</span>
</div>
<!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge ai-tool-badge" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
@@ -217,6 +231,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai'
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
defineProps<{
@@ -356,6 +371,28 @@ const currentConvTitle = computed(() => {
return conv?.title || ''
})
// ── 无人值守任务进度(设计 §4.3,AiTaskProgress per-conv 快照)──
// 消费 useAiEvents 归集的 pendingTaskProgress(per-conv Map,终局即清):无快照不渲染。
// 切会话自动跟随(Map 按 activeId 取),多会话并发各显各的,不串台。
const taskProgress = computed(() => {
const id = store.state.activeConversationId
if (!id) return null
return pendingTaskProgress.get(id) ?? null
})
const taskProgressText = computed(() => {
const p = taskProgress.value
if (!p) return ''
const tokens = p.tokensUsed >= 1000 ? `${(p.tokensUsed / 1000).toFixed(1)}k` : String(p.tokensUsed)
return p.maxRounds > 0
? t('ai.taskProgress.running', { round: p.round, max: p.maxRounds, tokens })
: t('ai.taskProgress.unlimited', { round: p.round, tokens })
})
const taskProgressPct = computed(() => {
const p = taskProgress.value
if (!p || p.maxRounds <= 0) return 0
return Math.min(100, Math.round((p.round / p.maxRounds) * 100))
})
// 🎯 对话目标面板(对话透明化 L1:Goal visibility)
const goals = ref<GoalEntry[]>([])
const goalsExpanded = ref(false)
@@ -744,7 +781,10 @@ onBeforeUnmount(() => {
color: var(--df-text-secondary);
min-width: 0;
margin-right: auto;
/* 长标题单行截断(防换行撑高工具行;右侧徽章族 flex-shrink:0,分离窄窗口更挤) */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-bottom-tools-right {
display: flex;
@@ -753,6 +793,45 @@ onBeforeUnmount(() => {
flex-shrink: 0;
margin-left: auto;
}
/* 无人值守任务进度(设计 §4.3):绿点+轮次/token 文本+细进度条,与右侧徽章族同排 */
.ai-task-progress {
display: inline-flex;
align-items: center;
gap: 5px;
margin-right: 4px;
white-space: nowrap;
}
.ai-task-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-success);
flex-shrink: 0;
animation: ai-task-progress-pulse 1.6s ease-in-out infinite;
}
.ai-task-progress-text {
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-task-progress-bar {
display: inline-block;
width: 48px;
height: 3px;
border-radius: 2px;
background: color-mix(in srgb, var(--df-text-dim) 25%, transparent);
overflow: hidden;
}
.ai-task-progress-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--df-success);
transition: width .4s var(--df-ease);
}
@keyframes ai-task-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ═══ Provider Bar (header 中间区) ═══ */
.ai-provider-bar {
display: inline-flex;