新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送
- 新增 UnattendedPanel.vue 挂机配置面板(引擎自动认领看板任务,Settings KV 热读即改即生效) - Settings.vue 接入挂机面板;searchIndex.ts 补挂机搜索词条 - TopBar.vue 新增挂机进行中徽章(脉冲动效+透明度过渡) - TaskBoundaryCard.vue 精简边界状态判定(wallState 合并比对,删冗余 watch) - i18n zh-CN/en: settings/ai/aiChat 六文件补挂机词条双语 - unattended.rs 新增 push_monitor_alert 监控告警推送(异常快照+告警头直发 monitor_chat) - feishu_notify.rs push/push_card 出口重构收敛 - lib.rs 注册 unattended_status/unattended_set_enabled 两 IPC - aiShared.ts/useAiStreamingEvents.ts 挂机状态流接入
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<!-- 无人值守边界配置卡(设计 §2.2):发起时一次性授权,存续期生效,随会话同亡。
|
||||
<!-- 无人值守边界配置卡(P1 总装简化版):发起时一次性授权,存续期生效,随会话同亡。
|
||||
授权面收敛为单一档(风险上限固定 Medium + 无目录扩权,破坏性命令恒禁止),
|
||||
可调项只剩预算两项(最大轮次/最长运行时长,各带「不限」)。
|
||||
默认收起(现状零变化);展开后对当前活跃会话 ai_task_boundary_set 一次拍板。
|
||||
折叠行在有边界时只读回显摘要(数据源镜像,拉取见下);任务 generating 中锁编辑
|
||||
(中途改边界=边跑边扩权,设计明令禁止)。不引入对话框,最小折叠卡。 -->
|
||||
@@ -22,42 +24,6 @@
|
||||
<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>
|
||||
@@ -67,18 +33,22 @@
|
||||
{{ t('ai.taskBoundary.budgetToggle') }}
|
||||
</button>
|
||||
<div v-if="budgetOpen" class="ai-boundary-budget">
|
||||
<label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'rounds' }">
|
||||
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'rounds' }">
|
||||
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
|
||||
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
|
||||
</label>
|
||||
<label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'tokens' }">
|
||||
<span>{{ t('ai.taskBoundary.budgetTokens') }}</span>
|
||||
<input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
|
||||
</label>
|
||||
<label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'wallSecs' }">
|
||||
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || roundsUnlimited" @input="budgetErrField = ''" />
|
||||
<label class="ai-boundary-unlimited">
|
||||
<input v-model="roundsUnlimited" type="checkbox" :disabled="locked" />
|
||||
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'wallSecs' }">
|
||||
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
|
||||
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
|
||||
</label>
|
||||
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || wallUnlimited" @input="budgetErrField = ''" />
|
||||
<label class="ai-boundary-unlimited">
|
||||
<input v-model="wallUnlimited" type="checkbox" :disabled="locked" />
|
||||
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="ai-boundary-dim">{{ t('ai.taskBoundary.budgetHint') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -96,8 +66,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 无人值守边界配置卡:字段宁少勿预留(设计 §2.2)。目录白名单用简化交互(文本添加+删除),
|
||||
// 不复刻设置页完整目录选择器;预算三项折叠,空=不限(不传 budget)。
|
||||
// 无人值守边界配置卡(P1 总装简化):授权面收敛单一档——风险上限固定 Medium、
|
||||
// 目录白名单不开放(空=不额外扩权,全局 allowed_dirs 照常生效),破坏性命令恒禁止;
|
||||
// 可调项只剩预算两项(最大轮次/最长运行时长),各带「不限」勾选(0 直传,后端 0=不限)。
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
@@ -117,34 +88,27 @@ 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('')
|
||||
// 「不限」勾选:勾中即清空并禁用输入,保存按 0 直传(无上限语义)
|
||||
const roundsUnlimited = ref(false)
|
||||
const wallUnlimited = ref(false)
|
||||
// A12: in-flight 防重(双击双 IPC 后到覆盖先到)+ 出错字段定位
|
||||
const confirming = ref(false)
|
||||
const budgetErrField = ref<'' | 'rounds' | 'tokens' | 'wallSecs'>('')
|
||||
// A11: 未提交脏编辑保护(切会话不静默蒸发正在输入的目录/预算)
|
||||
const budgetErrField = ref<'' | 'rounds' | 'wallSecs'>('')
|
||||
// A11: 未提交脏编辑保护(切会话不静默蒸发正在输入的预算)
|
||||
const hasDirtyEdits = computed(() => {
|
||||
if (!current.value) {
|
||||
return dirs.value.length > 0 || dirInput.value.trim() !== ''
|
||||
|| rounds.value.trim() !== '' || tokens.value.trim() !== '' || wallSecs.value.trim() !== ''
|
||||
}
|
||||
const b = current.value
|
||||
const dirsDirty = JSON.stringify(dirs.value) !== JSON.stringify([...b.dir_allowlist])
|
||||
const budgetDirty = (b.budget
|
||||
? rounds.value.trim() !== String(b.budget.max_rounds)
|
||||
|| tokens.value.trim() !== String(b.budget.max_tokens)
|
||||
|| wallSecs.value.trim() !== String(b.budget.wall_secs)
|
||||
: rounds.value.trim() !== '' || tokens.value.trim() !== '' || wallSecs.value.trim() !== '')
|
||||
return dirsDirty || budgetDirty
|
||||
const bd = current.value?.budget
|
||||
const roundsState = `${rounds.value.trim()}|${roundsUnlimited.value}`
|
||||
const wallState = `${wallSecs.value.trim()}|${wallUnlimited.value}`
|
||||
if (!bd) return roundsState !== '|' || wallState !== '|'
|
||||
const savedRounds = bd.max_rounds > 0 ? String(bd.max_rounds) : ''
|
||||
const savedWall = bd.wall_secs > 0 ? String(bd.wall_secs) : ''
|
||||
return roundsState !== `${savedRounds}|${bd.max_rounds === 0}`
|
||||
|| wallState !== `${savedWall}|${bd.wall_secs === 0}`
|
||||
})
|
||||
|
||||
const activeId = computed(() => store.state.activeConversationId)
|
||||
@@ -154,18 +118,16 @@ 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 = ''
|
||||
roundsUnlimited.value = false
|
||||
wallUnlimited.value = false
|
||||
}
|
||||
|
||||
/** 从镜像回填表单(展开或切会话时调);镜像 miss 先拉一次再回填。
|
||||
* A1: 拉取失败与「确认无边界」分流——失败态置 loadFailed 禁授权,表单保留现状不清空 */
|
||||
* A1: 拉取失败与「确认无边界」分流——失败态置 loadFailed 禁授权,表单保留现状不清空。
|
||||
* 预算 0 值按「不限」勾选回显(0=无上限);存量边界的 tokens 字段已收出配置面,回显忽略 */
|
||||
async function hydrate(): Promise<void> {
|
||||
const id = activeId.value
|
||||
if (!id) {
|
||||
@@ -185,13 +147,12 @@ async function hydrate(): Promise<void> {
|
||||
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
|
||||
const bd = b.budget
|
||||
rounds.value = bd && bd.max_rounds > 0 ? String(bd.max_rounds) : ''
|
||||
roundsUnlimited.value = !!bd && bd.max_rounds === 0
|
||||
wallSecs.value = bd && bd.wall_secs > 0 ? String(bd.wall_secs) : ''
|
||||
wallUnlimited.value = !!bd && bd.wall_secs === 0
|
||||
budgetOpen.value = !!bd
|
||||
}
|
||||
|
||||
/** 失败态重试(错误条按钮):重走拉取+回填 */
|
||||
@@ -215,54 +176,21 @@ 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 = ''
|
||||
// 勾「不限」即清空输入(保存按 0 语义,输入框不再携带脏值)
|
||||
watch(roundsUnlimited, (v) => {
|
||||
if (v) rounds.value = ''
|
||||
})
|
||||
watch(wallUnlimited, (v) => {
|
||||
if (v) wallSecs.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(带字段键定位) */
|
||||
/** 预算两项解析:两项均不限=undefined(整个预算不设,后端 None=不限);
|
||||
* 单项不限直传 0(0=无上限;预算闸消费侧接线时按此语义对齐,当前仅随行落库);
|
||||
* 任一非空但非正整数视为无效返回 null(带字段键定位)。max_tokens 字段已收出配置面,恒传 0 */
|
||||
function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: number } | undefined | null {
|
||||
const fields = [
|
||||
{ v: rounds.value.trim(), key: 'rounds' as const },
|
||||
{ v: tokens.value.trim(), key: 'tokens' as const },
|
||||
{ v: wallSecs.value.trim(), key: 'wallSecs' as const },
|
||||
{ v: roundsUnlimited.value ? '' : rounds.value.trim(), key: 'rounds' as const },
|
||||
{ v: wallUnlimited.value ? '' : wallSecs.value.trim(), key: 'wallSecs' as const },
|
||||
]
|
||||
if (fields.every(f => !f.v)) return undefined
|
||||
const nums: number[] = []
|
||||
@@ -273,16 +201,17 @@ function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: num
|
||||
}
|
||||
const n = Number(f.v)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
|
||||
// A12: 记录出错字段,toast 定位提示(替代笼统「预算需为正整数」)
|
||||
// A12: 记录出错字段,toast 定位提示
|
||||
budgetErrField.value = f.key
|
||||
return null
|
||||
}
|
||||
nums.push(n)
|
||||
}
|
||||
return { max_rounds: nums[0], max_tokens: nums[1], wall_secs: nums[2] }
|
||||
return { max_rounds: nums[0], max_tokens: 0, wall_secs: nums[1] }
|
||||
}
|
||||
|
||||
/** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行 */
|
||||
/** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行。
|
||||
* 风险上限固定 Medium(后端默认档,配置面已收)、目录白名单空(不额外扩权) */
|
||||
async function confirmSet(): Promise<void> {
|
||||
const id = activeId.value
|
||||
if (!id || locked.value || loadFailed.value || confirming.value) return
|
||||
@@ -291,15 +220,13 @@ async function confirmSet(): Promise<void> {
|
||||
// A12: 出错字段定位(红字标到具体输入框 + toast 指名)
|
||||
const fieldLabel = budgetErrField.value === 'rounds'
|
||||
? t('ai.taskBoundary.budgetRounds')
|
||||
: budgetErrField.value === 'tokens'
|
||||
? t('ai.taskBoundary.budgetTokens')
|
||||
: t('ai.taskBoundary.budgetWallSecs')
|
||||
: t('ai.taskBoundary.budgetWallSecs')
|
||||
emit('toast', { msg: t('ai.taskBoundary.budgetInvalidField', { field: fieldLabel }), type: 'warning' })
|
||||
return
|
||||
}
|
||||
const boundary: TaskBoundary = {
|
||||
risk_ceiling: risk.value,
|
||||
dir_allowlist: dirs.value.slice(),
|
||||
risk_ceiling: 'medium',
|
||||
dir_allowlist: [],
|
||||
forbid_destructive: true,
|
||||
budget: budget ?? null,
|
||||
}
|
||||
@@ -337,23 +264,14 @@ async function clearBoundary(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 折叠行只读回显摘要:「已授权 · 风险上限 中 · 2 个目录 · 预算不限」
|
||||
// 折叠行只读回显摘要:「预算 最多 {n} 轮 / 最长 {n} 秒」或「预算不限」
|
||||
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(' · ')
|
||||
if (!current.value) return ''
|
||||
const bd = current.value.budget
|
||||
if (!bd) return t('ai.taskBoundary.echoNoBudget')
|
||||
const roundsLabel = bd.max_rounds > 0 ? t('ai.taskBoundary.echoRounds', { n: bd.max_rounds }) : t('ai.taskBoundary.budgetUnlimited')
|
||||
const secsLabel = bd.wall_secs > 0 ? t('ai.taskBoundary.echoSecs', { n: bd.wall_secs }) : t('ai.taskBoundary.budgetUnlimited')
|
||||
return t('ai.taskBoundary.echoBudget', { rounds: roundsLabel, secs: secsLabel })
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -413,74 +331,27 @@ const echoSummary = computed(() => {
|
||||
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 {
|
||||
/* 预算「不限」勾选(勾中即不设该项上限,输入框禁用) */
|
||||
.ai-boundary-unlimited {
|
||||
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-unlimited input { accent-color: var(--df-accent); margin: 0; }
|
||||
.ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
|
||||
.ai-boundary-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
>
|
||||
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
|
||||
</button>
|
||||
<!-- 挂机引擎态徽章:开启=「挂机中」(在途附「执行中」),熔断=红徽点击复位;关闭态不显示 -->
|
||||
<button
|
||||
v-if="unattendedStatus?.breaker_tripped"
|
||||
type="button"
|
||||
class="ai-unattended-badge ai-unattended-badge--tripped"
|
||||
:title="$t('aiChat.unattendedResetHint')"
|
||||
@click="resetUnattendedBreaker"
|
||||
>
|
||||
{{ $t('aiChat.unattendedTripped') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="unattendedStatus?.enabled"
|
||||
type="button"
|
||||
class="ai-unattended-badge"
|
||||
:title="$t('aiChat.unattendedBadgeHint')"
|
||||
@click="jumpToUnattendedSettings"
|
||||
>
|
||||
🛌 {{ $t('aiChat.unattendedOn') }}<template v-if="unattendedStatus.in_flight_conv"> · {{ $t('aiChat.unattendedExecuting') }}</template>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 提供商状态 + 模型选择器 -->
|
||||
<div class="ai-header-center">
|
||||
@@ -281,7 +300,10 @@
|
||||
// 父持有 clear/compress/confirm 逻辑 + 单一 toast 源(provider-switched 回父弹 toast)。
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { aiApi } from '../../api/ai'
|
||||
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
|
||||
import { convRounds } from '../../composables/ai/aiShared'
|
||||
@@ -335,6 +357,54 @@ const emit = defineEmits<{
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── 挂机引擎态徽章(P1 总装):消费 unattended_status,开启/熔断时 30s 级轮询刷新 ──
|
||||
// 关闭态不显示徽章,轮询降频到 2 分钟(仍能感知设置页里的开启动作;单次 KV 级纯读,开销可忽略)
|
||||
interface UnattendedEngineStatus {
|
||||
enabled: boolean
|
||||
claim_scope: string
|
||||
in_flight_conv: string | null
|
||||
breaker_tripped: boolean
|
||||
poll_secs: number
|
||||
}
|
||||
const UNATTENDED_ACTIVE_MS = 30_000
|
||||
const UNATTENDED_IDLE_MS = 120_000
|
||||
const router = useRouter()
|
||||
const appSettings = useAppSettingsStore()
|
||||
const unattendedStatus = ref<UnattendedEngineStatus | null>(null)
|
||||
let unattendedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// 卸载守卫:onBeforeUnmount 清定时器后,在途 invoke 的续延还会回来排下一轮,须拦住防卸载后空转
|
||||
let unattendedDisposed = false
|
||||
async function refreshUnattended() {
|
||||
if (unattendedDisposed) return
|
||||
try {
|
||||
unattendedStatus.value = await invoke<UnattendedEngineStatus>('unattended_status')
|
||||
} catch { /* IPC 失败保留上次状态,下轮重试 */ }
|
||||
if (unattendedDisposed) return
|
||||
const s = unattendedStatus.value
|
||||
const delay = s?.enabled || s?.breaker_tripped ? UNATTENDED_ACTIVE_MS : UNATTENDED_IDLE_MS
|
||||
unattendedTimer = setTimeout(() => void refreshUnattended(), delay)
|
||||
}
|
||||
/** 点击徽章跳设置页挂机区块(集成类首面板;activeCategory 由 appSettings 持久化驱动) */
|
||||
function jumpToUnattendedSettings() {
|
||||
void appSettings.set('df-settings-active-category', 'integrations')
|
||||
void router.push('/settings')
|
||||
}
|
||||
/** 熔断复位:开关 false→true(引擎在关态轮询里清熔断与失败计数,再开即恢复领单) */
|
||||
async function resetUnattendedBreaker() {
|
||||
try {
|
||||
await invoke('unattended_set_enabled', { enabled: false })
|
||||
await invoke('unattended_set_enabled', { enabled: true })
|
||||
} catch (e) {
|
||||
console.warn('[topbar] 挂机熔断复位失败:', e)
|
||||
}
|
||||
void refreshUnattended()
|
||||
}
|
||||
onMounted(() => { void refreshUnattended() })
|
||||
onBeforeUnmount(() => {
|
||||
unattendedDisposed = true
|
||||
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
|
||||
})
|
||||
|
||||
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
|
||||
// 顶部按钮过多时,低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起。
|
||||
const moreMenuOpen = ref(false)
|
||||
@@ -800,6 +870,31 @@ onBeforeUnmount(() => {
|
||||
outline: 1.5px solid var(--df-accent, #4a9eff);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* 挂机引擎态徽章(开启=accent 弱底;熔断=红底红字,点击复位) */
|
||||
.ai-unattended-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
appearance: none;
|
||||
border: none;
|
||||
transition: opacity 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-unattended-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-unattended-badge--tripped {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
border: 0.5px solid var(--df-danger);
|
||||
}
|
||||
|
||||
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
|
||||
.ai-model-picker {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<!-- ═══ 挂机模式(引擎自动认领看板任务持续执行;配置走 Settings KV 热读,改后即时生效) ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('settings.panelUnattended') }}</h2>
|
||||
</div>
|
||||
<div class="unattended-settings">
|
||||
<SettingRow :label="$t('settings.labelUnattendedEnabled')" :desc="$t('settings.descUnattendedEnabled')">
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="enabled"
|
||||
:disabled="toggling"
|
||||
@change="onEnabledChange(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow :label="$t('settings.labelUnattendedScope')" :desc="$t('settings.descUnattendedScope')">
|
||||
<div class="unattended-radios">
|
||||
<label class="unattended-radio" :class="{ 'is-checked': scope === 'red_only' }">
|
||||
<input v-model="scope" type="radio" value="red_only" />
|
||||
<span>{{ $t('settings.unattendedScopeRed') }}</span>
|
||||
</label>
|
||||
<label class="unattended-radio" :class="{ 'is-checked': scope === 'all_open' }">
|
||||
<input v-model="scope" type="radio" value="all_open" />
|
||||
<span>{{ $t('settings.unattendedScopeAll') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow :label="$t('settings.labelUnattendedMonitorChat')" :desc="$t('settings.descUnattendedMonitorChat')">
|
||||
<input
|
||||
v-model="monitorChat"
|
||||
type="text"
|
||||
class="setting-input unattended-input"
|
||||
:placeholder="$t('settings.phUnattendedMonitorChat')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<button type="button" class="unattended-advanced-toggle" @click="advancedOpen = !advancedOpen">
|
||||
<span class="unattended-chevron" :class="{ 'is-open': advancedOpen }">▶</span>
|
||||
{{ $t('settings.unattendedAdvanced') }}
|
||||
</button>
|
||||
<div v-if="advancedOpen" class="unattended-advanced">
|
||||
<SettingRow :label="$t('settings.labelUnattendedPollSecs')" :desc="$t('settings.descUnattendedPollSecs')">
|
||||
<input
|
||||
:value="pollSecs"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="setting-input unattended-input unattended-input--num"
|
||||
@change="onPollSecsChange(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
// ============================================================
|
||||
// 挂机模式面板 — 引擎配置读写全走 Settings KV(后端每轮热读,改后即时生效):
|
||||
// - 模式开关走专用 IPC unattended_set_enabled(写 KV + 立即唤醒轮询器 + 飞书确认卡),
|
||||
// 其余三项(范围/推送目标/轮询周期)走 appSettings store(settings_get/settings_set,自带防抖)
|
||||
// - 开关关闭语义 = 引擎空转(下一轮只读配置即停),不杀在途任务
|
||||
// ============================================================
|
||||
|
||||
/** 后端引擎状态(unattended_status 纯读,作开关回显的权威真值) */
|
||||
interface UnattendedStatus {
|
||||
enabled: boolean
|
||||
claim_scope: string
|
||||
in_flight_conv: string | null
|
||||
breaker_tripped: boolean
|
||||
poll_secs: number
|
||||
}
|
||||
|
||||
/** 轮询周期下限(与后端 MIN_POLL_SECS 同源钳制,防误配热循环) */
|
||||
const MIN_POLL_SECS = 30
|
||||
const DEFAULT_POLL_SECS = 300
|
||||
|
||||
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
const enabled = ref(false)
|
||||
const toggling = ref(false)
|
||||
const advancedOpen = ref(false)
|
||||
|
||||
// 领单范围/推送目标:store 防抖落库(v-model 即存);范围历史脏值归一到 red_only
|
||||
const scope = appSettings.useSetting<string>('df-unattended-claim-scope', 'red_only')
|
||||
const monitorChat = appSettings.useSetting<string>('df-monitor-chat-id', '')
|
||||
const pollSecs = appSettings.useSetting<number>('df-unattended-poll-secs', DEFAULT_POLL_SECS)
|
||||
|
||||
onMounted(async () => {
|
||||
enabled.value = appSettings.get<boolean>('df-unattended-enabled', false)
|
||||
if (scope.value !== 'all_open') scope.value = 'red_only'
|
||||
try {
|
||||
const s = await invoke<UnattendedStatus>('unattended_status')
|
||||
enabled.value = s.enabled
|
||||
// 展示值对齐后端生效值(后端有 30s 下限钳制,KV 手改成更小值时这里自愈回写)
|
||||
if (s.poll_secs !== pollSecs.value) pollSecs.value = s.poll_secs
|
||||
} catch (e) {
|
||||
console.error('[UNATTENDED] 状态读取失败,回退 KV 缓存值:', e)
|
||||
}
|
||||
})
|
||||
|
||||
/** 模式开关:专用 IPC(确认卡在后端推);失败回拨开关并 toast */
|
||||
async function onEnabledChange(next: boolean) {
|
||||
if (toggling.value || next === enabled.value) return
|
||||
toggling.value = true
|
||||
try {
|
||||
await invoke('unattended_set_enabled', { enabled: next })
|
||||
enabled.value = next
|
||||
// 同步 store 缓存(后端直写 KV 不经前端 store,不回写则下次进面板读到旧值)
|
||||
void appSettings.set('df-unattended-enabled', next)
|
||||
emit('toast', t(next ? 'settings.toastUnattendedOn' : 'settings.toastUnattendedOff'), 'success')
|
||||
} catch (e) {
|
||||
console.error('[UNATTENDED] 开关失败:', e)
|
||||
emit('toast', t('settings.toastUnattendedFail', { msg: String(e) }), 'error')
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询周期:非法输入回退当前值;合法值钳下限后落库(store 防抖) */
|
||||
function onPollSecsChange(raw: string) {
|
||||
const n = Number.parseInt(raw.trim(), 10)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
pollSecs.value = appSettings.get<number>('df-unattended-poll-secs', DEFAULT_POLL_SECS)
|
||||
return
|
||||
}
|
||||
pollSecs.value = Math.max(n, MIN_POLL_SECS)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* panel/panel-header/toggle/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
|
||||
.unattended-settings { display: flex; flex-direction: column; gap: 4px; }
|
||||
.unattended-input { width: 280px; }
|
||||
.unattended-input--num { width: 110px; }
|
||||
.unattended-radios { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.unattended-radio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.unattended-radio.is-checked { color: var(--df-accent); }
|
||||
.unattended-radio input { accent-color: var(--df-accent); margin: 0; }
|
||||
.unattended-advanced-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
align-self: flex-start;
|
||||
margin-top: 6px;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.unattended-advanced-toggle:hover { color: var(--df-text); }
|
||||
.unattended-chevron {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
transition: transform 0.15s var(--df-ease);
|
||||
}
|
||||
.unattended-chevron.is-open { transform: rotate(90deg); }
|
||||
.unattended-advanced { display: flex; flex-direction: column; gap: 4px; }
|
||||
</style>
|
||||
@@ -86,6 +86,12 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
||||
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
|
||||
|
||||
// ===== 集成 =====
|
||||
// 挂机模式(引擎自动认领看板任务;TopBar 徽章点击跳转即落到本类)
|
||||
{ category: 'integrations', key: 'unattended', labelKey: 'settings.panelUnattended' },
|
||||
{ category: 'integrations', key: 'unattendedEnabled', labelKey: 'settings.labelUnattendedEnabled', descKey: 'settings.descUnattendedEnabled' },
|
||||
{ category: 'integrations', key: 'unattendedScope', labelKey: 'settings.labelUnattendedScope', descKey: 'settings.descUnattendedScope' },
|
||||
{ category: 'integrations', key: 'unattendedMonitorChat', labelKey: 'settings.labelUnattendedMonitorChat', descKey: 'settings.descUnattendedMonitorChat' },
|
||||
{ category: 'integrations', key: 'unattendedPollSecs', labelKey: 'settings.labelUnattendedPollSecs', descKey: 'settings.descUnattendedPollSecs' },
|
||||
{ category: 'integrations', key: 'feishu', labelKey: 'settings.panelFeishu' },
|
||||
{ category: 'integrations', key: 'feishuEnabled', labelKey: 'settings.labelFeishuEnabled', descKey: 'settings.descFeishuEnabled' },
|
||||
{ category: 'integrations', key: 'feishuAppId', labelKey: 'settings.labelFeishuAppId', descKey: 'settings.descFeishuAppId' },
|
||||
|
||||
Reference in New Issue
Block a user