Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aac14ed9a | ||
|
|
6bfcf01641 | ||
|
|
2441101978 |
+7
-1
@@ -1,7 +1,8 @@
|
||||
# DevFlow 工作看板
|
||||
|
||||
> ⚠️ **挂机必读(2026-10-06 10:50 仓库状态变更)**:今早 10:13 的三笔提交(cec4d11/84df9b5/622507c)已被外部退回(G2 🔴门禁/G3 描述不达标),当前 HEAD=03dc27b,全部改动在工作区未提交。11:23 汇报中「已提交」的说法与事实不符。**重做要求见文末退回段;每次汇报前先跑 `git log -1` 实测再报。**
|
||||
> 🔒 **提交冻结(2026-10-06 深夜用户令)**:后续一切改动攒批等「攒批提交通知」才可提交,期间只改不提;通知由用户逐批下达。
|
||||
> 🔓 **提交冻结已解除(2026-10-07 用户推送令)**:攒批提交通知已下(含 18H 存量推送令),提交动作由 DevFlow agent 实战执行(G4 前置走查+分组规范)。
|
||||
|
||||
> 📦 **2026-10-07 攒批状态**:工作区 20 文件验证全绿(cargo test --workspace 1941 passed 0 failed + vue-tsc EXIT 0 + cargo check -p devflow EXIT 0)。本批新增/修复:① parked 断点续跑分流——ai_continue_loop 非活跃时查 DB loop_state=parked → recover_parked_inner 正典恢复链(chat.rs:2690+conversation.rs:328,与 ai_recover_parked IPC/unattended B5 三路同源,停靠卡「继续」伪失败闭环);② 3 处在途编译错顺手修(feishu_channel.rs `async async fn` 语法错 + send_markdown_card pub(crate)、tool_failure_memory.rs 测试 serde_json::from_str)。待提交通知后随批入库。
|
||||
> 📤 **2026-10-07 推送**:14 笔已入库提交已推送 origin/main(03dc27b..e36b55a,含冻结令 e36b55a)。工作区 20 文件攒批原样保留,零 commit 零推送,冻结令纪律不违。推送时遇 DNS 解析失败(代理工具隔离 env),用 `--resolve gitea.1216.top:443:39.99.243.191` 临时直连绕过,未污染 gitconfig。
|
||||
|
||||
@@ -955,3 +956,8 @@ graph TD
|
||||
40. [ ] **桌面侧 u-relay 常驻连接管理器(设计命题,待综合评估后再规划设计)**(2026-10-07 用户议定「登记后处理」):ask_uclaw 现为每请求独立 WS 短连接(connect→hello→探活→request→progress/result→close,任务执行期内本就实时);用户提议改「全程一条常驻连接中转所有上下行(zhub 式单连接多路复用)」。**本项仅登记命题,具体方案待后续综合评估**——评估须回答:①当前唯一用例(ask_uclaw 15min 级委托)下常驻是否真有必要(握手 ~0.1s 可忽略、u-claw 侧单 channel 串行 worker 锁死并发=1)②push 型需求(u-claw 主动推事件/高频短指令/多 channel 并行/跨链路统一观测)何时兑现 ③是否与 10-05「推翻网关链路、定稿桌面直连」决策冲突(飞书直连接入-形态修正与问题记录-2026-10-05.md)。命中上述 push 型触发条件之一即启动评估;同构先例可参 df-im-gateway(一条常驻 tunnel 连接中转四端全部上下行)与 feishu_channel.rs。
|
||||
|
||||
900. [x](自动续作) 🔴 测试条目
|
||||
|
||||
901. [x](自动续作) 🔴 提交工作区改动(转交实战,2026-10-07):将当前工作区改动按主题分组提交并推送 origin main。(21:4x AI-Ops 重置回队:前两轮引擎会话被 20:38-21:02 u-switch 故障窗口「网络中断不完整」腰斩,重入预算耗尽停 [→],网络已恢复)要求:①提交前 G4 前置走查(风格/明显缺陷/调试残留/注释规范,发现问题先处理再提交) ②分组建议:挂机模式 P1 总装一笔(UnattendedPanel/Settings/TopBar/TaskBoundaryCard/i18n/unattended 两 IPC/lib.rs/feishu_notify)+看板销账一笔(todo.md) ③swap-install.ps1 若有改动先审 diff 再定去留;删除 scripts/check_plaintext_key.py(一次性诊断脚本已完成使命) ④提交信息中文类型开头逐件列全,commit-msg hook 会拦截违规 ⑤完成后本条改 [x] 并附 git log --oneline -3 输出。
|
||||
|
||||
902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。
|
||||
903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 覆盖标志 + 看板 🔒 行,写入执行闸静态位。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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} 需为正整数',
|
||||
|
||||
@@ -22,7 +22,13 @@ export default {
|
||||
archived: '已归档',
|
||||
archivedCount: '已归档 ({n})',
|
||||
recoverableCount: '可恢复 ({n})',
|
||||
recoverableHint: '任务因重启中断停靠,点击恢复继续执行',
|
||||
recoverableHint: '任务已安全停靠,点击恢复继续执行',
|
||||
// ── 挂机引擎态徽章(TopBar;开启时显示,熔断红徽点击复位) ──
|
||||
unattendedOn: '挂机中',
|
||||
unattendedExecuting: '执行中',
|
||||
unattendedTripped: '挂机熔断',
|
||||
unattendedBadgeHint: '挂机模式运行中,点击前往设置',
|
||||
unattendedResetHint: '连续启动失败已自动熔断,点击复位并重新开启',
|
||||
recoverableDone: '任务已恢复,继续后台执行',
|
||||
recoverableFail: '恢复失败: {msg}',
|
||||
emptyConversation: '暂无对话',
|
||||
|
||||
@@ -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 地址',
|
||||
|
||||
@@ -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'
|
||||
|
||||
+6
-2
@@ -120,8 +120,12 @@ try {
|
||||
"[$(Get-Date -Format 'HH:mm:ss')] verify mismatch disk baseline, retry once" | Add-Content $log
|
||||
Wait-HandleReleased | Out-Null
|
||||
$retryHash = Invoke-SwapInstall 'retry'
|
||||
if ($retryHash -and $firstHash -eq $retryHash -and $retryHash -ne $before.hash) {
|
||||
"[$(Get-Date -Format 'HH:mm:ss')] verify pass (installer deterministic output $($retryHash.Substring(0, 12)))" | Add-Content $log
|
||||
if ($retryHash -and $firstHash -eq $retryHash) {
|
||||
if ($retryHash -ne $before.hash) {
|
||||
"[$(Get-Date -Format 'HH:mm:ss')] verify pass (installer deterministic output $($retryHash.Substring(0, 12)))" | Add-Content $log
|
||||
} else {
|
||||
"[$(Get-Date -Format 'HH:mm:ss')] verify pass (already up to date, same version reinstalled)" | Add-Content $log
|
||||
}
|
||||
$pass = $true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user