新增: 挂机模式 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:
lxy
2026-10-07 22:03:41 +08:00
parent e60bd38444
commit 2441101978
16 changed files with 525 additions and 264 deletions
+17 -15
View File
@@ -1,33 +1,35 @@
//! 飞书告警出口(todo 37 监控轮询):挂机引擎自监控快照的推送通道。
//! 飞书推送出口(todo 37 监控轮询 + 挂机开关确认卡)。
//!
//! 职责单一:把 unattended.rs run_self_monitor 的异常快照送达指定飞书群。
//! 职责单一:把挂机引擎的异常快照与开关确认消息送达指定飞书群。
//! 复用 feishu_channel 的卡片发送链路(md_to_lark_friendly 净化 + markdown 卡),
//! 目标 chat 显式传入(KV df-monitor-chat-id),不走 conv 绑定路由——监控告警
//! 是运维通道,与会话投影(卡片滚动句柄/终态判据)互不污染。
//!
//! 失败语义:告警是锦上添花,发送失败只落 feishu_log 不 panic 不重试——
//! 失败语义:推送是锦上添花,发送失败只落 feishu_log 不 panic 不重试——
//! 下一监控周期同段异常仍会再次尝试(同段只告警一次的闸在调用方)。
use crate::feishu_channel::{feishu_log, send_markdown_card};
/// 监控告警推送:快照文本直发 monitor_chat(飞书 markdown 卡)。
/// 渠道未启动/发送失败均静默降级为 feishu_log 落盘(告警不阻塞引擎主循环)。
pub(crate) async fn push_monitor_alert(app: &tauri::AppHandle, chat_id: &str, snapshot: &str) {
let _ = app; // AppHandle 预留(将来查 DB 富化告警),当前快照自包含
/// 通用推卡:文本直发指定 chat_id(飞书 markdown 卡)。
/// 未配目标/渠道未启动/发送失败均降级为 feishu_log 落盘,不阻塞调用方主流程。
pub(crate) async fn push_card(_app: &tauri::AppHandle, chat_id: &str, text: &str) {
if chat_id.is_empty() {
feishu_log(&format!("[monitor] 未配置 df-monitor-chat-id,告警仅落日志: {snapshot}"));
feishu_log(&format!("[notify] 未配置 df-monitor-chat-id,仅落日志: {text}"));
return;
}
let Some(client) = crate::feishu_channel::active_client() else {
feishu_log(&format!(
"[monitor] 飞书渠道未启动,告警仅落日志: {snapshot}"
));
feishu_log(&format!("[notify] 飞书渠道未启动,仅落日志: {text}"));
return;
};
let text = format!("🛡 **DevFlow 挂机监控告警**\n{snapshot}");
let sent = send_markdown_card(client, "", chat_id, &text).await;
let sent = send_markdown_card(client, "", chat_id, text).await;
match sent {
Some(mid) => feishu_log(&format!("[monitor] 告警已推送 chat={chat_id} card={mid}")),
None => feishu_log(&format!("[monitor] 告警推送失败(卡片与文本均不可达),快照: {snapshot}")),
Some(mid) => feishu_log(&format!("[notify] 已推送 chat={chat_id} card={mid}")),
None => feishu_log(&format!("[notify] 推送失败(卡片与文本均不可达),原文: {text}")),
}
}
/// 监控告警推送:异常快照加告警头直发 monitor_chat。
pub(crate) async fn push_monitor_alert(app: &tauri::AppHandle, chat_id: &str, snapshot: &str) {
let text = format!("🛡 **DevFlow 挂机监控告警**\n{snapshot}");
push_card(app, chat_id, &text).await;
}
+58
View File
@@ -494,6 +494,64 @@ async fn run_self_monitor(app: &AppHandle, cfg: &EngineConfig) {
.await;
}
// ============================================================
// IPC:引擎状态查询 + 模式开关(设置页挂机区块 / TopBar 徽章消费)
// ============================================================
/// 引擎状态快照(KV 配置 + 引擎静态状态,纯读零副作用)
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnattendedStatus {
enabled: bool,
claim_scope: String,
in_flight_conv: Option<String>,
breaker_tripped: bool,
poll_secs: u64,
}
/// 查询挂机引擎当前状态(设置页挂机区块回显 / TopBar 徽章轮询)
#[tauri::command]
pub async fn unattended_status(app: AppHandle) -> Result<UnattendedStatus, String> {
let cfg = read_config(&app).await;
let e = engine();
let in_flight = e
.in_flight
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
Ok(UnattendedStatus {
enabled: cfg.enabled,
claim_scope: match cfg.claim_scope {
ClaimScope::RedOnly => "red_only".to_string(),
ClaimScope::AllOpen => "all_open".to_string(),
},
in_flight_conv: in_flight,
breaker_tripped: e.breaker_tripped.load(Ordering::SeqCst),
poll_secs: cfg.poll_secs,
})
}
/// 挂机模式开关:写 KV(引擎每轮热读)+ 唤醒轮询器即时生效 + 经飞书推确认卡。
/// 关闭语义 = 引擎空转(下一轮只读配置即停),不动在途任务。
#[tauri::command]
pub async fn unattended_set_enabled(app: AppHandle, enabled: bool) -> Result<bool, String> {
let state = app.state::<AppState>();
state
.settings
.set(KV_ENABLED, if enabled { "true" } else { "false" })
.await
.map_err(|e| e.to_string())?;
// 唤醒轮询器:开=不等当前周期走完立即扫板(领单/恢复 parked),关=立即空转并复位熔断
engine().notify.notify_one();
let chat = read_config(&app).await.monitor_chat;
let text = if enabled {
"🛌 挂机模式已开启:将自动认领看板高危任务"
} else {
"挂机模式已关闭"
};
crate::commands::ai::feishu_notify::push_card(&app, chat.as_deref().unwrap_or(""), text).await;
Ok(enabled)
}
const RECOVER_NONE: &str = "无可恢复 parked 会话";
/// 提交冻结标志同步(39 治理):读 KV 覆盖标志 + 看板 🔒 行,写入执行闸静态位。
+3
View File
@@ -508,6 +508,9 @@ pub fn run() {
commands::ai::ai_conversation_list,
commands::ai::ai_list_recoverable,
commands::ai::ai_recover_parked,
// 挂机引擎(无人值守):状态查询 + 模式开关(设置页挂机区块/TopBar 徽章)
commands::ai::unattended::unattended_status,
commands::ai::unattended::unattended_set_enabled,
commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek,
+72 -201
View File
@@ -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;
+95
View File
@@ -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 {
+183
View File
@@ -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>
+6
View File
@@ -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' },
+6 -2
View File
@@ -532,10 +532,14 @@ export function flushCurrentText(): void {
resetDelta()
return
}
// 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复)
// 从末尾向前找最后一个非 isError assistant 气泡写入。skip 模式而非 break:
// 尾部可能插着非 assistant 消息(incomplete 系统提示/error 气泡/重试过渡气泡等),
// 原 break 会让循环在非 assistant 处提前终止,本轮 LLM 回复文本不写入任何气泡即被
// 清空,屏幕只剩空气泡+光标(用户多次反馈的「流式后瞬间清空」主根因)。
// continue 跳过非 assistant,继续向前找最近的 assistant 气泡承接。
for (let i = s.messages.length - 1; i >= 0; i--) {
const m = s.messages[i]
if (m.role !== 'assistant') break
if (m.role !== 'assistant') continue
if (!m.isError) {
m.content = s.currentText
break
+14 -6
View File
@@ -49,12 +49,20 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
flushCurrentText()
state.currentText = ''
clearTextIdleTimer()
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
// 防空气泡叠罗汉(BUG-2026-10-07):flush 后若末条仍是「空 content 且非 isError 的
// assistant 气泡」,说明上一轮 LLM 只发 tool_calls 无文本(工具结果在 toolCalls 桶不在
// content),直接复用该气泡承接本轮,不再新推——否则纯工具轮每轮推一个空气泡,
// 折叠后尾泡渲染成「空气泡+光标」(BUG-260624 表症根因之二)。
const tail = state.messages[state.messages.length - 1]
const reusableTail = tail && tail.role === 'assistant' && !tail.isError && !tail.content?.trim()
if (!reusableTail) {
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
}
// round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁)
if (event.round > 0) state.agentRound = event.round
// #14 桌面任务卡:per-conv 轮次追踪,前台会话在此写(取 max 防事件乱序回退);
+9 -19
View File
@@ -133,7 +133,7 @@ export default {
circuit_breaker: 'Circuit breaker',
stall_breaker: 'Stall breaker',
max_rounds: 'Max rounds reached',
fatal: 'Repeated failures (retry budget exhausted)',
fatal: 'Repeated failures; paused automatically',
midstream: 'Stream interrupted',
},
unknownKind: 'Parked',
@@ -148,25 +148,17 @@ export default {
unlimited: 'Running · round {round} · {tokens} tokens',
barHint: 'Round {round}/{max}',
},
// Unattended boundary config card (authorized once at kickoff, effective for the task
// lifetime, dies with the conversation)
// Unattended boundary config card (P1 simplified: single authorization tier,
// only the two budget fields remain user-tunable)
taskBoundary: {
toggle: 'Unattended',
badge: 'Unattended boundary authorized',
riskCeiling: 'Risk ceiling',
riskLow: 'Low',
riskMedium: 'Medium',
dirs: 'Directory allowlist',
dirPlaceholder: 'Enter absolute directory path',
dirAdd: 'Add',
dirEmpty: 'No directories added',
forbidDestructive: 'Destructive commands always blocked',
budgetToggle: 'Budget (advanced)',
budgetRounds: 'Max rounds',
budgetTokens: 'Max tokens',
budgetWallSecs: 'Wall-clock seconds',
budgetHint: 'Leave empty for unlimited',
budgetInvalid: 'Budget values must be positive integers',
budgetWallSecs: 'Max run duration',
budgetUnlimited: 'Unlimited',
budgetHint: 'Check "Unlimited" to lift a limit',
confirmSet: 'Authorize once, no interruptions for the whole task',
clearBoundary: 'Clear boundary',
setOk: 'Unattended boundary authorized',
@@ -175,12 +167,10 @@ export default {
lockedGenerating: 'Task is running: only clearing (revoke) is allowed, fields are locked',
loadFailed: 'Failed to load boundary; the form below is not trustworthy',
retryLoad: 'Retry',
dirErrIllegal: 'Path contains illegal characters < > : " | ? *',
dirErrRelative: 'Enter an absolute path (e.g. D:\\projects or /home/user)',
clearConfirm: 'Clearing the boundary revokes unattended authorization immediately; tool calls in the running task will fall back to per-action approval. Clear?',
echoRisk: 'risk ceiling {risk}',
echoDirs: '{n} dirs',
echoBudget: 'budget {rounds} rounds / {tokens} tokens / {secs}s',
echoRounds: 'up to {n} rounds',
echoSecs: 'up to {n}s',
echoBudget: 'budget {rounds} / {secs}',
echoNoBudget: 'no budget limit',
// A12: budget validation field locator; A11: dirty-edit kept notice
budgetInvalidField: '{field} must be a positive integer',
+7 -1
View File
@@ -22,7 +22,13 @@ export default {
archived: 'Archived',
archivedCount: 'Archived ({n})',
recoverableCount: 'Recoverable ({n})',
recoverableHint: 'Task parked by restart interruption; click to resume',
recoverableHint: 'Task parked safely; click to resume',
// ── Unattended engine badge (TopBar; shown while on, red badge resets the breaker) ──
unattendedOn: 'Unattended',
unattendedExecuting: 'working',
unattendedTripped: 'Unattended halted',
unattendedBadgeHint: 'Unattended mode is on. Click to open settings',
unattendedResetHint: 'Auto-halted after repeated start failures. Click to reset and resume',
recoverableDone: 'Task resumed, running in background',
recoverableFail: 'Recover failed: {msg}',
emptyConversation: 'No conversations',
+18
View File
@@ -272,6 +272,24 @@ export default {
// Submit guard: when enabled=true, either of id/secret empty blocks the write
feishuValidateIncomplete: 'Fill in App ID and App Secret to enable the Feishu integration',
// ===== Integrations: Unattended mode (engine auto-claims board tasks; TopBar badge jump target) =====
panelUnattended: 'Unattended Mode',
labelUnattendedEnabled: 'Enable unattended mode',
descUnattendedEnabled: 'Automatically claims open board tasks and keeps working; turning it off stops claiming (running tasks are not interrupted)',
labelUnattendedScope: 'Claim scope',
descUnattendedScope: 'Which tasks are handled automatically while unattended',
unattendedScopeRed: 'High-priority tasks only (recommended)',
unattendedScopeAll: 'All open tasks',
labelUnattendedMonitorChat: 'Notification target',
descUnattendedMonitorChat: 'Mode switches and runtime alerts are pushed to this Feishu group (enable the Feishu integration below first)',
phUnattendedMonitorChat: 'Feishu group chat_id; leave empty to log only',
labelUnattendedPollSecs: 'Check interval (seconds)',
descUnattendedPollSecs: 'How often to look for new board tasks, minimum 30 seconds',
unattendedAdvanced: 'Advanced',
toastUnattendedOn: 'Unattended mode on; board tasks will be claimed automatically',
toastUnattendedOff: 'Unattended mode off',
toastUnattendedFail: 'Failed to switch unattended mode: {msg}',
// ===== Integrations: u-claw (ask_uclaw tool, u-relay commander protocol) =====
panelUclaw: 'u-claw Integration',
labelUclawRelayUrl: 'Relay URL',
+8 -18
View File
@@ -130,7 +130,7 @@ export default {
circuit_breaker: '熔断保护',
stall_breaker: '停滞保护',
max_rounds: '已达轮次上限',
fatal: '连续失败(重入预算耗尽)',
fatal: '连续失败,已自动暂停',
midstream: '响应流中断',
},
unknownKind: '任务停靠',
@@ -144,24 +144,16 @@ export default {
unlimited: '进行中 · 第 {round} 轮 · {tokens} tokens',
barHint: '第 {round}/{max} 轮',
},
// 无人值守边界配置卡(发起时一次授权,存续期生效,随会话同亡)
// 无人值守边界配置卡(P1 总装简化:授权面收敛单一档,可调项只剩预算两项)
taskBoundary: {
toggle: '无人值守',
badge: '无人值守边界已授权',
riskCeiling: '风险上限',
riskLow: '低',
riskMedium: '中',
dirs: '目录白名单',
dirPlaceholder: '输入目录绝对路径',
dirAdd: '添加',
dirEmpty: '尚未添加目录',
forbidDestructive: '破坏性命令恒禁止',
budgetToggle: '预算(高级)',
budgetRounds: '最大轮次',
budgetTokens: '最大 tokens',
budgetWallSecs: '挂钟秒数',
budgetHint: '留空表示不限',
budgetInvalid: '预算需为正整数',
budgetWallSecs: '最长运行时长',
budgetUnlimited: '不限',
budgetHint: '勾选「不限」即不设该项上限',
confirmSet: '一次授权,任务期内不再打扰',
clearBoundary: '清除边界',
setOk: '已授权无人值守边界',
@@ -170,12 +162,10 @@ export default {
lockedGenerating: '任务运行中,仅支持清除边界(收权),字段不可修改',
loadFailed: '边界读取失败,当前表单不可信',
retryLoad: '重试',
dirErrIllegal: '路径含非法字符 < > : " | ? *',
dirErrRelative: '请输入绝对路径(如 D:\\projects 或 /home/user)',
clearConfirm: '清除边界将立即收回无人值守授权,运行中的任务后续工具调用回到逐次审批。确定清除?',
echoRisk: '风险上限 {risk}',
echoDirs: '{n} 个目录',
echoBudget: '预算 {rounds} 轮 / {tokens} tokens / {secs}s',
echoRounds: '最多 {n} 轮',
echoSecs: '最长 {n} 秒',
echoBudget: '预算 {rounds} / {secs}',
echoNoBudget: '预算不限',
// A12: 预算校验错误定位字段;A11: 脏编辑保留提示
budgetInvalidField: '{field} 需为正整数',
+7 -1
View File
@@ -22,7 +22,13 @@ export default {
archived: '已归档',
archivedCount: '已归档 ({n})',
recoverableCount: '可恢复 ({n})',
recoverableHint: '任务因重启中断停靠,点击恢复继续执行',
recoverableHint: '任务已安全停靠,点击恢复继续执行',
// ── 挂机引擎态徽章(TopBar;开启时显示,熔断红徽点击复位) ──
unattendedOn: '挂机中',
unattendedExecuting: '执行中',
unattendedTripped: '挂机熔断',
unattendedBadgeHint: '挂机模式运行中,点击前往设置',
unattendedResetHint: '连续启动失败已自动熔断,点击复位并重新开启',
recoverableDone: '任务已恢复,继续后台执行',
recoverableFail: '恢复失败: {msg}',
emptyConversation: '暂无对话',
+18
View File
@@ -271,6 +271,24 @@ export default {
// 提交校验:enabled=true 时 id/secret 任一为空都拦在写库前
feishuValidateIncomplete: '启用飞书集成需先填写 App ID 和 App Secret',
// ===== 集成:挂机模式(引擎自动认领看板任务,TopBar 徽章跳转落点) =====
panelUnattended: '挂机模式',
labelUnattendedEnabled: '开启挂机模式',
descUnattendedEnabled: '开启后自动认领看板待办任务并持续执行,关闭即全部停止(不影响正在执行的任务)',
labelUnattendedScope: '认领范围',
descUnattendedScope: '挂机时自动处理哪些任务',
unattendedScopeRed: '仅高危任务(推荐)',
unattendedScopeAll: '全部待办',
labelUnattendedMonitorChat: '消息推送目标',
descUnattendedMonitorChat: '挂机开启/关闭确认和运行异常会推送到这个飞书群(需先在下方启用飞书集成)',
phUnattendedMonitorChat: '飞书群 chat_id,留空仅记录日志',
labelUnattendedPollSecs: '轮询周期(秒)',
descUnattendedPollSecs: '每隔多久检查一次看板新任务,最小 30 秒',
unattendedAdvanced: '高级',
toastUnattendedOn: '挂机模式已开启,将自动认领看板任务',
toastUnattendedOff: '挂机模式已关闭',
toastUnattendedFail: '挂机开关操作失败:{msg}',
// ===== 集成:u-claw(ask_uclaw 工具,u-relay commander 协议) =====
panelUclaw: 'u-claw 集成',
labelUclawRelayUrl: 'Relay 地址',
+4 -1
View File
@@ -69,9 +69,11 @@
@toast="showToast"
/>
<!-- 集成:飞书渠道凭证(WS 直连飞书开放平台,整体保存到 KV feishu-config)
<!-- 集成:挂机模式(引擎自动认领看板任务,KV 配置热读)
+ 飞书渠道凭证(WS 直连飞书开放平台,整体保存到 KV feishu-config)
+ u-claw(ask_uclaw 工具,u-relay commander 协议三键配置) -->
<template v-else-if="activeCategory === 'integrations'">
<UnattendedPanel @toast="showToast" />
<FeishuPanel @toast="showToast" />
<UclawPanel @toast="showToast" />
</template>
@@ -107,6 +109,7 @@ import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
import FeishuPanel from '@/components/settings/FeishuPanel.vue'
import UclawPanel from '@/components/settings/UclawPanel.vue'
import UnattendedPanel from '@/components/settings/UnattendedPanel.vue'
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
import { useAppSettingsStore } from '@/stores/appSettings'
import { aiApi } from '@/api'