Files
DevFlow/src/components/ai/TaskBoundaryCard.vue
T
lxy b2d44f85e9 新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
2026-10-04 16:27:30 +08:00

531 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>