Compare commits

..
7 Commits
Author SHA1 Message Date
lxy 91773da602 文档: 提交重整处方与在飞收尾 2026-10-04 16:31:17 +08:00
lxy b994cf2c54 文档: 设计定稿与评估纪律登记
- 无人值守长任务设计定稿,含效率与可观测补遗
- u-relay wire 协议规格与五项评估结论
- 新能力引入评估纪律五项清单
- todo 销账与狗粮短板分析登记
2026-10-04 16:27:32 +08:00
lxy 08e9d0292d 优化: 子代理截断信号与缓存失效
- max_tokens 截断标记穿透到完成卡
- 缓存写后失效补测试锁定
- 派发参数卡头信息补全
2026-10-04 16:27:32 +08:00
lxy d9e0ce73d1 优化: 跨端链路协议对齐
- miniapp 配置域旧端点强制重置
- df-tunnel 隧道协议与 relay 现网对齐
- relay e2e 例程入 examples
2026-10-04 16:27:31 +08:00
lxy 7aa0da32f2 修复: 协议层与体验缺陷六件
- temperature 按模型适配,仅收 1 的模型不再 400
- 流式 error 帧不再被 choices 必填整帧丢弃
- 只读缓存写后失效
- git diff 中文截断撞 UTF-8 边界 panic
- 非视觉模型纯图片直接拒绝
- 全局快捷键补输入焦点守卫
2026-10-04 16:27:31 +08:00
lxy 01d231c39d 优化: 子代理体验与纪律注入
- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 960s 统一出口报告不再蒸发
2026-10-04 16:27:31 +08:00
lxy b2d44f85e9 新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
2026-10-04 16:27:30 +08:00
96 changed files with 6227 additions and 509 deletions
Generated
+10
View File
@@ -869,6 +869,7 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -4422,6 +4423,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "system-configuration" name = "system-configuration"
version = "0.7.0" version = "0.7.0"
+10 -9
View File
@@ -1,10 +1,11 @@
/** /**
* df-relay WS 客户端(uni-app 跨端适配) * u-relay WS 客户端(uni-app 跨端适配)
* *
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection): * 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/miniapp` * 1. 建立 WS 连接到 `/ws/commander`
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时) * 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时)
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close * 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close
* (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed)
* *
* 跨端适配: * 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket) * - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
@@ -176,10 +177,10 @@ class WsClient {
} }
/** /**
* 发送 Command(miniapp → device) * 发送 Command(commander → agent)
* *
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成 * 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。 * BroadcastMessage(channel_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/ */
send(cmd: MiniCommand): boolean { send(cmd: MiniCommand): boolean {
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked) if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
@@ -314,8 +315,8 @@ class WsClient {
this.setStatus('handshaking') this.setStatus('handshaking')
const cfg = getConfig() const cfg = getConfig()
const hello: Hello = { const hello: Hello = {
kind: 'miniapp', kind: 'commander',
device_id: cfg.deviceId, channel_id: cfg.channelId,
token: cfg.token, token: cfg.token,
} }
try { try {
+35 -17
View File
@@ -1,18 +1,18 @@
/** /**
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填) * miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填)
* *
* 这些值在真实部署前是占位: * 这些值在真实部署前是占位:
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址 * - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID * - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键)
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token) * - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3)
*/ */
export interface MiniappConfig { export interface MiniappConfig {
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */ /** relay WS 地址(含协议),如 'ws://192.168.1.100:9180/ws/commander' 或 'wss://relay.example.com/ws/commander' */
relayHost: string relayHost: string
/** 配对绑定的设备 ID */ /** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */
deviceId: string channelId: string
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */ /** 配对 token(对齐 u-relay U_RELAY_TOKEN) */
token: string token: string
/** 心跳间隔(ms) */ /** 心跳间隔(ms) */
heartbeatInterval: number heartbeatInterval: number
@@ -29,17 +29,17 @@ export interface MiniappConfig {
/** /**
* 默认配置(占位值,需替换) * 默认配置(占位值,需替换)
* *
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。 * 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接, * MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
* 这符合预期,提示用户配置)。 * 这符合预期,提示用户配置)。
*/ */
export const defaultConfig: MiniappConfig = { export const defaultConfig: MiniappConfig = {
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws) // u-relay 现行协议 commander 端点:wss://u-work.1216.top → nginx 终止 TLS → u-relay :9180 明文 ws
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名) // 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/miniapp', relayHost: 'wss://u-work.1216.top/ws/commander',
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端) // 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端)
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程) // 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程)
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0', channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token', token: 'devflow-relay-default-token',
heartbeatInterval: 30000, heartbeatInterval: 30000,
reconnectBaseDelay: 1000, reconnectBaseDelay: 1000,
@@ -54,6 +54,23 @@ const STORAGE_KEY = 'df-miniapp-config'
/** 单例配置(null 表示尚未从 storage 加载) */ /** 单例配置(null 表示尚未从 storage 加载) */
let _config: MiniappConfig | null = null let _config: MiniappConfig | null = null
/**
* 旧版字段迁移(df-relay 一代 → u-relay):
* storage 里的旧 JSON 含 deviceId 字段,映射为 channelId,避免老用户升级后配置丢失。
*/
function migrateLegacyFields(parsed: Record<string, unknown>): Partial<MiniappConfig> {
const out = { ...parsed } as Record<string, unknown>
if (out.deviceId !== undefined && out.channelId === undefined) {
out.channelId = out.deviceId
}
delete out.deviceId
// 旧 relayHost 指向已下线的 /ws/miniapp 端点,升级后强制重置为新端点
if (typeof out.relayHost === 'string' && out.relayHost.includes('/ws/miniapp')) {
out.relayHost = defaultConfig.relayHost
}
return out as Partial<MiniappConfig>
}
/** /**
* 从 storage 读取并合并默认配置 * 从 storage 读取并合并默认配置
* *
@@ -64,9 +81,10 @@ function loadFromStorage(): MiniappConfig {
try { try {
const raw = uni.getStorageSync(STORAGE_KEY) const raw = uni.getStorageSync(STORAGE_KEY)
if (!raw) return { ...defaultConfig } if (!raw) return { ...defaultConfig }
const parsed = JSON.parse(raw) as Partial<MiniappConfig> const parsed = JSON.parse(raw) as Record<string, unknown>
// 合并默认值,避免旧版本字段缺失导致 undefined // 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined
return { ...defaultConfig, ...parsed } const migrated = migrateLegacyFields(parsed)
return { ...defaultConfig, ...migrated }
} catch (e) { } catch (e) {
console.warn('[df-miniapp] load config from storage failed:', e) console.warn('[df-miniapp] load config from storage failed:', e)
return { ...defaultConfig } return { ...defaultConfig }
+12 -12
View File
@@ -6,7 +6,7 @@ import { useAiChat } from '@/composables/useAiChat'
/** /**
* 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。 * 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。
* *
* 默认查看模式(只读展示 relayHost/deviceId/token,token 脱敏);点「编辑」进入编辑模式, * 默认查看模式(只读展示 relayHost/channelId/token,token 脱敏);点「编辑」进入编辑模式,
* 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」, * 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」,
* 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。 * 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。
* *
@@ -82,13 +82,13 @@ function onConnOp(): void {
/** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */ /** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */
const editing = ref(false) const editing = ref(false)
const draftRelayHost = ref('') const draftRelayHost = ref('')
const draftDeviceId = ref('') const draftChannelId = ref('')
const draftToken = ref('') const draftToken = ref('')
/** 进入编辑模式:以当前配置填充草稿 */ /** 进入编辑模式:以当前配置填充草稿 */
function enterEdit(): void { function enterEdit(): void {
draftRelayHost.value = cfg.value.relayHost draftRelayHost.value = cfg.value.relayHost
draftDeviceId.value = cfg.value.deviceId draftChannelId.value = cfg.value.channelId
draftToken.value = cfg.value.token draftToken.value = cfg.value.token
editing.value = true editing.value = true
} }
@@ -101,12 +101,12 @@ function cancelEdit(): void {
/** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */ /** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */
function saveEdit(): void { function saveEdit(): void {
const relayHost = draftRelayHost.value.trim() const relayHost = draftRelayHost.value.trim()
const deviceId = draftDeviceId.value.trim() const channelId = draftChannelId.value.trim()
if (!relayHost || !deviceId) { if (!relayHost || !channelId) {
uni.showToast({ title: '中继地址和设备 ID 不能为空', icon: 'none' }) uni.showToast({ title: '中继地址和通道 ID 不能为空', icon: 'none' })
return return
} }
setConfig({ relayHost, deviceId, token: draftToken.value.trim() }) setConfig({ relayHost, channelId, token: draftToken.value.trim() })
editing.value = false editing.value = false
uni.showToast({ title: '已保存,正在重连…', icon: 'none' }) uni.showToast({ title: '已保存,正在重连…', icon: 'none' })
ai.resumeIfDisconnected() ai.resumeIfDisconnected()
@@ -139,7 +139,7 @@ function saveEdit(): void {
</view> </view>
</view> </view>
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/deviceId/token) --> <!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/channelId/token) -->
<view class="card"> <view class="card">
<view class="card-head"> <view class="card-head">
<text class="card-title">连接配置</text> <text class="card-title">连接配置</text>
@@ -151,13 +151,13 @@ function saveEdit(): void {
</view> </view>
<view class="row"> <view class="row">
<text class="row-label">中继地址</text> <text class="row-label">中继地址</text>
<input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/miniapp" placeholder-class="row-ph" /> <input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/commander" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.relayHost }}</text> <text v-else class="row-value mono">{{ cfg.relayHost }}</text>
</view> </view>
<view class="row"> <view class="row">
<text class="row-label">设备 ID</text> <text class="row-label">通道 ID</text>
<input v-if="editing" v-model="draftDeviceId" class="row-input" placeholder="桌面端设备 ID" placeholder-class="row-ph" /> <input v-if="editing" v-model="draftChannelId" class="row-input" placeholder="u-relay 通道 ID" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.deviceId }}</text> <text v-else class="row-value mono">{{ cfg.channelId }}</text>
</view> </view>
<view class="row"> <view class="row">
<text class="row-label">配对 Token</text> <text class="row-label">配对 Token</text>
+38 -35
View File
@@ -1,44 +1,47 @@
/** /**
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/) * u-relay 协议类型镜像(对齐 E:/wk-lab/u-relay/src/)
* *
* 类型证据: * 类型证据:
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41 * - ClientKind / MessageKind:u-relay/src/broadcast.rs
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control') * (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control')
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts) * - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts)
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token) * - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token)
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp' * kind 用 ClientKindWire,序列化为 'agent'/'commander'
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点) * - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端)
* *
* 关键协议点(relay.rs:332-365 handle_inbound_text): * 关键协议点(relay.rs handle_inbound_text):
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。 * relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。 * 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage * 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
* 再从 payload 取 AiChatEvent。 * 再从 payload 取 AiChatEvent。
*
* 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线,
* 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。
*/ */
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */ /** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */
export type ClientKind = 'device' | 'miniapp' export type ClientKind = 'agent' | 'commander'
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */ /** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */
export type MessageKind = 'event' | 'command' | 'control' export type MessageKind = 'event' | 'command' | 'control'
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */ /** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */
export type PresenceState = 'online' | 'offline' export type PresenceState = 'online' | 'offline'
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */ /** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number;0 为 NIL 伪值 */
export type ConnId = number export type ConnId = number
/** /**
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62) * 中继路由消息骨架(对齐 Rust BroadcastMessage)
* *
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。 * miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
*/ */
export interface BroadcastMessage { export interface BroadcastMessage {
/** 配对绑定的设备 ID */ /** 配对绑定的通道 ID */
device_id: string channel_id: string
/** 消息语义 */ /** 消息语义 */
kind: MessageKind kind: MessageKind
/** 消息来源连接 ID(relay 分配,u64) */ /** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */
source: ConnId source: ConnId
/** 来源客户端类型 */ /** 来源客户端类型 */
from: ClientKind from: ClientKind
@@ -49,36 +52,36 @@ export interface BroadcastMessage {
} }
/** /**
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53) * 客户端首消息:身份宣告(对齐 Rust Hello)
* *
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。 * 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。 * kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。
*/ */
export interface Hello { export interface Hello {
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */ /** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */
kind: 'miniapp' | 'device' kind: 'commander'
/** 配对绑定的设备 ID(用户首次配置时生成) */ /** 配对绑定的通道 ID(非空,≤128B,无控制字符) */
device_id: string channel_id: string
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */ /** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */
token: string token: string
} }
/** /**
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind") * 控制面消息(对齐 Rust ControlMessage,tag = "control_kind")
* *
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。 * miniapp 主要发 Ping 心跳(relay 本地直回 pong,不经对端);Pair/Heartbeat/Presence 骨架保留。
*/ */
export type ControlMessage = export type ControlMessage =
| { control_kind: 'pair'; device_id: string; token: string } | { control_kind: 'pair'; channel_id: string; token: string }
| { control_kind: 'heartbeat'; device_id: string } | { control_kind: 'heartbeat'; channel_id: string }
| { control_kind: 'presence'; device_id: string; state: PresenceState } | { control_kind: 'presence'; channel_id: string; state: PresenceState }
| { control_kind: 'ping' } | { control_kind: 'ping' }
| { control_kind: 'pong' } | { control_kind: 'pong' }
/** /**
* 小程序 → 桌面端 Command 协议(miniapp 发起操作) * 小程序(控制端)→ 桌面端(执行端)Command 协议
* *
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。 * 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。
* 字段: * 字段:
* - cmd:命令名(对齐 src-tauri command 函数名) * - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数) * - args:命令参数(对齐各 command 的参数)
@@ -227,6 +230,6 @@ export interface ContinueLoopArgs {
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */ /** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
export interface StopLoopArgs { export interface StopLoopArgs {
/** 霷停止循环的会话 id */ /** 需停止循环的会话 id */
conversation_id: string conversation_id: string
} }
+12
View File
@@ -138,6 +138,14 @@ pub struct ModelConfig {
#[serde(default = "default_context_window")] #[serde(default = "default_context_window")]
pub context_window: usize, pub context_window: usize,
/// 是否支持 temperature 参数(默认 true)。
///
/// 部分模型仅接受固定温度值(如商汤 sensenova 系列仅允许 1.0),透传其它温度(标题 0.3/
/// 压缩 0.2 等)→ 上游 HTTP 400,实测 9 次。false 时协议转换层省略 temperature 字段
/// (交由上游默认),避免无效往返;true 保持原样透传(零回归)。
#[serde(default = "default_true")]
pub temperature_supported: bool,
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)── // ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub probe_source: Option<ProbeSource>, pub probe_source: Option<ProbeSource>,
@@ -173,6 +181,9 @@ impl ModelConfig {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: default_weight(), weight: default_weight(),
context_window: default_context_window(), context_window: default_context_window(),
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
@@ -339,6 +350,7 @@ mod tests {
intelligence: IntelligenceTier::Plus, intelligence: IntelligenceTier::Plus,
weight: 70, weight: 70,
context_window: 128_000, context_window: 128_000,
temperature_supported: true,
probe_source: Some(ProbeSource::PresetTable), probe_source: Some(ProbeSource::PresetTable),
}; };
let json = serde_json::to_string(&mc).unwrap(); let json = serde_json::to_string(&mc).unwrap();
+1
View File
@@ -472,6 +472,7 @@ mod tests {
usage: None, usage: None,
error: None, error: None,
reasoning_content: Some("thought".to_string()), reasoning_content: Some("thought".to_string()),
truncated_by_max_tokens: false,
}; };
let json = serde_json::to_string(&chunk).unwrap(); let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("reasoning_content")); assert!(json.contains("reasoning_content"));
+5
View File
@@ -350,6 +350,11 @@ pub struct StreamChunk {
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
#[serde(skip)] #[serde(skip)]
pub error: Option<String>, pub error: Option<String>,
/// 本流因 provider 输出上限截断收尾(openai finish_reason=length /
/// anthropic stop_reason=max_tokens)。finished 仍为 true(正常终止非断连),但文本/
/// tool_calls 可能不完整;消费方跨帧 OR 累积该标志,轮产出据此标记截断,不冒充正常完成。
#[serde(default)]
pub truncated_by_max_tokens: bool,
/// DeepSeek thinking 模式推理内容增量 /// DeepSeek thinking 模式推理内容增量
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
+2 -1
View File
@@ -133,7 +133,8 @@
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"weight": 85, "weight": 85,
"context_window": 262144 "context_window": 262144,
"temperature_supported": false
}, },
{ {
"model_id": "deepseek-v4-flash", "model_id": "deepseek-v4-flash",
+27 -1
View File
@@ -262,12 +262,23 @@ impl AnthropicCompatProvider {
.collect() .collect()
}); });
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
// 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。
let temperature = if req.temperature.is_some()
&& !crate::model_probe::probe(&model).temperature_supported
{
tracing::debug!(model = %model, "模型不支持 temperature,已省略该参数");
None
} else {
req.temperature
};
AnthropicRequest { AnthropicRequest {
model, model,
messages, messages,
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
system, system,
temperature: req.temperature, temperature,
stream: req.stream, stream: req.stream,
tools, tools,
tool_choice: req.tool_choice, tool_choice: req.tool_choice,
@@ -927,6 +938,21 @@ mod tests {
assert_eq!(a.total_tokens, 15); assert_eq!(a.total_tokens, 15);
} }
/// stop_reason=max_tokens(message_delta 帧携带)必须置截断标志;end_turn 不置。
/// 终态 finished 在后续 message_stop 帧,消费方跨帧 OR 累积标志。
#[test]
fn anthropic_max_tokens_stop_reason_sets_truncation_flag() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":9}}"#;
let c = apply_anthropic_event(data, &mut acc);
assert!(!c.finished, "message_delta 非终态帧");
assert!(c.truncated_by_max_tokens, "max_tokens 必须置截断标志");
let mut acc2: Option<TokenUsage> = None;
let c2 = apply_anthropic_event(&message_delta(5), &mut acc2);
assert!(!c2.truncated_by_max_tokens, "end_turn 不置截断标志");
}
/// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8,结果应是 8 不是 13 /// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8,结果应是 8 不是 13
#[test] #[test]
fn anthropic_message_delta_output_is_cumulative_not_incremental() { fn anthropic_message_delta_output_is_cumulative_not_incremental() {
+16 -10
View File
@@ -137,7 +137,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let v: serde_json::Value = match serde_json::from_str(data) { let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v, Ok(v) => v,
Err(_) => { Err(_) => {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
}; };
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
@@ -169,7 +169,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
reasoning_tokens: 0, reasoning_tokens: 0,
}); });
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => { "message_delta" => {
@@ -179,9 +179,12 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
acc.completion_tokens = out as u32; acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens); acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
} }
// stop_reason=max_tokens(message_delta 帧携带):输出被 max_tokens 截断。
// 终态 finished 在后续 message_stop 帧,截断标志挂本帧,消费方跨帧 OR 累积。
let truncated_by_max_tokens = v.get("delta").and_then(|d| d.get("stop_reason")).and_then(|s| s.as_str()) == Some("max_tokens");
// 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop // 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。 // 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens }
} }
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
@@ -190,16 +193,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。 // 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string(); let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text) }; return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text), truncated_by_max_tokens: false };
} }
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传 // signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。 // anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
} }
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
} }
// 工具入参增量 // 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
@@ -217,10 +220,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
// 工具块开始:带 id + name // 工具块开始:带 id + name
"content_block_start" => { "content_block_start" => {
@@ -253,10 +257,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
// 消息结束:带出累积 usage // 消息结束:带出累积 usage
"message_stop" => StreamChunk { "message_stop" => StreamChunk {
@@ -266,15 +271,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}, },
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库), // 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。 // 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => { "error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string(); let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
error!(%msg, "Anthropic 流式错误事件"); error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None, truncated_by_max_tokens: false }
} }
// content_block_stop / ping 等不产出 chunk // content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }, _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false },
} }
} }
+23 -8
View File
@@ -841,12 +841,12 @@ fn phase1_push_only_keeps_clean_no_dirty_flag() {
#[test] #[test]
fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() { fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() {
// advance_persisted_count(new_count):推进基线 + 清 needs_full_rewrite(DB 已与内存一致) // advance_persisted_count(new_count, full):推进基线;全量重写才清 needs_full_rewrite
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("x")); mgr.push(ChatMessage::user("x"));
mgr.push(ChatMessage::user("y")); mgr.push(ChatMessage::user("y"));
// 模拟 save 成功后推进 // 模拟 save(全量重写)成功后推进
mgr.advance_persisted_count(2); mgr.advance_persisted_count(2, true);
assert_eq!(mgr.persisted_msg_count(), 2); assert_eq!(mgr.persisted_msg_count(), 2);
assert!(!mgr.needs_full_rewrite()); assert!(!mgr.needs_full_rewrite());
} }
@@ -855,11 +855,26 @@ fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() {
fn phase1_advance_persisted_count_max_no_regression() { fn phase1_advance_persisted_count_max_no_regression() {
// 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写) // 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写)
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));
mgr.advance_persisted_count(5); mgr.advance_persisted_count(5, true);
mgr.advance_persisted_count(3); // 误传更小 mgr.advance_persisted_count(3, true); // 误传更小
assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退"); assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退");
} }
#[test]
fn phase1_advance_incremental_keeps_dirty() {
// 增量/无变化路径(full=false)不得清 dirty:写库期间新置的 dirty(如压缩链 mark)
// 与本次 append 写库无关,误清会致改动滞留内存永不落库
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
mgr.advance_persisted_count(1, false); // 增量 save 成功
assert!(!mgr.needs_full_rewrite());
mgr.mark_needs_full_rewrite(); // 模拟写库期间压缩 mark
mgr.advance_persisted_count(1, false); // 又一次增量 save
assert!(mgr.needs_full_rewrite(), "增量路径 advance 不得清 dirty");
mgr.advance_persisted_count(1, true); // 直到全量重写才清
assert!(!mgr.needs_full_rewrite());
}
#[test] #[test]
fn phase1_compress_old_messages_sets_dirty() { fn phase1_compress_old_messages_sets_dirty() {
// compress 改既有消息 status(active→compressed) → 必须全量重写收敛 // compress 改既有消息 status(active→compressed) → 必须全量重写收敛
@@ -881,7 +896,7 @@ fn phase1_compress_no_op_does_not_set_dirty() {
mgr.push(ChatMessage::user("a")); mgr.push(ChatMessage::user("a"));
let _ = mgr.compress_old_messages(1); let _ = mgr.compress_old_messages(1);
assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty"); assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty");
mgr.advance_persisted_count(1); // 模拟 save 后清 dirty mgr.advance_persisted_count(1, true); // 模拟 save(全量)后清 dirty
// 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty // 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty
let compressed = mgr.compress_old_messages(1); let compressed = mgr.compress_old_messages(1);
assert!(compressed.is_empty()); assert!(compressed.is_empty());
@@ -974,7 +989,7 @@ fn phase1_clear_resets_baseline_and_sets_dirty() {
// clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB) // clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB)
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a")); mgr.push(ChatMessage::user("a"));
mgr.advance_persisted_count(1); mgr.advance_persisted_count(1, true);
assert_eq!(mgr.persisted_msg_count(), 1); assert_eq!(mgr.persisted_msg_count(), 1);
mgr.clear(); mgr.clear();
assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0"); assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0");
@@ -1021,7 +1036,7 @@ fn phase1_append_after_dirty_stays_dirty_until_save() {
mgr.push(ChatMessage::user("b")); // 追加 mgr.push(ChatMessage::user("b")); // 追加
assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty"); assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty");
// 模拟 save(全量重写)成功后推进 + 清 // 模拟 save(全量重写)成功后推进 + 清
mgr.advance_persisted_count(2); mgr.advance_persisted_count(2, true);
assert!(!mgr.needs_full_rewrite()); assert!(!mgr.needs_full_rewrite());
} }
+6 -2
View File
@@ -528,12 +528,16 @@ impl ContextManager {
} }
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。 /// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
/// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。 /// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。
pub fn advance_persisted_count(&mut self, new_count: usize) { /// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty
/// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。
pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) {
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。 // 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
self.persisted_msg_count = self.persisted_msg_count.max(new_count); self.persisted_msg_count = self.persisted_msg_count.max(new_count);
if full_rewrite_applied {
self.needs_full_rewrite = false; self.needs_full_rewrite = false;
} }
}
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。 /// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
pub fn needs_full_rewrite(&self) -> bool { pub fn needs_full_rewrite(&self) -> bool {
+18
View File
@@ -159,6 +159,24 @@ mod tests {
assert!(m.modalities.contains(&Modality::Text)); assert!(m.modalities.contains(&Modality::Text));
// context_window 应为预设的 256K // context_window 应为预设的 256K
assert_eq!(m.context_window, 262144); assert_eq!(m.context_window, 262144);
// P1 温度:商汤 sensenova 仅接受 temperature=1.0,透传其它值(标题 0.3/压缩 0.2)
// → 上游 HTTP 400(实测 9 次)。预设表须标 temperature_supported=false,
// 协议转换层据此省略该字段。
assert!(
!m.temperature_supported,
"sensenova 应标 temperature_supported=false(仅接受 1.0)"
);
}
/// 温度支持判定:仅 sensenova 系列为 false,其余模型默认 true(零回归)。
#[test]
fn probe_temperature_supported_defaults_true_except_sensenova() {
assert!(!probe("sensenova-6.7-flash-lite").temperature_supported);
// 启发式路径(非预设表):sensenova 前缀仍判 false
assert!(!probe("sensenova-unknown-model").temperature_supported);
// 常规模型保持 true(原样透传)
assert!(probe("deepseek-v4-flash").temperature_supported);
assert!(probe("glm-4v").temperature_supported);
} }
#[test] #[test]
+10
View File
@@ -74,6 +74,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
}; };
} }
@@ -106,6 +107,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
} }
} }
@@ -120,3 +122,11 @@ pub(super) fn has_vision_token(name: &str) -> bool {
|| name.contains("vl") || name.contains("vl")
|| name.split(['-', '.', '_']).any(|seg| seg.ends_with('v')) || name.split(['-', '.', '_']).any(|seg| seg.ends_with('v'))
} }
/// 温度参数支持判定(命名约定式):商汤 sensenova 系列仅接受 temperature=1.0,
/// 透传其它温度值(标题 0.3/压缩 0.2 等)→ 上游 HTTP 400(实测 9 次)。
/// 返回 false 时协议转换层省略 temperature 字段(交上游默认),避免无效往返;
/// 其余模型默认 true(原样透传,零回归)。
pub(super) fn temperature_supported_by_name(name: &str) -> bool {
!name.starts_with("sensenova")
}
+12 -1
View File
@@ -209,10 +209,21 @@ impl OpenAICompatProvider {
.collect() .collect()
}); });
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
// 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。
let temperature = if req.temperature.is_some()
&& !crate::model_probe::probe(&model).temperature_supported
{
tracing::debug!(model = %model, "模型不支持 temperature,已省略该参数");
None
} else {
req.temperature
};
OpenAiRequest { OpenAiRequest {
model, model,
messages, messages,
temperature: req.temperature, temperature,
max_tokens: req.max_tokens, max_tokens: req.max_tokens,
stream: req.stream, stream: req.stream,
tools, tools,
+36
View File
@@ -274,6 +274,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
@@ -296,6 +297,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: None, usage: None,
error: Some(msg), error: Some(msg),
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积) // 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
@@ -334,6 +336,9 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
choice.finish_reason.as_deref(), choice.finish_reason.as_deref(),
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter") Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter")
); );
// length = 输出被 max_tokens 截断:仍按正常终止收敛(finished=true),
// 但截断标志随 chunk 带出,消费方据此把该轮产出标记截断,不冒充完整。
let truncated_by_max_tokens = choice.finish_reason.as_deref() == Some("length");
let tool_calls = choice.delta.tool_calls.map(|tcs| { let tool_calls = choice.delta.tool_calls.map(|tcs| {
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index // 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
@@ -379,6 +384,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.clone(), usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: choice.delta.reasoning_content, reasoning_content: choice.delta.reasoning_content,
truncated_by_max_tokens,
} }
} else { } else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出 // choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出
@@ -389,6 +395,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.clone(), usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
} }
} }
} }
@@ -401,6 +408,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
} }
} }
} }
@@ -503,4 +511,32 @@ mod tests {
); );
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结"); assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结");
} }
/// finish_reason=length:max_tokens 截断仍按正常终止收敛(finished=true),
/// 但截断标志必须随 chunk 带出(零信号会让腰斩报告冒充完整完成);
/// stop 等正常终态不置标志。
#[test]
fn finish_reason_length_carries_truncation_flag() {
let mut accum = None;
let chunk = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"报告写到一半"},"finish_reason":"length"}]}"#,
&mut accum,
);
assert!(chunk.finished, "length 属正常终止");
assert!(chunk.truncated_by_max_tokens, "length 必须带截断标志");
let mut accum2 = None;
let chunk2 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}"#,
&mut accum2,
);
assert!(chunk2.finished && !chunk2.truncated_by_max_tokens, "stop 不置截断标志");
let mut accum3 = None;
let chunk3 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
&mut accum3,
);
assert!(!chunk3.truncated_by_max_tokens, "普通 delta 不置截断标志");
}
} }
+95 -7
View File
@@ -5,7 +5,7 @@
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。 //! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
//! //!
//! 策略: //! 策略:
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter //! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误 //! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错 //! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合 //! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
@@ -34,6 +34,11 @@ const BASE_BACKOFF_SECS: u64 = 1;
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。 /// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// 单次退避基准上限(毫秒),与 [`MAX_TOTAL_BUDGET`] 同口径: 超预算的退避没有意义,
/// attempt 走深时不再指数膨胀。与总预算的一致性由测试 `backoff_cap_matches_budget` 守住。
/// (同构先例: df-tunnel 的 backoff_delay 也以 RECONNECT_MAX 封顶。)
const MAX_BACKOFF_MS: u64 = 30_000;
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。 /// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。 /// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0; const JITTER_RATIO: f64 = 1.0;
@@ -65,20 +70,45 @@ pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status) status == 429 || (500..600).contains(&status)
} }
/// 退避倍率 `2^(attempt-1)`,位移溢出安全。
///
/// 替代裸 `1u64 << (attempt - 1)`: attempt > 64 时位移越界(debug panic / release 环绕)、
/// attempt = 0 时下溢 panic。现 attempt = 0 视作 1(倍率 1),attempt - 1 ≥ 64 时
/// checked_shl 返回 None,倍率封顶 u64::MAX,最终时长由 [`MAX_BACKOFF_MS`] 兜底。
/// 全输入无 panic,序列单调不减。
fn backoff_multiplier(attempt: u32) -> u64 {
match 1u64.checked_shl(attempt.saturating_sub(1)) {
Some(m) => m,
None => u64::MAX,
}
}
/// 退避基准毫秒(未加 jitter),`BASE_BACKOFF_SECS * 2^(attempt-1) * 1000`,封顶 [`MAX_BACKOFF_MS`]。
///
/// 乘法全程 saturating: 倍率封顶 u64::MAX 后 `* 1000` 也会二次溢出,不能裸乘。
/// 单一事实源——backoff_delay 与单测的 base 断言都走这里,不再各写一份公式。
fn base_backoff_ms(attempt: u32) -> u64 {
BASE_BACKOFF_SECS
.saturating_mul(backoff_multiplier(attempt))
.saturating_mul(1000)
.min(MAX_BACKOFF_MS)
}
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。 /// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。 /// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
/// ///
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。 /// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
/// ///
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用 /// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。 /// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration { pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000; let base_ms = base_backoff_ms(attempt) as f64;
// ±50% jitter,相对 base 时长的浮动比例 // ±50% jitter,相对 base 时长的浮动比例
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5) let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
let factor = 1.0 + jitter_ratio * JITTER_RATIO; let factor = 1.0 + jitter_ratio * JITTER_RATIO;
let ms = (base_ms as f64 * factor).max(0.0) as u64; let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64;
Duration::from_millis(ms) Duration::from_millis(ms)
} }
@@ -160,12 +190,12 @@ mod tests {
#[test] #[test]
fn backoff_is_monotonic_within_jitter() { fn backoff_is_monotonic_within_jitter() {
// base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围 // base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围
for attempt in 1..=3 { for attempt in 1..=3 {
let d = backoff_delay(attempt); let d = backoff_delay(attempt);
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000; let base_ms = base_backoff_ms(attempt);
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64; let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64; let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
assert!( assert!(
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi, d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
"attempt={} delay={:?} 应在 [{},{}]ms", "attempt={} delay={:?} 应在 [{},{}]ms",
@@ -177,6 +207,64 @@ mod tests {
} }
} }
/// 倍率溢出安全: attempt=0 不下溢,≥65 封顶 u64::MAX,序列单调不减
#[test]
fn backoff_multiplier_extremes_safe() {
assert_eq!(backoff_multiplier(0), 1, "attempt=0 视作 1,不下溢 panic");
assert_eq!(backoff_multiplier(1), 1);
assert_eq!(backoff_multiplier(2), 2);
assert_eq!(backoff_multiplier(3), 4);
assert_eq!(backoff_multiplier(63), 1u64 << 62);
assert_eq!(backoff_multiplier(64), 1u64 << 63, "u64 可表达的最大位移");
assert_eq!(backoff_multiplier(65), u64::MAX, "checked_shl 越界封顶");
assert_eq!(backoff_multiplier(100), u64::MAX);
assert_eq!(backoff_multiplier(u32::MAX), u64::MAX);
let seq: Vec<u64> = [0u32, 1, 2, 3, 10, 63, 64, 65, 100, u32::MAX]
.iter()
.map(|&a| backoff_multiplier(a))
.collect();
assert!(seq.windows(2).all(|w| w[0] <= w[1]), "倍率应单调不减: {:?}", seq);
}
/// 基准毫秒单调不减、封顶值正确(2^5×1s=32s 已超 30s 预算,从 attempt=6 起封顶)
#[test]
fn base_backoff_ms_monotonic_and_capped() {
let mut prev = 0u64;
for attempt in [0u32, 1, 2, 3, 4, 5, 6, 10, 63, 64, 65, 100, u32::MAX] {
let b = base_backoff_ms(attempt);
assert!(b >= prev, "attempt={} base_ms={} 应 ≥ 前值 {}", attempt, b, prev);
assert!(b <= MAX_BACKOFF_MS, "attempt={} base_ms={} 应 ≤ {}", attempt, b, MAX_BACKOFF_MS);
prev = b;
}
assert_eq!(base_backoff_ms(1), 1000);
assert_eq!(base_backoff_ms(2), 2000);
assert_eq!(base_backoff_ms(3), 4000);
assert_eq!(base_backoff_ms(5), 16_000, "未触及封顶");
assert_eq!(base_backoff_ms(6), MAX_BACKOFF_MS, "32s 超预算封顶 30s");
assert_eq!(base_backoff_ms(u32::MAX), MAX_BACKOFF_MS);
}
/// 极端 attempt 下 backoff_delay 不 panic(修复: 1u64<<(attempt-1) 曾 debug panic/release 环绕)
#[test]
fn backoff_delay_extreme_attempts_no_panic_and_capped() {
for attempt in [0u32, 1, 10, 63, 64, 65, 100, u32::MAX] {
let ms = backoff_delay(attempt).as_millis() as u64;
assert!(
ms <= MAX_BACKOFF_MS,
"attempt={} 实际 {}ms 应 ≤ {}ms(含 jitter 后仍不超)",
attempt,
ms,
MAX_BACKOFF_MS
);
}
}
/// 封顶常量与总预算同口径(改预算时两处同步)
#[test]
fn backoff_cap_matches_budget() {
assert_eq!(MAX_BACKOFF_MS, MAX_TOTAL_BUDGET.as_millis() as u64);
}
#[test] #[test]
fn status_classification() { fn status_classification() {
assert!(is_status_retryable(429)); assert!(is_status_retryable(429));
+1
View File
@@ -163,6 +163,7 @@ mod tests {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
+20 -3
View File
@@ -1,9 +1,26 @@
# df-relay — 已迁移(deprecated) # df-relay — 已迁移(deprecated)
> **本 crate 已于 2026-08-23 提取为独立产品 [`u-relay`](../u-relay)**(`E:/wk-lab/u-relay`,git filter-repo 保留完整提交历史,脱敏改名 df_\*→u_\*)。 > **本 crate 已于 2026-08-23 提取为独立产品 [`u-relay`](../../../u-relay)**(`E:/wk-lab/u-relay`,git filter-repo 保留完整提交历史,脱敏改名 df_\*→u_\*)。
- **新功能一律在 u-relay 仓库开发**,本 crate 不再接受功能演进(防双份漂移) - **新功能一律在 u-relay 仓库开发**,本 crate 不再接受功能演进(防双份漂移)
- 本目录暂保留:现役生产部署(容器 :9180)仍从 devflow 侧构建,待 devflow 客户端(df-tunnel / 小程序 ws.ts)迁移 u-relay 后整体下线(设计 Q7) - 本目录暂保留:现役生产部署(容器 :9180)仍从 devflow 侧构建,待 devflow 客户端(df-tunnel / df-miniapp ws.ts)迁移 u-relay 后整体下线(设计 Q7)
- u-relay 协议为 df-relay 超集:hello / device_id / Event / Command 帧原样兼容,迁移只需切 host
## ⚠️ 协议兼容性(2026-10-04 更正)
早期陈述「u-relay 协议为 df-relay 超集,hello/device_id 原样兼容,迁移只需切 host」**已过时**。
u-relay 在协议正名(agent/commander 第三代)时**已拆除全部兼容层**:
| 维度 | df-relay(本 crate) | u-relay(现行) |
|---|---|---|
| 端点 | `/ws/device`、`/ws/miniapp` | **`/ws/agent`、`/ws/commander`**(旧端点不存在) |
| Hello.kind | `"device"`、`"miniapp"` | **`"agent"`、`"commander"`**(旧 kind 握手期直接拒绝) |
| 配对键字段 | `device_id` | **`channel_id`**(不再 alias) |
| 错误码 | `invalid_device_id` | `invalid_channel_id` |
| token env | `DF_RELAY_TOKEN` | `U_RELAY_TOKEN` |
**迁移不是"切 host"而是协议升级**。devflow 侧客户端已于 2026-10-04 完成升级:
- `crates/df-tunnel`(Rust,桌面端 Agent)✅
- `apps/df-miniapp`(uni-app,Commander:config.ts / ws.ts / types/relay.ts / settings 页 + storage 旧字段迁移)✅
协议规格见 `docs/02-架构设计/专项设计/u-relay-wire-protocol-2026-10-04.md`。
设计文档:`C:/Users/23780/docs/u-box-design.md` §3.3 + `u-relay-extract-plan.md` 设计文档:`C:/Users/23780/docs/u-box-design.md` §3.3 + `u-relay-extract-plan.md`
@@ -108,6 +108,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
// origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式): // origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式):
// 迁移未跑/极端情况防御,NULL=主会话。 // 迁移未跑/极端情况防御,NULL=主会话。
origin: row.get("origin").unwrap_or(None), origin: row.get("origin").unwrap_or(None),
// boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。
boundary_json: row.get("boundary_json").unwrap_or(None),
}) })
} }
@@ -183,24 +185,25 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin) "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13", "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
params![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
], ],
) )
} }
@@ -783,6 +786,29 @@ impl AiConversationRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 无人值守任务边界快照写入(V45 boundary_json 列)。None=清除(回退逐次审批)。
///
/// 仅改 boundary_json,不动 updated_at(纯元数据标记,对齐 set_archived/set_pinned)。
/// 返回 affected>0:对话尚未落库(懒创建,首条消息前)时影响 0 行——调用方以内存
/// per_conv.boundary 为真相源,首插 save 会随 INSERT 带上,无需报错。
pub async fn set_boundary_json(&self, id: &str, boundary_json: Option<&str>) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let value = boundary_json.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_conversations SET boundary_json = ?1 WHERE id = ?2",
params![value, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。 /// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。
/// ///
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联 /// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联
+59 -6
View File
@@ -51,7 +51,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。 // 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。
// V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库, // V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库,
// 桌面侧栏/miniapp 列表按 origin 过滤子会话)。 // 桌面侧栏/miniapp 列表按 origin 过滤子会话)。
let steps: [(i32, fn(&Connection) -> Result<()>); 44] = [ // V45 = 无人值守任务边界(ai_conversations.boundary_json TEXT,TaskBoundary JSON 快照,
// 发起时一次声明存续期生效,随会话同亡)。
let steps: [(i32, fn(&Connection) -> Result<()>); 45] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -96,6 +98,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(42, migrate_v42), (42, migrate_v42),
(43, migrate_v43), (43, migrate_v43),
(44, migrate_v44), (44, migrate_v44),
(45, migrate_v45),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1334,6 +1337,23 @@ fn migrate_v44(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V45: 无人值守任务边界 — ai_conversations 加 boundary_json TEXT 列。
///
/// TaskBoundary JSON 快照(风险上限/目录白名单/禁破坏性/预算),发起时一次声明、
/// 任务存续期生效、随会话同亡(无独立表,设计拍板点 2: 任务与会话 1:1,独立表过度设计)。
/// NULL=未声明(现状逐次审批,渐进增强零倒退)。
/// - **幂等**:column_exists 守卫(同 V42/V44 模式),崩溃重跑/全链两遍不炸;
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN boundary_json`(SQLite 3.35+)。
fn migrate_v45(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "boundary_json") {
conn.execute("ALTER TABLE ai_conversations ADD COLUMN boundary_json TEXT", [])?;
tracing::info!("v45: ai_conversations 加 boundary_json 列(无人值守任务边界)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [45])?;
tracing::info!("迁移 v45 完成: ai_conversations 加 boundary_json 列");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2338,7 +2358,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)" "tasks.module_id 列缺失(V41 加)"
); );
// 3. schema_version 应推进到 43(全量迁移成功落版本号) // 3. schema_version 应推进到 45(全量迁移成功落版本号)
let max_version: i64 = conn let max_version: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version", "SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2347,8 +2367,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 44, max_version, 45,
"全量迁移后 schema_version 应为 44(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 45(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2425,7 +2445,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 44, "首轮应推进到 44"); assert_eq!(max_v, 45, "首轮应推进到 45");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2437,7 +2457,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 44, "重跑后应重新推进到 44"); assert_eq!(max_v2, 45, "重跑后应重新推进到 45");
} }
// ============================================================ // ============================================================
@@ -2510,4 +2530,37 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(v, 41); assert_eq!(v, 41);
} }
/// V45 幂等:最小库补建 boundary_json 列;重跑(含手动回退版本号模拟崩溃断点)不炸、
/// 不报 duplicate column、版本号 INSERT OR IGNORE 防 PK 冲突。
#[test]
fn v45_boundary_json_idempotent() {
let conn = setup_legacy_db();
migrate_v45(&conn).expect("v45 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "boundary_json"), "应有 boundary_json 列");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 45, "应写入版本号 45");
// 模拟崩溃断点:版本号回退(45 未落)但列已建 → 重跑走 column_exists 短路
conn.execute("DELETE FROM schema_version WHERE version = 45", []).unwrap();
migrate_v45(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
let v2: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v2, 45, "重跑后版本号恢复 45");
// 列可写可读(NULL 语义=未声明边界)
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, boundary_json) \
VALUES ('c1', '[]', '0', '0', '{\"risk_ceiling\":\"medium\"}')",
[],
)
.unwrap();
let got: Option<String> = conn
.query_row("SELECT boundary_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert!(got.is_some(), "boundary_json 应可写可读");
}
} }
+3
View File
@@ -374,6 +374,9 @@ pub struct AiConversationRecord {
/// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库), /// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库),
/// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。 /// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。
pub origin: Option<String>, pub origin: Option<String>,
/// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
pub boundary_json: Option<String>,
} }
/// AI 工具执行审计记录 /// AI 工具执行审计记录
+68
View File
@@ -0,0 +1,68 @@
//! df-tunnel ↔ u-relay 端到端联调(Agent 侧握手 + 存活 + 断开)
//!
//! 起 WsTunnelClient 连本机 u-relay :19180 /ws/agent:
//! 1. Hello{kind:"agent", channel_id, token} → u-relay 立即回 hello_ack(3s PROBE 内收到)
//! 2. send_raw_event 发一条事件(relay 无 commander 在线 → 静默丢弃,不报错)
//! 3. 存活 2s(收发循环稳定,无 panic)
//! 4. 优雅 disconnect
//!
//! 运行:先起 u-relay(U_RELAY_TOKEN=devflow-relay-default-token U_RELAY_ADDR=127.0.0.1:19180),
//! 再 `cargo run -p df-tunnel --example e2e_u_relay`。
use std::sync::Arc;
use std::time::Duration;
use df_tunnel::tunnel::CommandHandler;
use df_tunnel::{TunnelClient, WsTunnelClient};
use tokio::sync::Mutex;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = "ws://127.0.0.1:19180/ws/agent";
let channel_id = "test-channel-e2e-01";
let token = "devflow-relay-default-token";
let client = Arc::new(WsTunnelClient::new());
// 注册 command 回调(收 commander 指令;本测试预计不触发)
let got_cmd = Arc::new(Mutex::new(0usize));
let counter = got_cmd.clone();
let on_command: CommandHandler = Arc::new(move |_payload| {
let counter = counter.clone();
Box::pin(async move {
*counter.lock().await += 1;
})
});
println!("[1] connect {url} channel={channel_id}");
client
.connect(url, channel_id, token, on_command)
.await
.map_err(|e| anyhow::anyhow!("connect 失败: {e}"))?;
println!(" connect Ok");
assert!(client.is_connected(), "is_connected 应为 true");
println!("[2] is_connected == true ✓(u-relay 已回 hello_ack,握手成功)");
// 发一条原始事件(relay 无 commander 在线 → 静默丢弃,不应报错)
client
.send_raw_event(serde_json::json!({"type": "e2e_probe", "note": "df-tunnel e2e"}))
.await
.map_err(|e| anyhow::anyhow!("send_raw_event 失败: {e}"))?;
println!("[3] send_raw_event Ok(事件出站;relay 无对端静默丢弃符合预期)");
// 保持 2s 让心跳/收发循环稳定跑(无 panic 即可)
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(client.is_connected(), "2s 后仍应 connected(心跳保活)");
println!("[4] 2s 存活 ✓");
// 优雅断开
client.disconnect().await?;
assert!(!client.is_connected(), "disconnect 后 is_connected 应为 false");
println!("[5] disconnect Ok,is_connected == false ✓");
let got = *got_cmd.lock().await;
println!("───");
println!("df-tunnel ↔ u-relay E2E: 5/5 通过(command 收到 {got} 条,预期 0)");
Ok(())
}
+27 -16
View File
@@ -11,41 +11,51 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// 握手身份宣告帧(对齐 df-relay::relay::Hello) /// 握手身份宣告帧(对齐 u-relay::relay::Hello)
/// ///
/// 桌面端 WS 连 `/ws/device` 后,首条消息发此 JSON,relay 校验 kind 与 token。 /// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 df-relay 严格一致(`#[serde(rename_all="snake_case")]`), /// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 否则 relay 反序列化失败 → 握手被拒。 /// 否则 relay 反序列化失败 → 握手被拒。
/// ///
/// 注意:本 crate 不依赖 df-relay(避免 lib→server 反向依赖),自行镜像协议结构。 /// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 df-relay::relay::Hello 与本类型。 /// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
///
/// 历史:df-relay 一代协议用 device_id + kind:"device",已在 u-relay 现行版全部下线
/// (端点 /ws/agent,kind:"agent",配对键 channel_id,旧名握手期直接拒绝)。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello { pub struct Hello {
/// 客户端类型("device" / "miniapp"),桌面端恒为 Device /// 客户端类型("agent" / "commander"),桌面端恒为 Agent
pub kind: HelloKind, pub kind: HelloKind,
/// 配对绑定的设备 ID /// 配对绑定的通道 ID(非空,≤128B,无控制字符)
pub device_id: String, pub channel_id: String,
/// 配对 token(env DF_RELAY_TOKEN 同源) /// 配对 token(env U_RELAY_TOKEN 同源)
pub token: String, pub token: String,
} }
/// Hello.kind 传输表示(serde 字符串,与 df-relay::ClientKindWire 一致) /// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
///
/// 现行仅两值:Agent(执行端,如 df-tunnel)/ Commander(控制端,如 df-miniapp)。
/// 历史名 Device/Miniapp/Runner/Console/Executor/Controller 已全部下线。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum HelloKind { pub enum HelloKind {
Device, Agent,
Miniapp, Commander,
} }
/// relay 控制面错误帧(握手失败时 relay 发送) /// relay 控制面错误帧(握手失败时 relay 发送)
/// ///
/// relay 在校验失败/连接异常时发 `{"kind":"control","error":"<reason>"}` 文本帧。 /// relay 在校验失败/连接异常时发 `{"kind":"control","error":"<reason>"}` 文本帧。
/// 桌面端据此识别握手失败原因(不依赖 error 帧亦可:连接被 Close 即视为失败)。 /// 桌面端据此识别握手失败原因(不依赖 error 帧亦可:连接被 Close 即视为失败)。
///
/// 现行 u-relay 错误码:handshake_failed / kind_mismatch / invalid_channel_id /
/// server_misconfigured / auth_failed。历史 `invalid_device_id` 已下线。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayControlError { pub struct RelayControlError {
/// 固定 "control"(用于与正常 BroadcastMessage 区分,relay 错误帧简化为两字段) /// 固定 "control"(用于与正常 BroadcastMessage 区分,relay 错误帧简化为两字段)
pub kind: String, pub kind: String,
/// 错误原因(handshake_failed / kind_mismatch / auth_failed) /// 错误原因(handshake_failed / kind_mismatch / invalid_channel_id /
/// server_misconfigured / auth_failed)
pub error: String, pub error: String,
} }
@@ -112,10 +122,11 @@ impl TunnelEvent {
} }
} }
/// 云后端 → 桌面端的操作指令(小程序远程操作映射) /// 云后端 → 桌面端的操作指令(控制端远程操作映射)
/// ///
/// 对齐设计文档「Layer1 指令路由」: /// 对齐设计文档「Layer1 指令路由」:
/// send/stop/approve/regenerate/switch 五类操作。 /// send/stop/approve/regenerate/switch 五类操作。
/// 由控制端(df-miniapp 等 Commander)发出,经 u-relay 路由到本桌面端 Agent。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelCommand { pub enum TunnelCommand {
@@ -133,8 +144,8 @@ pub enum TunnelCommand {
/// 隧道传输的消息包装(双向) /// 隧道传输的消息包装(双向)
/// ///
/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent) /// 区分「桌面端 → u-relay → 控制端」的事件流(TunnelEvent)
/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。 /// 与「控制端 → u-relay → 桌面端」的指令流(TunnelCommand)。
/// 同一 WS 连接复用,靠 kind 标签区分。 /// 同一 WS 连接复用,靠 kind 标签区分。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "dir", rename_all = "snake_case")] #[serde(tag = "dir", rename_all = "snake_case")]
+3 -3
View File
@@ -3,9 +3,9 @@
//! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。 //! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。
//! //!
//! ## 职责 //! ## 职责
//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射) //! - 主动连接云后端 u-relay(出站穿 NAT,无需端口映射)
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序 //! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 控制端(df-miniapp 等)
//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command //! - 指令路由:接收控制端远程操作 → 调对应 Tauri command
//! //!
//! ## 边界 //! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制) //! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
+35 -31
View File
@@ -1,18 +1,20 @@
//! df-tunnel 隧道客户端(Phase2 完整实现) //! df-tunnel 隧道客户端(Phase2 完整实现,2026-10-04 切换 u-relay 协议)
//! //!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`, //! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/agent`,
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。 //! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。
//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处): //! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处):
//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。 //! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。
//! //!
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e) //! ## 协议对齐(u-relay 现行,2026-10-04 起)
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }` //! - 桌面端连 `/ws/agent`,首帧发 `Hello { kind:"agent", channel_id, token }`
//! - relay 双因子校验(kind == expected_device + token 匹配),失败发 //! - relay 双因子校验(kind == agent + token 匹配 + channel_id 格式校验),
//! `{"kind":"control","error":"..."}` + Close,成功则静默进入收发循环(无显式 ack) //! 失败发 `{"kind":"control","error":"..."}` + Close,
//! - 握手成功信号:**未收到 error 帧 且 socket 保持打开**(connect 用 timeout 探测首帧) //! 成功立即回 `{"kind":"control","payload":{"control_kind":"hello_ack"}}` 显式 ack
//! - 入站消息:relay 把对端 Miniapp 的文本帧包成 BroadcastMessage 转发,桌面端收到的 //! - 握手成功信号:**收到 hello_ack 控制帧 或 收到任何非 error 首帧**(u-relay 有显式 ack)
//! 是完整 BroadcastMessage JSON,payload 是 miniapp 指令原样 JSON(Phase3 纯透传,不解析) //! - 入站消息:relay 把对端 Commander 的文本帧包成 BroadcastMessage 转发,桌面端收到的
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 BroadcastMessage) //! 是完整 BroadcastMessage JSON,payload 是 commander 指令原样 JSON(纯透传,不解析)
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 Event 广播)
//! - 心跳:relay 本地回 pong(`{"control_kind":"ping"}` → pong 不经对端),路径短延迟低
//! //!
//! ## AiChatEvent 透传原则 //! ## AiChatEvent 透传原则
//! payload 用 serde_json::Value 透传,本 crate 不依赖 src-tauri/df-types(避跨 crate 强耦合)。 //! payload 用 serde_json::Value 透传,本 crate 不依赖 src-tauri/df-types(避跨 crate 强耦合)。
@@ -21,7 +23,7 @@
//! ## 并发模型 //! ## 并发模型
//! 内部 spawn 收发循环 task,通过 mpsc 解耦 send_event(调用方)与 socket 写入。 //! 内部 spawn 收发循环 task,通过 mpsc 解耦 send_event(调用方)与 socket 写入。
//! 收到 command 方向 payload(`serde_json::Value`)走 on_command 回调(调用方在 connect 时注册),不阻塞收发循环。 //! 收到 command 方向 payload(`serde_json::Value`)走 on_command 回调(调用方在 connect 时注册),不阻塞收发循环。
//! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 device 端桥接层(src-tauri)。 //! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 agent 端桥接层(src-tauri)。
//! `&self` + `Arc<Mutex<...>>` 持连接状态,trait 方法全部 async + 不持 stream 借用。 //! `&self` + `Arc<Mutex<...>>` 持连接状态,trait 方法全部 async + 不持 stream 借用。
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -38,7 +40,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::error::{Result, TunnelError}; use crate::error::{Result, TunnelError};
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent}; use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent};
/// 收到 miniapp→device 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await) /// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
/// ///
/// Phase3 协议统一(D1=A):tunnel 纯透传,不再反序列化 TunnelCommand 强类型, /// Phase3 协议统一(D1=A):tunnel 纯透传,不再反序列化 TunnelCommand 强类型,
/// payload 以 `serde_json::Value` 上抛,业务协议解析在 device 端桥接层(src-tauri)。 /// payload 以 `serde_json::Value` 上抛,业务协议解析在 device 端桥接层(src-tauri)。
@@ -61,14 +63,14 @@ pub type CommandHandler = Arc<dyn Fn(serde_json::Value) -> futures_util::future:
pub trait TunnelClient: Send + Sync { pub trait TunnelClient: Send + Sync {
/// 主动连接云后端 + 注册指令回调 /// 主动连接云后端 + 注册指令回调
/// ///
/// `url` 形如 `wss://host/ws/device`(query 不带 token,token 走 Hello 帧)。 /// `url` 形如 `wss://host/ws/agent`(query 不带 token,token 走 Hello 帧)。
/// 成功后进入「已连接」状态并 spawn 收发循环。失败返回 TunnelError::Connect/Auth。 /// 成功后进入「已连接」状态并 spawn 收发循环。失败返回 TunnelError::Connect/Auth。
/// `on_command` 收到云后端转发的 command 方向 payload Value 时被调用(在收发 task 内异步执行)。 /// `on_command` 收到云后端转发的 command 方向 payload Value 时被调用(在收发 task 内异步执行)。
/// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 device 端桥接层 match cmd 路由。 /// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 agent 端桥接层 match cmd 路由。
async fn connect( async fn connect(
&self, &self,
url: &str, url: &str,
device_id: &str, channel_id: &str,
token: &str, token: &str,
on_command: CommandHandler, on_command: CommandHandler,
) -> Result<()>; ) -> Result<()>;
@@ -79,12 +81,12 @@ pub trait TunnelClient: Send + Sync {
/// 是否处于已连接状态(轻量查询,不阻塞,不发心跳) /// 是否处于已连接状态(轻量查询,不阻塞,不发心跳)
fn is_connected(&self) -> bool; fn is_connected(&self) -> bool;
/// 发送事件(桌面端 → 云后端 → 小程序) /// 发送事件(桌面端 → 云后端 → 控制端)
/// ///
/// 非阻塞入队,实际 socket 写入异步完成。连接断开时返回 NotConnected。 /// 非阻塞入队,实际 socket 写入异步完成。连接断开时返回 NotConnected。
async fn send_event(&self, event: TunnelEvent) -> Result<()>; async fn send_event(&self, event: TunnelEvent) -> Result<()>;
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value /// 发送原始事件(桌面端 → 云后端 → 控制端),payload 为 AiChatEvent 序列化 JSON Value
/// ///
/// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法 /// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法
/// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行, /// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行,
@@ -92,7 +94,7 @@ pub trait TunnelClient: Send + Sync {
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>; async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>;
} }
/// 握手探测窗口(relay 校验通过后静默进入收发循环,无 ack 帧; /// 握手探测窗口(u-relay 校验通过会立即回 hello_ack 控制帧;
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败) /// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3); const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
@@ -104,6 +106,7 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收) /// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25); const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
/// 重连初始退避 /// 重连初始退避
@@ -136,7 +139,7 @@ pub struct WsTunnelClient {
/// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询 /// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
/// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。 /// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。
server_url: Mutex<Option<String>>, server_url: Mutex<Option<String>>,
device_id: Mutex<Option<String>>, channel_id: Mutex<Option<String>>,
token: Mutex<Option<String>>, token: Mutex<Option<String>>,
/// 当前活跃连接状态(None = 未连接) /// 当前活跃连接状态(None = 未连接)
conn: Mutex<Option<ConnState>>, conn: Mutex<Option<ConnState>>,
@@ -150,19 +153,19 @@ impl WsTunnelClient {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
server_url: Mutex::new(None), server_url: Mutex::new(None),
device_id: Mutex::new(None), channel_id: Mutex::new(None),
token: Mutex::new(None), token: Mutex::new(None),
conn: Mutex::new(None), conn: Mutex::new(None),
connected: Arc::new(AtomicBool::new(false)), connected: Arc::new(AtomicBool::new(false)),
} }
} }
/// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连复用) /// 当前已连接时取一份重连参数(URL/channel_id/token,供调用方 supervisor 断线重连复用)
pub async fn reconnect_params(&self) -> Option<(String, String, String)> { pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
let url = self.server_url.lock().await.clone()?; let url = self.server_url.lock().await.clone()?;
let device_id = self.device_id.lock().await.clone()?; let channel_id = self.channel_id.lock().await.clone()?;
let token = self.token.lock().await.clone()?; let token = self.token.lock().await.clone()?;
Some((url, device_id, token)) Some((url, channel_id, token))
} }
} }
@@ -177,7 +180,7 @@ impl TunnelClient for WsTunnelClient {
async fn connect( async fn connect(
&self, &self,
url: &str, url: &str,
device_id: &str, channel_id: &str,
token: &str, token: &str,
on_command: CommandHandler, on_command: CommandHandler,
) -> Result<()> { ) -> Result<()> {
@@ -204,8 +207,8 @@ impl TunnelClient for WsTunnelClient {
// 2. 发 Hello 握手帧 // 2. 发 Hello 握手帧
let hello = Hello { let hello = Hello {
kind: HelloKind::Device, kind: HelloKind::Agent,
device_id: device_id.to_string(), channel_id: channel_id.to_string(),
token: token.to_string(), token: token.to_string(),
}; };
let hello_json = serde_json::to_string(&hello)?; let hello_json = serde_json::to_string(&hello)?;
@@ -216,13 +219,14 @@ impl TunnelClient for WsTunnelClient {
// 3. 探测握手结果:HANDSHAKE_PROBE 窗口内 // 3. 探测握手结果:HANDSHAKE_PROBE 窗口内
// - 收到 error 帧 → relay 拒绝(返回 Auth 错误,附原因) // - 收到 error 帧 → relay 拒绝(返回 Auth 错误,附原因)
// - 收到 Close / EOF → 连接被关(返回 Connect 错误) // - 收到 Close / EOF → 连接被关(返回 Connect 错误)
// - 收到首条正常消息 / 超时无负帧 → 视为握手成功(relay 静默进入收发循环) // - 收到 hello_ack / 首条正常消息 / 超时无负帧 → 视为握手成功(u-relay 显式 ack)
// - Ping/Pong 协议帧:忽略继续等 // - Ping/Pong 协议帧:忽略继续等
let mut first_frame_pending: Option<WsMessage> = None; let mut first_frame_pending: Option<WsMessage> = None;
let probe = tokio::time::timeout(HANDSHAKE_PROBE, stream.next()).await; let probe = tokio::time::timeout(HANDSHAKE_PROBE, stream.next()).await;
match probe { match probe {
Err(_) => { Err(_) => {
// 超时无负帧:relay 静默通过(常见路径) // 超时无负帧:hello_ack 错过探测窗(可能性低)或 relay 异常,
// 仍按成功处理(后续帧会进 run_loop 正常分派)
tracing::debug!("握手探测窗口无负帧,视为成功"); tracing::debug!("握手探测窗口无负帧,视为成功");
} }
Ok(None) => { Ok(None) => {
@@ -253,7 +257,7 @@ impl TunnelClient for WsTunnelClient {
// 4. 握手成功:保存参数 + 建发送队列 + spawn 收发循环 // 4. 握手成功:保存参数 + 建发送队列 + spawn 收发循环
*self.server_url.lock().await = Some(url.to_string()); *self.server_url.lock().await = Some(url.to_string());
*self.device_id.lock().await = Some(device_id.to_string()); *self.channel_id.lock().await = Some(channel_id.to_string());
*self.token.lock().await = Some(token.to_string()); *self.token.lock().await = Some(token.to_string());
let (tx, rx) = mpsc::unbounded_channel::<OutFrame>(); let (tx, rx) = mpsc::unbounded_channel::<OutFrame>();
@@ -271,7 +275,7 @@ impl TunnelClient for WsTunnelClient {
)); ));
*self.conn.lock().await = Some(ConnState { tx, task }); *self.conn.lock().await = Some(ConnState { tx, task });
tracing::info!(%url, device_id, "WsTunnelClient 连接就绪,收发循环已启动"); tracing::info!(%url, channel_id, "WsTunnelClient 连接就绪,收发循环已启动");
Ok(()) Ok(())
} }
@@ -332,7 +336,7 @@ impl WsTunnelClient {
} }
} }
*self.server_url.lock().await = None; *self.server_url.lock().await = None;
*self.device_id.lock().await = None; *self.channel_id.lock().await = None;
*self.token.lock().await = None; *self.token.lock().await = None;
self.connected.store(false, Ordering::Relaxed); self.connected.store(false, Ordering::Relaxed);
} }
+1
View File
@@ -90,6 +90,7 @@
| [多Agent并行执行与仲裁合并设计-2026-07-01.md](./专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md) | 📐 设计 | 混合数据模型(messages加FK列+Plan/SubTask/Conflict独立表)、Git worktree隔离、JoinSet层内并行、四层冲突防线、Reviewer仲裁、事件协议、UI交互 | | [多Agent并行执行与仲裁合并设计-2026-07-01.md](./专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md) | 📐 设计 | 混合数据模型(messages加FK列+Plan/SubTask/Conflict独立表)、Git worktree隔离、JoinSet层内并行、四层冲突防线、Reviewer仲裁、事件协议、UI交互 |
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 | | [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 | | [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
| [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 |
--- ---
@@ -0,0 +1,61 @@
# context_helpers.rs 拆分备勘(REFACTOR-260619-09 修订)· 设计(2026-10-04)
> 来源:todo.md「REFACTOR-260619-09 [P2 暂缓] — context.rs(1332)等 F-15 压缩链路稳定再拆」。
> **勘误(2026-10-04 现场核实)**:原条目指向的 `crates/df-ai/src/context.rs`(1332 行)**已不存在**——context 已拆为目录模块 `context/{mod.rs(38.5KB), sanitize.rs(64.5KB), manager_tests.rs(51.8KB)}`。**原 REFACTOR-260619-09 事实完成,建议 todo 销账**。
> 但拆分域出现**新超标 God 文件**:`crates/df-ai/src/context_helpers.rs` = **1733 行**(80KB,无测试分离),TokenEstimator/ContextConfig/工具结果压缩/分组/淘汰/公共工具混居。本设计以它为对象。
> 同域现状参考:df-ai/src/ 下平级大文件尚有 coordinator.rs(68.7KB)、intent.rs(66.8KB)、anthropic_compat.rs(69.9KB)、openai_compat.rs(65.4KB)——均超 60KB,属后续拆分候选池,本期不扩scope。
## 1. 拆分对象画像
`context_helpers.rs` 1733 行,混合至少六类职责(已知锚点):
- **Token 估算**:TokenEstimator 相关(行段待精确,拆时以 read_symbol 逐个锚定);
- **上下文配置**:ContextConfig 及常量;
- **工具结果压缩**:`:410 TOOL_RESULT_SUMMARIZE_BYTES=2048`、`:425 TOOL_RESULT_JSON_STR_FIELD_MAX=512`、`should_summarize_tool_result(:440)`、extract_key_info 系列(head/tail/array/char/json-str 五种截断);
- **namespace 引用转发**:`:646-647 NAMESPACE_REF_PREFIX`(仅常量 re-export,薄);
- **淘汰/分组逻辑**(eviction/grouping,与 context/mod.rs 的 ContextManager 呼应);
- **杂项公共工具**(is_han `:396` 等纯函数)。
**前置动作(拆分前 0.5 天)**:用 read_symbol 对 1733 行做一次全骨架清点,产出「函数→职责块→行段」映射表落 `docs/05-代码审查/context_helpers骨架-2026-10-XX.md`——本设计给出块划分与迁移策略,具体行号以骨架表为准。
## 2. 目标模块划分(建议)
拆为 `context_helpers/` 目录(与 context/ 同款目录化先例,lib.rs 加 `pub mod` 不动外部路径——`df_ai::context_helpers::X` 引用形态不变):
| 子模块 | 职责 | 预计行数 | 风险 |
|---|---|---|---|
| `token.rs` | TokenEstimator + 估算纯函数 | ~250 | 低(纯函数,单测易迁) |
| `config.rs` | ContextConfig + 阈值常量(含 TOOL_RESULT_* 六常量) | ~150 | 低 |
| `tool_compress.rs` | should_summarize_tool_result + extract_key_info 系列 | ~350 | **中(压缩热点,殿后)** |
| `eviction.rs` | 淘汰/分组 + namespace 常量转发 | ~400 | **中高(压缩链路高频改动区,殿后)** |
| `misc.rs` | is_han 等纯工具(或就近下沉使用方) | ~100 | 低 |
| `mod.rs` | re-export 保路径 + 少量真公共胶水 | ~50 | — |
**测试**:本文件测试与生产代码混居(无 #[cfg(test)] 分离迹象,拆时确认)——随成员走,`token.rs`/`tool_compress.rs` 各带自己的测试模块;若测试体量大,仿照 context/manager_tests.rs 惯例平级 `helpers_tests.rs`。
## 3. 迁移顺序(四批,压缩热点殿后)
```
批1 低风险: config.rs + misc.rs → cargo test -p df-ai 全绿
批2 低风险: token.rs (+其单测) → 同上
批3 热点: tool_compress.rs → 同上 + 手测长会话压缩路径
批4 热点: eviction.rs + mod.rs 收口 → 同上 + 手测 eviction 滚动摘要
```
每批验收门:`cargo test -p df-ai`(基线 560+)+ `cargo check --workspace`;批3/4 加:起应用开长会话触发压缩与淘汰各一次,行为无回归。**每批独立提交**,冲突时可安全停在任意批。
## 4. re-export 兼容策略
`context_helpers/mod.rs` 对全部原 pub 项 `pub use` 转发——仓内 grep 显示引用方走 `df_ai::context_helpers::*` 与 `crate::context_helpers::*` 两种路径,目录化后**零调用方改动**。仅 `use df_ai::context_helpers` 整体 use(若有)需 mod.rs 全量 re-export 兜住。
## 5. 与原条目的关系
- todo 销账动作:REFACTOR-260619-09 原 context.rs 拆分 ✅ 完成;**新登记**本设计为 REFACTOR-261004-01(context_helpers.rs 1733 行拆分),优先级维持 P2 暂缓——**触发条件**:上下文管理批(无人值守 P0/P1)合入且稳定 ≥1 周后启动批1/2(低风险先行),批3/4 继续等压缩链路静默期;
- context_helpers.rs 若在触发前因功能开发继续膨胀 >2000 行,优先把「工具结果压缩」块先行拆出(批3 提前),不整文件硬拆。
## 6. 工作量估算
骨架清点 0.5 天 + 批1/2 各 0.5 天 + 批3/4 各 1 天 ≈ 3.5 天(可拆散穿插,非连续占用)。
## 7. 拍板点(1 项)
- 触发时机:上下文管理批稳定后自动启动 vs 等用户指令?建议**等用户指令**(与 REFACTOR 暂缓惯例一致,本设计备勘即可)。
@@ -1,6 +1,6 @@
# devflow → u-relay → u-claw 集成可行性研究 # devflow → u-relay → u-claw 集成可行性研究
> 2026-09-28 · 状态:可行性已定,待 devflow/u-claw 两侧细勘察补全 > 2026-09-28 · 状态:可行性已定 ~~待 devflow/u-claw 两侧细勘察补全~~ ✅勘察已补全(2026-10-04,见 [uclaw两侧勘察补全-2026-10-04.md](./uclaw两侧勘察补全-2026-10-04.md)——多轮锚定定案+**协议漂移修正:现行 /ws/commander 角色,实施勿按本文档端点**)
> 需求原话:「考虑 devflow -> u-relay -> u-claw, 让 devflow 集成 u-claw 能力」 > 需求原话:「考虑 devflow -> u-relay -> u-claw, 让 devflow 集成 u-claw 能力」
## 一、定性结论 ## 一、定性结论
@@ -0,0 +1,154 @@
# DevFlow 集成三方 IM + 自定义 Web:AI Chat 远程接入规划
> 创建: 2026-10-04 | 状态: 📐 规划设计(待评审)
> 上级索引: [../INDEX.md](../INDEX.md) | 姊妹篇: [F-260620-01-跨端AIChat-微信小程序](../已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md)
## 一、目标与范围
在**飞书、企业微信、钉钉**三个 IM 内以及**自定义 Web 页面**中远程使用 DevFlow AI Chat:发消息、停止、重新生成、审批工具调用、切换会话——体验对齐桌面端。
**范围外**(本期不做):IM 内直接查看项目/任务列表等富 UI(依赖对话即入口,由 AI 工具调用返回文本结果);多人协作/团队版。
## 二、现状盘点(可复用的基础设施)
| 组件 | 现状 | 复用方式 |
|------|------|----------|
| `u-relay`(E:/wk-lab/u-relay) | 生产运行(u-relay.1216.top:9180,nginx+systemd),channel_id 配对点对点转发,payload 透传零业务耦合,25 项测试 | 直接复用,IM 网关作为新 Commander 接入 |
| `df-tunnel`(devflow crate) | 桌面端出站 WS 隧道(Agent 角色),已实现 TunnelCommand 五指令路由(Send/Stop/Approve/Regenerate/Switch)+ TunnelEvent 四事件透传(TextDelta/ToolCall/Approval/Completed)+ 指数退避重连 | **零改动**,桌面端不感知控制端是小程序还是 IM 机器人 |
| `df-miniapp`(小程序端) | F-260620-01 方案先行(Commander 模式先例) | 自定义 Web 端直接套用该模式 |
| AI Chat 引擎 | Agentic Loop + 工具审批,多会话 conv_id 路由(F-09) | 不动,仅靠隧道透传 |
**核心洞察**:三方 IM 机器人 + 自定义 Web = 新增的 **Commander 端**。桌面端(Agent)与 AI 引擎完全无感,全部新增工作收敛在「网关层」。
## 三、总体架构:IM 网关(df-im-gateway)
```
飞书 ─┐ ┌─ DevFlow 桌面端 (Agent)
企微 ─┼─► df-im-gateway ──WS Commander──► u-relay ──WS Agent ──┤ df-tunnel + AI Chat 引擎
钉钉 ─┤ (新增,Rust 独立服务) (生产已有,零改动) │ (零改动)
Web ──┘ 协议转换: IM API/回调 ↔ TunnelCommand/TunnelEvent └─ 多桌面端=多 channel_id
```
**df-im-gateway 职责**(唯一新增组件):
1. 以 WS Commander 身份连 u-relay(每条 channel 绑一个 DevFlow 桌面端)
2. 接收 IM 平台事件(回调 Webhook 或长连接推送),转 `TunnelCommand::Send/Stop/Approve`
3. 接收 `TunnelEvent`,聚合节流后调各 IM 平台**消息 API** 回复(流式 → 卡片刷新或一次性回复)
4. `Approval` 事件 → 发交互卡片(同意/拒绝按钮)→ 点击回填 `TunnelCommand::Approve`
5. IM 用户白名单 + channel_id 路由管理
**为什么独立服务而不并入 u-relay**:u-relay 的设计原则是「纯转发、零业务耦合、无外部依赖」。IM 对接需要凭证管理、回调验签、卡片模板、节流聚合等大量业务逻辑,塞进去会让中继失控。独立部署同机即可。
**语言选型 Rust**:直接 path 引用 df-tunnel 的 `TunnelCommand/TunnelEvent` 类型(同 workspace 或 git 依赖),编译时保证协议类型一致——这正是 F-260620-01 已论证的 Rust 中继优势。三方 IM SDK 均为 HTTP API,reqwest 直调即可,无 SDK 依赖硬伤。
## 三A、新组件必要性分析(df-im-gateway 为什么必须存在)
新增组件的正当性须逐一排除所有替代方案,全部不成立才立得住:
| 替代方案 | 排除理由 |
|---------|---------|
| **并入 u-relay** | ① u-relay 设计原则是「纯转发、payload 透传、零业务耦合、无外部依赖」,IM 对接需引入各平台 HTTP 客户端/AES 加解密/卡片模板/SQLite/节流器,塞入即毁掉其小审计面与高可用定位;② 故障域耦合:u-relay 是全局单点(小程序+IM+未来端全挂它),IM 平台 API 迭代快(钉钉卡片版本频繁),会让生产中继被迫高频重启;③ 二进制膨胀:3.6MB musl 单二进制的部署优势丧失 |
| **做进桌面端(df-tunnel/src-tauri)** | ① NAT 硬伤:企微回调必须公网 URL+可信 IP/域名,桌面端在内网/笔记本无法满足;飞书/钉钉长连接虽是出站,但桌面端休眠/离线时连「桌面端离线」提示都发不出(只有云端网关能兜底回复);② 桌面端会变成三套 IM 协议怪兽,Tauri 依赖拖重;③ 桌面端 IP 不固定、生命周期随人,不适合承载对外服务 |
| **Go/Node 跨语言独立服务** | 违背 F-260620-01 已论证的选型结论:TunnelCommand/TunnelEvent 跨语言镜像必然漂移,运行时消息丢失;Rust git 依赖 df-tunnel 类型 = 编译期协议一致 + 复用 publish-df-relay 同款 musl→scp→systemd 发布流水线。诚实 trade-off:飞书/钉钉官方 SDK 无 Rust 版需自写 HTTP 客户端,但均为标准 REST+签名协议,工作量可控 |
| **现成开源桥接器(onebot/matrix bridge 等)** | ① 三家 IM 官方机器人协议均非 onebot 标准,桥接器面向「自建聊天服务」场景,与「驱动本机 AI 引擎+工具审批」语义不匹配;② 审批按钮/卡片流式深度交互现成方案覆盖不了;③ 徒增第三方供应链风险 |
| **不做(维持现状)** | 需求价值真实:手机端随时驱动工作机 AI、审批卡片当场点掉无需回工位——是「桌面 AI 使用场景从工位扩展到任何地方」的产品能力增量,非技术炫技 |
**结论**:df-im-gateway 是「协议转换 + 凭证管理 + 公网可达性」三个约束的唯一交汇落点,必要性成立。
## 三B、破坏性分析(对既有系统的侵入面)
零改动承诺(桌面端/u-relay/AI 引擎)逐项压力测试,暴露真实破坏点:
| 受影响面 | 破坏性 | 等级 | 缓解/收敛策略 |
|---------|--------|------|--------------|
| **u-relay 双 Commander 并存语义** | 网关与 df-miniapp 同 channel 时:TunnelEvent 广播给两个 Commander → 双端重复回复用户;审批卡片双消费(小程序已点同意,IM 端卡片仍挂起可再点 → 重复 Approve) | 🔴 唯一高危点 | P0 实测 u-relay 多 Commander 投递行为;桌面端 Approve 处理按 approval_id 状态机幂等(pending→approved,二次点击无效——**待 P0 验证,若无则补**);过渡期运营约定同 channel 单活跃控制端;长期可演进「每端独立 channel + 桌面端多连接」(代价是破坏桌面端零改动,仅双端并存真实痛点出现时再议) |
| **df-tunnel 协议扩展压力** | IM 场景或需新指令/事件变体(如列会话、选择会话),触发桌面端升级 | 🟡 低 | 「新增变体只加不减」契约已内建向后兼容(serde `#[serde(other)]` 兜底);u-relay payload 透传对新变体天然无感;版本错配窗口仅存在于桌面端未升级期间 |
| **桌面端资源与连接** | 无新增常驻负担(仍是一条出站 WS,连接数不变) | 🟢 无 | — |
| **公网暴露面** | 攻击面从「u-relay 一个 WS 服务」扩大到「+网关 HTTP/WS(企微回调、Web Chat 端点)」 | 🟡 中 | 网关自身不执行任何命令/写盘(纯转发,真正工具执行仍在桌面端审批门禁之后);IM 用户白名单默认拒绝;审批分级(IM 端仅低风险工具) |
| **密钥面** | 三家 IM app_secret 新增落地,泄漏面 +1 | 🟡 中 | 网关 env/600 权限配置文件,不进 git;泄漏即换 secret 的应急预案 |
| **运维面** | 同机新增 systemd 服务(Rust 常驻 ~10-30MB)+ nginx +1 路由 | 🟢 低 | 发布节奏与 u-relay 解耦,互不牵连 |
| **数据一致性** | chat_id↔conv_id 映射漂移(桌面端删会话后悬空);IM 卡片状态与桌面端真实状态漂移(审批后卡片未刷新) | 🟡 低 | 映射悬空时网关回复「会话不存在」并自动清理重映射;卡片带状态版本号 + 过期失效文案 |
**总结论**:破坏性集中收敛于「双 Commander 并存」单点(🔴→🟡 可收敛),其余均为低危可控。零改动承诺成立——不需要为接入 IM 而改动任何现有生产组件;仅当未来「多控制端并存成常态」时才需重审 channel 架构。
## 四、四端集成模式对比
| 维度 | 飞书 | 企微 | 钉钉 | 自定义 Web |
|------|------|------|------|-----------|
| 应用形态 | 企业自建应用 | 企业自建应用 | 企业内部应用 + 机器人 | 网关托管静态页或桌面端内嵌页 |
| 事件接收 | **长连接模式**(官方 WS 推送,免公网回调)✅ | HTTP 回调(GET 验签 + POST AES 加密解密,需公网 URL) | **Stream Mode**(官方 WS 推送,免公网回调)✅ | 用户浏览器直连网关 HTTP/WS |
| 主动回复 | im/v1/messages(卡片消息) | 应用消息 send(Markdown 卡片) | 机器人发送 + 卡片 Stream 更新 | WS 推流(复用 df-miniapp 模式) |
| 流式体验 | 卡片流式更新(官方支持) | 消息可编辑(弱流式,节流刷新) | 卡片 AI 流式更新(官方支持) | 原生流式,体验最佳 |
| 审批按钮 | 交互卡片(最完善) | 按钮交互卡片(支持但样式受限) | 卡片回调按钮(支持) | 页面按钮(自由发挥) |
| 接入复杂度 | 中(长连接最省事) | 高(回调验签/AES/IP 白名单/可信域名) | 中 | 低(无三方审核) |
| 前置条件 | 管理员后台建应用+开权限 | 管理员后台建应用+配置可信 IP | 开放平台建应用+Stream 开关 | 无 |
**接入顺序建议**:Web(0 审核成本,先打通网关全链路)→ 飞书(长连接,免公网回调)→ 钉钉(Stream,同免公网)→ 企微(回调配置最繁琐,放最后)。
## 五、消息协议映射
```
IM 用户 @机器人 "把登录任务推进到 in_review"
→ 网关识别平台+用户+群聊 chat_id → 映射 conv_id(chat_id ↔ conv_id 持久化映射表)
→ TunnelCommand::Send { conv_id, content }
→ [桌面端 AI 引擎执行,ToolCall 需审批时回 TunnelEvent::Approval]
→ 网关发交互卡片 "[审批] advance_task(id=xxx, in_review) [同意] [拒绝]"
→ 用户点同意 → TunnelCommand::Approve { conv_id, approval_id, accepted: true }
→ TunnelEvent::TextDelta(节流聚合) / Completed
→ 网关更新卡片 / 发最终回复 + 来源标注("由 DevFlow AI 生成")
```
**关键设计点**:
1. **流式节流**:IM 平台消息 API 有频控(飞书 50 QPS/应用级、企微 200 次/分钟级),TextDelta 需聚合(如 800ms 窗口批量刷卡片);P1 可先非流式(进行中卡片 → Completed 后一次性回复)
2. **会话映射**:IM chat_id(单聊/群聊)↔ conv_id 一对一映射存网关侧 SQLite;群聊内仅 @机器人 时响应
3. **多桌面端路由**:网关支持配多 channel_id(如 `chan-work`→工作机、`chan-home`→家用机),IM 指令默认路由或按前缀路由(`/dev2 任务描述`)
4. **离线兜底**:Agent 不在线时网关直接回「桌面端未连接」(u-relay 有 PresenceState 可查询)+ 可选指令缓存重发
5. **消息长度**:IM 单条上限(飞书卡片较大、企微 Markdown 4096 字节),超长回复分段或转摘要+附件
## 六、安全设计
1. **双因子**:u-relay 侧 channel_id + token 配对(已有,常量时间比较);IM 侧回调验签(飞书 encrypt key/verification token、企微 AES+签名、钉钉 Stream 免验签)
2. **用户白名单**:企业成员 user_id 白名单表(默认拒绝所有),防止应用可见范围内任何人都能驱动桌面端 AI(高危:AI 可执行命令/改数据)
3. **审批降权**:IM 端默认只允许 `Approve` 低风险审批(只读工具);高风险(run_command/生产写入)强制回桌面端确认——网关侧按工具风险等级过滤
4. **凭证管理**:各平台 app_id/secret 存网关本地 env/配置文件(600 权限),不进 git;token 自动续期(企微 access_token 2h 缓存)
5. **审计**:网关侧记录 IM 消息 ↔ TunnelCommand 全量日志(谁在何时通过哪个平台驱动了什么指令)
## 七、实施分阶段
| Phase | 内容 | 产出 | 预估工作量 | 依赖 |
|-------|------|------|-----------|------|
| **P0 网关骨架** | df-im-gateway Rust 项目:WS Commander 连 u-relay(复用 df-tunnel 类型)、会话映射表、节流聚合器、配置管理、白名单框架 | 可连通桌面端收发消息的骨架 + 单测 | 2-3 天 | u-relay 生产实例(已有) |
| **P1 Web 端** | 自定义 Web Chat 页(复用 df-miniapp 交互模式:流式渲染/审批按钮/会话切换)+ 网关托管 | 浏览器/手机浏览器可用完整 AI Chat | 2-3 天 | P0 |
| **P2 飞书** | 长连接事件订阅 + 消息卡片(流式更新)+ 交互卡片审批 + 群聊 @识别 | 飞书内完整可用 | 3-4 天 | P0;管理员开应用权限 |
| **P3 钉钉** | Stream Mode + 卡片流式 + 按钮回调 | 钉钉内完整可用 | 3-4 天 | P0;开放平台建应用 |
| **P4 企微** | 回调 URL 验签 + AES 解密 + IP 白名单 + Markdown 卡片(节流伪流式)+ 按钮审批 | 企微内完整可用 | 3-4 天 | P0;管理员后台配置 |
| **P5 收尾** | 审批分级(IM 低风险白名单)、全平台 E2E 联调、运维脚本(复用 publish-df-relay 模式发布网关)、文档 | 生产上线 | 2 天 | P1-P4 |
总计约 **15-20 人天**。里程碑切法:P0+P1 完成即有「自定义 Web 远程 AI Chat」可日常自用(最快见效);P2/P3 并行推进;P4 视需求紧迫度排后。
## 八、风险与对策
| 风险 | 影响 | 对策 |
|------|------|------|
| IM 平台 API 频控/卡片更新限制 | 流式体验降级 | 节流聚合 + 降级为「进行中→完成」两段式 |
| 企微回调配置(可信域名/IP 白名单/AES) | 接入周期拉长 | 放 P4;提前准备备案域名与出口固定 IP |
| IM 端误触高危操作(命令执行/生产写入) | 安全事故 | 审批分级:IM 端仅放行低风险工具,高危强制桌面端确认 |
| 桌面端离线/休眠 | IM 无响应 | 离线提示 + PresenceState 感知 + 桌面端防休眠提醒 |
| u-relay 单 token 全局配对粒度粗 | 多渠道管理乱 | 网关侧单连接多路复用(channel 维度由网关统一持有);若需多 token 再扩 u-relay |
| 三平台 API 版本迭代(钉钉旧版机器人已停更) | 维护成本 | 统一走各家「新版卡片 + Stream/长连接」官方推荐路径,不碰 legacy |
## 九、待决策项
| # | 决策点 | 选项 | 建议 |
|---|--------|------|------|
| D1 | 网关部署位置 | a) 与 u-relay 同机部署 b) 独立小鸡 | a(省成本,nginx 加路由即可) |
| D2 | df-im-gateway 与 df-tunnel 类型复用方式 | a) git 依赖 df-tunnel crate b) 复制镜像类型 | a(编译期类型一致,协议演进单点维护) |
| D3 | Web 端形态 | a) 独立静态页托管在网关 b) 复用 df-miniapp 代码库分支 | a(小程序与 Web 场景差异渐大) |
| D4 | IM 审批放权策略 | a) 全审批 IM 可批 b) 低风险白名单 | b(安全优先) |
| D5 | 飞书/钉钉/企微三个应用是否现在就申请 | a) 立即申请配权限 b) 到对应 Phase 再申请 | b 中的 a:**飞书立即申请**(管理员审批周期不可控,先启动) |
## 十、与现有架构关系
- **F-260620-01 跨端 AI Chat**:本规划是其横向扩展(Commander 端从「微信小程序」扩展到「IM 机器人 + Web」),协议与中继完全复用
- **df-mcp**:对外 stdio 暴露数据层(本地进程级);本规划是远程人类入口(IM 级),互补不重叠
- **u-relay**:零改动(除非 D5 触发多 token 需求)
- **df-tunnel / AI 引擎**:零改动
@@ -0,0 +1,91 @@
# Namespace 淘汰互踩治理 · 设计(2026-10-04)
> ✅ 拍板通过(2026-10-04,2/2 按建议:C1 pin 50% 上限/C2 本期仅报告 pin;见 [拍板集](../滚动规范/拍板集-2026-10-04.md))
> 来源:todo.md 子代理深查「P2 namespace 10MB 全局 LRU 淘汰:报告蒸发为『重新调用』死路+多代理共池互踩[需设计]」。
> 勘察结论(2026-10-04 现场核实,以 `crates/df-ai/src/namespace_store.rs` 全文为准):
> - `NamespaceStore` = 纯内存 `HashMap<key, NamespaceEntry>` + 手动 LRU(`evict_if_needed`),默认 10MB(`:85 default()`),单条超容量拒绝入库(`:108`,返回不可解析引用→展开点落 `EVICTED_PLACEHOLDER`)。
> - 阈值:`NAMESPACE_BYTE_THRESHOLD=2048` / `NAMESPACE_LINE_THRESHOLD=50`,`ALWAYS_LARGE_TOOLS=[read_file, list_directory, grep, diff_files]`。
> - **无淘汰回调/通知**:淘汰静默发生,父 LLM 只在下次展开引用时经 `EVICTED_PLACEHOLDER`(`:17`)得知「已淘汰,如需请重新调用」。
> - 展开点:`agentic/context_lifecycle.rs:145-155`(压缩路径)、`agentic/mod.rs:1632`(主循环),均 `is_namespace_ref` 检查。
> - 作用域:store 实例按会话生命周期持有(`ContextManager` 或 agentic loop 内),**子代理 loop 复用同一全局/会话 store**——互踩根源。
## 1. 失败模式拆解
| 模式 | 现状 | 后果 |
|---|---|---|
| F1 报告蒸发死路 | 子代理 A 报告进 namespace → 池满淘汰 → 父 LLM 展开得 EVICTED_PLACEHOLDER → 指引「重新调用对应工具」 | 「重新调用」= 重跑子代理(spawn 新一轮),成本爆炸且非确定性 |
| F2 多代理互踩 | 同会话并发/串行多子代理共用一个 10MB 池,A 的大结果把 B 的挤掉 | 无归属概念,淘汰不看 owner |
| F3 跨会话残留 | 引用路径含 args_hash 不含会话维度,会话切换后引用可能指向别会话条目(或已蒸发) | 语义错乱(EVICTED_PLACEHOLDER 兜底但误导) |
## 2. 设计目标
1. **报告类内容不可淘汰**(pin 机制):子代理报告、用户显式产出的长文标记 pin,LRU 跳过;
2. **淘汰可感知**:淘汰发生时记录「淘汰清单」,下次展开引用时给出**结构化**提示(含原工具名+参数摘要+淘汰时间),而非裸 EVICTED_PLACEHOLDER;
3. **隔离互踩**:store 增 owner 维度(conv_id 或子代理 id),淘汰优先跨 owner 不回收他域(软隔离,不引入硬配额复杂度);
4. **零 schema 变更**:全部内存态增强,不动 DB。
非目标:不做磁盘 spill(超容量落盘另线);不改阈值(2048B/50 行实测合理);不动 ALWAYS_LARGE_TOOLS 名单。
## 3. 方案
### 3.1 NamespaceEntry 扩展
```
pub struct NamespaceEntry {
key, tool_name, content, created_at, access_count, // 现有
+ pinned: bool, // pin 标记,LRU 跳过
+ owner: Option<String>, // 归属 conv_id/agent_id,软隔离
+ evict_hint: Option<String>, // 预生成的淘汰提示(工具名+参数前 80 字),淘汰时留档
}
```
### 3.2 pin API
```
impl NamespaceStore {
+ pub fn store_pinned(&mut self, tool_name, content, owner) -> String // 子代理报告/关键产出走此
+ pub fn pin(&mut self, key) // 事后 pin
+ pub fn unpin(&mut self, key)
}
```
调用点:`sub_agent.rs` 统一出口(:495 emit AiAgentFinished 处)把报告 `store_pinned`;`run_command`/`generate_image` 等大产出可选 pin(后续)。
### 3.3 淘汰清单(EvictionLog)
store 内加 `VecDeque<EvictionRecord>`(容量上限 50 条):
```
struct EvictionRecord { key, tool_name, hint(前80字), evicted_at, owner }
```
`evict_if_needed` 淘汰时 push。展开点 `read_only()=None` 时:
1. 查 EvictionLog 命中 → 返回富提示:`[结果已淘汰] 原为 read_file(src/main.rs 前 80 字…) 于 3 分钟前因容量淘汰。可重新调用 read_file 取回。`
2. 未命中(跨会话残留/未知)→ 现 EVICTED_PLACEHOLDER 不变。
### 3.4 owner 软隔离
`evict_if_needed` 淘汰顺序改为:**先淘无 owner 且未 pin 的最旧 → 再淘他 owner(非当前活跃 conv)未 pin → 最后当前 owner 未 pin**。pin 永不淘(除非 pin 总量超 50% 容量,此时最旧 pin 降级可淘——防 pin 滥用撑死池)。
### 3.5 store 传递 owner
`store(tool_name, content)` → `store(tool_name, content, owner: Option<&str>)`。调用方在 agentic loop / sub_agent 处已知 conv_id,传入即可。旧两参签名内部转调 `store(.., None)` 兼容。
## 4. 风险与对策
| 风险 | 对策 |
|---|---|
| pin 滥用撑死池 | §3.4 降级规则 + pin 总量监控日志 |
| EvictionLog 内存增长 | 50 条上限 VecDeque,单条 ~200B,可忽略 |
| owner 传错 | 软隔离只影响淘汰顺序,不错即退回现状行为,无正确性风险 |
| 兼容 | 旧 store() 两参保留转调;EVICTED_PLACEHOLDER 文案仅在 Log 命中时升级,未命中原样 |
## 5. 拍板点(2 项)
- **拍板1**:pin 是否上限保护(pin 总量超 50% 降级最旧 pin)?建议加(防失控)。
- **拍板2**:报告外的哪些工具产出默认 pin?建议本期**仅子代理报告**,其余观察后再加。
## 6. 工作量估算
约 0.5-1 天:entry 扩展+pin API(2h)+ EvictionLog(1.5h)+ owner 软隔离(1.5h)+ 两展开点富提示(1h)+ 单测(2h,现有 6 个 namespace 测试全保留+新增 pin/淘汰清单/隔离顺序 3 组)。
@@ -0,0 +1,254 @@
# u-relay Wire 协议规格(改造蓝本)
> 版本:现行(第三代 agent/commander 命名,兼容层全拆)
> 来源:`E:/wk-lab/u-relay/src/{relay.rs, broadcast.rs, conn.rs}` + `README.md`
> 适用:df-tunnel / df-miniapp 升级对接
---
## 1. 拓扑与端点
```
Commander(控制端) ──wss──> nginx(:443 TLS 终结) ──ws──> u-relay(:9180)
↑
Agent(执行端) ──wss 主动出站────────────────────────────┘
(穿 NAT,无监听端口)
```
| 角色 | 端点 | 典型实现 |
|---|---|---|
| **Agent**(执行端,事件真相源) | `GET /ws/agent` | df-tunnel(桌面端)、u-claw runner |
| **Commander**(控制端,指令发起方) | `GET /ws/commander` | df-miniapp(小程序)、u-claw console、u-ppt |
**端点即身份**:端点决定期望的 `ClientKind`,与 Hello.kind 不一致直接拒。
---
## 2. 握手协议
### 2.1 客户端首帧(10s 内必发,否则断开)
```json
{
"kind": "agent" | "commander",
"channel_id": "<非空, ≤128 字节, 无控制字符>",
"token": "<U_RELAY_TOKEN 值>"
}
```
| 字段 | 约束 |
|---|---|
| `kind` | 必须与端点匹配:`/ws/agent` 端点仅收 `"agent"`;`/ws/commander` 端点仅收 `"commander"` |
| `channel_id` | 配对绑定键;非空 + ≤128B + 无控制字符;同 channel_id 的 agent 与 commander 互为对端 |
| `token` | 服务端常量时间比较(防时序侧信道) |
### 2.2 握手成功响应(relay 立即下发)
```json
{ "kind": "control", "payload": { "control_kind": "hello_ack" } }
```
**客户端判定**:收到首条非 error 消息即握手通过(不要求对端在线)。
### 2.3 握手失败(relay 发错误帧后立即 Close)
```json
{ "kind": "control", "error": "<reason>" }
```
| `error` 值 | 触发条件 |
|---|---|
| `handshake_failed` | 10s 未发 Hello / Hello JSON 解析失败 / 首帧非文本 / Hello 超 1 MiB |
| `kind_mismatch` | Hello.kind 与端点不匹配(如 agent 端点收 commander) |
| `invalid_channel_id` | channel_id 为空 / >128B / 含控制字符 |
| `server_misconfigured` | 服务端 `U_RELAY_TOKEN` 未设置 |
| `auth_failed` | token 不匹配 |
### 2.4 已下线的旧值(一律拒绝)
| 维度 | 现行 | 已下线(收到即拒) |
|---|---|---|
| 执行端端点 | `/ws/agent` | `/ws/runner` `/ws/device` `/ws/executor` |
| 控制端端点 | `/ws/commander` | `/ws/console` `/ws/miniapp` `/ws/controller` |
| Hello.kind 执行端 | `"agent"` | `"runner"` `"device"` `"executor"` |
| Hello.kind 控制端 | `"commander"` | `"console"` `"miniapp"` `"controller"` |
| 配对键字段 | `channel_id` | `device_id`(不再 alias) |
| 错误码 | `invalid_channel_id` | `invalid_device_id` |
---
## 3. 数据帧(握手通过后)
### 3.1 客户端 → relay(入站)
入站文本帧即业务 payload,relay **不解析**直接透传:
```json
{ "任意 JSON" }
```
非 JSON 文本会被包成 `{"payload": "<原字符串>"}`。
**特殊语义**:若 payload 顶层有 `control_kind: "ping"`,触发 relay 本地心跳响应(见 §4),不透传对端。
### 3.2 relay → 客户端(出站 BroadcastMessage)
```json
{
"channel_id": "chan-xxx",
"kind": "event" | "command" | "control",
"source": 12345,
"from": "agent" | "commander",
"payload": { "原始 JSON" },
"ts": 1725000000000
}
```
| 字段 | 说明 |
|---|---|
| `channel_id` | 配对通道 |
| `kind` | `event`=agent→commander;`command`=commander→agent;`control`=控制面 |
| `source` | 来源 conn_id(u64,0=NIL 伪值,relay 自构造消息用) |
| `from` | 来源客户端类型 |
| `payload` | 原始 JSON 透传 |
| `ts` | relay 收到入站帧的毫秒时间戳 |
### 3.3 路由规则(relay 内部,客户端无需关心但需理解)
| 消息 | 投递目标 |
|---|---|
| `Event`(agent 发) | 同 channel_id 的**所有 commander**(除来源自身) |
| `Command`(commander 发) | 同 channel_id 的**所有 agent**(除来源自身) |
| `Control` | 同 channel_id 的**所有连接**(除来源自身) |
**方向硬约束**(relay 路由层强制):
- `Event` 永不投递给 agent
- `Command` 永不投递给 commander
**多端登录**:同 channel_id 可并存多个 agent 和多个 commander,全投递(Vec 索引)。
### 3.4 帧限制
| 限制 | 值 |
|---|---|
| 入站文本帧 | **≤ 1 MiB**(含 Hello),超限直接断开 |
| 二进制帧 | **忽略**(MVP 仅文本) |
| WS Ping/Pong | 协议层自动处理(tungstenite),不进业务流 |
---
## 4. 心跳协议
### 4.1 客户端发起
```json
{ "control_kind": "ping" }
```
**关键**:relay 收到后**本地直接回 pong**,不透传对端,也不依赖对端在线。
### 4.2 relay 响应
```json
{
"kind": "control",
"from": "agent",
"source": 0,
"payload": { "control_kind": "pong" },
"ts": 1725000000000
}
```
**字段细节**:
- `source: 0` 是 `ConnId::NIL` 伪值 —— 若填接收方自己的 conn_id,路由防回环会跳过,pong 永远回不来
- `from: "agent"` 是 relay 代理执行端身份 —— 让 commander 端识别为合法控制响应
### 4.3 客户端实现要点
| 端 | 用途 |
|---|---|
| **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 |
| **Agent**(跑桌面/移动网络) | 用 pong 重置读死线,死线超时走既有重连 |
---
## 5. 部署配置
### 5.1 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `U_RELAY_ADDR` | `0.0.0.0:8080` | 监听地址 |
| `U_RELAY_TOKEN` | **无(必填)** | 配对 token,未设置启动即 exit 1 |
| `RUST_LOG` | `info` | tracing env-filter |
### 5.2 nginx 反代参考
```nginx
location /ws/ {
proxy_pass http://127.0.0.1:9180;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
```
### 5.3 部署形态
- **musl 静态编译**:`x86_64-unknown-linux-musl` → 单二进制 ~3.6MB
- **无 openssl/native-tls**:TLS 完全由 nginx 终结
- **systemd 单元**:`/usr/local/bin/u-relay` + `/etc/u-relay.env`(权限 600)
---
## 6. 客户端实现 Checklist
### Agent(执行端,如 df-tunnel)
- [ ] 连 `wss://host/ws/agent`
- [ ] Hello `{kind:"agent", channel_id, token}`
- [ ] 等首条非 error 帧判定握手通过
- [ ] 心跳:周期发 `{"control_kind":"ping"}`,等 pong 重置读死线
- [ ] 入站 `BroadcastMessage`:仅处理 `kind=="command"`(来自 commander 的指令)和 `kind=="control"`
- [ ] 出站:业务事件 JSON 直接作为文本帧发送(relay 包成 `Event` 投递给 commander)
### Commander(控制端,如 df-miniapp)
- [ ] 连 `wss://host/ws/commander`
- [ ] Hello `{kind:"commander", channel_id, token}`
- [ ] 等首条非 error 帧判定握手通过
- [ ] 心跳:周期发 `{"control_kind":"ping"}`,等 pong 更新 `lastPongTime` 看门狗
- [ ] 入站 `BroadcastMessage`:仅处理 `kind=="event"`(来自 agent 的事件)和 `kind=="control"`
- [ ] 出站:业务指令 JSON 直接作为文本帧发送(relay 包成 `Command` 投递给 agent)
---
## 7. 离线语义
| 场景 | 行为 |
|---|---|
| 对端不在线时发消息 | relay **丢弃**(不缓存),debug 日志记录"广播无对端在线" |
| 客户端判断对端在线 | 通过 `PresenceState`(relay 不主动推送,由客户端心跳或业务层判活) |
| 重连 | 客户端指数退避自行实现(df-miniapp 已有 `reconnectBaseDelay`/`reconnectMaxDelay`) |
---
## 8. 安全说明
- **token 常量时间比较**:relay 端实现,防时序侧信道
- **channel_id 校验**:格式防御,无 per-channel token 绑定(Phase3 留)
- **风险**:单共享 token 下,持 token 者可声明任意 channel_id 冒充该通道 —— 生产部署需确保 token 不泄露
---
## 9. 与旧版 df-relay 协议差异速查
| 维度 | df-relay(已下线) | u-relay(现行) |
|---|---|---|
| 端点 | `/ws/device` `/ws/miniapp` | `/ws/agent` `/ws/commander` |
| Hello.kind | `"device"` `"miniapp"` | `"agent"` `"commander"` |
| 配对键 | `device_id` | `channel_id` |
| 心跳 pong | 由对端回(路径长) | relay 本地回(路径短) |
| 旧名兼容 | 多代别名 | **一律拒绝** |
| 错误码 | `invalid_device_id` | `invalid_channel_id` |
| 帧上限 | 无 | 1 MiB |
| token env | `DF_RELAY_TOKEN` | `U_RELAY_TOKEN` |
@@ -0,0 +1,75 @@
# u-claw 两侧细勘察补全(多轮会话锚定 + 协议漂移)
> 2026-10-04 · 上游:[devflow集成u-claw-可行性研究-2026-09-28.md](./devflow集成u-claw-可行性研究-2026-09-28.md) 第六节待补槽位「u-claw 侧收尾:WS 渠道的多轮会话锚定细节(request_id↔chain 映射、会话持久化边界)」。
> 勘察基线:u-claw HEAD 021a230(基线 d8dc003 后 18 提交)/ u-relay 现行源码 / u-ppt 参考实现未变。
> 全部结论以源码为准(wsch.go 405 行全文 + chainpool.go + agentd.go + u-relay src)。
## 一、多轮会话锚定:channel 级单 chain 模型(结论定案)
**WS 渠道不是 per-request 会话,是 channel 级单 chain 粘性会话**:
```
wsChannel (1:1)
└─ chain = NewSessionChain("ws", cwd, statePath, ...) // wsch.go:113 run() 启动时建一次
├─ TakeSummary() 每轮取摘要 → RenderWithSummary 注入下轮 prompt // process():224
├─ HandleWithResult(prompt, progressWatcher) // 常驻 claude -p 子进程
└─ 会话自动连续(各轮 session_id 相同,无需 --resume) // agentd.go:6 注释
```
- **request_id 仅是单次指令标识**(配对进度/结果帧),不映射 chain——所有 request_id 共享同一 chain 与同一 claude 进程
- **上下文维持**:常驻进程各轮 session_id 相同(agentd.go:6),链层另有摘要注入(`TakeSummary`→`RenderWithSummary`)双保险;崩溃/超时后 `ensureAlive` 自动重启并 `--resume` 上次会话 ID 恢复(agentd.go:154)
- **持久化边界**:会话状态文件 `sessionStatePath("ws")`(exports.go:96,落 `~/.u-claw/sessions/`);会话 jsonl 落 claude 自己的项目目录(cwd 对应);**跨进程重启后上下文不丢**(--resume + 会话文件保留)
- **devflow 侧含义**:接入后 devflow 与 u-claw 的对话天然是**一条连续会话**(同一 channel_id 下所有 ask_uclaw 调用共享上下文)——多轮追问无需传会话 ID,但也意味着 devflow 内不同 devflow 会话若共用 channel_id 会互相污染上下文(见 §4 设计含义 D-2)
## 二、协议漂移:可行性文档已过时(重要修正)
2026-09-28 可行性文档记载 `/ws/device` + `{kind,device_id,token}` hello——**已漂移**,e8113c5「中继握手协议对齐服务端」重定义了角色:
| 维度 | 可行性文档(9-28 记载) | 现行源码(10-04 实证) |
|---|---|---|
| 端点 | `/ws/device` / `/ws/miniapp` | **`/ws/agent`(执行端,u-claw 连此)** / **`/ws/commander`(控制端,devflow 连此)** |
| hello | `{kind, device_id, token}` | **`{kind:"commander", channel_id, token}`**(commander);agent 端 `{kind:"agent", channel_id, token}` |
| 帧路由 | Event→Miniapp / Command→Device | **Event(执行端发)→同 channel_id 全部 Commander;Command(控制端发)→同 channel_id 绑定的 Agent**(conn.rs:114, relay.rs:117-119) |
| 心跳 | ping/pong(relay 本地回) | 同款仍在:30s ping / 90s 读死线 / 写死线 10s(wsch.go:45-50;miniapp 侧同款) |
| 鉴权 | 全局共享 token(鉴权债) | **配对语义已改**:Commander 持 token 申请绑定 channel_id(broadcast.rs:138 `Pair{channel_id, token}`),Agent 端 token 校验;但 relay 侧仍是 env `U_RELAY_TOKEN` 单 token(relay.rs:9-10, main.rs:28 fail-fast),**per-channel token 未做**——鉴权债部分缩小 |
**接入实施时必须按本节现行协议实现,不能照抄 9-28 文档或 u-ppt relay.ts 原样**(u-ppt 用的还是旧 Miniapp/Device 语义,需确认 u-ppt 是否已随 e8113c5 升级,若未升级则 u-ppt relay.ts 仅作 TS 结构参考、协议字段按本节改)。
## 三、执行语义与时限(新增已核实事实)
- **串行 worker**:每 channel 一条串行 worker goroutine,reqCh 缓冲 4,满则**丢弃**由发起方重试(wsch.go:108-109)——devflow 侧需配超时重试与队列已满可感知的语义(当前协议无「已丢弃」回执,devflow 端只能在超时后重发)
- **单条指令硬超时 15min**(`wsExecTimeout`,wsch.go:38):超时 AbortRunning 杀进程、回「处理超时」结果、链保 session ID 下条 --resume 续跑——**devflow aichat 工具的 client 侧超时须 >15min 或对齐处理**
- **进度节流 2s**(progressWatcher,wsch.go:368):assistant text=阶段叙述+tool_use=当前动作,2s 节流回传——devflow 工具卡「流式」粒度即此(块级,非逐 token)
- **空闲超时链回收**:chainPool idle 后整链回收(杀 claude 进程 ~500MB),来消息 --resume 重建冷启动 ~7s(chainpool.go:1-7)——**devflow 侧期望「空闲后首次调用慢」为正常**
- **链池挤占语义**:槽满 LRU 挤最旧 idle 链,活跃链不挤;hardCap 全忙时拒绝建链返错(chainpool.go:100-128)
## 四、devflow 侧接入设计含义(增量到第五节)
- **D-1 工具卡流式**:ask_uclaw 工具卡可直接消费 agent_progress 帧(2s 块级)作流式显示,agent_result 全文为最终 tool result——零改造对齐 devflow 现有工具卡模式
- **D-2 channel_id 策略**:单 chain 会话模型下,建议 devflow **per-conv 生成 channel_id**(如 `devflow-{conv_id}`)接入——每个 devflow 会话映射独立 u-claw chain,会话隔离天然成立;代价是 u-claw 侧 chainPool 槽位占用(max 上限与 LRU 挤占,见 chainpool.go),需确认 u-claw 侧 max 配置余量(当前 max 值未在本次勘察范围,实施前核 `~/.u-claw/u-claw.yml`)
- **D-3 超时对齐**:devflow ask_uclaw 客户端超时建议 16min(>15min 硬上限),超时即回失败;进度 2s 节流下客户端 watchdog 应容忍 30s+ 无 progress(u-claw 长任务中间可能无 assistant 输出)
- **D-4 队列满丢弃无回执**:reqCh 满时静默丢——devflow 侧在 15/16min 超时后重发(最多 2 次),并把「u-claw 渠道忙」文案明确给用户
- **D-5 offline 语义**:relay 无离线缓存(9-28 已载),叠加本节串行+丢弃语义:**devflow 发送前可先发探活 ping**(30s 内无 pong 视为渠道不可达),不可达直接报「u-claw 离线」不进队列
## 五、接入实施批清单(更新版,覆盖 9-28 第五节)
1. **协议实现(Rust 客户端)**:连 `/ws/commander`,hello `{kind:"commander", channel_id: devflow-{conv_id}, token}`,按本节协议处理 Event 帧(agent_progress/agent_result 按 request_id 配对)——**不照抄 u-ppt relay.ts 字段**,仅参考结构
2. **工具注册**:`ask_uclaw(prompt)` → declare_tool!(Low/只读? 不,执行类 Medium),progress 帧接 ToolCard 流式,result 全文为 tool result
3. **配置页**:relay URL / channel 前缀 / token(keyring)——默认关
4. **超时与重试**:客户端 16min 超时 + 2 次重试 + 发送前探活 ping
5. **可选 phase2**:消息渠道形态(u-claw 代言 aichat)/ 反向 MCP——维持 9-28 形态 2/3 描述
6. ~~**u-ppt 核对**(实施前置小步)~~ ✅2026-10-04 已核:u-ppt relay.ts 65bae53(2026-10-01)已随 e8113c5 升级 commander/channel_id 协议——端点自动补全 `/ws/commander`(:39)/hello `{kind:'commander',channel_id,token}`(:135)/10s 握手看门狗+hello_ack(:127)/30s ping+15s pong(:33)/指数退避 1s→30s(:36)/agent_request|progress|result 按 request_id 分发(:158) 六项与 u-relay 现行源码一致。**relay.ts 可直接作参考实现**(去 deck/SEP 专有逻辑),devflow 接入是 commander 协议第二个实现,非首个
## 六、基线更新
```
基线 HEAD(2026-10-04):
devflow 9216d4c u-relay e8113c5后现行(见 src) u-claw 021a230 u-ppt 待核
漂移检查(下次):u-claw git log --oneline 021a230..HEAD;u-relay per-channel token 是否落地
```
## 关联
- 上游:`devflow集成u-claw-可行性研究-2026-09-28.md`(本文件填其第六节末槽位)
- u-claw 源:`wsch.go`(WS 渠道全文 405 行)/ `internal/core/chainpool.go`(链池)/ `internal/core/agentd.go`(常驻 claude + --resume)
- u-relay 源:`src/relay.rs`(端点+握手)/ `src/conn.rs`(配对路由)/ `src/broadcast.rs`(Pair 协议)
@@ -0,0 +1,97 @@
# 子代理完成卡双轨「已消化」降级 · 设计(2026-10-04)
> ✅ 拍板通过(2026-10-04,2/2 按建议:D1 自动折叠默认开/D2 trace 随 namespace 批;见 [拍板集](../滚动规范/拍板集-2026-10-04.md))
> 来源:todo.md 子代理深查「P2 完成卡(前端全文)与父上下文(压缩)双轨无『已消化』降级机制[需设计]」。
> 勘察结论(2026-10-04 现场核实,以源码为准,**修正深查一处过时结论**):
> - 完成卡前端已非「重启蒸发」:`aiShared.ts:677` 已有按父 conv 经 `ai_sub_agent_list` 反查重建完成卡(轨迹存在即视为 completed),重启回补链路已通。剩余缺口即「已消化」降级。
> - 双轨现状:前端卡持 summary 全文(`AgentFinishedCard.summary`,卡面 160 字截断展示);父上下文中报告经 namespace 引用+`context_helpers.rs` 视图压缩(`TOOL_RESULT_SUMMARIZE_BYTES=2048` / `TOOL_RESULT_JSON_STR_FIELD_MAX=512`),**父 LLM 后续轮次只能看到 ≤512 字符折叠摘要**。
> - 事件链:`sub_agent.rs:495` 统一出口 emit `AiAgentFinished{status,summary,rounds,tokens,rejected_tools,stop_kind}`(挂父会话)+ 整轨迹落库(V44 origin='sub_agent')。
## 1. 问题定义
「双轨无降级」的三层含义:
1. **信息层**:父 LLM 与前端卡各持一份(上下文折叠摘要 vs 前端全文),两者无互认,用户在卡上看到的细节 LLM 答不上;
2. **生命周期层**:卡永久挂在消息流(直到会话删除),无「已读/已消化」状态;多轮追问后卡仍以初始完整态占据视觉焦点;
3. **一致性层**:父 LLM 被追问报告细节时若折叠摘要不够,唯一出路是重新 `ai_sub_agent_list` 反查轨迹(LLM 未必想到/舍得花 token)。
## 2. 设计目标
1. 卡可标记「已消化」,视觉降级(折叠/淡化)但**可展开复原**,信息不丢;
2. 父 LLM 追问细节时有**结构化自助通道**(不用重跑子代理);
3. 「已消化」状态**持久化**(重启/切会话后保留);
4. 零后端 schema 变更(复用现有落库轨迹)。
非目标:不做卡全文注入上下文(会反向膨胀 prompt);不做自动消化判定(何时算「消化」留人工,v2 可议 LLM 显式 ack)。
## 3. 方案
### 3.1 「已消化」状态与持久化
前端 `AgentFinishedCard` 增字段:
```
+ digested?: boolean // 默认 false
```
持久化载体:**复用 KV 存储**(app 已有 settings/KV 表),key = `agentCardDigested:{parentConvId}` → `string[]`(agentId 列表,JSON)。切换/挂载回补时(aiShared.ts:677 重建路径)读 KV 合并标记。不落 SQLite 新列/新表——卡本身是派生态(由轨迹重建),消化标记是用户偏好,KV 语义足够。
### 3.2 视觉降级交互
```
未消化卡: 现状(标题行+status 徽章+160 字摘要+展开全文)
已消化卡: 标题行单行化(✓ 已消化 · summary 首 40 字 · status 徽章淡化),点击展开复原全文
手动标记: 卡右上角「标记已消化」动作(hover 显现),支持批量(同会话多卡)
自动辅助: 同会话出现新完成卡时,旧未消化卡自动折叠为单行(不标已消化,仅视觉收纳,复用 ToolCardList 分组折叠的 touched 语义——用户手动展开过则不自动收)
```
### 3.3 父 LLM 自助通道(关键补强)
现状 LLM 有 `ai_sub_agent_list`(列轨迹元数据)但**没有读单条轨迹全文的工具**(前端卡经 IPC `ai_sub_agent_list` 拉的是轨迹列表,LLM 侧工具面无对应读全文入口)。
新增 LLM 工具(注册进 tool_registry,risk=Low 只读):
```
ai_sub_agent_trace(conversation_id, agent_id) -> { summary, status, rounds, tokens, messages_tail? }
```
- 返回该子代理轨迹的关键内容(summary 全文 + 末 N 条消息摘要),**只读、走现有 V44 落库数据**,零新存储;
- system prompt 子代理指引段补一句:「追问子代理产出细节时,先调 ai_sub_agent_trace 而非重新 spawn」;
- 这同时是 namespace 淘汰治理( sister 设计文档)中「报告蒸发死路」的兜底——即使 namespace 条目被淘,轨迹在 DB,工具可读。
### 3.4 「已消化」与上下文压缩的联动(可选增强,默认关)
`AgentFinishedCard.digested=true` 时,前端在后续轮次注入的用户消息尾部不加任何内容(保持零侵入);该标记仅影响 UI 与用户心智。**不**把 digested 同步回后端压缩策略(避免跨端状态同步复杂度)。
## 4. 改动面清单
| 层 | 文件 | 改动 |
|---|---|---|
| 前端 | `src/composables/ai/aiShared.ts` | AgentFinishedCard+digested;重建路径读 KV 合并 |
| 前端 | 完成卡渲染组件(MessageList 内 SubAgentCard 或同域) | 降级态渲染+标记动作+批量 |
| 前端 | KV 读写封装(复用现有 settings composable) | get/set `agentCardDigested:{convId}` |
| 后端 | `src-tauri/src/commands/ai/agentic/sub_agent.rs` | 暴露 ai_sub_agent_trace IPC(读库组装) |
| 后端 | `tool_registry.rs` | 注册 ai_sub_agent_trace(Low/只读白名单) |
| 提示词 | system prompt 子代理指引段 | 追问先 trace 不重跑 |
## 5. 风险与对策
| 风险 | 对策 |
|---|---|
| KV 标记与轨迹删除不同步(轨迹删了 KV 残留) | 重建路径只对「轨迹存在的卡」应用标记,孤儿 key 自然失效,无需清理任务 |
| trace 工具被滥用(LLM 反复查大轨迹) | 返回体加尾部分页/上限(messages_tail ≤ 20 条),复用 T-05 工具结果截断口径 |
| 卡交互改动碰 MessageList 渲染热点 | 卡是独立组件,降级态纯展示层,vue-tsc+手测三态(未消化/已消化/展开) |
## 6. 验收
1. 标记已消化 → 卡单行化 → 重启应用后仍单行(KV 持久);
2. 展开已消化卡 → 全文复原;
3. LLM 调 ai_sub_agent_trace 能返回指定子代理 summary 全文;
4. 未做任何标记的会话,渲染与现状逐像素一致(回归门);
5. cargo test(新增 trace IPC 组装单测)+ vue-tsc 0 error。
## 7. 拍板点(2 项)
- **拍板1**:旧卡自动折叠(§3.2)默认开还是关?建议开( touched 语义保护手动展开)。
- **拍板2**:ai_sub_agent_trace 本期做还是随 namespace 治理批一起做?建议**随 namespace 批**(同域文件+互为兜底)。
## 8. 工作量估算
约 1-1.5 天:digested+KV(2h)+ 卡降级渲染(3h)+ trace IPC+注册(3h)+ 提示词(0.5h)+ 测试(2h)。
@@ -0,0 +1,318 @@
# 无人值守长任务 — 机制设计
> 日期: 2026-10-04 | 状态: ~~设计稿(待拍板)~~ ✅拍板通过(2026-10-04,13/13 按建议;全量拍板记录见 [拍板集-2026-10-04.md](./滚动规范/拍板集-2026-10-04.md)) | 勘察: 只读实测锚点
> 拍板背景: 产品方向定为「任务规划一次性确定边界后,无人值守、自主无干预、稳定完成长任务」;S2-B 真审批通道延后不做。
> 设计目标(用户原话转译): ①长任务中途零打断(不弹审批、不等人) ②发起时一次拍板授权范围,存续期内 AI 全自主 ③无人盯着也能信任——失败自愈、进度可观测、异常安全停靠、完成/异常有通知。
---
## 一、现状勘察(锚点实测)
### 1.1 打断点全景与无人值守处置
| # | 打断点 | 现状机制(锚点) | 现行为 | 无人值守处置 |
|---|--------|---------------|--------|-------------|
| 1 | 风险审批挂起 | `audit/approval.rs` handle_approval_tool → insert_pending_approval;auto_exec_mode 三档(low/medium/all) + patch_file 全档放行特例 + 会话信任(session_trust,`mod.rs:542` TrustKey) | Medium/High 挂 loop,弹卡 + 系统通知,默认 15 分钟超时自动取消(`state.rs:240` approval_timeout_minutes) | **边界内放行**:风险≤任务上限 ∧ 非破坏性命令 → 直接执行(decided_by=boundary_auto);边界外不弹卡,拒绝 tool_result + 记待办 |
| 2 | 路径授权挂起 | `audit/path_auth.rs` check_file_tool_auth → NeedsAuth → ApprovalKind::Path + emit AiDirAuthRequired | 挂 loop 等三档授权(once/session/always,`state/allowed_dirs.rs`) | **边界内放行**:路径命中任务白名单 → Authorized;白名单外 → 不弹卡,结构化拒绝(L1 可改提示) |
| 3 | 黑名单硬拒 | 同上 Denied 分支 + validate_path 黑名单;已有先例:all 档保黑名单(audit/mod.rs:667),bypass 开关才连黑名单放行(PathBypass::All),子代理 KeepBlacklist | 就地拒绝,loop 继续 | **永不放行**(安全底线,同 all 档/子代理先例);拒绝 envelope 提示换路径 |
| 4 | 破坏性命令 | `mod.rs:603` is_destructive_command(rm/del/format/... 首 token 精配) | 信任放行门控,命中走正常审批 | **永不放行**;边界内 run_command 也过此闸 |
| 5 | LLM 流错误停 | `agentic/mod.rs` RoundOutcome::Fatal(候选 4xx 非429 或全 candidate 耗尽)→ return;流前 InitFailed Retryable 已有退避重试+candidate 链切换(retry.rs 同源分类) | Fatal 直接终轮 | **降级为安全停靠**(见 4.2):非正常终局统一停靠出口,不再裸 return |
| 6 | 工具执行失败 | Low 失败 = 失败 envelope 进 tool_result,loop 继续(AR-6);外层超时 60s/run_command clamp/spawn_agent 挂钟+60 | 错误交 LLM 自纠 | **可降级继续**:已有,不改;唯一化入停靠判定(连续失败见 4.1) |
| 7 | 断路器求助 | L1 失败断路器(THRESHOLD=3/WINDOW=20)+ G2 探索熔断(3 次) → emit 求助,等用户 | 挂起等介入 | **安全停靠**:触发即停靠(保留熔断,语义从「等」改「存档+通知」) |
| 8 | 达最大轮次 | agentic/mod.rs:1150 已改自动收尾(incomplete=true)+ NeedAction 通知 | 自动终止 | **已是停靠雏形**,补停靠说明+待办清单,预算可由任务边界放大 |
结论:打断点 1/2 是无人值守的**主要敌人**(挂起等输入);3/4 是**安全线**(保留);5-8 已有自动终局雏形,统一成语义即可。
### 1.2 既有边界件盘点(可直接复用为任务级边界底座)
| 件 | 锚点 | 复用方式 |
|----|------|---------|
| spawn_agent 白名单钳制 | `agentic/sub_agent.rs:260` clamp_tool_whitelist(tools ∩ Low ∖ spawn_agent)+ 执行期双闸(audit/mod.rs:172 sub_agent_tool_clamped) | 判定结构参照;**子代理不放宽**(fail-closed 保持,见拍板点 6) |
| 会话级授权目录 | `state/allowed_dirs.rs` AllowedDirs{persistent/session/once} | 任务白名单独立于三档(任务级,不写用户 persistent 白名单——一次性授权不污染全局);判定函数复用 check_path_authorization 的规范化/黑名单逻辑 |
| 三重熔断 | `sub_agent.rs:301` round_budget_stop(轮次/200k token/900s 挂钟) | 结构直接抄给任务级预算(参数独立可配,主会话量级上限实测后定) |
| 看门狗/心跳 | execute_with_heartbeat 30s AiHeartbeat + 前端 130s watchdog + stall_detector check_retry_exhausted | 已覆盖长工具误杀与流断连,零改动 |
| fail-closed 拒绝协议 | deny_sub_agent_tool 结构化 envelope(denial_layer 四层)+ is_sub_agent_deny_envelope 解析 | 边界外拒绝**照抄此协议**(新 denial_layer=boundary-exceeded),LLM 可自纠、报告可申报缺口 |
| 审批恢复/落库 | pending_approvals 序列化 + restore_pending_approvals 启动恢复;auto checkpoint(save_conversation 每 20 条消息 <1MB 快照入 ai_conversation_checkpoints) | 断点续跑底座(P2) |
| 全局全自动先例 | df-ai-auto-execute-mode=all + df-ai-path-auth-bypass | 即「完全信任」档的存量实现(L3 远期档,见五);本设计不重造 |
### 1.3 稳定性现状
- 重试面: retry.rs 指数退避(1s→2s→4s+jitter,30s 预算,Retryable/Fatal 分类)覆盖 complete() 与流前;candidate 链自动切 provider。**工具级无自动重试**——被 detect_retry_count/去重缓存刻意压制(防死循环),维持。
- 进度可观测: AiToolCallStarted/Completed 全事件流 + AiHeartbeat + 审计表 + checkpoint。缺「任务级」聚合视图(轮次/预算余量)。
- 通知: `notify.rs` 三类(Completed/Failed/NeedAction),主窗聚焦跳过 + 5s 节流合并 + 失败静默。P3-6 基建完整,停靠/完成通知直接挂现有出口。
- 断点续跑: checkpoint 表有快照、审批有启动恢复,但 **loop 本身重启后不自动续跑**,需人工。
---
## 二、任务级授权边界模型
### 2.1 数据结构(挂会话,不建新表——宁少勿预留)
```rust
// commands/ai/boundary.rs(新文件,纯结构+判定函数+单测)
pub struct TaskBoundary {
pub risk_ceiling: RiskLevel, // 上限 Low|Medium;High 不经此通道(拍板点 1)
pub allowed_dirs: Vec<PathBuf>, // 任务目录白名单(canonicalize 规范形态,含项目根)
pub forbid_destructive: bool, // 恒 true,字段留作显式声明(UI 展示用),判定不读
pub budget: TaskBudget { max_rounds: u32, max_tokens: u64, wall_secs: u64 }, // 缺省继承现网常量
}
```
存储与生命周期:
- 内存: per_conv 会话条目新增 `boundary: Option<TaskBoundary>`(会话级单真相源先例,`mod.rs:621`);
- 落库: ai_conversations 新列 `boundary_json`(迁移一个 ALTER TABLE,save_conversation 随现有 meta 链路写,启动 load 还原);
- 生命周期 = 会话存续: 发起时一次声明 → 整个任务存续生效 → 会话删除级联消失。不做中途改边界(改边界=新拍板,UI 提示重开任务),防「边跑边扩权」;
- 与全局档正交: 有 boundary 的会话 boundary 优先;无 boundary = 现状逐次审批,**渐进增强零倒退**。
### 2.2 UI 形态(发起时一次性配置卡)
参照 spawn_agent 参数卡(审批卡 approval_hint 模板,`tools/spawn_agent.rs:172`)+ 设置页范式:
- 入口: 新任务发起对话框(或首条消息发送时)展开「无人值守边界」折叠卡;非必填——不展开 = 现状审批模式;
- 字段四项: 风险上限(Low/medium 单选,默认 Low)/ 目录白名单(复用设置页 allowed_dirs 目录选择交互,默认预填当前项目绑定目录)/ 预算(轮次/token/挂钟,默认值预填,高级折叠)/ 确认按钮文案「一次授权,任务期内不再打扰」;
- 展示态: 任务进行中侧栏会话条目挂边界徽标,点开只读回显(禁中途改)。
### 2.3 IPC 面
`ai_set_task_boundary(conv_id, boundary_json)` 发起时调用;`ai_get_task_boundary(conv_id)` 回显。删除/停止会话既有级联不动(边界随会话条目同亡,无需新清理点)。
---
## 三、边界内审批旁路(risk 分流改造)
### 3.1 判定链插桩(单点,两处)
现判定链: path_auth 预校验(path_auth.rs)→ classify_risk_and_auto(approval.rs:33)→ trust → pending。改造在 `process_tool_calls`(audit/mod.rs:604)读取 auto_exec_mode 处并列读取 boundary 快照,然后:
```
对每条工具调用(有 boundary 会话):
① 黑名单命中 → Denied(现状,永不放行)
② 文件工具: 路径 ∈ boundary.allowed_dirs → Authorized;
路径 ∉ 白名单 → 拒绝 envelope{denial_layer:"boundary-dirs"},不弹卡不挂起,
LLM 换路径或写入报告未尽事项(fail-closed 同构)
③ 风险: risk ≤ risk_ceiling ∧ (run_command → !is_destructive_command)
→ 归入 low_risk 并行执行向量(复用既有执行+审计链),
decided_by = "boundary_auto"(risk 真实等级透传,F-#97 先例防审计失真)
④ 超上限(含 High、破坏性命令) → 拒绝 envelope{denial_layer:"boundary-risk"},
记入会话待办清单(4.3),不 insert pending
```
### 3.2 兼容性(不倒退保证)
- 无 boundary 声明: 上述分支全部跳过,auto_exec_mode/trust/审批/路径授权全链路现状,零行为变更;
- ai_approve / ai_authorize_dir IPC 不动: 有边界会话不产生新 pending(黑名单本来也不产生),审批 UI 自然不触发;
- 审批超时定时器(15 分钟)对边界会话天然不触发(无新挂起),零改动;
- PathBypass 枚举不加新形态: 边界会话白名单判定在 check_file_tool_auth **调用前**完成(boundary 命中即 Authorized,bypass 参数传 Off 保黑名单),避免与 bypass 开关语义纠缠;
- 审计: boundary_auto / boundary-dirs / boundary-risk 三种 decided_by/denial_layer 全留痕,无人值守事后可全量追溯。
---
## 四、无人值守稳定性机制
### 4.1 失败分级自愈(增量最小化)
| 级别 | 判定 | 处置 | 增量 |
|------|------|------|------|
| 瞬态可重试 | 网络超时/5xx/429(复用 retry::is_status_retryable) | 已有: 退避重试+candidate 链,零改动 | 无 |
| 工具级失败 | 失败 envelope | 已有: 交 LLM 自纠(loop 继续),死循环已被去重缓存+retry guard 治理 | 无 |
| 探索停滞 | L1/G2 断路器触发 | 改: 熔断求助出口 → 安全停靠(4.2) | 小 |
| 不可恢复 | Fatal 全 candidate 耗尽 / 边界预算耗尽 / 连续 N 轮零产出(阈值实测后定) | 统一安全停靠出口 | 中 |
不新造工具级自动重试层——既有 envelope+熔断已覆盖,自动重试同参数正是 detect_retry_count 刻意压制的行为(反馈_no_guess_api 同源: 盲重试制造风暴)。
### 4.2 安全停靠语义(不是死等)
现网「非正常终局」五路出口(Fatal/Stale/MidStream/熔断/达轮次)行为不一。统一停靠出口:
1. 保存现场: 既有 teardown_round 落库链路不变;
2. 会话尾部追加停靠说明(system 提示块): 停止原因 / 已完成什么(取本轮 assistant 文本) / **待办清单**(边界外被拒操作 + 未完成事项,数据源=3.1④ 的拒绝记录 + 熔断采样);
3. emit `AiTaskParked { conv_id, reason, pending_items }` 新事件(前端操作卡: 继续恢复/停止归档,复用 pendingMaxRounds 操作卡骨架——lib.rs:435 既有续/停 IPC);
4. notify NeedAction 系统通知(托盘场景补位,复用 P3-6 全套);
5. **不保持 generating=true**: guard 复位(达轮次出口已有先例),用户回来点「继续」走 ai_continue_loop(start_iter 语义沿用: 预算耗尽重计=用户再授权)。
### 4.3 进度可观测
- 已有零改动: 工具事件流 + 心跳 + 审计表即事实源;
- 增量: 每 N 轮(建议 5,实测后定)emit `AiTaskProgress { conv_id, round, budget, tokens_used, pending_items_count }`;前端会话头部挂轻量进度条(轮次/预算维度),长任务静默期(如 spawn_agent 子代理长跑)用户可判断「在干活还是死了」。不落新表,事件即逝。
### 4.4 完成与异常通知
复用 notify.rs 现有三类出口,无需新通知通道;增量仅两处挂点: 停靠出口(4.2)发 NeedAction、边界会话正常收敛发 Completed(emit_ai_completed_once 已挂,核对边界会话不因 incomplete 路径漏发即可)。
### 4.5 断点续跑(P2,进程重启后)
- 现状: checkpoint 快照已有、pending_approvals 有启动恢复,loop 不自动续;
- 设计: ai_conversations 新列 `loop_state`(running/parked/finished),边界会话 loop 生命周期同步落库;启动时检测「loop_state=running 但进程重启」→ **不自动续跑**(本地桌面场景,重启即用户在场,自动续跑反而惊吓),侧栏该会话标「任务中断可恢复」+ 一键恢复按钮;
- V21 迁移教训直接套用(docs/todo.md:860: 全局 COUNT 幂等窗口,中途崩溃→永久残缺被版本号掩盖): 恢复判据必须**按会话逐条落进度标记**(loop_state per-conv),禁止依赖全局内存态或推算;快照恢复走既有 checkpoint 表,恢复粒度=消息级(本轮已落库消息不重放,ai_messages 增量基线既有机制天然支持)。
---
## 五、信任分级(拍板项)
| 档 | 语义 | 载体 | 状态 |
|----|------|------|------|
| L1 逐次审批 | 现状: 三档 auto + 审批卡 + 路径授权弹窗 | df-ai-auto-execute-mode | 现网,不动 |
| L2 任务边界自主 | 本设计主体: 发起时一次拍板,边界内零打断,黑名单/破坏性命令永不放行 | TaskBoundary(会话级) | 本设计 P0 |
| L3 完全信任 | 等价 skip-permissions: 全档自动+连黑名单也放行 | 存量组合: auto=all + path-auth-bypass=on | 远期可选,UI 不与 L2 并列推荐,标注「含敏感目录」风险;不新造代码 |
分级关系: L2 是 L1 之上的会话级增强,L3 是全局级现状开关的组合,三层互不替代。
---
## 六、分期实施建议
### P0 边界模型 + 旁路主干(可独立交付验收)
- 改动面: 新增 `commands/ai/boundary.rs`(结构+判定+单测)、`commands/ai/mod.rs`(per_conv 字段+IPC 注册)、`conversation.rs`(boundary_json 落库列)、`audit/mod.rs`(判定链插桩:auto_exec_mode 读取处)、`audit/path_auth.rs`(boundary 白名单判定前置)、`audit/approval.rs`(boundary_auto 分流)、迁移一条(boundary_json 列)、前端任务发起配置卡 + useAiConversations 透传;
- 验收: 边界会话 Medium 工具/白名单路径零弹卡直接执行+审计 boundary_auto;黑名单/破坏性命令/超上限三项被拒且不弹卡;无边界会话全链路行为与改造前逐字节一致(既有测试全绿);
- 冲突面: audit/mod.rs、approval.rs、path_auth.rs、conversation.rs、chat.rs、state.rs **全部在当前在飞批次工作区改动中**(git status 30+ 文件,befix2/dx1/dx2 三代理在改)——P0 必须等在飞批次合入后开工,或以 worktree 零交集摘出纯新增文件(boundary.rs+前端卡)先行、插桩点后置。串行,禁双头互踩(uswitch-403 同款事故预防)。
### P1 自愈 + 通知(依赖 P0 边界字段)
- 改动面: `agentic/mod.rs`(五路非正常终局统一停靠出口)、`stall_detector.rs`(熔断求助→停靠)、`notify.rs` 挂点、event_bus 新事件 AiTaskParked/AiTaskProgress、前端操作卡+进度条;
- 验收: 构造 Fatal/熔断/预算耗尽三种场景 → 各自产停靠说明+待办清单+托盘通知,generating 复位,点继续可续跑;
- 冲突面: agentic/mod.rs 在飞改动中,同 P0 策略。
### P2 断点续跑 + 信任分级(依赖 P1 停靠语义)
- 改动面: `conversation.rs`(loop_state 列+落库点)、`lib.rs`(启动扫描)、侧栏恢复入口;信任分级 UI 是否进设置页等拍板点 7;
- 验收: 边界会话长任务跑一半杀进程重启 → 侧栏标「可恢复」→ 一键恢复从 checkpoint 续跑不重放已落库轮次;
- 冲突面: lib.rs 启动段低频改动,冲突小。
---
## 拍板点清单
| # | 决策 | 选项 | 建议 |
|---|------|------|------|
| 1 | risk_ceiling 是否开放 High | A 封顶 Medium;B 开放 High 但破坏性命令仍拒 | **A**。High(run_command 等)无人看过命令串风险不可控;确需 High 归 L3 完全信任档 |
| 2 | 边界存储形态 | A 会话级 JSON 列;B 独立任务表 | **A**。任务与会话 1:1,独立表是过度设计;字段随会话同亡零清理成本 |
| 3 | 边界外动作处置 | A 拒绝+记待办随停靠报告;B 拒绝即停靠;C 弹卡(违背无人值守) | **A**。fail-closed 同构子代理,LLM 有自纠空间,缺口随停靠说明透出 |
| 4 | 进度事件频率 | A 每 5 轮;B 每轮;C 不做 | **A**。B 噪音,C 牺牲静默期可观测;具体值实测后定 |
| 5 | 重启后自动续跑 | A 不自动,标记+一键恢复;B 自动续跑 | **A**。本地桌面重启即用户在场,自动续跑惊吓且 V21 教训表明恢复判据须可人工确认 |
| 6 | 边界会话内子代理是否放宽 | A 维持 Low-only fail-closed;B 继承父边界 | **A**。子代理无监督面,放宽破坏隔离语义;Medium 需求由父会话边界内直接执行 |
| 7 | L3 完全信任是否进设置页 UI | A 不做 UI,文档说明存量开关组合;B 设置页显式三档 | **A(P2 再议 B)**。存量开关已可达,先验证 L2 使用率 |
| 8 | 中途改边界 | A 全不可改;B risk/目录不可改,预算可由用户中途调 | **B(补遗后修订)**。一次性拍板约束的是「AI 不回头问用户」,不是「用户不能动自己的任务」;risk 上限与目录白名单一次性不可改(安全边界),预算字段开放用户中途收紧/放宽(经 #12);**纠偏注入(7.2)≠改边界**——只注入指令文本,边界字段零触碰 |
---
## 补遗(2026-10-04 二次拍板追加,与主体章节并列生效)
> 追加两维:效率(任务完成得快、tokens 高效)与可观测/可控。不重写主体章节,冲突处以本补遗为准。
## 七、效率维度
### 7.1 token 效率(复用为主,增量小)
| 设计点 | 既有件(复用) | 无人值守增量 |
|--------|--------------|-------------|
| 上下文膨胀控制 | ContextManager::build_for_request 预算裁剪;namespace_store 大工具结果进 namespace 主队列留引用(audit/mod.rs 两处 T2);TOOL_RESULT_COMPRESS_ENABLED 工具结果压缩;TOPIC_MARKER 话题标记;自动 checkpoint | 长任务场景将「工具结果冗余」放大,既有链路已按预算自动收敛,**零新增**;视图裁剪滚动摘要(派工决策单 B2 待做)落地后收益直接作用于长任务,不另立项 |
| 重复调用抑制 | 只读工具跨轮缓存+轮内去重(audit/mod.rs:976+,cache_hit_warning 递增威慑);High 去重缓存;detect_retry_count 断重试循环;G2 探索熔断(同签名重复派发检测) | **自动降级**:缓存命中计数达阈值(现警告头已有,阈值实测后定)时自动插入一条 system 降级指令(「同参调用已 N 次,必须换策略或写入报告」),把「威慑」升级为「强制转向」;G2 熔断在无人值守下直接走安全停靠(4.1 已定),不再等用户 |
| 任务级模型分级 | router ModelTier(Code/Debug→Heavy/Chat→Fast,agentic/mod.rs:1617 tier tiebreak);resolve_model_with_override;spawn_agent 的 model 参数(子代理可指定模型) | 边界配置卡加可选「子代理模型」高级项;**默认策略: 子代理派发缺省走轻量模型**(重活主循环继承会话模型,机械探索活子代理小模型),路由复用 ModelTier 既有分档,零新机制(#10) |
| prompt 瘦身/任务书复用 | spawn_agent 任务书自包含语义(子代理看不到父对话,不重复注入父轨迹);GOAL_PIN 钉目标防逐轮重述(GOAL_INJECT_BANNER);SKILL_LIST 描述截断 80 字 | 零新增——「任务书复用而非重注入」正是子代理架构的既有形态;长任务多轮目标防漂移由 GOAL_PIN 覆盖 |
### 7.2 速度设计点
| 设计点 | 机制 | 与既有的关系 |
|--------|------|-------------|
| 并行工具批最大化 | 旁路后 Medium 工具归入 low_risk 向量走 join_all 并行(§3.1③) | **本设计免费提速点**:现网 spawn_agent(Medium)逐个走审批串行挂起;边界会话内 LLM 一轮发多个 spawn_agent → join_all 天然并行,上限 MAX_TOOL_CALLS_PER_ROUND=8。**P0 零代码副产品,须在验收清单标注** |
| 子代理并行编排 | 同上——并行编排不需要新调度器,是「旁路 × join_all」的组合效应 | 零新增;子代理各自独立预算熔断(三重熔断互不挤占),父侧只等 join_all 汇合 |
| 空轮/确认轮消除 | 无人值守任务书模板追加一条纪律:「不向用户提问;信息不足按明确假设继续,假设写入报告」;纯文本收敛轮(无 tool_calls)即终态,不存在「等确认轮」 | 复用 PURE_GREETING_TOOL_GATE 与 G2 熔断兜底异常空转;确认类行为在无人值守下=fail-closed 反面(挂起等输入),任务书纪律+边界语义双重压制,P0 随配置卡文案交付 |
### 7.3 效率度量口径(为「快」提供数据)
- **任务耗时** = 首条 user 消息 → 终态事件(AiTaskParked / 正常收敛)的挂钟差;落库复用 ai_conversations.created_at + 终态时间戳(补 parked_at 小列或复用 updated_at,P0 定);
- **token 花费** = 会话累计 prompt/completion 列(已有,save_conversation 落库)+ **子代理单列**(SubAgentOutcome rounds/tokens 已经 AiAgentFinished 透出;子代理出口整轨迹落库但 token 不进父 TokenAccumulator,故口径必须「父会话 / 子代理 / 合计」三段呈现,禁混算,#11);
- **每轮均摊** = 合计 token / 轮次(趋势指标,识别哪段任务烧钱);不做成本换算(密钥价目不进引擎,数据不出门边界)。
**分期归属**: 并行编排/确认轮消除/度量口径落库=P0(大部分零代码或小列);缓存降级注入+子代理默认小模型=P1;度量面板汇总视图=P2(随可观测面板同批)。
## 八、可观测/可控维度
### 8.1 可观测(10 秒三问:跑到哪/花多少/剩多少)
| 呈面 | 数据源(复用) | 新增量 |
|------|--------------|--------|
| 当前进度呈面(阶段/当前动作/轮次与 token/预算剩余) | 当前动作=最近一条 AiToolCallStarted 的 tool+GoalMeta 摘要(goal_of 已有,如 run_command→「执行 xxx」);轮次/token=AiTaskProgress 事件(§4.3 已设计) | 会话头部常驻任务面板(#12):一行「阶段·当前动作」+进度条(轮次/预算)+token 累计;打开界面即答三问,不依赖滚动消息流 |
| 子代理树状态一览 | SubAgentRegistry(parent→children 内存注册表,已有)+ AiAgentFinished(每子代理终态 rounds/tokens/rejected 已透出) | 树 UI 为纯前端消费:运行中子代理=spawn 事件实时挂节点,完成=Finished 回填终态;父会话面板内嵌一行树,不建独立页面 |
| 花费实时累计 | TokenAccumulator 快照 + usage_delta_since 增量口径(均已有) | AiTaskProgress 携带 tokens_used 即可,零新事件 |
### 8.2 可控(逐项标注复用/新建)
| 操作 | 机制 | 复用 or 新建 |
|------|------|-------------|
| 中途停止 | ai_chat_stop → stop_flag 置位 + cascade_stop_children(Stop form)级联子代理 | **纯复用**,零改动;边界会话停止=任务终止,停靠说明照发(4.2) |
| 暂停/恢复 | 「停止 + 一键恢复」组合即暂停:stop 保现场落库,恢复走 P2 断点续跑入口(ai_continue_loop, checkpoint 粒度) | **复用组合,不新造「暂停态」**(宁少勿预留——独立暂停态需新增 loop 挂起检查点,收益不抵复杂度);P2 交付 |
| 中途纠偏(向运行中任务注入指令) | ai_chat_force_send 既有通道:打断当前轮 + 注入新 user 指令 + F1 并发 epoch 新 loop 接管(旧 loop 让位,epoch 机制已有)+ 级联 Stop form 子代理 | **纯复用**;语义定义:纠偏只注入指令文本,边界字段零触碰(**纠偏≠改边界**,与拍板点 8 对齐);P0 即可用,任务面板加「插话」入口即可(前端小改) |
| 接管转人工 | = 任务终止的单向形态:用户纠偏注入「停下我来」或点停止 → 移除 boundary 标记,会话回落 L1 逐次审批模式继续对话 | **复用 stop + boundary 摘除**;单向降级(任务态→普通态)不是边界修改,不违反拍板点 8;P1 随停靠操作卡交付「转人工」按钮 |
| 预算中途调整 | IPC ai_update_task_boundary 仅开放 budget 三字段(轮次/token/挂钟),risk/目录拒改 | **新建一个窄 IPC**(P1);用户主动调整合规(拍板点 8 修订后口径),UI 在任务面板预算条旁放开编辑 |
| 与看门狗/级联取消的关系 | 纠偏/接管经 epoch 接管不与前端 watchdog 冲突(新 loop 自带 guard+heartbeat);级联取消(Stop/Delete 双形态)覆盖停止与删除两路 | 既有机制全覆盖,零新增 |
**分期归属**: 中途停止/纠偏注入=P0(复用件,仅前端入口);任务面板+子代理树+预算调整+接管=P1;暂停/恢复=P2(与断点续跑同批,共享恢复入口)。
## 补遗新增拍板点(续编)
| # | 决策 | 选项 | 建议 |
|---|------|------|------|
| 9 | 缓存命中的自动降级策略 | A 命中超阈值自动插 system 强制转向指令;B 维持警告头威慑 | **A**。无人值守无人读警告,机制必须自己踩刹车;阈值实测后定 |
| 10 | 任务级模型分级默认 | A 边界卡显式选;B 子代理默认轻量模型+主循环继承会话模型,卡内高级项可覆盖;C 不做 | **B**。自动分级零配置收益最大,显式选择作兜底;轻量模型具体指认实测后定 |
| 11 | token 度量口径 | A 父会话/子代理分列+合计;B 只算父会话 | **A**。子代理 token 不进父 TokenAccumulator,混算会严重低估;合计是「任务花费」的正口径 |
| 12 | 任务面板形态 | A 会话头部常驻面板(进度+花费+预算+插话+树);B 仅侧栏徽标点开看 | **A**。「10 秒三问」要求免点击可达;徽标作为托盘化场景补充 |
| 13 | 预算中途调整方向 | A 双向(收紧/放宽)都允许;B 只许收紧 | **A**。用户主动放宽=追加授权,与「一次性拍板」不冲突(拍板约束 AI 不约束用户) |
## 九、效率补遗二:u-claw 实战思想迁移(2026-10-04,历史会话汲取)
> 来源:`~/.u-claw/willcard/decisions.jsonl`(298 条决策,效率相关 60+ 条实证)+ devflow 知识库。
> 基调(用户原话):「从我历史使用 u-claw 的历史会话、我主张的思想中汲取知识辅助设计和决策;无人值守同时也要确保极高效率任务推进能力」。
> 以下思想在 u-claw 侧均经生产实证,迁移 devflow 时逐条标注落点与分期。知识库已沉淀两条 workflow_optimization(f6cd9de8 派工契约/c2c0a444 A/B 层纪律)。
### 9.1 派工契约:颗粒约束+验收前置+首轮任务图(u-claw 09-25/09-30 拍板)
- **废除时长预估**:不要求「预计 N 分钟」——u-claw 实证时长预估无锚定价值,超时即杀导致活任务被误杀;
- **替代契约**:①任务书必含**验收命令**(一轮内自证完成并带回验收证据) ②**超一轮未完成=派工颗粒过大**,主控拆批重派,不盲等 ③杀进程仅作卡死/跑偏兜底;
- **首轮任务图**(09-30 第 7 条):整包委托语义令下,round-0 **必须产出任务分解+派工清单**再动手,禁主控先亲手做首个任务(亲手兜底仅 1-2 行 typo 级);
- **devflow 落点**:无人值守任务书模板加「round-0 规划纪律」段(P0 文案);spawn_agent 超轮触发拆批提示(而非熔断即弃);子代理任务书 acceptance 对齐(批 2 指引真空项同源改造)。
### 9.2 A/B 层 prompt 纪律:预算行进 user 尾部不毁缓存(u-claw 08-31)
- system 层(A):编排纪律五判据(耗时意识/试错预算/委派契约/超时自查/并行合并)——稳定段进 cache_control;
- **每轮 user 尾部(B):任务预算行**(当前阶段/剩余轮次/剩余 token)——user 消息注入不动 system 前缀,prompt cache 零失效(u-claw 实证口径);
- **devflow 落点**:对齐已拍板的 AC-EFF-C0-2(稳定段/易变段分离)——预算行作为易变段追加到消息流尾部,AiTaskProgress 的前端展示与后端注入同源数据;**P1 实施**(与 A9 缓存降级同批)。
### 9.3 换代水位 70% 主动切(u-claw 09-03)
- rollover 判据=窗口×70%(如 1M→700K),**不等硬爆**;水位取值=最后一条 assistant 行 usage(真实窗口口径,非估算);
- **devflow 落点**:无人值守长任务压缩触发阈值联动此口径(maybe_auto_compress 判据核对,P1 顺带);devflow 主循环已有压缩链,补「长任务下水位提前」参数化。
### 9.4 文件互斥分区并行(u-claw 09-29-6)
- 多代理并行按**文件清单显式隔离**,各自批次文件集无交集才能并行写;
- **devflow 落点**:spawn_agent 派工时若子代理任务书含明确目标文件集,主循环日志登记防重叠(软约束:提示词级「不要改本任务书之外的文件」已有,补主控侧登记);硬锁在 namespace owner 隔离(已设计)同批考虑。
### 9.5 卡片活性与聚合回填(u-claw 09-06 三连拍板)
- 后台执行被否决:**用户以为任务丢失**;卡片须延续活性+回填结果+多任务聚合展示;
- **devflow 落点**:对齐 A12 任务面板+AiAgentFinished 完成卡(已设计);面板补「子代理状态/任务清单/等待原因/危险动作告警」四件套语义(u-claw 08-29 processing 卡四件套实证有效),P1 面板实施时并入。
### 9.6 失败按码分级冷却(u-claw 09-13)
- 402/401(确定性失败)冷却 10min,其余(瞬态)30s——**确定性失败不烧重试预算**;
- **devflow 落点**:对齐已修的「Retryable/Fatal 分层」(10-02 批次2),补冷却时长分级(401/403/404 系 10min,超时/网络 30s);retry.rs 已有骨架,P2 效率批接线。
### 9.7 高置信自主推进决策法(u-claw 10-02)
- 决策项先查历史指令(will-card+memory+decisions.jsonl)辅助直裁;实在不决记录挂起,**自主连续推进**;
- **devflow 落点**:已对齐——拍板集+知识库检索+挂起登记(todo 遗留),无需新增;持续把用户长期偏好(如「决策影响边界简洁呈现」「列表禁表格」)沉淀进知识库,供 AI 直裁。
### 9.8 汇报与决策呈现纪律(u-claw 09-28)
- 决策须给**足够且简洁易读的影响边界**(降低决策心智投入);默认列表禁表格(宽列截断看不见内容);
- **devflow 落点**:停靠卡/NeedAction 通知文案规范(A12 面板实施时并入:一行定性结论+决策级技术事实+影响边界,列表化呈现)。
### 分期并入
| 思想 | 分期 |
|---|---|
| 9.1 round-0 任务图+验收契约 | **P0 文案**(任务书模板,P0 收尾即生效) |
| 9.2 预算行 user 尾注入 | P1(与 A9 同批) |
| 9.3 水位 70% 联动 | P1(压缩链顺带) |
| 9.4 文件互斥登记 | P1(子代理批同做) |
| 9.5 面板四件套 | P1(A12 面板) |
| 9.6 冷却分级 | P2(效率批) |
| 9.7/9.8 | 已对齐/随 P1 文案 |
@@ -0,0 +1,90 @@
# 跨会话召回最小闭环 · 设计(2026-10-04)
> ✅ 拍板通过(2026-10-04,3/3 按建议:B1 mention 唯一才启用/B2 软排序/B3 新增 scoped 方法;见 [拍板集](../滚动规范/拍板集-2026-10-04.md))
> 来源:todo.md「P1 跨会话召回最小闭环(项目级记忆+会话结束提炼进知识库,source_project 已有字段未用)」。
> 勘察结论(2026-10-04 现场核实,以源码为准):
> - 会话内自动提炼链已存在:`knowledge_inject.rs` `extract_knowledge_from_conversation`(user/assistant 过滤+`KNOWLEDGE_EXTRACT_WINDOW` 窗口+JSON 提炼+candidate 批量插入)+ `EXTRACT_WATERMARKS` 水位防重 + `conv(cid).knowledge_extracted` 哨兵 + `trigger_extraction_now` 手动强制。
> - `source_project` 字段在**主仓存储层零使用**:仅 `idea_repo.rs` 列定义/INSERT/UPDATE 贯通,`idea_repo.rs:949` 注释自证「source_project/source_ref 等元字段未被使用却仍随每行读出」。检索侧 `search_keywords`(idea_repo.rs:699)无项目过滤。
> - 注入点:`knowledge_inject.rs:622 inject_knowledge_into_prompt`(`search_keywords(keywords, include_candidates, limit)`,无项目维度)。
> - 会话→项目关联:`ai_conversations` 表**无 project_id 列**(V4 仅补 archived、V33 pending_approvals、V44 origin、V45 boundary_json);项目归属当前只能靠会话内 mention/`boundary_json`/`@项目` enrichment 间接推断。
> - candidate→published 机制已通(自动晋升 + knowledge_promote 工具 + V43 存量迁移 854 条),**跨会话可见性**只欠「published 参与检索注入」+「项目维度过滤」两环。
## 1. 目标与非目标
**目标(最小闭环)**:
1. 会话结束(AiCompleted 收敛点)自动触发提炼时,把当前会话**归属项目**写入产出知识的 `source_project`;
2. 知识注入(`inject_knowledge_into_prompt`)按**当前项目优先**:同项目知识加权/前置,跨项目知识仍可召回但不淹没;
3. 形成闭环:会话 A 的踩坑 → candidate → 晋升 published → 会话 B(同项目)注入时优先命中。
**非目标(本期不做)**:
- 不做会话↔项目的强绑定 schema 变更(不加 `ai_conversations.project_id` 列)——归属用**弱推断**(见 §3.1),避免迁移风险;
- 不动 candidate 晋升策略(C3 已拍板项);
- 不做向量检索(embedding 现状 1033 条全 NULL,另线);
- 不做 UI 变更(Knowledge 页项目筛选留后续)。
## 2. 核心取舍:项目归属从哪来
会话本身不存 project_id。归属来源三选,按可靠性排序:
| 来源 | 可靠性 | 实现成本 | 结论 |
|---|---|---|---|
| a. `boundary_json`(V45 无人值守边界)含项目路径 | 高(显式绑定) | 低(已落库) | ✅ 首选:有则用 |
| b. 会话消息中的 `@项目` enrichment / mention 记录 | 中(用户显式引用过) | 中(解析消息) | ✅ 次选:回退 |
| c. `ai_conversations` 加 project_id 列 | 高但侵入 schema | 高(迁移+全链) | ❌ 本期不做 |
**设计决策 D1(建议拍板)**:归属采用 a→b 两级弱推断,都不命中则 `source_project = NULL`(全局知识,不过滤)。这样零 schema 变更,且与「无人值守长任务」边界模型(boundary 已带项目上下文)天然对齐。
**提炼函数签名扩展**(knowledge_inject.rs 内,不新建文件):
```
async fn resolve_source_project(db, conv_id) -> Option<String>
// 1. 读 ai_conversations.boundary_json → 解析 project_path → 反查 projects 表 path 匹配 → id
// 2. 回退:读最近 N 条消息找 "[项目:xxx]" enrichment 标记 → 反查 → id
// 3. 皆无 → None
```
候选插入处(`extract_knowledge_from_conversation` 内 INSERT 前)把 `source_project = resolve_source_project(...)` 赋上。
## 3. 注入侧:项目维度过滤
**设计决策 D2**:注入不做硬过滤(`WHERE source_project = ?` 会把同问题跨项目通用知识挡死),改**软排序**——SQL 层 `search_keywords` 增加可选 `project_id` 参数:
```
ORDER BY (CASE WHEN source_project = ?1 THEN 0 ELSE 1 END), reuse_count DESC, ...
```
- 同项目知识排前(limit 截断时优先保留);
- `source_project IS NULL`(全局知识)视为第二档但优于异项目:`(CASE WHEN source_project = ?1 THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END)`;
- `inject_knowledge_into_prompt` 调用前同样走 `resolve_source_project`(此时是当前活跃会话)取 project_id 传入;
- `knowledge_search` 工具(LLM 显式调用)暂不加项目参数——LLM 不知道当前 project_id,靠注入侧自动带。
**接口改动面**:
- `df-storage/crud/idea_repo.rs`:`search_keywords(&keywords, include_candidates, limit)` → 增参 `project_id: Option<&str>`(或新方法 `search_keywords_scoped`,保旧签名给 MCP 等外部调用方,内部转调);
- `knowledge_inject.rs` 两个调用点(:423 检索、:622 注入前置);
- `knowledge.rs` 工具(tools/knowledge.rs)不动。
## 4. 闭环验证与防劣化
| 风险 | 对策 |
|---|---|
| 提炼重复(每会话产相似 candidate 洪水) | 已有水位+哨兵;新增:插入前按 `title` 相似度(`search_keywords` 查同 title 命中即跳过或 `reuse_count+1`)——复用现有检索,零新机制 |
| 弱推断错误归属(张冠李戴) | 只在高置信(boundary 显式路径)时写 source_project;mention 回退可拍板是否启用(D1-b) |
| 注入 token 膨胀 | limit 不变,软排序只改顺序不增量 |
| 存量 967 条 candidate/907 published 无 source_project | 不回填(NULL=全局,行为正确);增量自然带项目 |
**验收**:
1. 新会话提及某项目 → 完成后提炼的 candidate `source_project` 非空且指向该项目;
2. 同项目新会话注入时该知识(晋升后)排在异项目知识前;
3. 无项目会话(source_project=NULL)注入行为与现状逐字一致(回归门);
4. `cargo test -p df-storage -p df-ai` 全绿 + search_keywords 新旧签名编译兼容。
## 5. 拍板点(3 项)
- **拍板1(D1-b)**:mention 回退推断是否启用?建议启用但仅当会话中项目 mention ≥1 次且唯一(多项目 mention 不猜,置 NULL)。
- **拍板2(D2)**:注入软排序 vs 硬过滤?建议软排序(理由见 §3)。
- **拍板3**:`search_keywords` 改签名(breaking,内部 crate)vs 新增 scoped 方法?建议**新方法**(df-mcp 等外部链路零影响)。
## 6. 工作量估算
约 1 天:resolve_source_project(2h)+ search_keywords_scoped + 两调用点接线(3h)+ 去重跳过(1h)+ 单测(2h)。
@@ -0,0 +1,99 @@
# 拍板集 · 2026-10-04(全量待拍板清单)
> 用途:一次性过完所有散落拍板点,拍板后全量规划自主推进。每项附建议,逐项表态(「全按建议」或指定「#N 改 X」)。
> 拍板通过后本文件转为决策记录,各设计文档同步销账。
## A. 无人值守长任务(13 项,来自 [无人值守长任务-设计-2026-10-04.md](./无人值守长任务-设计-2026-10-04.md) §拍板点清单+补遗)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| A1 | risk_ceiling 是否开放 High | A 封顶 Medium / B 开放 High 但破坏性命令仍拒 | **A** |
| A2 | 边界存储形态 | A 会话级 JSON 列 / B 独立任务表 | **A** |
| A3 | 边界外动作处置 | A 拒绝+记待办随停靠报告 / B 拒绝即停靠 / C 弹卡 | **A** |
| A4 | 进度事件频率 | A 每 5 轮 / B 每轮 / C 不做 | **A**(值实测后定) |
| A5 | 重启后自动续跑 | A 不自动,标记+一键恢复 / B 自动续跑 | **A** |
| A6 | 子代理是否放宽 | A 维持 Low-only fail-closed / B 继承父边界 | **A** |
| A7 | L3 完全信任 UI | A 不做 UI 文档说明 / B 设置页三档 | **A**(P2 再议 B) |
| A8 | 中途改边界 | A 全不可改 / B risk+目录锁死,预算可由用户中途调 | **B** |
| A9 | 缓存命中降级 | A 超阈值自动插 system 强制转向 / B 维持警告头 | **A**(阈值实测后定) |
| A10 | 模型分级默认 | A 卡内显式选 / B 子代理默认轻量+主循环继承会话模型,高级项可覆盖 / C 不做 | **B** |
| A11 | token 度量口径 | A 父/子分列+合计 / B 只算父会话 | **A** |
| A12 | 任务面板形态 | A 会话头部常驻面板 / B 仅侧栏徽标 | **A** |
| A13 | 预算中途调整 | A 双向 / B 只许收紧 | **A** |
## B. 跨会话召回最小闭环(3 项)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| B1 | mention 回退推断是否启用 | A 启用(仅唯一 mention 才猜) / B 只信 boundary_json | **A** |
| B2 | 注入项目维度 | A 软排序(同项目优先不硬滤) / B 硬过滤 | **A** |
| B3 | search_keywords 改造方式 | A 改签名 / B 新增 scoped 方法保旧签名 | **B** |
## C. namespace 淘汰治理(2 项)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| C1 | pin 上限保护 | A 加(pin 总量>50% 降级最旧 pin) / B 不加上限 | **A** |
| C2 | 报告外默认 pin 范围 | A 仅子代理报告 / B 加 run_command 大产出等 | **A**(本期仅报告) |
## D. 完成卡已消化降级(2 项)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| D1 | 旧卡自动折叠 | A 默认开(touched 保护) / B 默认关 | **A** |
| D2 | ai_sub_agent_trace 工具时机 | A 随 namespace 批 / B 单独批 | **A** |
## E. context_helpers 拆分(1 项)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| E1 | 拆分触发时机 | A 上下文管理批稳定后自动启动 / B 等用户指令 | **B** |
## F. 观察转决策(3 项,使用数据分析深查产物)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| F1 | 压缩无 memoization 修法 | A 同内容哈希跳过(方案已在数据分析报告) / B 不修 | **A** |
| F2 | 标题源取 user 首条 | A 改取 user 首条 / B 维持 assistant 馶句 | **A** |
| F3 | checkpoints.token_total 语义失效 | A 修口径(真实 token 累计) / B 删字段 / C 不动 | **A** |
## G. 子代理深查需拍板项(4 项)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| G1 | 熔断无收尾轮 | A 补一次无工具收尾 LLM 调用(报告完整化) / B 维持末轮独白 | **A** |
| G2 | 报告被视图压缩吞掉 | A 新近度豁免(报告 envelope 豁免折叠) / B 不豁免 | **A** |
| G3 | 被拒事实父上下文保留 | A LLM 压缩 prompt 加保留指令 / B 不加 | **A** |
| G4 | 子会话缺 maybe_auto_compress | A 补齐对齐主循环 / B 维持 128k 默认 | **A** |
## H. aichat 交互成本第三批(1 项,涉开源边界)
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| H1 | 图片默认上传外部域名 | A 上传(涉开源边界) / B 保持本地 base64 | **B**(保守) |
## 拍板记录
**✅ 2026-10-04 全量拍板通过(按建议 29/29)**,方向性基调(用户原话):「未来我更倾向原生 ai-working,很多时候无人值守模式」——
- **A 组 13 项全按建议**:A8 采 B(预算可中途双向调,risk/目录锁死)——「一次性拍板约束 AI 不约束用户」;A5 维持 A(标记+一键恢复,桌面崩溃无自启守护,自动续跑无触发面;若未来做应用自启守护则复议);
- **B/C/D/E/F/G/H 组全按建议**;H1 保持本地 base64,上传方案留档不实施;
- **E1 维持等用户指令**——context_helpers 拆分不自主启动(备勘已就绪,随时可开工)。
### 全量推进规划(拍板后自主推进,批次依赖序)
> 基调:无人值守主线优先(A 组实施=最高优先);子代理确定性项并行批次;设计实施批按拍板解锁。**无阻断,全量开工**。
| 批 | 内容 | 规模 | 依赖 |
|---|---|---|---|
| **1 无人值守 P0** | ✅执行侧已实现(99a3846:V45+边界放行拒绝+安全停靠+进度事件);**剩 P0 文案项:任务书模板加 round-0 规划纪律+验收契约(效率补遗二 9.1,u-claw 思想迁移)**+度量口径落库(7.3 P0 部分) | 0.5-1 天 | 无 |
| **2 子代理确定性** | 指引真空(共享指引段)+ 分离窗死点击 + 幽灵态 + 960s 统一出口 + G1 熔断收尾轮 + G2 报告豁免折叠 + G3 压缩保留指令 + G4 auto-compress + P2 确定性 4 项(max_tokens 信号/任务书钉扎/预算告知/Fatal payload) | 2-3 天 | 无(与批 1 可并行,文件域不交集) |
| **3 设计实施 I** | C 组 namespace 治理(pin+EvictionLog+owner 隔离)+ D 组完成卡降级(D2 同批:ai_sub_agent_trace+digested+KV) | 1.5-2 天 | 无 |
| **4 设计实施 II** | B 组跨会话召回(source_project 赋值+scoped 软排序+去重) | 1 天 | 批 3 后同域顺手(knowledge 域独立,实际无硬依赖) |
| **5 无人值守 P1** | 自愈+通知细化 + 任务面板(头部常驻,A12+补遗二 9.5 四件套/9.8 文案规范)+ 子代理树 + 预算窄 IPC(A13)+ 接管转人工 + 缓存自动降级(A9)+ 子代理默认轻量模型(A10)+ **效率补遗二:9.2 预算行 user 尾注入/9.3 水位 70% 联动/9.4 文件互斥登记** | 3-4 天 | 批 1 |
| **6 数据分析批** | F1 压缩 memoization(1.2 亿 token 大头)+ F2 标题源 + F3 token 口径 + P2×7(status 建模/审计耗时/blob 冗余/日志轮转等) | 2-3 天 | 无 |
| **7 机械小批** | remote_bridge 6 处+agentic 4 处裸 emit 换 emit_ai_chat_event + i18n 死键 + forceConverge 守卫 | 0.5 天 | 无(穿插 filler) |
| **8 交互成本攒批** | 9-29 第二批 P2 16 项 + UX 第四轮 P3 余 9 项 + 子代理 P3×25 | 按域分组 | 批 2/3 后 |
| **9 无人值守 P2** | 断点续跑+信任分级 UI(A7 复议)+ 暂停/恢复组合 + u-claw 接入实施(勘察已齐,relaying.ts 65bae53 可移植) | 4-5 天 | 批 5 |
**执行纪律**:每批独立提交+核验(cargo test --workspace + vue-tsc);批 1/2 并行开;E1 context_helpers 拆分不排期(等指令);H1 无动作。
+159 -19
View File
@@ -30,7 +30,7 @@
- [x] P0 candidate→published 断链打通 ✅已修(机制先行 7c62d8d:复用自动晋升+knowledge_promote 工具+晋升补嵌入;存量晋升迁移 V43 2026-10-02 落地:candidate+confidence=high→published,库副本实证 854 条晋升 967/53→113/907,重跑幂等 0 行,df-storage 180 测试全绿;增量 confidence 分级与 auto_publish 默认值等 C3 拍板) - [x] P0 candidate→published 断链打通 ✅已修(机制先行 7c62d8d:复用自动晋升+knowledge_promote 工具+晋升补嵌入;存量晋升迁移 V43 2026-10-02 落地:candidate+confidence=high→published,库副本实证 854 条晋升 967/53→113/907,重跑幂等 0 行,df-storage 180 测试全绿;增量 confidence 分级与 auto_publish 默认值等 C3 拍板)
- [x] P0 视图裁剪补滚动摘要 ✅已修(9b89610 淘汰补偿摘要插视图首部,user 角色保缓存指纹,纯视图不落库,eviction 11 测试全绿,2026-10-02 核验) - [x] P0 视图裁剪补滚动摘要 ✅已修(9b89610 淘汰补偿摘要插视图首部,user 角色保缓存指纹,纯视图不落库,eviction 11 测试全绿,2026-10-02 核验)
- [x] P1 LLM 知识工具注册 ✅已修(7c62d8d 注册 knowledge_search/insert/promote 三工具,tool_registry.rs:798,检索与注入同源分词) - [x] P1 LLM 知识工具注册 ✅已修(7c62d8d 注册 knowledge_search/insert/promote 三工具,tool_registry.rs:798,检索与注入同源分词)
- [ ] P1 跨会话召回最小闭环(项目级记忆+会话结束提炼进知识库,source_project 已有字段未用) - [ ] P1 跨会话召回最小闭环 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/跨会话召回最小闭环-设计-2026-10-04.md)):勘察修正——提炼链已存在(knowledge_inject.rs 水位+哨兵+手动触发),缺口仅 source_project 赋值(boundary_json→mention 两级弱推断,零 schema 变更)+注入软排序(search_keywords_scoped 新方法);3 拍板点待过,约 1 天
- [x] P1 存量测试修复 4 个 ✅已修(df3ed55 断言同步+e7cd035 笔误修正,2026-10-02 全量 cargo test --workspace 1827 passed/0 failed 复核) - [x] P1 存量测试修复 4 个 ✅已修(df3ed55 断言同步+e7cd035 笔误修正,2026-10-02 全量 cargo test --workspace 1827 passed/0 failed 复核)
- [ ] P2 DB 落库截断分 role 定阈值(user 原话均 41 字被 3072 截断无必要)/namespace 重启懒重建/兜底压缩路径 sync_after_compress 复议 - [ ] P2 DB 落库截断分 role 定阈值(user 原话均 41 字被 3072 截断无必要)/namespace 重启懒重建/兜底压缩路径 sync_after_compress 复议
@@ -39,8 +39,8 @@
> 链路:devflow → u-relay → u-claw。通路已生产验证(u-ppt 试点 2026-08-29),u-relay 零改动,唯一工作面 devflow 侧接入。 > 链路:devflow → u-relay → u-claw。通路已生产验证(u-ppt 试点 2026-08-29),u-relay 零改动,唯一工作面 devflow 侧接入。
> 详情:[02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md](./02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md) > 详情:[02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md](./02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md)
- [ ] devflow/u-claw 两侧细勘察补全(接入点 trait/WS 渠道能力边界/多轮会话锚定) - [x] devflow/u-claw 两侧细勘察补全 ✅2026-10-04 完成([勘察补全](./02-架构设计/专项设计/uclaw两侧勘察补全-2026-10-04.md)):多轮锚定=channel 级单 chain 粘性会话(request_id 仅单指令标识,常驻 claude 各轮同 session_id,崩溃 --resume 恢复);**协议已漂移**——端点改 /ws/commander(commander/agent 角色,e8113c5),hello {kind,channel_id,token},不能照抄 9-28 文档/u-ppt relay.ts;新增核实:串行 worker+队列满丢弃、单条 15min 硬超时、进度 2s 节流、空闲链回收 7s 冷启动;接入实施批清单六步已列(含 per-conv channel_id 隔离策略)
- [ ] 接入实施:WS 客户端(移植 u-ppt relay.ts 或并入 df-tunnel)+ aichat u-claw 渠道 + 配置页(默认关,对齐 cfg.WS==nil 模式) - [ ] 接入实施:WS 客户端(参考 u-ppt relay.ts 65bae53——✅2026-10-04 已核,commander 协议现行对齐(端点/hello/握手/心跳/重连/业务帧六项一致),去 deck/SEP 专有逻辑即可移植)+ ask_uclaw 工具 + 配置页(默认关,对齐 cfg.WS==nil 模式);客户端超时 16min+2 次重试+发送前探活;per-conv channel_id 隔离(devflow-{conv_id})
### 💡 2026-09-28 aichat 交互走查清尾(启动「日常主入口」标准,1P1+7P2+9P3) ### 💡 2026-09-28 aichat 交互走查清尾(启动「日常主入口」标准,1P1+7P2+9P3)
@@ -289,7 +289,7 @@ graph TD
**P2 暂缓(窗口未到/收益低)** **P2 暂缓(窗口未到/收益低)**
- [ ] **REFACTOR-260619-09 [P2 暂缓]** — **context.rs(1332)等 F-15 压缩链路稳定再拆**。生产 745+测试 587。当前 context.rs 被压缩频繁改动,拆分窗口未到。— `crates/df-ai/src/context.rs` - [ ] **REFACTOR-260619-09 [P2 暂缓]** — **✅事实完成销账(2026-10-04 勘误)**:原 context.rs(1332)已拆为 context/{mod,sanitize,manager_tests}.rs 目录模块,条目过时。**新超标对象=context_helpers.rs(1733 行)**,拆分备勘已出(2026-10-04 [设计文档](./02-架构设计/专项设计/context-helpers拆分备勘-设计-2026-10-04.md),四批迁移+压缩热点殿后+触发条件=上下文管理批稳定后等用户指令) — `crates/df-ai/src/context_helpers.rs`
--- ---
@@ -856,21 +856,21 @@ graph TD
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。 > 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
### 批次2(后端高危)并入项 ### 批次2(后端高危)并入项
- [ ] P0 压缩 conv_id 分裂【已亲核实锤】:`commands/chat.rs:1734` 起 cid 切 active——压缩后台会话时目标 conv compressing 永久卡死 + active 会话被插入他人摘要入库。修::1734 改 `cid = conversation_id.clone()`(约 4 行,首位) - [x] P0 压缩 conv_id 分裂【已亲核实锤】:`commands/chat.rs:1734` 起 cid 切 active——压缩后台会话时目标 conv compressing 永久卡死 + active 会话被插入他人摘要入库。修::1734 改 `cid = conversation_id.clone()`(约 4 行,首位) ✅已修(2026-10-04 核验:压缩路径全函数用 conversation_id,零 active_conversation_id 引用,注释含修复说明)
- [ ] P0 V21 迁移 COUNT 幂等窗口:`df-storage/migrations.rs:481-488,570` 批次中途崩溃→剩余对话永不迁移但版本号补齐。修:按对话进度标记替代全局 COUNT - [x] P0 V21 迁移 COUNT 幂等窗口:`df-storage/migrations.rs:481-488,570` 批次中途崩溃→剩余对话永不迁移但版本号补齐。修:按对话进度标记替代全局 COUNT ✅已修(2026-10-04 核验:已改逐对话进度判定——批事务保证对话级原子,已落库数=JSON 消息数即跳过,重跑只补未迁对话)
- [ ] P1 conv delete 不停 in-flight loop:`commands/conversation.rs:608-640` 无 stop_flag/epoch bump——zombie 复活+审批吊死。**与上方「conv 删除族 6 处」同域合并做** - [x] P1 conv delete 不停 in-flight loop:`commands/conversation.rs:608-640` 无 stop_flag/epoch bump——zombie 复活+审批吊死。**与上方「conv 删除族 6 处」同域合并做** ✅已修(2026-10-04 核验:删除路径全链齐备——tombstone deleted_convs 先于 DB 删+stop_flag/notify/epoch bump 同锁停 in-flight loop+级联停子代理+审批 retain 清理+conv_states/per_conv/save_locks/semaphore 全量摘除+loop_was_alive 时 emit_ai_completed_once 单点补终态事件(带 epoch 守卫天然防重复))
- [ ] P1 只读结果缓存无写失效:`audit/cache.rs` 写后读回过期内容。修:per_conv 脏标记,写后整体失效 - [x] P1 只读结果缓存无写失效:`audit/cache.rs` 写后读回过期内容。修:per_conv 脏标记,写后整体失效 ✅已修(2026-10-04:PerConvState 新增 readonly_cache_floor 失效下界——非只读工具 completed 回填后推进到 messages.len(),find_cached_readonly_result 扫描 skip(floor) 过界即脏;4 个写执行回填点全接入(Low 并行/trust 放行/boundary 放行/ai_approve 审批后)+min(len) 钳制防索引漂移+单调 max;ai 域 608 测试全绿含 2 个新增锁定测试)
- [ ] P1 OpenAI 流式 error 帧被 choices 必填整帧丢弃:`df-ai/openai_helpers.rs:193-203`(无声截断)+ 同批 serde 结构错误判 Retryable(`anthropic_compat.rs:629`/`openai_compat.rs:527`) - [x] P1 OpenAI 流式 error 帧被 choices 必填整帧丢弃:`df-ai/openai_helpers.rs:193-203`(无声截断)+ 同批 serde 结构错误判 Retryable(`anthropic_compat.rs:629`/`openai_compat.rs:527`) ✅已修(2026-10-04 核验:OpenAiStreamChunk.choices 改 #[serde(default)]+error:Option<Value> 字段提取,apply_openai_sse 识别 error 帧返 error 载荷由 stream_llm 发 AiError;双协议非流式改 text 先取后 from_str 分层——读取中断(is_decode)判 Retryable,JSON 结构错判 Fatal 防确定性失败白烧重试预算)
- [ ] P1 sanitize 合并连续同 role 丢多模态 parts:`df-ai/context/sanitize.rs:406-425` 带图消息图对 LLM 隐身。修:合并分支 extend parts - [x] P1 sanitize 合并连续同 role 丢多模态 parts:`df-ai/context/sanitize.rs:406-425` 带图消息图对 LLM 隐身。修:合并分支 extend parts ✅已修(2026-10-04 核验:ensure_sequence_legal 合并分支已含 `if let Some(parts) = m.parts { last.parts.get_or_insert_with(Vec::new).extend(parts) }`——纯图空 content 消息的 parts 随合并迁移,图不再隐身)
- [ ] P2-2 maybe_auto_compress protect_start 跨锁过期切片 panic:`context_lifecycle.rs:118` 加 `min(len)` 钳制(一行,与 F2 前移同域) - [x] P2-2 maybe_auto_compress protect_start 跨锁过期切片 panic:`context_lifecycle.rs:118` 加 `min(len)` 钳制(一行,与 F2 前移同域) ✅已修(2026-10-04 核验:第二段锁内 `let cut = protect_start.min(msgs.len())` 钳制已在,注释明确记载跨锁窗口消息变短防御,与 compress_old_messages 内部钳制口径一致)
- [ ] P2-3 try_continue_agent_loop 裸 emit 绕过 emit_ai_completed_once:`agentic/helpers.rs:100/:171` 占位 tool_result 与 pending 残留 - [x] P2-3 try_continue_agent_loop 裸 emit 绕过 emit_ai_completed_once:`agentic/helpers.rs:100/:171` 占位 tool_result 与 pending 残留 ✅已修(2026-10-04 核验:两处非续跑分支(!should_continue 与 !still_generating)均改走正典 emit_ai_completed_once(零 token+notify=false),随出口终态化占位 tool_result+清 pending_approvals,注释记载防 __PENDING__ 占位残留)
### 批次3(前端体验)并入项 ### 批次3(前端体验)并入项
- [ ] P1 后台审批/授权事件被 isCurrent 丢弃→会话挂死:`useAiEvents.ts:94-123` !isCurrent 分支对 ApprovalRequired/DirAuthRequired 放行(与 P2-5 审批数组 per-conv 合并同域) - [x] P1 后台审批/授权事件被 isCurrent 丢弃→会话挂死:`useAiEvents.ts:94-123` !isCurrent 分支对 ApprovalRequired/DirAuthRequired 放行(与 P2-5 审批数组 per-conv 合并同域) ✅已修(2026-10-04 核验:isGlobalApprovalEvent 白名单(AiApprovalRequired/AiDirAuthRequired)+isCurrent 守卫改双条件 `!isCurrent && !isGlobalApprovalEvent(event)`,审批挂起跨会话放行归口 per-conv 待审批集合)
- [ ] P1 switchConversation「对话不存在」兜底漏置 activeId:`useAiConversations.ts:294-309` 补一行(视图/高亮/发送目标三重错配) - [x] P1 switchConversation「对话不存在」兜底漏置 activeId:`useAiConversations.ts:294-309` 补一行(视图/高亮/发送目标三重错配) ✅已修(2026-10-04 核验:兜底新建路径已含 `state.activeConversationId = created.id` 置位+持久化,注释记载旧 id 残留致 isCurrent 误路由;另附 P3-7 noCreateFallback 程序化跳转分支)
- [ ] P2 看门狗 legacy/per-conv 双轨三错配:useAiSend.ts:169/:241/:512 统一迁移传 convId + `_resumeAttempts` 收尾清(债务本就要还) - [x] P2 看门狗 legacy/per-conv 双轨三错配:useAiSend.ts:169/:241/:512 统一迁移传 convId + `_resumeAttempts` 收尾清(债务本就要还) ✅已修(2026-10-04 核验:regenerate(:183)/editMessage(:259)/stopChat(:512+)三处均已传 per-conv convId 重置/清理看门狗,stopChat 收尾 clearResumeAttempts(activeConversationId),注释明确记载「不再落 legacy 单 timer fallback」)
- [ ] P2 _lastDeltas 双路径漏清:useAiEvents.ts:96-115/:154-161 补 clearLastDelta - [x] P2 _lastDeltas 双路径漏清:useAiEvents.ts:96-115/:154-161 补 clearLastDelta ✅已修(2026-10-04 核验:后台终止分支(:217)与停靠卡归集分支(:131)均补 clearLastDelta,前台收尾 cleanupTerminatedConversation 亦有显式清理,注释记载防残留记录误丢下轮同内容 delta)
- [ ] P2 removeGoal 不回写 store/不回滚:TopBar.vue:530-539 对齐 withConvOp 模式 - [x] P2 removeGoal 不回写 store/不回滚:TopBar.vue:530-539 对齐 withConvOp 模式 ✅已修(2026-10-04 核验:removeGoal 已回写 `conv.pinned_goals = next`(注释 UX 走查-对话域 B4),防 loadGoals 重读旧列表复活已删目标)
- [ ] P2 forceConverge 收敛/错误气泡缺 activeId 守卫(useAiStream.ts:115-121,与本批 onStreamTimeout 修复同家族) - [ ] P2 forceConverge 收敛/错误气泡缺 activeId 守卫(useAiStream.ts:115-121,与本批 onStreamTimeout 修复同家族)
### 机械批(可独立小批) ### 机械批(可独立小批)
@@ -878,8 +878,54 @@ graph TD
- [ ] i18n searchFilesBlocked 死键裸 @(zh-CN/en aiChat.ts:198/197)转义或删 - [ ] i18n searchFilesBlocked 死键裸 @(zh-CN/en aiChat.ts:198/197)转义或删
### 登记观察(定级前先核) ### 登记观察(定级前先核)
- [ ] model_fetch 空列表 Ok([]) 上层写回清空模型池(df-ai 侧已核验,src-tauri provider.rs 写回点待核后定级) - [x] model_fetch 空列表 Ok([]) 上层写回清空模型池 ✅已修(2026-10-03 N1):provider.rs 空守卫跳过写回+前端 toastFetchEmpty 同语义守卫
- [ ] save_conversation 需核 needs_full_rewrite 检查(advance_persisted_count 无条件清 dirty);secret.rs 未走查(权限拒绝) - [x] save_conversation needs_full_rewrite 复核 ✅已核+已修(2026-10-03 N1):实锤增量/无变化路径写库期间新置 dirty(压缩 mark)被 advance 无条件误清→改动滞留内存;改 advance_persisted_count(new,full_rewrite_applied) 仅全量清+新测试锁死;secret.rs 仍未走查
- [x] authz_debug 残留 ✅已清(2026-10-03 N1):全仓 grep 零残留(chat.rs/state.rs 注释提及已删)
# 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md)
> 前端 13 项(P1×2/P2×4/P3×7) + 后端 P1×1/P2×3/P3×8;后端报告含 6 点「确认无恙」防重查。
- [ ] P1 分离窗下 TaskDrawer 关联会话死点击:openConversation detached 分支无跨窗信号不聚焦(useAiConversations.ts:661)
- [ ] P1 子代理运行期切回父会话 spawn 卡「已完成」幽灵态:回载 parseConvMessages 恒标 completed+运行期零事件(useAiConversations.ts:230)
- [ ] P1 后端 外层 960s 超时 drop 子循环 future→统一出口全跳过(报告蒸发+注册表泄漏+子会话滞留):deadline 检查下沉 stream chunk 循环或 select 超时分支特殊化补 AiAgentFinished(truncated)(audit/mod.rs:306)
- [ ] P2 rejected_tools 前端丢弃不可扫描:完成卡收字段+warn 色 chip(useAiEvents.ts:70)
- [ ] P2 完成卡纯内存重启蒸发:切换/挂载按 origin='sub_agent' 回补(aiShared.ts:630)
- [ ] P2 完成卡恒贴底时序漂移:按完成轮次桶插入(MessageList.vue:1080)
- [ ] P2 轨迹浮层无身份标识:头补 res.title/任务书首行(MessageList.vue:1584)
- [ ] P2 后端 白名单执行期不拦 Low 工具:allowed 随子循环传入 process_tool_calls(audit/mod.rs:814)
- [ ] P2 后端 父删除级联孤儿轨迹照落库:cascade_stop_children_locked 删除形态补 deleted_convs.insert(sub_agent.rs:194)
- [ ] P2 后端 S4 反查 LIMIT 1000 先截断后过滤:匹配条件下沉 SQL 或辅助索引(audit/record.rs:315)
- [ ] P3×15(前端7+后端8):deny envelope 人话渲染/浮层 Esc/spawn_agent 本地化/卡 loading/停止标「已取消」/后台完成通知/已删会话静默新建 + 并发配额挤兑/事件广播噪音/流后取消补 envelope/bypass 连带解除黑名单/落库元数据错位/peek 校验/空收敛降级/AiAgentStarted——见两报告 P3 节
# 子代理深查两路(2026-10-03 晚,详情 temp/深查-子代理{AI效果面,x上下文管理交叉面}-2026-10-03.md)
> 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。
- [ ] P1[确定性] 子代理指引真空:父会话实测蒸馏的四段工具纪律(read_symbol 优先/URL 三工具纪律/重复调用检测/get_app_config)未注入子代理 system prompt,而白名单缺省下发 25+ Low 工具,主会话已根治的翻车模式(一次死磕烧 1/3 预算)会在子代理原样复发;修法=抽共享指引段同源拼接
- [ ] P1[需拍板] 熔断无收尾轮:max_rounds 打满是常态,截断时父拿到「末轮思考独白」而非报告;修法=熔断触发补一次无工具收尾 LLM 调用(涉预算策略)
- [ ] P1[需拍板] 报告被视图压缩吞掉:summarize_tool_results 无新近度豁免,报告 envelope>2KB 下一轮即被 JSON 分支折叠(512 字符),父 LLM 从未见过报告全文而 DB/前端卡是全文,追问细节答不上;涉上下文策略(stall_detector.rs:278-350/context_helpers.rs:461-513)
- [ ] P2 max_tokens=8192 截断零信号:finish_reason=length 被通道层按正常完成吞,报告腰斩仍标 completed[确定性可修]
- [ ] P2 任务书=首条 user 消息,超预算时第一个被逐出且子代理无目标钉扎(主循环有,子循环显式传空 sub_agent.rs:594)[确定性可修]
- [ ] P2 预算三值(轮次/token/900s)不告知子代理,系统提示词硬编码「默认最多 6 轮」[确定性可修,文案]
- [ ] P2 Fatal 错误细节被吞(RoundOutcome::Fatal 无 payload),父拿无因报告无法针对性重派[中小扩参]
- [ ] P2 namespace 10MB 全局 LRU 淘汰:报告蒸发为「重新调用」死路+多代理共池互踩 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/namespace淘汰治理-设计-2026-10-04.md)):pin 机制+EvictionLog 富提示(替代裸 EVICTED_PLACEHOLDER)+owner 软隔离三招,零 schema 变更;2 拍板点(pin 上限保护/报告外默认 pin 范围),约 0.5-1 天
- [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板]
- [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天
- [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板]
- [ ] P3 spawn task 全文永续无瘦身/8KB 截断文案对子代理语境失真/环境画像日期不注入/报告 8K 头尾截断恰切证据区/temperature 0.7 共享/model 回退不回报/namespace 挤兑/一次性语义未向父声明/报告约定缺「文件路径原样保留」/语言无 lang 参数——见两报告 P3 节
- [ ] 观察 uf2 修复引入:取消判定 summary.includes('已被取消') 中文子串启发式,根治=后端透出结构化 stop_reason/取消标志字段
# 无人值守长任务(2026-10-04 用户拍板方向,设计先行)
> 用户拍板:审批类(S2-B 真审批通道)延后不做;产品主线=任务规划一次性确定边界后,无人值守、自主无干预、稳定完成长任务,用户体验优先零打断。追加两维度:效率(完成快+tokens 高效)、可观测可控(进度花费看得见,暂停纠偏接管控得住)。
> 设计文档已定稿:docs/02-架构设计/专项设计/无人值守长任务-设计-2026-10-04.md(257 行,含效率/可观测可控补遗两章),拍板点 13 项各带建议。
> 缺陷靶心分析:temp/深查-无人值守稳定性缺陷-2026-10-04.md(P1×5/P2×7+效率 5+观测可控 5,接驳点已标注)。
- [ ] 设计评审后按分期实施(P0 边界模型+旁路主干 → P1 自愈+通知 → P2 断点续跑+信任分级)
- [ ] P1 缺陷随分期消化:~~①审批超时取消后循环永不恢复~~ ✅①已修(2026-10-04 核验:cancel_expired_approval 第6步清幽灵态迁回 Idle+第7步安全停靠 AiTaskParked+NeedAction 通知,audit/mod.rs:530-555) ②spawn_agent 注册 Medium 无人值守动线停摆(归 P0 旁路) ③MidStream 断流=整任务终止(入打断点表,P1) ④轮级重试 30s 总预算全候选共享无跨轮重入(P1,任务级重入;工作区已见 ROUND_REENTRY_MAX 跨轮重入层在飞实施,待合入核验) ⑤重启后任务静默失踪(归 P2 断点续跑)
- [ ] P2 缺陷登记:panic 收尾伪装正常完成无通知/context_length Fatal 无降级/空回复静默假成功/前端 3 次探测强制收敛与后端分裂/checkpoint 审计只增不清
- [ ] 效率面:模型无轮内分级/警示消息打断前缀缓存/换参同语义死磕不计数/子代理串行 await/token 花费中途不可查/纠偏只能排队不能注入/预算中途调整不可达(归设计补遗两章分期)
# 后续规划方向(2026-10-02 用户口述,不分先后) # 后续规划方向(2026-10-02 用户口述,不分先后)
@@ -894,3 +940,97 @@ graph TD
7. UX 体验交互体验(批次3 及后续 UX 批持续线) 7. UX 体验交互体验(批次3 及后续 UX 批持续线)
8. 内置数据库、远程服务器连接能力(与代理工具生态 mysql-proxy/ssh-proxy 等整合) 8. 内置数据库、远程服务器连接能力(与代理工具生态 mysql-proxy/ssh-proxy 等整合)
9. 远程内网设备文件程序管控(中继+代理 client,内置于 u-desk/devflow 或全新设计,关联 u-relay) 9. 远程内网设备文件程序管控(中继+代理 client,内置于 u-desk/devflow 或全新设计,关联 u-relay)
# 使用数据分析深查(2026-10-04,logs+DB 实证,详情 temp/使用数据分析-1004/)
> 89 会话/15126 消息/9398 工具执行(3.5 个月真实使用);全文与 out1~out7 中间数据见分析目录。修复以现场源码为准。
- [x] P0 git.rs:161 字节切片撞 UTF-8 边界 panic(中文 diff 必炸,13:44 两次实证) ✅已修(分析当场,回退字符边界)
- [x] P1 Temperature 原样透传不支持的模型(sensenova 等仅允许 1,9 次 400 实证)——按模型适配/省略 ✅已修(2026-10-04:ModelConfig 加 temperature_supported 字段+presets 标 sensenova-6.7-flash-lite=false+启发式 sensenova 前缀判定;openai/anthropic 双协议转换层不支持即省略字段;df-ai 560+df-ai-core 33 测试全绿含新增锁行为测试)
- [ ] P1 压缩无 memoization:view-only 压缩 2280 次中 369 组同内容反复压(最坏 54 次),prompt 1.2 亿 tokens=completion 115 倍——同内容哈希跳过
- [ ] P1 标题采到 assistant 首句('信息已足够。让我看一眼...'/'<details><summa')——标题源取 user 首条
- [ ] P2 checkpoints.token_total 语义失效(p50=120 vs 真实数万级,差 3 个量级)
- [ ] P2 ai_conversations.status 只有 active/compressed,incomplete/error 终局未建模入 status(17/89 停在 tool 结果无收尾,无法统计未完成率)
- [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)
- [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)
- [ ] P2 日志无轮转且放 %TEMP%(系统清理无声删除,建议迁数据目录+按大小轮转)
- [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
- [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落)
# ai-chat UX 走查第四轮(2026-10-04,增量深查,详情 temp/UX走查-aiChat缺陷-2026-10-04.md)
> P1 零;P2×7/P3×11;已修三轮清单已硬排除。修复批开工以现场源码为准。
- [x] P2-A2 边界配置卡目录白名单零校验 ✅已修(词法校验+就近红字),坏路径拖到运行期才暴露(TaskBoundaryCard.vue:163)
- [x] P2-A1 边界拉取失败静默降级 ✅已修(failed 态+授权禁用+重试)默认表单,一次授权即静默覆盖原授权(TaskBoundaryCard.vue:126+useTaskBoundary.ts:25)
- [x] P2-A4 熔断时求助卡被停靠卡顶替 ✅已修(AiTaskParked 保留 pendingHelp),help options 决策项丢失(useAiLifecycleEvents.ts:87,239)
- [x] P2-A3 generating 锁编辑连带锁死 ✅已修(清除解锁+confirm)「清除边界」(收权被盖,纯前端限制)(TaskBoundaryCard.vue:77,232)
- [x] P2-B1 非视觉模型贴图发送前零感知 ✅已修(modalities 判定+警示 chip),静默丢图(ChatInput.vue:185,modalities 全链路未消费;数据实证三连手敲重试)
- [x] P2-B2 纯发图+非视觉模型 ✅已修(前后端双闸拒纯图),占位串当用户消息喂模型跑偏一轮(chat.rs:789)
- [x] P2-B3 error_type 大面积 Unknown ✅已修(classify_permanent→ProviderConfig CTA),永久错误也显「重试」必再败(MessageList.vue:688+mod.rs:596)
- [x] 回归疑点 App.vue:227 全局快捷键 ✅已修(双文件 target 守卫+AIChat:339)(Ctrl+I/N/R/L/K/B)无 target 守卫,仅 Esc 有(走查复核提请,修时验证)
- [ ] P3×11(文案四条已顺带修 ✅:fatal 矛盾措辞/cancelled 续跑 CTA/high 裸串/TopBar ellipsis;余 9 布局+虚拟滚动+压缩面包屑待批)
# 后续规划登记(2026-10-04 用户口述)
- 各模块 UX 走查(全模块逐域走查,ai-chat 已做完四轮,剩余:项目/任务/工作流/想法池/知识/设置等域)——待令启动,建议逐域分批
# DevFlow agent 狗粮实测短板(2026-10-04,84cb104c 会话数据实证,登记待排批)
> 当日 ~810 次工具调用;主循环配置 max_iterations=0(不限)实证 225 轮零触顶;子代理 spawn max_rounds 6 默认/模型自给 10→20(上限 20)。
- [ ] P1 工具纪律失守:read_file 本支持 offset/limit 分页+search(工具描述明示)但模型不用,67+ 次 run_command PowerShell/python 切片绕道读文件——大文件 read_file 自动截断返回并带「用 offset/limit」引导+系统提示词禁 run_command 读文件
- [ ] P1 patch_file old_text 未命中 28%(38/136,全部「未找到目标文本」):加空白归一化回退匹配(唯一命中自动采用并提示),工具描述教「3-5 行锚点小步 patch」
- [ ] P2 子代理默认 max_rounds=6 偏小,父试探性提到 10/20:默认提到 12 或按任务书长度给建议值
- [ ] P2 验证烧轮:全量 cargo check 多次(单次数十秒),引导 check -p 单包/单文件快检策略
- [ ] P3 并行子代理轮次徽标无身份,交错显示「第3轮/第2轮」致乱序观感(机制=各自独立计轮,正常):徽标带 agent 序号/任务首词
# 新能力引入评估纪律(2026-10-04 用户令,长期生效)
> 引入任何新能力(协议/工具/机制/大重构)前,须做负面影响与破坏性判定,登记在设计文档内;审查(Review 会话)按此清单核。四维度基准:功能/接口适配/内容风格/性能,禁「未发现故障=不受影响」。
- [ ] **兼容性**:旧数据/旧客户端/旧配置共存与迁移路径;迁移须可断点续跑(借鉴 V21 教训)且可逆(SQL 迁移带 DOWN 或等价回退)
- [ ] **破坏性**:不可逆操作清单(删数据/拆兼容层/改协议旧值拒认);每项须有回退方案或「为何不可回退」论证
- [ ] **安全面**:新增攻击面(端点/帧/命令执行/路径)逐项列;权限是否放大(对齐边界模型 risk 分级)
- [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训)
- [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标
- [x] **追溯评估(实例,2026-10-04 完成)**:u-relay wire v3(「兼容层全拆」)——初判「零评估章」系 grep 截断误判,实有 §8 安全说明+§9 旧版差异;正式评估结论:生态四方(relay 现网/u-ppt/df-miniapp/df-tunnel)已全部 v3 一致,切换期破坏已吸收(均为开发期集成,无生产 SLA 损伤)。残余缺口登记:
- [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲
- [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵)
- [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明)
- [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s)
- [ ] P3 同 channel_id 多 agent 并存全投递(§3.3):双 devflow 实例共享通道=指令重复执行,补「一通道一 agent」运维纪律
# 提交历史重整(2026-10-04 用户令,交 DevFlow agent 执行)
> 现状:main ahead 6(本地未推送),六笔零 body/一笔主行超长/文档笔类型误标。**执行前置:自身无在飞任务+git status 干净**(历史重放期间不得并行改码)。
## 步骤(非交互,全程可脚本化)
1. `OLD=$(git rev-parse HEAD)` 记录现 HEAD(396f74d)
2. `git reset --hard origin/main`
3. 按原序 cherry-pick 六笔:3dfa45d → f90ff80 → 737cc1f → 8dd756c → d5d9d9a → 396f74d,每笔 pick 后 `git commit --amend -F <消息文件>` 换用下方草案
4. 校验:`git diff $OLD HEAD --stat` 必须为空(树逐字节一致)+ `git status` 干净 + `git log --format="%h %s"` 六笔齐
## 六笔消息草案(subject 全部 ≤50 字;body 每行一事项;可按你对实际改动的了解细化,但不得添加 diff 中不存在的说法)
1. `新增: 无人值守P0任务边界全链`
body: 任务级授权边界判定与审批旁路单点插桩 / boundary_json 随会话落库(V45 可逆迁移) / 六路非正常终局统一安全停靠与托盘通知 / 每五轮进度事件与会话头部进度条 / 前端边界配置卡侧栏徽标及 UX 校验补齐(每条一行)
2. `优化: 子代理体验与纪律注入`
body: 工具纪律段抽共享同源注入 / 子代理轨迹浮层与完成卡身份 / 分离窗会话切换修复 / 960s 统一出口报告不再蒸发
3. `修复: 协议层与体验缺陷六件`
body: temperature 按模型适配仅收 1 的模型不再 400 / 流式 error 帧不再被 choices 必填整帧丢弃 / 只读缓存写后失效 / git diff 中文截断撞 UTF-8 边界 panic / 非视觉模型纯图片直接拒绝 / 全局快捷键补输入焦点守卫
4. `优化: 跨端链路协议对齐`
body: miniapp 配置域旧端点强制重置 / df-tunnel 隧道协议与 relay 现网对齐 / relay e2e 例程入 examples
5. `优化: 子代理截断信号与缓存失效`
body: max_tokens 截断标记穿透到完成卡 / 缓存写后失效补测试锁定 / 派发参数卡头信息补全
6. `文档: 设计定稿与评估纪律登记`(类型由「优化」改「文档」,文件面全在 docs/)
body: 无人值守长任务设计定稿含效率与可观测补遗 / u-relay wire 协议规格与五项评估结论 / 新能力引入评估纪律五项清单 / todo 销账与狗粮短板分析登记
## 提交重整补充(防坑,必读)
- **reset --hard 前工作区必须干净**:当前存在未提交改动(含本节处方)。执行顺序=①先收尾在飞工作,连同本节处方一并提交为第 7 笔(消息如 `文档: 提交重整处方与在飞收尾`)②然后 reset --hard origin/main ③按原序 cherry-pick 前六笔并逐笔 amend 换消息 ④第 7 笔原样 cherry-pick 不改消息 ⑤树一致性校验(git diff 原HEAD 为空)
- 重放期间禁止并行改码;校验不过立即 `git reset --hard $OLD` 回滚到重整前
+3
View File
@@ -39,6 +39,9 @@ async-trait = { workspace = true }
# 零新依赖树)。rust-work 改代理端口只改网关映射表,工具代码不感知端口。 # 零新依赖树)。rust-work 改代理端口只改网关映射表,工具代码不感知端口。
axum = { version = "0.7" } axum = { version = "0.7" }
# 托盘菜单 i18n:系统 locale 判定(零传递依赖,判定一次缓存)
sys-locale = "0.3"
# 后端 crate # 后端 crate
df-types = { path = "../crates/df-types" } df-types = { path = "../crates/df-types" }
df-storage = { path = "../crates/df-storage" } df-storage = { path = "../crates/df-storage" }
+195 -14
View File
@@ -86,6 +86,18 @@ pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
/// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。 /// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。
pub const PROVIDER_SATURATION_DEMOTE_THRESHOLD: usize = 3; pub const PROVIDER_SATURATION_DEMOTE_THRESHOLD: usize = 3;
/// 无人值守进度可观测(设计 §4.3):主 loop 每 N 轮 emit AiTaskProgress 一次。
/// 5=设计拍板点 4 建议 A;每轮 emit 噪音大,不做则牺牲静默期可观测。实测后可调。
pub const PROGRESS_EVERY_N_ROUNDS: usize = 5;
/// 跨轮任务级重入(深查报告 P1-4):单轮流前重试共享 30s 挂钟预算,一次 >30s 的网络故障
/// (WiFi 切换/VPN 重连/上游限流窗口)即整任务失败。本层加「轮间」耐受力:轮失败不立即
/// 终局,指数退避后从当前轮重入(上下文已落库,每轮 save 天然支持;iteration 不推进,
/// 不烧 max_rounds 预算)。连续重入 ROUND_REENTRY_MAX 次,退避 1min/5min/15min,
/// 仍失败转停靠(fatal)。单轮内 30s 预算不动(防死循环),只加轮间容忍。
pub const ROUND_REENTRY_MAX: usize = 3;
pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs /// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。 /// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
/// ///
@@ -154,10 +166,15 @@ pub const MAX_GOALS: usize = 5;
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。 /// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
pub const SKILL_LIST_INJECT_ENABLED: bool = true; pub const SKILL_LIST_INJECT_ENABLED: bool = true;
/// 技能清单注入最大条数(默认 20)。 /// 技能清单注入最大条数(默认 120)。
/// ///
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。 /// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。
pub const SKILL_LIST_MAX_ITEMS: usize = 20; /// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能,
/// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见),
/// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。
/// 清单单条为「名称+80字描述」索引卡片(约 30 token/条),120 条 ≈ 3.6K token,
/// 且注入位于 system 稳定段(跨轮缓存命中,只付一次成本),120 上限兼顾覆盖与预算。
pub const SKILL_LIST_MAX_ITEMS: usize = 120;
/// 技能清单单条描述截断长度(默认 80 字符)。 /// 技能清单单条描述截断长度(默认 80 字符)。
/// ///
@@ -1039,6 +1056,10 @@ pub(crate) async fn run_agentic_loop(
// 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。 // 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。
let mut provider_saturation: HashMap<String, usize> = HashMap::new(); let mut provider_saturation: HashMap<String, usize> = HashMap::new();
// 跨轮任务级重入(ROUND_REENTRY_MAX):连续轮失败计数,Continue 成功即归零
// (只认连续失败,偶发一轮网络故障不消耗后续耐受力)。
let mut consecutive_round_failures: usize = 0;
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations }; let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations };
@@ -1053,6 +1074,20 @@ pub(crate) async fn run_agentic_loop(
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。 // 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async { let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max { for iteration in start_iteration..effective_max {
// ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ──
// round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。
// owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。
if iteration % PROGRESS_EVERY_N_ROUNDS == 0
&& loop_epoch_arc.load(Ordering::SeqCst) == my_epoch
{
emit_ai_chat_event(&app_handle, AiChatEvent::AiTaskProgress {
conversation_id: Some(conv_id.clone()),
round: (iteration + 1) as u32,
max_rounds: max_iterations as u32,
tokens_used: tokens.total(),
});
}
// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳) // ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段 // → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
// + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ── // + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ──
@@ -1070,11 +1105,14 @@ pub(crate) async fn run_agentic_loop(
PreparedRound::Exit => return, PreparedRound::Exit => return,
}; };
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)── // ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文 + 跨轮重入)──
// 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续; // 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续;
// StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/ // StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。 // Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)。
let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round( // 'stream_round 重入层(ROUND_REENTRY_MAX):Fatal 不再立即终局,指数退避后从
// 当前轮重入(stream_relay 每 per-permit 重取重入获全新 30s 预算 + 全新候选链)。
let (full_text, tool_calls_acc, round_usage, round_is_estimated, round_truncated) = 'stream_round: loop {
match stream_round(
&session_arc, &db, &conv_id, &session_arc, &db, &conv_id,
&messages, &tool_defs, &app_handle, &messages, &tool_defs, &app_handle,
&stop_flag, &notify, &stop_flag, &notify,
@@ -1087,12 +1125,61 @@ pub(crate) async fn run_agentic_loop(
&pinned_goals_snapshot, &pinned_goals_snapshot,
&loop_epoch_arc, my_epoch, &loop_epoch_arc, my_epoch,
).await { ).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => { RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens } => {
(full_text, tool_calls, round_usage, round_is_estimated) // 本轮成功:连续失败计数归零(重入只认连续失败)。
consecutive_round_failures = 0;
break (full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens);
} }
RoundOutcome::StaleAfterStream => return, RoundOutcome::StaleAfterStream => return,
RoundOutcome::MidStreamSaved => return, RoundOutcome::MidStreamSaved => {
RoundOutcome::Fatal => return, // 安全停靠(设计 §四):MidStream 原裸 return=整任务静默终止(保文
// AiCompleted(incomplete) 无停靠语义,托盘零感知)。对齐 Fatal 路径补
// AiTaskParked + NeedAction 通知。teardown/save/guard 复位已由
// stream_relay 保文路径完成(stop_reason="network"),此处不重复 save。
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
"响应流中途断开,已接收的部分内容已保存;任务安全停靠,重发消息可继续",
"midstream",
);
return;
}
RoundOutcome::Fatal { error } => {
// P2-4:error 已由 stream_round emit 错误气泡,此处仅留诊断日志
// (真实原因不再只进子会话事件面,排查有据可查)。
consecutive_round_failures += 1;
if consecutive_round_failures > ROUND_REENTRY_MAX {
// 连续重入预算耗尽:转停靠(网络故障已超出轮间退避可覆盖范围)。
tracing::warn!(
conv_id = %conv_id,
failures = consecutive_round_failures,
error = %error,
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
consecutive_round_failures,
);
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error),
"fatal",
);
return;
}
let delay = ROUND_REENTRY_BACKOFF_SECS[consecutive_round_failures - 1];
tracing::warn!(
conv_id = %conv_id,
attempt = consecutive_round_failures,
max_attempts = ROUND_REENTRY_MAX,
delay_secs = delay,
error = %error,
"[ai] 本轮失败,{}s 后从当前轮重入({}/{}): {}",
delay, consecutive_round_failures, ROUND_REENTRY_MAX, error,
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
// 退避后不显式查 stop/epoch:stream_round 内部闸门已兜
// (stop_flag→停止路径由 finalize 收尾、epoch 后校验→StaleAfterStream),
// 重入即被正确接管,无需重复判定。
continue 'stream_round;
}
}
}; };
// global/per_conv permit 已上移 loop 入口整 loop 持有, // global/per_conv permit 已上移 loop 入口整 loop 持有,
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit // stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit
@@ -1110,6 +1197,7 @@ pub(crate) async fn run_agentic_loop(
&full_text, &tool_calls_acc, &round_usage, round_is_estimated, &full_text, &tool_calls_acc, &round_usage, round_is_estimated,
&last_reasoning_content, &last_reasoning_content,
&mut goal_verify_rounds, &mut goal_verify_rounds,
round_truncated,
).await { ).await {
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致) // RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
RoundEpilogue::RunTools => {} RoundEpilogue::RunTools => {}
@@ -1182,6 +1270,14 @@ pub(crate) async fn run_agentic_loop(
&loop_epoch_arc, my_epoch, &loop_epoch_arc, my_epoch,
false, false,
).await; ).await;
// 安全停靠(设计 §四):预算(轮次)耗尽=五路非正常终局之一。teardown 已完成
// save + generating 复位 + AiCompleted(incomplete)+ NeedAction 通知(5s 节流
// 与停靠通知同组合并不会刷屏),此处补结构化 AiTaskParked 供前端操作卡消费。
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
&format!("已达最大轮次({}),任务安全停靠:未完成事项已随会话保存,点继续或重发消息可追加授权续跑", max_iterations),
"max_rounds",
);
return; return;
} }
@@ -1661,6 +1757,8 @@ async fn finalize_round_output(
round_is_estimated: bool, round_is_estimated: bool,
last_reasoning_content: &Option<String>, last_reasoning_content: &Option<String>,
goal_verify_rounds: &mut u32, goal_verify_rounds: &mut u32,
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
round_truncated_by_max_tokens: bool,
) -> RoundEpilogue { ) -> RoundEpilogue {
// 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。 // 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。
// 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 + // 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 +
@@ -1773,6 +1871,20 @@ async fn finalize_round_output(
); );
} }
// 正常收敛(loop 主体置 converged 后 break) // 正常收敛(loop 主体置 converged 后 break)
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
if round_truncated_by_max_tokens {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut notice = ChatMessage::system(
"⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。",
);
notice.model = Some(resolved_model.to_string());
conv.messages.push(notice);
}
tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示");
}
return RoundEpilogue::Converged; return RoundEpilogue::Converged;
} }
} }
@@ -1817,7 +1929,7 @@ async fn process_round_tools(
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
let round_tool_call_count = tool_calls_acc.len(); let round_tool_call_count = tool_calls_acc.len();
// 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent) // 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent)
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal).await; let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await;
// DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
tracing::debug!( tracing::debug!(
@@ -1864,6 +1976,13 @@ async fn process_round_tools(
return ToolsOutcome::Exit; return ToolsOutcome::Exit;
} }
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key); emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key);
// 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡,
// 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
&format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count),
"circuit_breaker",
);
return ToolsOutcome::Exit; return ToolsOutcome::Exit;
} }
} }
@@ -1881,6 +2000,12 @@ async fn process_round_tools(
*loop_warned = new_loop_warned; *loop_warned = new_loop_warned;
if tripped { if tripped {
guard.reset().await; guard.reset().await;
// 安全停靠(设计 §四):探索熔断从「等用户」改「存档+通知」,语义对齐五路统一出口。
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
"探索熔断(连续无进展的重复探索),任务安全停靠;已完成进度已保存,补充新方向后重发消息可继续",
"stall_breaker",
);
return ToolsOutcome::Exit; return ToolsOutcome::Exit;
} }
} }
@@ -2309,13 +2434,47 @@ async fn insert_tool_call_over_limit_warning(
.await; .await;
} }
// ── classify_permanent_provider_error: 可判定的永久性 provider 错误识别(词法,仅此一处) ──
// 命中 → ProviderConfig(前端隐藏「重试」改显「去设置」CTA,重试必再败不再循环);
// 未命中维持 Network(现状口径,可重试)。只认高置信特征,宁漏勿误(误判会把可恢复错误
// 锁死成「不可重试」)。输入是 fmt_diag 聚合诊断([provider] 场景(status): body),
// 关键词对 provider body 原文匹配,大小写不敏感。
fn classify_permanent_provider_error(error: &str) -> bool {
let lower = error.to_lowercase();
// 模型不存在: "model is not found" / "The model `x` does not exist" / 中文「模型不存在」
let model_missing = lower.contains("model")
&& (lower.contains("not found")
|| lower.contains("not_found")
|| lower.contains("does not exist")
|| lower.contains("不存在"));
// 令牌无权访问模型: 「该令牌无权访问模型」/ no permission / access denied
let no_access = lower.contains("无权")
|| lower.contains("无权限")
|| lower.contains("no permission")
|| lower.contains("access denied")
|| lower.contains("permission denied");
// 参数拒绝类 400: sensenova "field Temperature invalid" 实锤 / OpenAI invalid_request_error
let param_reject = lower.contains("invalid_request_error")
|| ((lower.contains("invalid") || lower.contains("非法") || lower.contains("无效"))
&& (lower.contains("temperature")
|| lower.contains("parameter")
|| lower.contains("field")
|| lower.contains("request")));
model_missing || no_access || param_reject
}
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ── // ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
// 双写经 emit_ai_chat_event。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类); // 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
// 两端统一 Network(原 publish 端漏传 None 属漂移,2026-10-01 顺带修)。 // 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) {
let error_type = if classify_permanent_provider_error(error) {
ErrorType::ProviderConfig
} else {
ErrorType::Network
};
emit_ai_chat_event(app_handle, AiChatEvent::AiError { emit_ai_chat_event(app_handle, AiChatEvent::AiError {
error: error.to_string(), error: error.to_string(),
error_type: Some(ErrorType::Network), error_type: Some(error_type),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。 // P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
@@ -2584,8 +2743,9 @@ async fn build_runtime_prompt(
.collect(); .collect();
if !lines.is_empty() { if !lines.is_empty() {
system_prompt = format!( system_prompt = format!(
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}", "{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述,已去重,上限 {} 条)。**自动触发规则**:用户请求(含普通任务指令)与某技能用途匹配时,你应先调 read_skill 工具读取该技能规程全文,按其流程直接执行,无需等待用户显式输入 /技能名;仅当技能涉及高风险操作或不确定用户意图时,才提示用户输入 /技能名 确认。未列出的技能也可用 read_skill 按名查询:\n{}",
system_prompt, system_prompt,
SKILL_LIST_MAX_ITEMS,
lines.join("\n") lines.join("\n")
); );
tracing::debug!( tracing::debug!(
@@ -2974,3 +3134,24 @@ mod panic_guard_tests {
assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行"); assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行");
} }
} }
// 跨轮任务级重入(深查 P1-4)退避调度契约:重入循环按 `BACKOFF_SECS[failures - 1]` 取值,
// 表长必须覆盖 ROUND_REENTRY_MAX(越界 panic 面)且单调递增(指数退避语义)。
#[cfg(test)]
mod round_reentry_tests {
use super::{ROUND_REENTRY_BACKOFF_SECS, ROUND_REENTRY_MAX};
#[test]
fn backoff_schedule_covers_max_attempts_and_ascending() {
assert_eq!(
ROUND_REENTRY_BACKOFF_SECS.len(),
ROUND_REENTRY_MAX,
"退避表长须覆盖重入上限(取值索引 failures-1 不越界)",
);
for w in ROUND_REENTRY_BACKOFF_SECS.windows(2) {
assert!(w[0] < w[1], "退避须单调递增(指数退避语义): {:?}", w);
}
// 深查处方锚定:1min/5min/15min,覆盖 WiFi 切换/VPN 重连/上游限流窗口量级
assert_eq!(ROUND_REENTRY_BACKOFF_SECS, [60, 300, 900]);
}
}
@@ -69,6 +69,9 @@ enum StreamOutcome {
resolved_model: String, resolved_model: String,
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求) /// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
reasoning_content: Option<String>, reasoning_content: Option<String>,
/// 流因 provider max_tokens 上限截断(finish_reason=length / stop_reason=max_tokens),
/// 本轮文本/tool_calls 可能不完整,调用方据此标记该轮产出截断。
truncated: bool,
}, },
/// error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。 /// error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。
InitFailedExhausted { error: String }, InitFailedExhausted { error: String },
@@ -175,10 +178,11 @@ async fn stream_with_retry(
).await; ).await;
match result { match result {
StreamResult::Complete { text, tool_calls, usage, reasoning_content } => { StreamResult::Complete { text, tool_calls, usage, reasoning_content, truncated_by_max_tokens } => {
return StreamOutcome::Success { return StreamOutcome::Success {
text, tool_calls, usage, incomplete: false, text, tool_calls, usage, incomplete: false,
resolved_model: resolved_model.to_string(), reasoning_content, resolved_model: resolved_model.to_string(), reasoning_content,
truncated: truncated_by_max_tokens,
}; };
} }
StreamResult::Partial { text, tool_calls, usage, reasoning_content } => { StreamResult::Partial { text, tool_calls, usage, reasoning_content } => {
@@ -192,6 +196,8 @@ async fn stream_with_retry(
return StreamOutcome::Success { return StreamOutcome::Success {
text, tool_calls, usage, incomplete: true, text, tool_calls, usage, incomplete: true,
resolved_model: resolved_model.to_string(), reasoning_content, resolved_model: resolved_model.to_string(), reasoning_content,
// MidStream 已按不完整处理(incomplete=true),截断标志不再叠加
truncated: false,
}; };
} }
StreamResult::InitFailed { retryable, error } => { StreamResult::InitFailed { retryable, error } => {
@@ -257,6 +263,9 @@ pub(crate) enum RoundOutcome {
/// 本轮 prompt 是否估算兜底(= 进 stream_round 时的 round_usage.prompt_tokens == 0), /// 本轮 prompt 是否估算兜底(= 进 stream_round 时的 round_usage.prompt_tokens == 0),
/// 供 loop 主体 push 消息级 is_estimated,与 round_usage 同点派生防漂移。 /// 供 loop 主体 push 消息级 is_estimated,与 round_usage 同点派生防漂移。
round_is_estimated: bool, round_is_estimated: bool,
/// 本轮流因 provider max_tokens 上限截断(P2-1),文本/tool_calls 可能不完整。
/// 子循环据此把收敛报告标 Truncated,不冒充 Completed;主循环据此提示用户。
truncated_by_max_tokens: bool,
}, },
/// 流返回后 stale(epoch 已变,被新 loop 接管):guard 已 disarm, /// 流返回后 stale(epoch 已变,被新 loop 接管):guard 已 disarm,
/// loop 应直接 return(不再 push/save/执行工具)。 /// loop 应直接 return(不再 push/save/执行工具)。
@@ -265,8 +274,9 @@ pub(crate) enum RoundOutcome {
/// teardown_round save/reset/emit)→ loop 直接 return。 /// teardown_round save/reset/emit)→ loop 直接 return。
MidStreamSaved, MidStreamSaved,
/// Fatal(候选 Fatal 或全候选耗尽):guard.reset + epoch 检查 + emit AiError + /// Fatal(候选 Fatal 或全候选耗尽):guard.reset + epoch 检查 + emit AiError +
/// save 已完成 → loop 直接 return。 /// save 已完成 → loop 直接 return。`error` 为最后一条诊断文本(P2-4:子循环据此
Fatal, /// 在出口报告写明失败原因,父会话可针对性补救,不再拿到无因失败)。
Fatal { error: String },
} }
/// 单轮流式调用:抽自 `run_agentic_loop` for 循环体的「单轮流式」自包含块。 /// 单轮流式调用:抽自 `run_agentic_loop` for 循环体的「单轮流式」自包含块。
@@ -365,9 +375,9 @@ pub(crate) async fn stream_round(
); );
} }
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = { let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content, round_truncated) = {
// outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。
let mut outcome: Option<(String, std::collections::HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>)> = None; let mut outcome: Option<(String, std::collections::HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>, bool)> = None;
// 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。 // 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
// Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功, // Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功,
// emit 会留残留气泡)。 // emit 会留残留气泡)。
@@ -418,14 +428,14 @@ pub(crate) async fn stream_round(
).await; ).await;
match stream_outcome { match stream_outcome {
StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m, reasoning_content: rc } => { StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m, reasoning_content: rc, truncated } => {
// 成功:更新迭代级 resolved_model + provider_config(供后续 push/save/标题)。 // 成功:更新迭代级 resolved_model + provider_config(供后续 push/save/标题)。
// mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」 // mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」
// 而非主 candidate。compress 已在本轮 stream 之前用过 primary,不受影响。 // 而非主 candidate。compress 已在本轮 stream 之前用过 primary,不受影响。
*resolved_model = m; *resolved_model = m;
*provider_config = candidate.clone(); *provider_config = candidate.clone();
*last_reasoning_content = rc.clone(); *last_reasoning_content = rc.clone();
outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone())); outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone(), truncated));
break 'candidate; break 'candidate;
} }
StreamOutcome::InitFailedExhausted { error } => { StreamOutcome::InitFailedExhausted { error } => {
@@ -449,14 +459,14 @@ pub(crate) async fn stream_round(
guard.reset().await; guard.reset().await;
// F1:旧 loop(被新 loop 接管)不 emit 错误气泡(owner loop 负责呈现)。 // F1:旧 loop(被新 loop 接管)不 emit 错误气泡(owner loop 负责呈现)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return RoundOutcome::Fatal; return RoundOutcome::Fatal { error };
} }
emit_fatal_error(app_handle, conv_id, &error).await; emit_fatal_error(app_handle, conv_id, &error).await;
// G1.1(2026-08-05):Fatal 退出也落库 user 消息(镜像下方 Exhausted 分支), // G1.1(2026-08-05):Fatal 退出也落库 user 消息(镜像下方 Exhausted 分支),
// 否则重启/切会话从 DB 恢复时末条 user 丢失(与 Exhausted 同根因不对称)。 // 否则重启/切会话从 DB 恢复时末条 user 丢失(与 Exhausted 同根因不对称)。
// epoch 校验通过后、return 前补 save(owner loop 才写,防 owner 已转移仍写)。 // epoch 校验通过后、return 前补 save(owner loop 才写,防 owner 已转移仍写)。
save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await; save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await;
return RoundOutcome::Fatal; return RoundOutcome::Fatal { error };
} }
} }
} }
@@ -471,16 +481,16 @@ pub(crate) async fn stream_round(
"[ai] 全 provider 候选流前失败重试耗尽,放弃本轮", "[ai] 全 provider 候选流前失败重试耗尽,放弃本轮",
); );
guard.reset().await; guard.reset().await;
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return RoundOutcome::Fatal;
}
let err_msg = last_exhausted_error let err_msg = last_exhausted_error
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()); .unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string());
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return RoundOutcome::Fatal { error: err_msg };
}
emit_fatal_error(app_handle, conv_id, &err_msg).await; emit_fatal_error(app_handle, conv_id, &err_msg).await;
// 全 provider 失败也需落库 user 消息(已 push 内存), // 全 provider 失败也需落库 user 消息(已 push 内存),
// 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。 // 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。
save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await; save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await;
return RoundOutcome::Fatal; return RoundOutcome::Fatal { error: err_msg };
} }
} }
}; };
@@ -584,6 +594,7 @@ pub(crate) async fn stream_round(
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
round_usage: round_usage_full, round_usage: round_usage_full,
round_is_estimated, round_is_estimated,
truncated_by_max_tokens: round_truncated,
} }
} }
+488 -38
View File
@@ -17,7 +17,8 @@
//! 统一出口(`run_sub_agent_loop`)摘除拦截后调一次 `save_conversation`,整条子轨迹经 //! 统一出口(`run_sub_agent_loop`)摘除拦截后调一次 `save_conversation`,整条子轨迹经
//! 既有 save 链路持久化,origin='sub_agent' 随首插落 `ai_conversations.origin`(V44), //! 既有 save 链路持久化,origin='sub_agent' 随首插落 `ai_conversations.origin`(V44),
//! 桌面侧栏/miniapp 列表按此过滤。Truncated/Failed 轨迹同样落库(调试与轨迹树数据源); //! 桌面侧栏/miniapp 列表按此过滤。Truncated/Failed 轨迹同样落库(调试与轨迹树数据源);
//! 父删除级联命中(deleted_convs 被级联重打/per_conv 已清)时放弃落库,防空壳复活。 //! 父删除级联命中(deleted_convs 被级联重打,见 cascade_stop_children_locked Delete 形态)
//! 时放弃落库,防空壳复活。
//! //!
//! ## 循环拼装(全部复用既有积木,不另起炉灶) //! ## 循环拼装(全部复用既有积木,不另起炉灶)
//! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链; //! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链;
@@ -33,9 +34,12 @@
//! //!
//! ## 事件与展示边界 //! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted / //! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留:其 Fatal 分支 //! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常
//! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦, //! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此):
//! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。 //! - Fatal 分支会向子 conv emit AiError;
//! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用);
//! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true,
//! stream_relay.rs MidStream 保文分支实证)。
use std::collections::{BTreeMap, HashMap, VecDeque}; use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -58,9 +62,11 @@ use crate::commands::ai::agentic::{
STALL_BREAKER_ENABLED, STALL_BREAKER_ENABLED,
}; };
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy}; use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{save_conversation, truncate_for_persist, TokenAccumulator}; use crate::commands::ai::conversation::{
save_conversation_meta, truncate_for_persist, SaveMetaOverride, TokenAccumulator,
};
use crate::commands::ai::event_bus::emit_ai_chat_event; use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent; use crate::commands::ai::{AiChatEvent, ToolCallDraft};
use crate::state::LlmConcurrency; use crate::state::LlmConcurrency;
use super::guard::GeneratingGuard; use super::guard::GeneratingGuard;
@@ -94,6 +100,17 @@ pub(crate) const SUB_AGENT_MAX_TOTAL_TOKENS: u64 = 200_000;
/// 外层超时对 spawn_agent 取同值,保证外层不先于子循环自腰斩。 /// 外层超时对 spawn_agent 取同值,保证外层不先于子循环自腰斩。
pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900; pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
/// 工具批执行的最小剩余预算(秒)。单件工具受 execute_with_heartbeat 外层超时封顶
/// (Low 工具默认 60s),并行 join_all 总时长 ≈ 单件上限;剩余挂钟不足一个批上限加
/// 收尾缓冲时不开新批(P1-1),本轮 tool_calls 按 P3-3 同款补 cancelled tool_result
/// 后截断退出,保证子循环总能在父侧外层超时(SUB_AGENT_WALL_TIMEOUT_SECS+60)前
/// 自达统一出口。
pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65;
/// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长
/// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。
const DIAG_TEXT_MAX_CHARS: usize = 400;
// ============================================================ // ============================================================
// 结果类型 // 结果类型
// ============================================================ // ============================================================
@@ -119,6 +136,41 @@ impl SubAgentStatus {
} }
} }
/// 子代理停止类别(为前端备料:AiAgentFinished.stop_kind,snake_case 字符串)。
/// 与 status 正交:status 表终态成败,stop_kind 表「为什么停」(取消/预算/错误/收敛)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubAgentStopKind {
/// 用户停止级联(own stop / 父停止 / 父删除)
Cancelled,
/// 挂钟预算(900s)耗尽
WallClock,
/// 轮次预算(max_rounds)耗尽
MaxRounds,
/// token 预算耗尽(累计 200K 上限;单轮 max_tokens 截断同映射至此——
/// 事件枚举无独立 max_tokens 值,截断本质即 token 预算触顶)
TokenBudget,
/// 末轮空回复收敛(未产出报告文本)
EmptyConvergence,
/// 运行期错误(熔断/Fatal/意外审批挂起/流中断)
Error,
/// 正常收敛(完成)
Converged,
}
impl SubAgentStopKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
SubAgentStopKind::Cancelled => "cancelled",
SubAgentStopKind::WallClock => "wall_clock",
SubAgentStopKind::MaxRounds => "max_rounds",
SubAgentStopKind::TokenBudget => "token_budget",
SubAgentStopKind::EmptyConvergence => "empty_convergence",
SubAgentStopKind::Error => "error",
SubAgentStopKind::Converged => "converged",
}
}
}
/// 子代理执行结果(经 spawn_agent 工具返回值交付给父 LLM)。 /// 子代理执行结果(经 spawn_agent 工具返回值交付给父 LLM)。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct SubAgentOutcome { pub(crate) struct SubAgentOutcome {
@@ -129,6 +181,8 @@ pub(crate) struct SubAgentOutcome {
/// 运行期被拒工具汇总(工具名→次数,BTreeMap 序确定性)。S2 报告协议:父会话须能 /// 运行期被拒工具汇总(工具名→次数,BTreeMap 序确定性)。S2 报告协议:父会话须能
/// 看见子代理被拒了什么,而非只拿到一份不知缺口的报告;空 Vec=本次零拒绝。 /// 看见子代理被拒了什么,而非只拿到一份不知缺口的报告;空 Vec=本次零拒绝。
pub(crate) rejected_tools: Vec<(String, u32)>, pub(crate) rejected_tools: Vec<(String, u32)>,
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
pub(crate) stop_kind: Option<SubAgentStopKind>,
} }
impl SubAgentOutcome { impl SubAgentOutcome {
@@ -140,6 +194,7 @@ impl SubAgentOutcome {
rounds: 0, rounds: 0,
tokens: 0, tokens: 0,
rejected_tools: Vec::new(), rejected_tools: Vec::new(),
stop_kind: Some(SubAgentStopKind::Error),
} }
} }
} }
@@ -185,22 +240,45 @@ pub(crate) fn sub_agents_children(reg: &SubAgentRegistry, parent: &str) -> Vec<S
.unwrap_or_default() .unwrap_or_default()
} }
/// 级联取消形态(P2-2):区分「停止」与「删除」两种父侧动作。
///
/// Delete 形态须同锁内对每个 child 补 deleted_convs 标记——否则子循环经停止检查点
/// 以「父会话已不存在」退出时,出口 alive 判定(child 不在 deleted_convs 且 per_conv
/// 存在)为真,会把父已删除的孤儿 sub_agent 轨迹照常 INSERT 落库(空壳复活,与模块
/// 头文档声明相反)。Stop 形态(ai_chat_stop / ai_chat_force_send)不打删除标记,
/// 子循环退出后轨迹照常落库。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CascadeStopForm {
/// 父会话停止/强制发送:仅置 child stop_flag。
Stop,
/// 父会话删除:置 stop_flag + 补 deleted_convs(出口 alive 判定即放弃落库)。
Delete,
}
/// 级联取消:父会话被停止/强制发送/删除时遍历 children 置 stop_flag + notify。 /// 级联取消:父会话被停止/强制发送/删除时遍历 children 置 stop_flag + notify。
/// ///
/// 调用方(ai_chat_stop / ai_chat_force_send / ai_conversation_delete)须已持 session 锁, /// 调用方(ai_chat_stop / ai_chat_force_send / ai_conversation_delete)须已持 session 锁,
/// 本函数短临界区纯内存操作(无 await),与「禁止持 session 锁跨 await」纪律兼容。 /// 本函数短临界区纯内存操作(无 await),与「禁止持 session 锁跨 await」纪律兼容。
/// 仅触碰已存在的子会话条目(不惰性重建,防取消动作反而制造空条目残留); /// 仅触碰已存在的子会话条目(不惰性重建,防取消动作反而制造空条目残留);
/// 子循环停止检查点读 own stop_flag 即退出,父侧条目消失/父 stop 是兜底判据。 /// 子循环停止检查点读 own stop_flag 即退出,父侧条目消失/父 stop 是兜底判据。
/// Delete 形态的 deleted_convs 标记对 child 不回收(与直接删除的标记同生命周期):
/// 防在途 save 于出口清理之后复活空壳,单条 ~40B 量级可忽略。
pub(crate) fn cascade_stop_children_locked( pub(crate) fn cascade_stop_children_locked(
session: &mut crate::commands::ai::AiSession, session: &mut crate::commands::ai::AiSession,
reg: &SubAgentRegistry, reg: &SubAgentRegistry,
parent: &str, parent: &str,
form: CascadeStopForm,
) { ) {
for child in sub_agents_children(reg, parent) { for child in sub_agents_children(reg, parent) {
if let Some(conv) = session.per_conv.get_mut(&child) { if let Some(conv) = session.per_conv.get_mut(&child) {
conv.stop_flag.store(true, Ordering::SeqCst); conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.notify_one(); conv.notify.notify_one();
} }
// 删除形态:child 补删除标记须与父删除同锁(与子循环出口的 alive 判定互斥,
// 防标记与出口读之间竞态)。child 的 per_conv 不动:退出路径仍要读消息做报告兜底。
if form == CascadeStopForm::Delete {
session.deleted_convs.insert(child);
}
} }
} }
@@ -260,27 +338,90 @@ pub(crate) fn clamp_tool_whitelist(
(allowed, trimmed) (allowed, trimmed)
} }
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some(原因) 即应截断退出,None 继续。 /// 单轮预算熔断判定(prepare 段前调用)。返回 Some((原因, 停止类别)) 即应截断退出,
/// 纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限)。 /// None 继续。纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限);
/// 停止类别随原因同点产出,供 AiAgentFinished.stop_kind 结构化透出。
pub(crate) fn round_budget_stop( pub(crate) fn round_budget_stop(
round: u32, round: u32,
max_rounds: u32, max_rounds: u32,
tokens_total: u64, tokens_total: u64,
deadline: tokio::time::Instant, deadline: tokio::time::Instant,
now: tokio::time::Instant, now: tokio::time::Instant,
) -> Option<String> { ) -> Option<(String, SubAgentStopKind)> {
if now >= deadline { if now >= deadline {
return Some(format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS)); return Some((
format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS),
SubAgentStopKind::WallClock,
));
} }
if tokens_total >= SUB_AGENT_MAX_TOTAL_TOKENS { if tokens_total >= SUB_AGENT_MAX_TOTAL_TOKENS {
return Some(format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS)); return Some((
format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS),
SubAgentStopKind::TokenBudget,
));
} }
if round >= max_rounds { if round >= max_rounds {
return Some(format!("达最大轮次上限({})", max_rounds)); return Some((
format!("达最大轮次上限({})", max_rounds),
SubAgentStopKind::MaxRounds,
));
} }
None None
} }
/// 工具批可开工判定(P1-1,纯函数供单测):剩余挂钟不足一个工具批上限加收尾缓冲
/// 时不开新批,防批执行把统一出口顶出父侧外层超时窗口。
pub(crate) fn tool_batch_affordable(
remaining: std::time::Duration,
) -> bool {
remaining >= std::time::Duration::from_secs(SUB_AGENT_TOOL_BATCH_HEADROOM_SECS)
}
/// 收敛轮终态判定(P3-7 空白降级 + P2-1 max_tokens 截断标记,纯函数供单测):
/// 末轮无 tool_calls 时,full_text 即报告。
/// - 空白(模型空回复)降级 Truncated——Completed+空报告让父会话拿到「成功却无内容」的失真终态;
/// - max_tokens 截断:报告在句中被腰斩,同样降级 Truncated 并在报告尾追加标注,
/// 不冒充正常完成(父会话据此判断报告完整性);stop_kind 映射 TokenBudget(事件
/// 枚举无独立 max_tokens 值,截断本质即单轮 token 预算触顶)。
/// 返回 (status, report, stop_reason 补充, stop_kind)。
pub(crate) fn convergence_round(
full_text: String,
truncated_by_max_tokens: bool,
) -> (SubAgentStatus, String, Option<String>, SubAgentStopKind) {
if full_text.trim().is_empty() {
(
SubAgentStatus::Truncated,
String::new(),
Some("末轮空回复收敛,未产出报告文本".to_string()),
SubAgentStopKind::EmptyConvergence,
)
} else if truncated_by_max_tokens {
let marked = format!(
"{}\n\n[输出因 max_tokens 截断,可能不完整]",
full_text.trim_end()
);
(
SubAgentStatus::Truncated,
marked,
Some("收敛报告因 max_tokens 截断".to_string()),
SubAgentStopKind::TokenBudget,
)
} else {
(SubAgentStatus::Completed, full_text, None, SubAgentStopKind::Converged)
}
}
/// 诊断文本封顶(P2-4):provider 原始错误串进父会话报告前截到 max_chars(按字符计,
/// 防多字节串截出半字符),保留头部(401/404/参数错等关键信息都在头部)。纯函数可单测。
pub(crate) fn cap_diag_text(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let mut out: String = s.chars().take(max_chars).collect();
out.push('…');
out
}
/// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。 /// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/ /// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。 /// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
@@ -314,19 +455,35 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
} }
/// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。 /// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。
pub(crate) fn sub_agent_system_prompt() -> String { ///
/// `max_rounds` 为本次 spawn 实际生效值(P2-3):预算三值(轮次/token/挂钟)动态注入,
/// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏,
/// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。
/// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。
pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
let mut p = format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\ "你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\ ## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\ - 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\ - 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\ - 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\ 遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\ - 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
## 输出要求\n\ ## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\ - 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\ - 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式" - 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
.to_string() max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
);
// 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh"));
p
} }
// ============================================================ // ============================================================
@@ -348,14 +505,20 @@ pub(crate) async fn run_sub_agent_loop(
system_prompt: String, system_prompt: String,
task_prompt: String, task_prompt: String,
tool_defs: Vec<ToolDefinition>, tool_defs: Vec<ToolDefinition>,
// 执行期工具白名单(P2-1):spawn 入口钳制产物,随子循环传入 process_tool_calls,
// 幻觉调用被裁剪面外的工具(含 Low)在执行期被就地拒绝,而不仅在 prompt 层承诺。
allowed: Vec<String>,
max_rounds: u32, max_rounds: u32,
model_override: Option<String>, model_override: Option<String>,
llm_concurrency: LlmConcurrency, llm_concurrency: LlmConcurrency,
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
// provider/created_at。
parent_save_meta: SaveMetaOverride,
) -> SubAgentOutcome { ) -> SubAgentOutcome {
let outcome = run_sub_agent_loop_inner( let outcome = run_sub_agent_loop_inner(
session_arc.clone(), tools_arc, db.clone(), app_handle.clone(), session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(), parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs, system_prompt, task_prompt, tool_defs, allowed,
max_rounds, model_override, llm_concurrency.clone(), max_rounds, model_override, llm_concurrency.clone(),
).await; ).await;
@@ -373,6 +536,8 @@ pub(crate) async fn run_sub_agent_loop(
} else { } else {
Some(format_rejected_tools(&outcome.rejected_tools)) Some(format_rejected_tools(&outcome.rejected_tools))
}, },
// 停止类别(结构化枚举,前端按「为什么停」差异化展示;正常收敛=converged)
stop_kind: outcome.stop_kind.map(|k| k.as_str().to_string()),
}); });
// 子轨迹落库(二期池①):摘除零落库拦截,整条轨迹经既有 save 链路持久化。 // 子轨迹落库(二期池①):摘除零落库拦截,整条轨迹经既有 save 链路持久化。
@@ -388,7 +553,9 @@ pub(crate) async fn run_sub_agent_loop(
alive alive
}; };
if child_persistable { if child_persistable {
save_conversation(&session_arc, &db, &child_conv_id, None, None, false).await; // P3-5:显式传父会话快照元数据,不走 save 内部的 session.active_* 读法
// (子代理后台长跑收尾时用户可能已切走会话,active 值已是别的会话的)。
save_conversation_meta(&session_arc, &db, &child_conv_id, None, None, false, parent_save_meta).await;
} }
// 清理子会话内存条目(轨迹已持久化或已随级联丢弃;origin 标记随 per_conv 一并回收) // 清理子会话内存条目(轨迹已持久化或已随级联丢弃;origin 标记随 per_conv 一并回收)
@@ -415,6 +582,7 @@ async fn run_sub_agent_loop_inner(
system_prompt: String, system_prompt: String,
task_prompt: String, task_prompt: String,
tool_defs: Vec<ToolDefinition>, tool_defs: Vec<ToolDefinition>,
allowed: Vec<String>,
max_rounds: u32, max_rounds: u32,
model_override: Option<String>, model_override: Option<String>,
llm_concurrency: LlmConcurrency, llm_concurrency: LlmConcurrency,
@@ -423,6 +591,16 @@ async fn run_sub_agent_loop_inner(
let deadline = tokio::time::Instant::now() let deadline = tokio::time::Instant::now()
+ std::time::Duration::from_secs(SUB_AGENT_WALL_TIMEOUT_SECS); + std::time::Duration::from_secs(SUB_AGENT_WALL_TIMEOUT_SECS);
// P2-2 目标钉扎(与主循环 G1 同构):任务书并入 system prompt。主循环把钉扎目标拼进
// system prompt 尾部对抗失焦;子会话的任务书是首条 user 消息,超预算裁剪
// (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是
// 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留,
// 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。
let system_prompt = format!(
"{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n<task>\n{}\n</task>\n",
system_prompt, task_prompt
);
// 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed // 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed
// (子代理不阻塞等待,防占满父 loop 的执行槽与全局配额) // (子代理不阻塞等待,防占满父 loop 的执行槽与全局配额)
let (_global_permit, _conv_permit) = match ( let (_global_permit, _conv_permit) = match (
@@ -515,9 +693,11 @@ async fn run_sub_agent_loop_inner(
let (mut stall_count, mut stall_warned, mut loop_count, mut loop_warned) = (0u32, false, 0u32, false); let (mut stall_count, mut stall_warned, mut loop_count, mut loop_warned) = (0u32, false, 0u32, false);
let mut rounds_done: u32 = 0; let mut rounds_done: u32 = 0;
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写 // for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写。
// stop_kind 缺省 MaxRounds 与默认截断原因(达最大轮次上限)同点对齐。
let mut status = SubAgentStatus::Truncated; let mut status = SubAgentStatus::Truncated;
let mut stop_reason = String::new(); let mut stop_reason = String::new();
let mut stop_kind = Some(SubAgentStopKind::MaxRounds);
let mut report = String::new(); let mut report = String::new();
// S2 报告协议:被拒工具累计(工具名→次数,BTreeMap 序确定性),出口汇总进报告尾+outcome // S2 报告协议:被拒工具累计(工具名→次数,BTreeMap 序确定性),出口汇总进报告尾+outcome
let mut rejected_tools: BTreeMap<String, u32> = BTreeMap::new(); let mut rejected_tools: BTreeMap<String, u32> = BTreeMap::new();
@@ -527,12 +707,14 @@ async fn run_sub_agent_loop_inner(
if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await { if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
status = SubAgentStatus::Truncated; status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason); stop_reason = format!("已被取消({})", reason);
stop_kind = Some(SubAgentStopKind::Cancelled);
break; break;
} }
let total_now = tokens_snapshot(&tokens).total_tokens as u64; let total_now = tokens_snapshot(&tokens).total_tokens as u64;
if let Some(reason) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) { if let Some((reason, kind)) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) {
status = SubAgentStatus::Truncated; status = SubAgentStatus::Truncated;
stop_reason = reason; stop_reason = reason;
stop_kind = Some(kind);
break; break;
} }
@@ -544,6 +726,7 @@ async fn run_sub_agent_loop_inner(
let Some(conv) = session.conv_read(&child_conv_id) else { let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string(); stop_reason = "子会话条目已消失".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break; break;
}; };
let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens);
@@ -582,7 +765,13 @@ async fn run_sub_agent_loop_inner(
estimated_context: estimated_prompt as usize, estimated_context: estimated_prompt as usize,
tier: None, tier: None,
}; };
match stream_round( // P1-1 deadline 下沉进轮内:流式段整体以「距 deadline 剩余」封顶,超时即 drop
// stream_round future 走正常截断路径退出统一出口。此前 deadline 检查只在轮与轮
// 之间,单轮流式+重试无上界,外层 execute_with_heartbeat 超时会先到并 drop 整个
// 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨
// await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。
let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(stream_budget, stream_round(
&session_arc, &db, &child_conv_id, &session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle, &messages, &tool_defs, &app_handle,
&child_stop, &child_notify, &child_stop, &child_notify,
@@ -594,8 +783,8 @@ async fn run_sub_agent_loop_inner(
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传) // 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[], &[],
&child_epoch, my_epoch, &child_epoch, my_epoch,
).await { )).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => { Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
rounds_done += 1; rounds_done += 1;
tokens.add_usage(&round_usage); tokens.add_usage(&round_usage);
let has_tool_calls = !tool_calls.is_empty(); let has_tool_calls = !tool_calls.is_empty();
@@ -613,16 +802,38 @@ async fn run_sub_agent_loop_inner(
); );
} }
} }
// 流后复查取消:停止信号到达时优先按截断处理,不把半截文本当完成报告 // 流后复查取消:停止信号到达时优先按截断处理,不把半截文本当完成报告。
// P3-3:本轮 tool_calls 尚未执行也未拒绝,补写 cancelled tool_result 再退,
// 防轨迹悬挂(带 tool_calls 却无对应结果的消息)。
if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await { if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
push_cancelled_tool_results(&session_arc, &child_conv_id, &tool_calls).await;
status = SubAgentStatus::Truncated; status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason); stop_reason = format!("已被取消({})", reason);
stop_kind = Some(SubAgentStopKind::Cancelled);
break; break;
} }
if !has_tool_calls { if !has_tool_calls {
// 收敛轮:末轮 full_text 即报告自摘要 // 收敛轮:末轮 full_text 即报告自摘要(P3-7 空白降级 / P2-1 截断标记)
status = SubAgentStatus::Completed; let (st, rep, extra_reason, kind) = convergence_round(full_text, truncated_by_max_tokens);
report = full_text; status = st;
report = rep;
stop_kind = Some(kind);
if let Some(r) = extra_reason {
stop_reason = r;
}
break;
}
// P1-1 工具批预算闸:剩余预算不足一个批上限(单件外层超时封顶)不开批,
// 按 P3-3 同款补 cancelled tool_result 后截断,保证统一出口在预算内必达。
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if !tool_batch_affordable(remaining) {
push_cancelled_tool_results(&session_arc, &child_conv_id, &tool_calls).await;
status = SubAgentStatus::Truncated;
stop_reason = format!(
"挂钟超时({}s),剩余预算不足以执行本轮工具批",
SUB_AGENT_WALL_TIMEOUT_SECS
);
stop_kind = Some(SubAgentStopKind::WallClock);
break; break;
} }
// ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)── // ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)──
@@ -634,6 +845,8 @@ async fn run_sub_agent_loop_inner(
let pending = process_tool_calls( let pending = process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id, &session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent, ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
).await; ).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析) // S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await; collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await;
@@ -641,6 +854,7 @@ async fn run_sub_agent_loop_inner(
// 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道) // 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道)
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
stop_reason = format!("子代理策略下出现意外审批挂起({} 项),终止", pending); stop_reason = format!("子代理策略下出现意外审批挂起({} 项),终止", pending);
stop_kind = Some(SubAgentStopKind::Error);
break; break;
} }
// L1 失败断路器(读子会话尾部消息,机制与主 loop 一致) // L1 失败断路器(读子会话尾部消息,机制与主 loop 一致)
@@ -652,6 +866,7 @@ async fn run_sub_agent_loop_inner(
"连续同类工具失败 {} 次(如 {}),已熔断", "连续同类工具失败 {} 次(如 {}),已熔断",
max_count, sample_key max_count, sample_key
); );
stop_kind = Some(SubAgentStopKind::Error);
break; break;
} }
// G2 探索熔断 + 写删回环熔断(读子会话尾部签名,机制与主 loop 一致) // G2 探索熔断 + 写删回环熔断(读子会话尾部签名,机制与主 loop 一致)
@@ -666,23 +881,38 @@ async fn run_sub_agent_loop_inner(
if STALL_BREAKER_ENABLED && tripped { if STALL_BREAKER_ENABLED && tripped {
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
stop_reason = "重复工具调用/写删回环探索熔断".to_string(); stop_reason = "重复工具调用/写删回环探索熔断".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break; break;
} }
} }
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明) // stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)。
RoundOutcome::Fatal => { // P2-4:Fatal 携带最后诊断文本,出口报告写明真实原因(封顶防超长污染)。
Ok(RoundOutcome::Fatal { error }) => {
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
stop_reason = "LLM 调用失败(全候选 provider 耗尽或致命错误)".to_string(); stop_reason = format!(
"LLM 调用失败({})",
cap_diag_text(&error, DIAG_TEXT_MAX_CHARS)
);
stop_kind = Some(SubAgentStopKind::Error);
break; break;
} }
RoundOutcome::MidStreamSaved => { Ok(RoundOutcome::MidStreamSaved) => {
status = SubAgentStatus::Truncated; status = SubAgentStatus::Truncated;
stop_reason = "流中途中断,已保文".to_string(); stop_reason = "流中途中断,已保文".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break; break;
} }
RoundOutcome::StaleAfterStream => { Ok(RoundOutcome::StaleAfterStream) => {
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
stop_reason = "子会话状态被外部接管".to_string(); stop_reason = "子会话状态被外部接管".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// P1-1:流式段触顶 deadline,drop 未返回任何消息(无悬挂 tool_calls),截断退出
Err(_elapsed) => {
status = SubAgentStatus::Truncated;
stop_reason = format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS);
stop_kind = Some(SubAgentStopKind::WallClock);
break; break;
} }
} }
@@ -734,6 +964,37 @@ async fn run_sub_agent_loop_inner(
rounds: rounds_done, rounds: rounds_done,
tokens: total_tokens, tokens: total_tokens,
rejected_tools: rejected, rejected_tools: rejected,
stop_kind,
}
}
/// 流后取消/预算截断窗口(P3-3)兜底:本轮已 push 的 assistant 消息带 tool_calls,
/// 但工具既未执行也未拒绝即退出,补写 cancelled tool_result 使轨迹闭合
/// (peek/TaskDrawer 不出现无结果卡)。id 生成与 push_assistant_message 同款
/// (tool_call_id_or_fallback 兜底空 id),保证 tool_result 与消息内 tool_call 可配对。
/// 条目不存在(级联删除等)时静默跳过,不惰性重建。
async fn push_cancelled_tool_results(
session_arc: &Arc<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
tool_calls: &HashMap<u32, ToolCallDraft>,
) {
if tool_calls.is_empty() {
return;
}
let cancelled = serde_json::json!({
"status": "cancelled",
"reason": "子代理在工具执行前被停止或预算截断",
}).to_string();
let mut session = session_arc.lock().await;
let Some(conv) = session.per_conv.get_mut(child_id) else {
return;
};
let mut order: Vec<u32> = tool_calls.keys().copied().collect();
order.sort_unstable();
for (pos, idx) in order.into_iter().enumerate() {
let draft = &tool_calls[&idx];
let id = df_ai::provider::tool_call_id_or_fallback(&draft.id, pos, "gen_stream");
conv.messages.push(ChatMessage::tool_result(id, cancelled.clone()));
} }
} }
@@ -857,7 +1118,7 @@ mod tests {
assert!(trimmed.is_empty(), "缺省请求不产生裁剪尾注"); assert!(trimmed.is_empty(), "缺省请求不产生裁剪尾注");
} }
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因;预算内返 None。 /// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因与结构化停止类别;预算内返 None。
#[test] #[test]
fn round_budget_trips_on_rounds_tokens_and_deadline() { fn round_budget_trips_on_rounds_tokens_and_deadline() {
let now = tokio::time::Instant::now(); let now = tokio::time::Instant::now();
@@ -865,15 +1126,18 @@ mod tests {
// 预算内:继续 // 预算内:继续
assert_eq!(round_budget_stop(0, 6, 0, deadline, now), None); assert_eq!(round_budget_stop(0, 6, 0, deadline, now), None);
// 轮次熔断 // 轮次熔断
let r = round_budget_stop(6, 6, 0, deadline, now).unwrap(); let (r, kind) = round_budget_stop(6, 6, 0, deadline, now).unwrap();
assert!(r.contains("轮次"), "轮次原因: {}", r); assert!(r.contains("轮次"), "轮次原因: {}", r);
assert_eq!(kind, SubAgentStopKind::MaxRounds);
// token 熔断 // token 熔断
let r = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap(); let (r, kind) = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap();
assert!(r.contains("token"), "token 原因: {}", r); assert!(r.contains("token"), "token 原因: {}", r);
assert_eq!(kind, SubAgentStopKind::TokenBudget);
// 挂钟超时熔断(优先级最高) // 挂钟超时熔断(优先级最高)
let past = now - std::time::Duration::from_secs(1); let past = now - std::time::Duration::from_secs(1);
let r = round_budget_stop(1, 6, 0, past, now).unwrap(); let (r, kind) = round_budget_stop(1, 6, 0, past, now).unwrap();
assert!(r.contains("超时"), "超时原因: {}", r); assert!(r.contains("超时"), "超时原因: {}", r);
assert_eq!(kind, SubAgentStopKind::WallClock);
} }
/// registry 级联:注册幂等追加、children 可查、注销移除且父条目空时整条清掉。 /// registry 级联:注册幂等追加、children 可查、注销移除且父条目空时整条清掉。
@@ -913,14 +1177,200 @@ mod tests {
assert_ne!(a, b, "同父连续生成的 child_id 必须唯一"); assert_ne!(a, b, "同父连续生成的 child_id 必须唯一");
} }
/// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束。 /// P2-2 级联形态:Delete 形态对 child 补 deleted_convs(出口 alive 判定即放弃落库,
/// 防孤儿 sub_agent 轨迹 INSERT),Stop 形态只置 stop_flag 不打删除标记(轨迹照常落库)。
#[test]
fn cascade_delete_marks_children_deleted() {
let child = "p::agent-1".to_string();
let reg = new_registry();
sub_agents_register(&reg, "p", &child);
let mut session = crate::commands::ai::AiSession::new();
let stop_flag = {
let conv = session.conv(&child);
conv.stop_flag.clone()
};
cascade_stop_children_locked(&mut session, &reg, "p", CascadeStopForm::Delete);
assert!(stop_flag.load(Ordering::SeqCst), "两形态都须置 stop_flag");
assert!(
session.deleted_convs.contains(&child),
"Delete 形态须补 deleted_convs,防孤儿轨迹落库"
);
// Stop 形态对照:不标删除(子循环退出后轨迹照常落库)
let child2 = "p2::agent-1".to_string();
let reg2 = new_registry();
sub_agents_register(&reg2, "p2", &child2);
let mut session2 = crate::commands::ai::AiSession::new();
let _ = session2.conv(&child2);
cascade_stop_children_locked(&mut session2, &reg2, "p2", CascadeStopForm::Stop);
assert!(
!session2.deleted_convs.contains(&child2),
"Stop 形态不得打删除标记"
);
}
/// P3-7 空收敛降级 + P2-1 max_tokens 截断标记:空白 → Truncated(原因=空收敛);
/// 截断 → Truncated + 报告尾标注;正常文本 → Completed 且报告原样。
#[test]
fn convergence_round_empty_text_degrades_to_truncated() {
let (st, rep, reason, kind) = convergence_round("结论:已完成检索".to_string(), false);
assert_eq!(st, SubAgentStatus::Completed);
assert_eq!(rep, "结论:已完成检索");
assert!(reason.is_none(), "正常收敛无补充原因");
assert_eq!(kind, SubAgentStopKind::Converged);
let (st, rep, reason, kind) = convergence_round(" \n\t".to_string(), false);
assert_eq!(st, SubAgentStatus::Truncated, "空白报告不得标 Completed(终态失真)");
assert!(rep.is_empty());
let reason = reason.expect("空收敛须带截断原因");
assert!(reason.contains("空"), "原因应可读标注空收敛: {}", reason);
assert_eq!(kind, SubAgentStopKind::EmptyConvergence);
}
/// P2-1 收敛报告被 max_tokens 腰斩:降级 Truncated(不冒充 Completed),报告尾
/// 追加截断标注,stop_kind 映射 TokenBudget(单轮 token 预算触顶)。
#[test]
fn convergence_round_max_tokens_truncation_marks_report() {
let (st, rep, reason, kind) =
convergence_round("结论:分析如下,核心证据包括 1".to_string(), true);
assert_eq!(st, SubAgentStatus::Truncated, "截断报告不得标 Completed");
assert!(rep.starts_with("结论:分析如下"), "报告本体保留: {}", rep);
assert!(rep.contains("[输出因 max_tokens 截断,可能不完整]"), "须带截断标注: {}", rep);
assert_eq!(reason.as_deref(), Some("收敛报告因 max_tokens 截断"));
assert_eq!(kind, SubAgentStopKind::TokenBudget);
}
/// P2-4 诊断文本封顶:短文本原样,超长按字符截断加省略号(多字节串不截半字符)。
#[test]
fn cap_diag_text_caps_long_error() {
assert_eq!(cap_diag_text("401 Unauthorized", 100), "401 Unauthorized");
let long = "错".repeat(500);
let capped = cap_diag_text(&long, DIAG_TEXT_MAX_CHARS);
assert_eq!(capped.chars().count(), DIAG_TEXT_MAX_CHARS + 1, "含省略号");
assert!(capped.ends_with('…'));
assert!(capped.starts_with("错错错"));
}
/// P1-1 工具批预算闸:剩余预算低于批上限(headroom)不开批,充足则放行。
#[test]
fn tool_batch_headroom_gate() {
assert!(!tool_batch_affordable(std::time::Duration::from_secs(0)));
assert!(!tool_batch_affordable(std::time::Duration::from_secs(
SUB_AGENT_TOOL_BATCH_HEADROOM_SECS - 1
)), "差 1s 也不开批(硬闸)");
assert!(tool_batch_affordable(std::time::Duration::from_secs(
SUB_AGENT_TOOL_BATCH_HEADROOM_SECS
)));
}
/// P3-3 取消窗口补 envelope:本轮 tool_calls 补写 cancelled tool_result 且与 assistant
/// 消息的 tool_call id 可配对;conv 缺失时静默跳过不惰性重建。
#[tokio::test]
async fn cancelled_tool_results_close_the_trajectory() {
use df_ai::provider::ToolCall;
let session_arc = Arc::new(Mutex::new(crate::commands::ai::AiSession::new()));
let child = "p::agent-1".to_string();
{
let mut s = session_arc.lock().await;
let conv = s.conv(&child);
conv.messages.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "read_file", "{}")],
));
}
let mut tool_calls: HashMap<u32, ToolCallDraft> = HashMap::new();
tool_calls.insert(0u32, ToolCallDraft {
id: "call_a".to_string(),
name: "read_file".to_string(),
args: "{}".to_string(),
});
push_cancelled_tool_results(&session_arc, &child, &tool_calls).await;
{
let s = session_arc.lock().await;
let msgs = s.conv_read(&child).unwrap().messages.all_messages_clone();
let last = msgs.last().expect("应已追加 cancelled tool_result");
assert!(matches!(last.role, MessageRole::Tool));
assert_eq!(last.tool_call_id.as_deref(), Some("call_a"), "须与消息内 tool_call 配对");
assert!(last.content.contains("\"cancelled\""), "envelope 内容: {}", last.content);
}
// 缺失条目:静默跳过,不惰性重建空壳
push_cancelled_tool_results(&session_arc, "ghost::agent-9", &tool_calls).await;
let s = session_arc.lock().await;
assert!(s.conv_read("ghost::agent-9").is_none(), "不得惰性重建缺失条目");
}
/// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束 + 动态预算三值 +
/// 同源工具纪律段(P1-1)。
#[test] #[test]
fn sub_agent_prompt_mentions_report_contract() { fn sub_agent_prompt_mentions_report_contract() {
let p = sub_agent_system_prompt(); let p = sub_agent_system_prompt(6);
assert!(p.contains("子代理")); assert!(p.contains("子代理"));
assert!(p.contains("最终回复")); assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)"); assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
assert!(p.contains("工具权限受限"), "S2 报告协议:应要求申报因工具受限未完成的子任务"); assert!(p.contains("工具权限受限"), "S2 报告协议:应要求申报因工具受限未完成的子任务");
// P2-3 预算三值动态注入(无硬编码 6 轮文案,实际生效值可见)
assert!(p.contains("最多 6 轮"), "轮次预算动态注入: {}", p);
assert!(p.contains(&SUB_AGENT_MAX_TOTAL_TOKENS.to_string()), "token 上限注入");
assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入");
assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p);
// 父设小预算时按真实值注入(防按 6 轮规划被截断)
assert!(sub_agent_system_prompt(3).contains("最多 3 轮"));
// P1-1 工具纪律段同源拼接
assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p);
assert!(p.contains("重复调用检测"), "重复调用检测须注入");
assert!(p.contains("严禁臆造 URL"), "禁臆造 URL 须注入");
assert!(p.contains("get_app_config"), "查配置正解须注入");
assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入");
assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理");
}
/// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。
#[test]
fn stop_kind_serializes_snake_case() {
assert_eq!(SubAgentStopKind::Cancelled.as_str(), "cancelled");
assert_eq!(SubAgentStopKind::WallClock.as_str(), "wall_clock");
assert_eq!(SubAgentStopKind::MaxRounds.as_str(), "max_rounds");
assert_eq!(SubAgentStopKind::TokenBudget.as_str(), "token_budget");
assert_eq!(SubAgentStopKind::EmptyConvergence.as_str(), "empty_convergence");
assert_eq!(SubAgentStopKind::Error.as_str(), "error");
assert_eq!(SubAgentStopKind::Converged.as_str(), "converged");
}
/// AiAgentFinished.stop_kind serde 序列化:Some 序列化为字符串字段,None 保留 null
/// (事件多字段向后兼容,前端不消费不破坏)。
#[test]
fn agent_finished_event_serializes_stop_kind() {
use crate::commands::ai::AiChatEvent;
let ev = AiChatEvent::AiAgentFinished {
agent_id: "p::agent-1".to_string(),
conversation_id: Some("p".to_string()),
summary: "结论:已完成".to_string(),
status: "truncated".to_string(),
rounds: 3,
tokens: 1200,
rejected_tools: None,
stop_kind: Some(SubAgentStopKind::MaxRounds.as_str().to_string()),
};
let json = serde_json::to_string(&ev).expect("事件须可序列化");
assert!(json.contains("\"stop_kind\":\"max_rounds\""), "stop_kind 须按字符串序列化: {}", json);
assert!(json.contains("\"type\":\"AiAgentFinished\""), "tag 字段: {}", json);
let ev_none = AiChatEvent::AiAgentFinished {
agent_id: "p::agent-2".to_string(),
conversation_id: None,
summary: "ok".to_string(),
status: "completed".to_string(),
rounds: 1,
tokens: 10,
rejected_tools: None,
stop_kind: None,
};
let json_none = serde_json::to_string(&ev_none).expect("事件须可序列化");
assert!(
json_none.contains(r#""stop_kind":null"#),
"None 序列化为 null(字段保留): {}",
json_none
);
} }
/// S2 报告协议:deny envelope 识别——结构化 JSON 判定,非 JSON/正常结果/其他拒绝不误计。 /// S2 报告协议:deny envelope 识别——结构化 JSON 判定,非 JSON/正常结果/其他拒绝不误计。
+80 -2
View File
@@ -257,6 +257,11 @@ pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value)
/// ///
/// 与 find_cached_high_risk_result 区别:后者治"超时→重试→重新审批"循环( High risk), /// 与 find_cached_high_risk_result 区别:后者治"超时→重试→重新审批"循环( High risk),
/// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。 /// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。
///
/// **写后失效**(治「只读结果缓存无写失效」缺陷):会话内 write_file/run_command 等
/// 非只读工具成功执行后,历史只读结果已可能过期(读的是改前世界)。本函数扫描时按
/// per_conv.readonly_cache_floor 过滤——floor 之后的索引才算可信,之前的旧调用全部
/// 跳过(过界即脏),写后 LLM 重读同参工具必然 miss 缓存走真执行拿到新内容。
pub(crate) async fn find_cached_readonly_result( pub(crate) async fn find_cached_readonly_result(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
@@ -281,7 +286,11 @@ pub(crate) async fn find_cached_readonly_result(
let cached: Option<(String, String, u32)> = (async { let cached: Option<(String, String, u32)> = (async {
let session = session_arc.lock().await; let session = session_arc.lock().await;
let Some(conv) = session.conv_read(conv_id) else { return None }; let Some(conv) = session.conv_read(conv_id) else { return None };
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect(); // 写后失效:floor 之前的只读结果一律不可信(写过世界状态),只扫 [floor, len)。
// floor > len 防御(pop_last_assistant_round/clear 缩短历史后索引漂移),钳到 len
// 等于扫描区间空 → 无缓存可命中,走真执行(保守方向安全)。
let floor = conv.readonly_cache_floor.min(conv.messages.len());
let msgs: Vec<&ChatMessage> = conv.messages.iter().skip(floor).collect();
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id, // 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id,
// 同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。 // 同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。
@@ -342,6 +351,29 @@ pub(crate) async fn find_cached_readonly_result(
/// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5): /// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5):
/// find_cached_readonly_result 改查 df_ai::ai_tools::is_readonly,本文件不再持名单。 /// find_cached_readonly_result 改查 df_ai::ai_tools::is_readonly,本文件不再持名单。
/// 写后失效:非只读工具成功回填后推进会话级缓存失效下界(readonly_cache_floor)。
///
/// 调用点 = 全部写工具真执行成功的回填处(Low 风险并行回填 / trust 放行回填 /
/// 边界放行回填 / ai_approve 审批后执行回填)。非只读工具(写文件/跑命令/改 DB 等)
/// 成功执行即视作世界状态变更,历史里的只读结果从此不可信——floor 推进到写结果
/// push 之后,find_cached_readonly_result 扫描时跳过 floor 之前的全部旧调用(过界即脏)。
///
/// 单调不减(max):多写工具同轮/跨轮先后执行,floor 取最大(最后写),前段只读
/// 结果全失效——保守方向安全(失效仅多一次真执行,误命中才喂脏数据)。
///
/// 须在持 session 锁的短临界区内调用(与写结果 push 同锁,防 floor 与 messages 漂移)。
/// is_readonly 未注册的工具按非只读处理(保守推进 floor,防未知新写工具漏失效)。
pub(crate) fn mark_readonly_cache_stale(conv: &mut super::super::PerConvState, tool_name: &str) {
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
if df_ai::ai_tools::is_readonly(tool_name) {
return; // 只读工具不改世界状态,缓存照常有效
}
let new_floor = conv.messages.len();
if conv.readonly_cache_floor < new_floor {
conv.readonly_cache_floor = new_floor;
}
}
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。 /// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
fn sort_object_keys(v: &mut serde_json::Value) { fn sort_object_keys(v: &mut serde_json::Value) {
match v { match v {
@@ -545,7 +577,53 @@ mod tests {
assert_ne!(a, b, "read_file 不同 offset 应不同 key(分段读正常)"); assert_ne!(a, b, "read_file 不同 offset 应不同 key(分段读正常)");
} }
// ── AC-2 ③ 列举命令检测测试 ── // ── 写后失效测试 ──
/// mark_readonly_cache_stale:非只读工具推进 floor,只读工具不动。
#[test]
fn mark_stale_advances_floor_for_write_tools_only() {
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let mut conv = super::super::super::PerConvState::new();
// 三条历史消息(user + assistant + tool)
conv.messages.push(df_ai::provider::ChatMessage::user("hi"));
conv.messages.push(df_ai::provider::ChatMessage::assistant("ack"));
conv.messages.push(df_ai::provider::ChatMessage::tool_result("t1", "r"));
assert_eq!(conv.readonly_cache_floor, 0, "初值 0");
// read_file 只读:不推进
mark_readonly_cache_stale(&mut conv, "read_file");
assert_eq!(conv.readonly_cache_floor, 0, "只读工具不推进 floor");
// write_file 写:推进到当前 len(3)
mark_readonly_cache_stale(&mut conv, "write_file");
assert_eq!(conv.readonly_cache_floor, 3, "写工具推进 floor 到 len");
// 再只读:不动(单调不减由 max 保证)
mark_readonly_cache_stale(&mut conv, "grep");
assert_eq!(conv.readonly_cache_floor, 3);
// run_command(写语义):取 max 不回退
conv.messages.push(df_ai::provider::ChatMessage::user("again"));
mark_readonly_cache_stale(&mut conv, "run_command");
assert_eq!(conv.readonly_cache_floor, 4, "第二个写工具推进到新 len");
}
/// floor 过滤逻辑纯函数级锁定:floor=min 钳制语义(消息缩短后 floor 不越界)。
#[test]
fn readonly_cache_floor_clamped_by_len() {
let mut conv = super::super::super::PerConvState::new();
conv.messages.push(df_ai::provider::ChatMessage::user("a"));
conv.messages.push(df_ai::provider::ChatMessage::user("b"));
mark_readonly_cache_stale(&mut conv, "write_file");
assert_eq!(conv.readonly_cache_floor, 2);
// 模拟 pop_last_assistant_round/clear 缩短历史后:len=0,floor 语义钳到 0
let floor = conv.readonly_cache_floor.min(conv.messages.len());
conv.messages.clear();
let floor2 = conv.readonly_cache_floor.min(conv.messages.len());
assert_eq!(floor, 2, "len 未缩短时 floor 原样");
assert_eq!(floor2, 0, "len 缩短后 floor 钳到 0(空扫描区间,安全)");
}
#[test] #[test]
fn listing_command_detects_powershell_and_unix() { fn listing_command_detects_powershell_and_unix() {
+308 -20
View File
@@ -31,7 +31,7 @@ mod diff;
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。 // 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越 // 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
pub(crate) mod path_auth; pub(crate) mod path_auth;
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY}; pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass, PATH_AUTH_BYPASS_KEY};
// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志 // 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
pub(super) use path_auth::is_blacklist_bypassed; pub(super) use path_auth::is_blacklist_bypassed;
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩 // 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
@@ -78,7 +78,7 @@ pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。 // 第三批从本文件抽离,行为零变更。
mod cache; mod cache;
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for, readonly_cache_args_key}; pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key};
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
@@ -166,8 +166,69 @@ async fn deny_sub_agent_tool(
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await; "rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
} }
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
/// 不 insert pending_approvals、不 emit AiApprovalRequired/AiDirAuthRequired——边界会话
/// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的
/// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed
/// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk,
/// 设计 §3.2:事后全量追溯)。
async fn deny_boundary_tool(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
draft: &ToolCallDraft,
tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo,
layer: &str,
reason: &str,
current_message_id: Option<&str>,
app_handle: &AppHandle,
) {
// 各拒绝层的可改尝试方向(行动提示,让 LLM 下一步有路可走)
let hint = match layer {
"boundary-dirs" => "该路径不在本任务目录白名单内,改用白名单内路径完成等价操作,或在报告未尽事项中说明",
_ => "该工具风险超出本任务风险上限,拆解为上限内的低风险步骤,或在报告未尽事项中说明由用户后续处理",
};
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
let msg = serde_json::json!({
"status": "rejected",
"reason": "boundary_exceeded",
"tool_name": draft.name,
"risk_level": risk_level,
"denial_layer": layer,
"message": format!("任务边界外操作已拒绝(无人值守模式零打断):{}。{}", reason, hint)
}).to_string();
// 短 lock 段:push tool_result(纯写,无 await)
{
let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg));
log_slow_lock(__lock_t, "process_tool_calls:deny_boundary");
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(msg.clone()),
conversation_id: Some(conv_id.to_string()),
};
emit_ai_chat_event(app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await;
}
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
/// spawn 入口的白名单钳制只裁工具定义面(LLM 可见 tools 列表),拦不住幻觉调用面外工具;
/// 此判定让裁剪在执行期同样生效。allowed=None(主会话调用方)恒放行,行为与历史版本一致。
fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
match allowed {
None => false,
Some(list) => !list.iter().any(|n| n == tool_name),
}
}
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成) /// 返回待审批的工具数量(0 = 全部自动执行完成)
///
/// `allowed`(P2-1):SubAgent 策略的执行期工具白名单(spawn 入口钳制产物)。
/// Low 风险工具不在名单同样就地拒绝(layer=whitelist-clamp);主会话调用方传 None,语义不变。
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs /// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。 /// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
/// ///
@@ -481,6 +542,33 @@ async fn cancel_expired_approval(
.await; .await;
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失) // 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await; super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
// 6. P1-1 修:清 generating 幽灵态。挂起时主 loop 已 return 且 guard.disarm()
// (保持 Generating 等审批恢复),超时取消后无人复位 → conv_state 恒 Generating,
// 会话永久卡生成态(消息发不进,can_accept_request=false)。迁回 Idle + emit 状态事件,
// 写法对齐 ai_chat_stop 审批分支(transition 非法仅 warn 不阻断停靠)。
if let Err(e) = state
.conv_states
.transition(conv_id, crate::commands::ai::agentic::conv_state::ConvState::Idle)
{
tracing::warn!(
conv_id = %conv_id,
error = %e,
"[ai] 审批超时取消 conv_states→Idle 迁移非法(不阻断停靠事件)"
);
}
// 7. 安全停靠(设计 §四):审批超时取消属非正常终局,补发 AiTaskParked + NeedAction
// 通知。原状只取消不复跑不留痕——用户(尤其托盘场景)对任务已死毫不知情。
// 不自动续跑(设计拍板点 5:恢复须可人工确认);恢复入口=既有重发/ai_chat_force_send。
let (park_reason, park_kind) = approval_timeout_park_parts(timeout_minutes);
super::emit_task_parked(app, conv_id, &park_reason, park_kind);
}
/// 审批超时停靠文案构造(纯函数,单测锚点;stop_kind 复用 AiTaskParked 词表)。
pub(crate) fn approval_timeout_park_parts(timeout_minutes: u64) -> (String, &'static str) {
(
format!("工具审批超时({}分钟)自动取消,任务已安全停靠:被拒操作已回执给 AI,重发消息或补充指令即可继续", timeout_minutes),
"approval_timeout",
)
} }
pub(crate) async fn process_tool_calls( pub(crate) async fn process_tool_calls(
@@ -491,6 +579,7 @@ pub(crate) async fn process_tool_calls(
app_handle: &AppHandle, app_handle: &AppHandle,
conv_id: &str, conv_id: &str,
policy: ToolExecPolicy, policy: ToolExecPolicy,
allowed: Option<&[String]>,
) -> usize { ) -> usize {
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档) // 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent); let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
@@ -598,6 +687,18 @@ pub(crate) async fn process_tool_calls(
.await .await
.unwrap_or_else(|| "low".to_string()); .unwrap_or_else(|| "low".to_string());
// ── 无人值守任务边界快照(设计 §三判定链输入)──
// per_conv.boundary 即会话级单真相源,None=未声明:下方两处插桩分支全部跳过,
// auto_exec_mode/trust/审批/路径授权全链路现状零变更(渐进增强零倒退)。
// 边界优先于全局三档:有边界会话上限内零打断,超上限结构化拒绝(不挂起不弹卡)。
let boundary: Option<crate::commands::ai::boundary::TaskBoundary> = {
let __lock_t = std::time::Instant::now();
let session = session_arc.lock().await;
let b = session.conv_read(conv_id).and_then(|c| c.boundary.clone());
log_slow_lock(__lock_t, "process_tool_calls:boundary_snapshot");
b
};
// ── 文件工具路径授权预校验 ── // ── 文件工具路径授权预校验 ──
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search) // 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单): // 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
@@ -623,6 +724,14 @@ pub(crate) async fn process_tool_calls(
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单; // 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 // 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
path_auth::set_blacklist_bypass(path_auth_bypass); path_auth::set_blacklist_bypass(path_auth_bypass);
// P3-4 bypass 按策略分流:主会话=All(体验优先,黑名单一并放行,产品拍板);
// SubAgent=KeepBlacklist——bypass 只豁免白名单外挂起,黑名单保持硬拒。子代理无审批
// 通道也无人监督,黑名单是安全线,不得被体验开关解除。关=Off 两策略行为一致。
let path_bypass = if path_auth_bypass {
if is_sub_agent { PathBypass::KeepBlacklist } else { PathBypass::All }
} else {
PathBypass::Off
};
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
@@ -630,11 +739,13 @@ pub(crate) async fn process_tool_calls(
// 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注—— // 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。 // 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new(); let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
// 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理)
let mut boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new();
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
for (idx, draft, args) in drafts { for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。 // handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) { match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) {
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)), FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
FileToolAuthOutcome::NeedsAuth(req) => { FileToolAuthOutcome::NeedsAuth(req) => {
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。 // SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
@@ -642,6 +753,21 @@ pub(crate) async fn process_tool_calls(
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth")); sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
continue; continue;
} }
// 任务边界优先(无人值守,设计 §3.1②):req.dirs 是三档白名单外的待授权
// 父目录集合,边界白名单全覆盖 → 直接放行(免弹 AiDirAuthRequired);
// 未全覆盖 → 结构化拒绝(boundary-dirs),不挂起——边界会话零新挂起。
if let Some(b) = &boundary {
if crate::commands::ai::boundary::covers_dirs(b, &req.dirs) {
authorized_drafts.push((idx, draft, args));
} else {
let uncovered: Vec<String> = req.dirs.iter()
.filter(|d| !crate::commands::ai::boundary::dir_covered(&b.dir_allowlist, d))
.map(|d| d.to_string_lossy().to_string())
.collect();
boundary_dir_denied.push((draft, format!("待授权目录 {} 不在任务白名单内", uncovered.join(", "))));
}
continue;
}
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。 // all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。 // 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。 // 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
@@ -702,6 +828,15 @@ pub(crate) async fn process_tool_calls(
).await; ).await;
} }
// 任务边界会话:白名单外路径结构化拒绝(boundary-dirs),零挂起零弹卡。
// LLM 拿 envelope 换路径或写入报告未尽事项;审计 decided_by=boundary-dirs 留痕。
for (draft, reason) in boundary_dir_denied {
deny_boundary_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"boundary-dirs", &reason, current_message_id, app_handle,
).await;
}
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。 // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 // pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
@@ -811,6 +946,12 @@ pub(crate) async fn process_tool_calls(
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn // trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。 // (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
// boundary_auto 收集(无人值守,设计 §3.1③):边界会话上限内非破坏性工具,归入独立
// 并行执行向量(join_all,与 trust_hits 同款执行+审计链)。不并入 low_risk 向量的原因:
// low_risk 回填的 decided_by 按风险等级映射(auto/auto_takeover_*),边界放行的来源留痕
// 须为 boundary_auto(审计可追溯授权来自任务边界而非全局档),且不动共享路径签名
// (无边界会话零倒退优先)。
let mut boundary_auto: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
for (_, draft, args) in drafts { for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。 // SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
@@ -825,6 +966,39 @@ pub(crate) async fn process_tool_calls(
).await; ).await;
continue; continue;
} }
// P2-1 执行期白名单:Low 风险工具不在 spawn 白名单同样就地拒绝(定义面裁剪
// 拦不住幻觉调用)。layer 恒 whitelist-clamp——是名单问题非风险问题,行动提示
// 指向改用名单内工具。主会话 allowed=None 判定恒 false,零行为变更。
if is_sub_agent && sub_agent_tool_clamped(allowed, &draft.name) {
deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"whitelist-clamp", "工具不在 spawn 白名单内", current_message_id, app_handle,
).await;
continue;
}
// 任务边界判定(无人值守,设计 §3.1③④;SubAgent 策略不受影响维持 fail-closed:
// 子代理 per_conv 新建恒无边界,此处再 gate 一道,防误设边界放宽子代理——拍板点 6):
// 上限内 ∧ 非破坏性命令 → boundary_auto 并行执行向量(零审批零挂起);
// 超上限(含 High、破坏性命令)→ 结构化拒绝(boundary-risk),不 insert pending。
// 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。
if !is_sub_agent {
if let Some(b) = &boundary {
if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) {
boundary_auto.push((draft, args, risk_level));
} else {
let reason = if risk_level == RiskLevel::High {
"工具风险 High 超出任务风险上限".to_string()
} else {
"工具风险超出任务风险上限".to_string()
};
deny_boundary_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"boundary-risk", &reason, current_message_id, app_handle,
).await;
}
continue;
}
}
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。 // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录, // 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常 // 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
@@ -893,6 +1067,7 @@ pub(crate) async fn process_tool_calls(
} }
})).await; })).await;
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit) // 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
for (draft, risk_level, outcome) in results { for (draft, risk_level, outcome) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
@@ -914,6 +1089,12 @@ pub(crate) async fn process_tool_calls(
content.clone() content.clone()
}; };
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content)); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
// 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界
// (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅
// completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。
if status == "completed" {
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
}
} }
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯 // 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await; audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await;
@@ -1067,6 +1248,12 @@ pub(crate) async fn process_tool_calls(
content.clone() content.clone()
}; };
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content)); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
// 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界
// (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅
// completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。
if status == "completed" {
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
}
} }
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。 // F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker: // decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
@@ -1135,32 +1322,98 @@ pub(crate) async fn process_tool_calls(
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content)); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
} }
dupe_audit_records.push(build_audit_record( dupe_audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, conv_id, &draft.id, &draft.name, &draft.args,
Some(warned_content), Some("cache_hit"), current_message_id, "completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id,
)); ));
tracing::info!(
conv_id = %conv_id,
tool = %draft.name,
tc_id = %draft.id,
hit_count = new_count,
"[ai] 只读工具轮内去重命中(第 {} 次同参,本轮首个已执行),跳过重复真执行",
new_count
);
} }
if !dupe_audit_records.is_empty() { if !dupe_audit_records.is_empty() {
let dup_len = dupe_audit_records.len(); let batch_len = dupe_audit_records.len();
if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await { if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await {
tracing::error!( tracing::error!(
conv_id = %conv_id, conv_id = %conv_id,
batch = dup_len, batch = batch_len,
"[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}", "[ai] insert_batch(轮内重复)批量写审计记录失败(已回滚,共 {} 条): {}",
dup_len, batch_len,
e e
); );
} }
} }
} }
// boundary_auto 并行执行(无人值守,设计 §3.1③):与 low_risk 同构——execute + emit 在
// 闭包内(锁外),push/audit 串行回填;区别仅审计 decided_by=boundary_auto
// (授权来自任务边界,事后全量可追溯),emit_data_changed 同步联动(数据变更类)。
// execute/emit 全在闭包内不持锁,push tool_result / audit 在 join_all 后串行回填。
if !boundary_auto.is_empty() {
tracing::info!(
conv_id = %conv_id,
count = boundary_auto.len(),
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
boundary_auto.len()
);
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
async move {
match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await {
Ok(val) => {
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: val.clone(),
conversation_id: Some(conv_clone),
};
emit_ai_chat_event(&app_clone, ev);
emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(val.to_string()))
}
Err(e) => {
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_clone),
};
emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(err_msg))
}
}
}
})).await;
for (draft, risk_level, outcome) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
};
// 短 lock 段:namespace T2 + push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
let msg_content = if status == "failed" {
serde_json::json!({"status":"failed","error":&content}).to_string()
} else if df_ai::namespace_store::should_use_namespace(&content, &draft.name) {
session.namespace_store.store(&draft.name, &content)
} else {
content.clone()
};
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
// 写后失效:边界放行的非只读工具成功同 Low 段推进 floor(口径一致)。
if status == "completed" {
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
}
}
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
// 标注授权来自任务边界(无人值守事后可全量追溯)。
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await;
}
}
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。 // 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
// 短 lock 段:读 pending_approvals 统计(纯读,无 await) // 短 lock 段:读 pending_approvals 统计(纯读,无 await)
let (path_count, risk_count, pending_approvals_len) = { let (path_count, risk_count, pending_approvals_len) = {
@@ -1195,7 +1448,7 @@ mod tests {
let allowed = crate::state::AllowedDirs::default(); let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" }); let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
let outcome = check_file_tool_auth("grep", &args, &allowed, false); let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off);
match outcome { match outcome {
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ } FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"), FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
@@ -1203,7 +1456,7 @@ mod tests {
} }
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异 // 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
let search_outcome = check_file_tool_auth("search_files", &args, &allowed, false); let search_outcome = check_file_tool_auth("search_files", &args, &allowed, PathBypass::Off);
match search_outcome { match search_outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ } FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"), FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
@@ -1219,7 +1472,7 @@ mod tests {
let allowed = crate::state::AllowedDirs::default_with_root(); let allowed = crate::state::AllowedDirs::default_with_root();
// .ssh 命中系统黑名单(is_in_system_blacklist) // .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" }); let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
let outcome = check_file_tool_auth("grep", &args, &allowed, false); let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off);
match outcome { match outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ } FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"), FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
@@ -1234,4 +1487,39 @@ mod tests {
let paths = extract_file_tool_paths("grep", &args); let paths = extract_file_tool_paths("grep", &args);
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)"); assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
} }
/// P2-1 执行期白名单判定真值表:SubAgent 名单外工具(含已注册 Low)被裁,名单内放行,
/// 主会话 None 恒放行(历史行为不变)。与 spawn 入口 clamp_tool_whitelist 组成两道闸;
/// 判定命中后的拒绝落地走既有 deny_sub_agent_tool 单点(layer=whitelist-clamp,
/// envelope 结构与被拒收集由 sub_agent.rs 测试锁定),此处锁判定本身。
#[test]
fn sub_agent_tool_clamped_truth_table() {
let allowed = vec!["read_file".to_string(), "grep".to_string()];
assert!(!sub_agent_tool_clamped(Some(&allowed), "read_file"), "名单内放行");
assert!(
sub_agent_tool_clamped(Some(&allowed), "list_tasks"),
"名单外已注册 Low 工具同样被裁(定义面裁剪拦不住的幻觉调用残面)"
);
assert!(
sub_agent_tool_clamped(Some(&allowed), "spawn_agent"),
"spawn_agent 恒被入口钳制剔除,执行期也不得放行(禁递归)"
);
assert!(!sub_agent_tool_clamped(None, "run_command"), "主会话 None 恒放行");
assert!(
sub_agent_tool_clamped(Some(&[]), "read_file"),
"空白名单全拒(白名单为空的子代理无可执行工具)"
);
}
/// 审批超时停靠文案(stop_kind=approval_timeout):锁定 P1-1 修复的停靠出口字段,
/// cancel_expired_approval 到点路径消费(发 AiTaskParked + NeedAction 通知)。
#[test]
fn approval_timeout_park_parts_contract() {
let (reason, kind) = approval_timeout_park_parts(15);
assert_eq!(kind, "approval_timeout", "stop_kind 词表(前端契约)");
assert!(reason.contains("15"), "文案携带超时分钟数");
assert!(reason.contains("停靠"), "文案含停靠语义(待办清单=stop_reason)");
let (reason_0, _) = approval_timeout_park_parts(0);
assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)");
}
} }
+58 -15
View File
@@ -59,22 +59,38 @@ pub(crate) enum FileToolAuthOutcome {
Denied(String), Denied(String),
} }
/// 「信任所有目录」总开关的放行面(P3-4 按策略分流)。
///
/// - `Off`:开关关闭(默认),白名单外挂起授权 + 黑名单硬拒,行为与历史版本一致;
/// - `All`:主会话全放行(体验优先,黑名单一并放行,产品拍板);
/// - `KeepBlacklist`:子代理策略——bypass 只豁免白名单外挂起(含 search_files 盲拒),
/// 黑名单保持硬拒。子代理无审批通道也无人监督,黑名单是安全线,不可被体验开关解除。
/// Copy:每条工具调用循环内按值传入 check_file_tool_auth,单元枚举按位复制免手动 clone。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PathBypass {
Off,
All,
KeepBlacklist,
}
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 /// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
/// ///
/// - 路径任一命中黑名单且 bypass 关:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起) /// - 路径任一命中黑名单且 bypass 非 `All`:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起)
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
/// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied /// - `bypass=All`(「信任所有目录」总开关,主会话):未授权目录不再产生 NeedsAuth,黑名单
/// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒 /// Denied 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
/// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入; /// 随 NeedsAuth 消失一并解除)。`bypass=KeepBlacklist`(子代理):只豁免 NeedsAuth,
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价, /// 黑名单硬拒保持。bypass 由调用方 process_tool_calls 读 KV 后按策略分流传入;
/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。 /// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(主会话语义,与 All 同源等价,
/// audit 层每轮刷新保证两处拦截点一致;子代理文件工具在预校验层已硬拒,handler 级标志不生效)。
/// 本函数同步签名,不自取异步 settings。
pub(crate) fn check_file_tool_auth( pub(crate) fn check_file_tool_auth(
tool_name: &str, tool_name: &str,
args: &serde_json::Value, args: &serde_json::Value,
allowed: &crate::state::AllowedDirs, allowed: &crate::state::AllowedDirs,
bypass: bool, bypass: PathBypass,
) -> FileToolAuthOutcome { ) -> FileToolAuthOutcome {
use crate::state::{check_path_authorization, PathAuthDecision}; use crate::state::{check_path_authorization, PathAuthDecision};
let paths = extract_file_tool_paths(tool_name, args); let paths = extract_file_tool_paths(tool_name, args);
@@ -88,16 +104,17 @@ pub(crate) fn check_file_tool_auth(
for p in &paths { for p in &paths {
match check_path_authorization(p, allowed) { match check_path_authorization(p, allowed) {
PathAuthDecision::Denied { reason } => { PathAuthDecision::Denied { reason } => {
// 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。 // 总开关=All 时黑名单一并放行(产品拍板:体验优先,用户知情选择,仅主会话);
// 关闭与 KeepBlacklist(子代理)时硬拒不变——黑名单是安全线。
// 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。 // 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。
if bypass { if matches!(bypass, PathBypass::All) {
continue; continue;
} }
return FileToolAuthOutcome::Denied(reason); return FileToolAuthOutcome::Denied(reason);
} }
PathAuthDecision::NeedsAuthorization { dir } => { PathAuthDecision::NeedsAuthorization { dir } => {
// 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理) // 总开关开启(All 或 KeepBlacklist):未授权目录直接放行(黑名单的放行仅在 All 分支)
if bypass { if !matches!(bypass, PathBypass::Off) {
continue; continue;
} }
if !pending_dirs.contains(&dir) { if !pending_dirs.contains(&dir) {
@@ -136,17 +153,43 @@ mod tests {
let allowed = crate::state::AllowedDirs::default(); let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" }); let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
// 关(基线,行为不变):NeedsAuth // 关(基线,行为不变):NeedsAuth
match check_file_tool_auth("read_file", &args, &allowed, false) { match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
FileToolAuthOutcome::NeedsAuth(_) => {} FileToolAuthOutcome::NeedsAuth(_) => {}
_ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"), _ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"),
} }
// 开:直接 Authorized,不挂起 // 开:直接 Authorized,不挂起
match check_file_tool_auth("read_file", &args, &allowed, true) { match check_file_tool_auth("read_file", &args, &allowed, PathBypass::All) {
FileToolAuthOutcome::Authorized => {} FileToolAuthOutcome::Authorized => {}
_ => panic!("bypass 开时未授权路径应直接 Authorized"), _ => panic!("bypass 开时未授权路径应直接 Authorized"),
} }
} }
/// P3-4 SubAgent 分流:KeepBlacklist 只豁免白名单外挂起(不再弹授权),黑名单保持硬拒
/// ——子代理无审批通道也无人监督,黑名单安全线不得被体验开关解除。
#[test]
fn test_bypass_keep_blacklist_passes_needs_auth_but_denies_blacklist() {
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let allowed = crate::state::AllowedDirs::default_with_root();
// 白名单外普通路径:KeepBlacklist 直接放行(不挂起授权)
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
assert!(
matches!(
check_file_tool_auth("read_file", &args, &allowed, PathBypass::KeepBlacklist),
FileToolAuthOutcome::Authorized
),
"KeepBlacklist 应豁免白名单外挂起"
);
// 黑名单路径:仍硬拒(与 All 的语义分界)
let secret = serde_json::json!({ "path": "/home/user/.ssh/config" });
assert!(
matches!(
check_file_tool_auth("read_file", &secret, &allowed, PathBypass::KeepBlacklist),
FileToolAuthOutcome::Denied(_)
),
"KeepBlacklist 下黑名单应保持硬拒"
);
}
/// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。 /// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。
/// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。 /// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。
#[test] #[test]
@@ -157,7 +200,7 @@ mod tests {
// .ssh 命中系统黑名单(is_in_system_blacklist) // .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
let outcome_allowed = matches!( let outcome_allowed = matches!(
check_file_tool_auth("read_file", &args, &allowed, true), check_file_tool_auth("read_file", &args, &allowed, PathBypass::All),
FileToolAuthOutcome::Authorized FileToolAuthOutcome::Authorized
); );
set_blacklist_bypass(false); set_blacklist_bypass(false);
@@ -171,7 +214,7 @@ mod tests {
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let allowed = crate::state::AllowedDirs::default_with_root(); let allowed = crate::state::AllowedDirs::default_with_root();
let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
match check_file_tool_auth("read_file", &args, &allowed, false) { match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
FileToolAuthOutcome::Denied(_) => {} FileToolAuthOutcome::Denied(_) => {}
_ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"), _ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"),
} }
+43 -3
View File
@@ -296,15 +296,23 @@ fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) ->
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单 /// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。 /// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
/// ///
/// P2-3 task_id 预筛下沉 SQL:`result LIKE '%"id":"<task_id>"%'` 只作预筛(缩小扫描窗口,
/// 参数化绑定防注入),精确判等仍在 Rust 侧 match_task_audit_row(解析 JSON 取顶层 id 字段
/// 判等,「禁裸 LIKE 判等」不破)。预筛只会多放进相关行(嵌套 id/巧合同形文本),由 Rust
/// 侧剔除;不会漏放——audit result 均由 serde_json 紧凑序列化落库(无空格),顶层 id 字段
/// 必然以 `"id":"<id>"` 形态出现。若无预筛,LIMIT 窗口取的是全局最近 1000 行,高流量下
/// 目标任务的关联行会被无关行挤出窗外(关联静默消失);预筛后窗口=含该任务 id 形文本的
/// 最近 1000 行,早期关联仍可命中(有单测锁定)。
///
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话 /// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除 /// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。 /// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行, /// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢。
/// 更早的关联对"跳回最近上下文"场景无意义。
pub(crate) async fn find_task_conversations( pub(crate) async fn find_task_conversations(
db: &Database, db: &Database,
task_id: &str, task_id: &str,
) -> Result<Vec<TaskConversationRef>, String> { ) -> Result<Vec<TaskConversationRef>, String> {
let pattern = format!("%\"id\":\"{}\"%", task_id);
let conn = db.conn(); let conn = db.conn();
let rows: Vec<(String, String, Option<String>, String)> = let rows: Vec<(String, String, Option<String>, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> { tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> {
@@ -315,11 +323,12 @@ pub(crate) async fn find_task_conversations(
"SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \ "SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
WHERE tool_name IN ('create_task','update_task','advance_task') \ WHERE tool_name IN ('create_task','update_task','advance_task') \
AND conversation_id IS NOT NULL \ AND conversation_id IS NOT NULL \
AND result LIKE ?1 \
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000", ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mapped = stmt let mapped = stmt
.query_map([], |row| { .query_map([&pattern], |row| {
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
@@ -590,6 +599,7 @@ mod tests {
created_at: "1".to_string(), created_at: "1".to_string(),
updated_at: "1".to_string(), updated_at: "1".to_string(),
origin: origin.map(|s| s.to_string()), origin: origin.map(|s| s.to_string()),
boundary_json: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
@@ -637,6 +647,36 @@ mod tests {
assert_eq!(out[0].requested_at, (t - 10).to_string()); assert_eq!(out[0].requested_at, (t - 10).to_string());
} }
/// P2-3 窗口外早期关联仍可命中:目标任务行之后灌 1000+ 条更新的无关任务工具行,
/// 旧的「全局最近 1000 条」窗口会把早期关联挤出(静默消失);task_id 预筛下沉后,
/// 窗口按预筛结果截取,早期关联仍须命中(精确判等仍在 Rust 侧,误命中不发生)。
#[tokio::test]
async fn find_task_conversations_early_hit_survives_window_flood() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t_early = 1_000_000_000_000i64;
insert_conversation(&db, "conv-early", Some("早期会话"), None).await;
insert_conv_audit(&repo, "conv-early", "create_task",
Some(r#"{"id":"t-early","title":"E"}"#.into()), t_early).await;
// 灌 1100 条更新的无关任务工具行(其他任务 id),把全表推过 1000 行窗口
let t_flood = t_early + 1_000_000;
for i in 0..1100i64 {
insert_conv_audit(&repo, "conv-noise", "update_task",
Some(format!(r#"{{"id":"noise-{}"}}"#, i)), t_flood + i).await;
}
let out = find_task_conversations(&db, "t-early").await.unwrap();
assert_eq!(out.len(), 1, "早期关联不得被 1000 行窗口挤出");
assert_eq!(out[0].conversation_id, "conv-early");
assert_eq!(out[0].title.as_deref(), Some("早期会话"));
// 预筛不放无关行进来:noise 任务查不到任何关联
assert!(
find_task_conversations(&db, "noise-1").await.unwrap().is_empty(),
"noise 会话不在库,即使 id 前缀巧合也不得列出"
);
}
/// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。 /// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
#[test] #[test]
fn match_task_audit_row_truth_table() { fn match_task_audit_row_truth_table() {
@@ -7,7 +7,7 @@
//! `resolve_all` 按 [`MentionRef::kind_str`] 分发到对应 Resolver,单条 resolve 失败 //! `resolve_all` 按 [`MentionRef::kind_str`] 分发到对应 Resolver,单条 resolve 失败
//! 收集进 errors 列表不阻断整批注入(返回 `(Vec<Augmentation>, Vec<ResolveError>)`)。 //! 收集进 errors 列表不阻断整批注入(返回 `(Vec<Augmentation>, Vec<ResolveError>)`)。
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use df_types::augmentation::{Augmentation, MentionRef, ResolveError}; use df_types::augmentation::{Augmentation, MentionRef, ResolveError};
@@ -45,6 +45,10 @@ impl ResolverRegistry {
/// - 失败(Resolver 返 Err / kind 无对应 Resolver)→ push 进 `errors`,**不中断** /// - 失败(Resolver 返 Err / kind 无对应 Resolver)→ push 进 `errors`,**不中断**
/// 后续 mention(整批注入最大化保留可用上下文)。 /// 后续 mention(整批注入最大化保留可用上下文)。
/// ///
/// 同一引用去重(先到先得):`/技能` 前导与 @mention span 可指向同一技能,按
/// `kind + ref_id`(skill 的 ref_id 即 name)跳过重复,避免同一正文重复 resolve
/// 并在 prompt 注入两份。首现位置决定保留哪条,顺序不变。
///
/// 返回 `(成功列表, 失败列表)`,调用方据 errors 数决定是否提示用户某 mention 跳过。 /// 返回 `(成功列表, 失败列表)`,调用方据 errors 数决定是否提示用户某 mention 跳过。
pub async fn resolve_all( pub async fn resolve_all(
&self, &self,
@@ -53,7 +57,12 @@ impl ResolverRegistry {
) -> (Vec<Augmentation>, Vec<ResolveError>) { ) -> (Vec<Augmentation>, Vec<ResolveError>) {
let mut augs = Vec::with_capacity(refs.len()); let mut augs = Vec::with_capacity(refs.len());
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut seen: HashSet<String> = HashSet::with_capacity(refs.len());
for rf in refs { for rf in refs {
// 去重键 = kind + ref_id(id 或 skill 的 name):同键 mention 只 resolve 首条
if !seen.insert(format!("{}:{}", rf.kind_str(), rf.ref_id())) {
continue;
}
let kind = rf.kind_str(); let kind = rf.kind_str();
match self.resolvers.get(kind) { match self.resolvers.get(kind) {
Some(resolver) => match resolver.resolve(rf, loc).await { Some(resolver) => match resolver.resolve(rf, loc).await {
@@ -139,4 +148,48 @@ mod tests {
assert!(augs.is_empty(), "两条 mention kind 均≠test 应全 KindMismatch"); assert!(augs.is_empty(), "两条 mention kind 均≠test 应全 KindMismatch");
assert_eq!(errors.len(), 2, "两条失败都应收集"); assert_eq!(errors.len(), 2, "两条失败都应收集");
} }
/// 测试用 Resolver:kind 固定 "skill",resolve 固定返 Skill(模拟成功注入)。
struct OkSkillResolver;
#[async_trait]
impl MentionResolver for OkSkillResolver {
fn kind(&self) -> &'static str {
"skill"
}
async fn resolve(
&self,
rf: &MentionRef,
_loc: ProviderLocality,
) -> Result<Augmentation, ResolveError> {
let name = match rf {
MentionRef::Skill { name } => name.clone(),
_ => String::new(),
};
Ok(Augmentation::Skill {
name,
source: "test".into(),
body: "BODY".into(),
})
}
}
#[tokio::test]
async fn resolve_all_dedups_same_ref_first_wins() {
// 去重(先到先得):/技能 前导 + @mention span 指向同一技能时只 resolve/注入一份;
// 不同技能不受影响;首现位置决定保留顺序。
let mut reg = ResolverRegistry::new();
reg.register(Arc::new(OkSkillResolver));
let refs = vec![
MentionRef::Skill { name: "review".into() },
MentionRef::Skill { name: "review".into() },
MentionRef::Skill { name: "idea".into() },
];
let (augs, errors) = reg.resolve_all(&refs, ProviderLocality::Local).await;
assert!(errors.is_empty());
assert_eq!(augs.len(), 2, "同名技能两条应去重为一份");
match &augs[0] {
Augmentation::Skill { name, .. } => assert_eq!(name, "review", "先到先得保留首条"),
other => panic!("expected Skill, got {:?}", other),
}
}
} }
+256
View File
@@ -0,0 +1,256 @@
//! 无人值守长任务 — 任务级授权边界(TaskBoundary)
//!
//! 设计: docs/02-架构设计/专项设计/无人值守长任务-设计-2026-10-04.md §二/§三。
//! 拍板点 1: risk_ceiling 封顶 Medium,High 归 L3 完全信任档不经此通道。
//! 生命周期 = 会话存续: 发起时一次声明,随会话同亡(落库 ai_conversations.boundary_json V45)。
//! 无边界会话 = 现状逐次审批零倒退;子代理策略(SubAgent)不受影响维持 fail-closed(拍板点 6)。
//!
//! 判定函数全部纯函数(无 session/AppHandle 依赖),单测锁定真值表;
//! 插桩点在 audit/process_tool_calls(会话边界快照 → path_auth NeedsAuth 分支 +
//! 风险分类循环两处消费)。
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use df_ai::ai_tools::RiskLevel;
use crate::commands::ai::is_destructive_command;
/// 任务预算(复用子代理三熔断量纲: 轮次/token/挂钟)。None=不限(继承现网常量)。
///
/// P0 仅随行落库 + IPC 读写,主 loop 预算闸待后续批接线(设计拍板点 8: 预算可中途调)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskBudget {
pub max_rounds: u32,
pub max_tokens: u64,
pub wall_secs: u64,
}
fn default_risk_ceiling() -> RiskLevel {
RiskLevel::Medium
}
fn default_forbid_destructive() -> bool {
true
}
/// 任务级授权边界(挂会话: per_conv.boundary 内存真相源 + boundary_json 落库随行)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskBoundary {
/// 风险上限(Low|Medium)。set 入口经 [`TaskBoundary::normalized`] 把 High 钳到 Medium
/// (拍板点 1: 无人看命令串的 High 风险不可控,确需 High 归 L3 完全信任档)。
#[serde(default = "default_risk_ceiling")]
pub risk_ceiling: RiskLevel,
/// 任务目录白名单(目录级 starts_with 覆盖;判定时双侧 best-effort canonicalize 对齐形态,
/// 与 AllowedDirs::is_authorized 同款归一口径)。存原始字符串: 白名单目录可能尚不存在
/// (write_file 新建),规范化失败回退词法形态尽力匹配。
#[serde(default)]
pub dir_allowlist: Vec<String>,
/// 破坏性命令禁令(恒 true,字段留作显式声明供 UI 展示)。
/// 判定**不读本字段**——直接调 is_destructive_command,防手改 JSON 置 false 绕过
/// (安全判定不信任输入)。
#[serde(default = "default_forbid_destructive")]
pub forbid_destructive: bool,
/// 任务预算,None=不限。
#[serde(default)]
pub budget: Option<TaskBudget>,
}
impl TaskBoundary {
/// set 入口规范化: risk_ceiling=High 钳到 Medium(拍板点 1,边界声明不开放 High 档)。
pub fn normalized(mut self) -> Self {
if self.risk_ceiling == RiskLevel::High {
tracing::warn!("[boundary] risk_ceiling=High 不开放(拍板点 1),已钳到 Medium");
self.risk_ceiling = RiskLevel::Medium;
}
self
}
}
fn risk_rank(r: RiskLevel) -> u8 {
match r {
RiskLevel::Low => 0,
RiskLevel::Medium => 1,
RiskLevel::High => 2,
}
}
/// 风险是否在边界上限内(High 恒超 Medium 上限;ceiling 已在 set 时钳到 Medium,
/// 此函数对 High ceiling 不做特判——正常入口不会出现,留给出处可信的内存态)。
pub fn risk_within_ceiling(boundary: &TaskBoundary, risk: RiskLevel) -> bool {
risk_rank(risk) <= risk_rank(boundary.risk_ceiling)
}
/// 路径是否被任务白名单覆盖(目录级 starts_with)。
///
/// 双侧 best-effort canonicalize + strip_verbatim 对齐形态(白名单存词法串/路径可能
/// 不存在/candidate 可能带 \\?\ 前缀),归一口径与 AllowedDirs::is_authorized 一致。
pub fn dir_covered(allowlist: &[String], path: &Path) -> bool {
use crate::state::{best_effort_canonicalize, strip_verbatim};
let cand = strip_verbatim(best_effort_canonicalize(path));
allowlist.iter().any(|d| {
let dir_norm = strip_verbatim(best_effort_canonicalize(Path::new(d)));
cand.starts_with(&dir_norm)
})
}
/// NeedsAuth 待授权目录集合是否**全部**被任务白名单覆盖(全覆盖才放行,部分覆盖=拒;
/// 部分放行会把未覆盖目录也带过闸,违背 fail-closed)。
pub fn covers_dirs(boundary: &TaskBoundary, dirs: &[PathBuf]) -> bool {
!dirs.is_empty() && dirs.iter().all(|d| dir_covered(&boundary.dir_allowlist, d))
}
/// 边界对单条工具调用**风险面**的放行判定(路径维度在 path_auth NeedsAuth 分支单点判定,
/// 不在本函数——两者判定时机不同,见 process_tool_calls 插桩注释)。
///
/// 条件: 风险 ≤ 上限 ∧ 非破坏性命令。run_command 注册为 High 本就恒超 Medium 上限,
/// 破坏性闸是纵深防御(任务书要求 is_destructive_command 永不 auto,与 session_trust
/// 放行门控同源同闸)。
pub fn risk_allowed(
boundary: &TaskBoundary,
tool_name: &str,
args: &serde_json::Value,
risk: RiskLevel,
) -> bool {
if !risk_within_ceiling(boundary, risk) {
return false;
}
if tool_name == "run_command" {
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
if is_destructive_command(cmd) {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn boundary(ceiling: RiskLevel, dirs: &[&str]) -> TaskBoundary {
TaskBoundary {
risk_ceiling: ceiling,
dir_allowlist: dirs.iter().map(|s| s.to_string()).collect(),
forbid_destructive: true,
budget: None,
}
}
/// serde roundtrip: 全字段 ↔ JSON 双向一致(落库 boundary_json 的序列化契约)。
#[test]
fn serde_roundtrip_full_fields() {
let b = TaskBoundary {
risk_ceiling: RiskLevel::Medium,
dir_allowlist: vec!["E:/wk-lab/devflow".to_string(), "C:/temp".to_string()],
forbid_destructive: true,
budget: Some(TaskBudget { max_rounds: 50, max_tokens: 200_000, wall_secs: 900 }),
};
let json = serde_json::to_string(&b).unwrap();
let back: TaskBoundary = serde_json::from_str(&json).unwrap();
assert_eq!(back, b, "全字段 roundtrip 应一致");
}
/// serde 缺省字段容错: 前端精简 JSON(缺 forbid_destructive/budget/risk_ceiling/
/// dir_allowlist)反序列化落到安全默认(Medium/空名单/true/None),不报错。
#[test]
fn serde_missing_fields_fall_back_to_safe_defaults() {
let b: TaskBoundary = serde_json::from_str("{}").unwrap();
assert_eq!(b.risk_ceiling, RiskLevel::Medium, "缺省 ceiling=Medium(任务书拍板)");
assert!(b.dir_allowlist.is_empty());
assert!(b.forbid_destructive, "缺省禁破坏性命令");
assert!(b.budget.is_none(), "缺省不限预算");
}
/// 边界内 Medium 放行 / 边界外仍拒: Medium 上限下 Low/Medium 过闸,High 恒拒;
/// Low 上限下 Medium 也拒(上限收紧生效)。
#[test]
fn risk_ceiling_truth_table() {
let b = boundary(RiskLevel::Medium, &[]);
let args = serde_json::json!({});
assert!(risk_allowed(&b, "write_file", &args, RiskLevel::Low), "Low ≤ Medium 放行");
assert!(risk_allowed(&b, "write_file", &args, RiskLevel::Medium), "Medium = 上限 放行(边界内 Medium 放行)");
assert!(!risk_allowed(&b, "run_command", &args, RiskLevel::High), "High 超上限恒拒(边界外仍拒)");
let low = boundary(RiskLevel::Low, &[]);
assert!(!risk_allowed(&low, "write_file", &args, RiskLevel::Medium), "Medium 超 Low 上限拒");
}
/// 目录覆盖判定: 白名单内放行 / 白名单外拒 / 部分覆盖拒(全 covered 才放行)。
#[test]
fn covers_dirs_all_or_nothing() {
let b = boundary(RiskLevel::Medium, &["E:/wk-lab/devflow"]);
assert!(covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src/main.rs")]), "白名单内覆盖");
assert!(covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src"), PathBuf::from("E:/wk-lab/devflow/crates")]), "多个路径全在内");
assert!(!covers_dirs(&b, &[PathBuf::from("E:/other/x")]), "白名单外拒(边界外仍拒)");
assert!(
!covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src"), PathBuf::from("E:/other/x")]),
"部分覆盖=拒(全 covered 才放行,fail-closed)"
);
assert!(!covers_dirs(&b, &[]), "空目录集不判覆盖(NeedsAuth 必带目录,空=异常数据保守拒)");
}
/// 破坏性命令恒拒: 即使风险面在名义上限内(测试构造 High ceiling 模拟"上限失守"),
/// is_destructive_command 命中仍拒——纵深防御闸独立于风险等级。
#[test]
fn destructive_command_never_auto() {
let high = boundary(RiskLevel::High, &[]);
let rm = serde_json::json!({ "command": "rm -rf /" });
assert!(
!risk_allowed(&high, "run_command", &rm, RiskLevel::High),
"破坏性命令即使 High 上限也恒拒(纵深防御)"
);
let med = boundary(RiskLevel::Medium, &[]);
assert!(
!risk_allowed(&med, "run_command", &rm, RiskLevel::High),
"Medium 上限下 run_command 双闸全拒"
);
// 非破坏性命令只受风险等级约束(首 token 精配不误伤 delta/delve)
let cargo = serde_json::json!({ "command": "cargo check --workspace" });
assert!(
!risk_allowed(&med, "run_command", &cargo, RiskLevel::High),
"Medium 上限下非破坏 High 命令仍超上限拒"
);
}
/// set 入口规范化: High ceiling 钳到 Medium(拍板点 1),其余字段原样保留。
#[test]
fn normalized_clamps_high_ceiling_to_medium() {
let clamped = boundary(RiskLevel::High, &["E:/x"]).normalized();
assert_eq!(clamped.risk_ceiling, RiskLevel::Medium, "High 钳到 Medium");
assert_eq!(clamped.dir_allowlist, vec!["E:/x".to_string()], "其余字段不动");
assert_eq!(boundary(RiskLevel::Low, &[]).normalized().risk_ceiling, RiskLevel::Low, "Low 不变");
}
/// 黑名单恒拒与边界无关: .ssh 路径在 check_file_tool_auth 先行 Denied,
/// 边界层永不参与 Denied 分支(即使 allowlist 看似覆盖其父目录)。
/// 插桩结构保证: Denied 在 NeedsAuth 分支之前分流,boundary 判定只接 NeedsAuth。
#[test]
fn blacklist_denied_before_boundary_consulted() {
let _guard = crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let allowed = crate::state::AllowedDirs::default_with_root();
// allowlist 故意覆盖 /home/user:若边界误参与黑名单分支,这里会误判"覆盖"
let b = boundary(RiskLevel::Medium, &["/home/user"]);
let secret = serde_json::json!({ "path": "/home/user/.ssh/config" });
assert!(
matches!(
crate::commands::ai::audit::path_auth::check_file_tool_auth(
"read_file", &secret, &allowed, crate::commands::ai::audit::path_auth::PathBypass::Off,
),
crate::commands::ai::audit::path_auth::FileToolAuthOutcome::Denied(_)
),
"黑名单路径恒 Denied(不进 NeedsAuth,边界判定无机会放行)"
);
}
/// 无边界零变化: PerConvState 默认 boundary=None,process_tool_calls 边界快照为 None
/// 时全部分支跳过(auto_exec_mode/trust/审批/路径授权全链路现状)。
#[test]
fn per_conv_default_boundary_is_none() {
let conv = crate::commands::ai::PerConvState::new();
assert!(conv.boundary.is_none(), "新会话默认无边界(无边界=现状逐次审批零倒退)");
}
}
+44 -14
View File
@@ -724,6 +724,14 @@ pub(crate) async fn spawn_agentic_session(
other => (other, false), other => (other, false),
}; };
// 纯发图(无文本无技能)且模型非视觉:降级后无任何有效内容,原「[图片已忽略…]」占位串
// 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端
// 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前,
// 拒绝零副作用(不落库不占生成态)。
if degraded && message.trim().is_empty() && skill.is_none() {
return Err("当前模型不支持图片,无法发送纯图片消息".to_string());
}
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话 // 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
// F-260616-09 B 批4(决策 e 真并发上线): // F-260616-09 B 批4(决策 e 真并发上线):
// - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback // - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
@@ -790,12 +798,12 @@ pub(crate) async fn spawn_agentic_session(
} else { } else {
format!("{} [图片已忽略:当前模型不支持图片]", base) format!("{} [图片已忽略:当前模型不支持图片]", base)
} }
} else if message.trim().is_empty() { } else if let Some(ref skill_name) = skill {
if let Some(ref skill_name) = skill { // 技能调用留痕(问题3):带文本的技能调用落库加 /技能名 前缀,否则技能名只在
format!("/{}", skill_name) // system prompt 注入、会话历史零痕迹(用户无从得知是否用了技能)。前缀同时是
} else { // regenerate/edit 的 backtrack_leading_skill 回溯依据(非空文本此前无前缀,
message.clone() // 重生成时回溯不到即丢技能注入——本修复顺带治好该链)。
} format!("/{} {}", skill_name, message)
} else { } else {
message.clone() message.clone()
}; };
@@ -1135,6 +1143,12 @@ pub async fn ai_approve(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id { if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string()); session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
// 写后失效(审批执行路径,第4接入点):审批通过执行成功的非只读工具改变世界状态,
// 推进该会话只读缓存失效下界,防 LLM 写后重读命中改前缓存。replace 不改消息条数,
// floor 取当前 len 正确覆盖占位位之前的全部历史;仅 completed 失效(口径同其余三段)。
if audit_status == "completed" {
crate::commands::ai::audit::mark_readonly_cache_stale(&mut session.conv(cid), &approval.tool_name);
}
} }
let ev_completed = AiChatEvent::AiToolCallCompleted { let ev_completed = AiChatEvent::AiToolCallCompleted {
@@ -1287,7 +1301,7 @@ pub async fn ai_authorize_dir(
decision: String, decision: String,
) -> Result<String, String> { ) -> Result<String, String> {
// F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug, // F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
// 避免污染 stderr(用户可见);authz_debug 文件日志保留(读 authz-debug.log 定位,排障仍可用)。 // 避免污染 stderr(用户可见)。
tracing::debug!( tracing::debug!(
tool_call_id = %tool_call_id, tool_call_id = %tool_call_id,
decision = %decision, decision = %decision,
@@ -2014,6 +2028,12 @@ pub async fn ai_chat_force_send(
other => (other, false), other => (other, false),
}; };
// 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
// 置于锁与消息 push 之前,拒绝零副作用。
if degraded && message.trim().is_empty() && skill.is_none() {
return Err("当前模型不支持图片,无法发送纯图片消息".to_string());
}
// 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。 // 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态; // stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。 // 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
@@ -2072,8 +2092,10 @@ pub async fn ai_chat_force_send(
// spawn_agent 级联取消:强制发送复位父 loop,其派生的子代理同步置停。 // spawn_agent 级联取消:强制发送复位父 loop,其派生的子代理同步置停。
// force_send 随后会把父 stop_flag 复位 false(新 loop 起跑),子循环不能依赖父 // force_send 随后会把父 stop_flag 复位 false(新 loop 起跑),子循环不能依赖父
// flag 长期置位,own stop_flag 是子循环主判据(父条目消失为兜底)。 // flag 长期置位,own stop_flag 是子循环主判据(父条目消失为兜底)。
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target, &mut session, &state.sub_agent_registry, &target,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
); );
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截—— // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
// 这是"强制"语义本身,前面已主动复位) // 这是"强制"语义本身,前面已主动复位)
@@ -2102,12 +2124,11 @@ pub async fn ai_chat_force_send(
} else { } else {
format!("{} [图片已忽略:当前模型不支持图片]", base) format!("{} [图片已忽略:当前模型不支持图片]", base)
} }
} else if message.trim().is_empty() { } else if let Some(ref skill_name) = skill {
if let Some(ref skill_name) = skill { // 技能调用留痕(同 ai_chat_send,问题3):带文本的技能调用落库加 /技能名 前缀,
format!("/{}", skill_name) // 会话历史可见技能使用痕迹 + regenerate/edit 回溯依据。非空文本此前无前缀,
} else { // 强制重发后重生成时回溯不到即丢技能注入。
message.clone() format!("/{} {}", skill_name, message)
}
} else { } else {
message.clone() message.clone()
}; };
@@ -2279,10 +2300,17 @@ pub async fn ai_chat_stop(
is_estimated: false, is_estimated: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: Some(target), conversation_id: Some(target.clone()),
pinned_goals, pinned_goals,
}; };
emit_ai_chat_event(&app, ev); emit_ai_chat_event(&app, ev);
// 安全停靠(设计 §四):挂起后取消(审批等待态用户主动停止)= 五路非正常终局之一。
// 上方 AiCompleted 收敛前端流式态,此处补 AiTaskParked 留停靠记录(挂起家族统一出口)。
crate::commands::ai::emit_task_parked(
&app, &target,
"任务已停止(审批挂起中被取消):现场已保存,重发消息或补充指令可继续",
"cancelled",
);
return Ok(()); return Ok(());
} }
// 流式生成中:置位让目标 conv 的 loop 自行收尾 // 流式生成中:置位让目标 conv 的 loop 自行收尾
@@ -2294,8 +2322,10 @@ pub async fn ai_chat_stop(
// spawn_agent 级联取消:父 loop 收到停止,其派生的子代理同步置停 // spawn_agent 级联取消:父 loop 收到停止,其派生的子代理同步置停
// (子循环停止检查点读 own stop_flag 即退出,不等挂钟超时)。 // (子循环停止检查点读 own stop_flag 即退出,不等挂钟超时)。
// 短临界区纯内存操作(无 await),不延长 session 持锁。 // 短临界区纯内存操作(无 await),不延长 session 持锁。
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target, &mut session, &state.sub_agent_registry, &target,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
); );
// 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!, // 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!,
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。 // 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
@@ -400,6 +400,21 @@ pub async fn ai_conversation_switch(
}; };
conv.pinned_goals = pinned_goals; conv.pinned_goals = pinned_goals;
} }
// 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。
// 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级
// (fail-closed:无边界=逐次审批,绝不因坏数据放大授权)。
conv.boundary = record.boundary_json.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| match serde_json::from_str::<crate::commands::ai::boundary::TaskBoundary>(s) {
Ok(b) => Some(b),
Err(e) => {
tracing::warn!(
"切换对话 {} 解析 boundary_json 失败(按无边界处理): {}",
conversation_id, e
);
None
}
});
// 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位 // 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位
if !conv_was_present { if !conv_was_present {
conv.model_override = None; conv.model_override = None;
@@ -640,6 +655,80 @@ pub async fn ai_conversation_peek(
})) }))
} }
// ============================================================
// 子代理轨迹列表(S3 完成卡备料:父会话 → 其子代理轨迹精简元数据,只读)
// ============================================================
/// 子代理轨迹精简条目(前端完成卡/轨迹树回补用;零 schema 变更,父子关系由 id 约定推导)。
#[derive(Debug, Clone, serde::Serialize)]
pub struct SubAgentRef {
pub conversation_id: String,
/// 轨迹标题(懒创建未命名时 None,前端回退显 agent_id)
pub title: Option<String>,
/// 创建时间(毫秒字符串,同 ai_conversations.created_at 口径)
pub created_at: String,
}
/// 按父会话 id 反查其派生的子代理轨迹(供 IPC 与单测共用,不依赖 AppState)。
///
/// 子代理轨迹落库 origin='sub_agent'(V44);父关系无独立列,靠 child_id 约定
/// `{parent}::agent-{n}`(agentic/sub_agent.rs next_child_id)推导:SQL LIKE 粗筛
/// (参数化绑定)+ Rust 侧 starts_with 精确校验——LIKE 的 _/% 通配语义不作判等依据,
/// 与 record.rs「禁裸 LIKE 判等」同口径。app 层直查先例:find_task_conversations。
/// 按 created_at 升序返回(轨迹时间线序)。
pub(crate) async fn find_sub_agent_conversations(
db: &df_storage::db::Database,
parent_conv_id: &str,
) -> Result<Vec<SubAgentRef>, String> {
let prefix = format!("{}::agent-", parent_conv_id);
let like = format!("{}%", prefix);
let conn = db.conn();
let guard_prefix = prefix.clone();
let rows: Vec<(String, Option<String>, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, Option<String>, String)>, String> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, title, created_at FROM ai_conversations \
WHERE origin = 'sub_agent' AND id LIKE ?1 \
ORDER BY CAST(created_at AS INTEGER) ASC",
)
.map_err(|e| e.to_string())?;
let mapped = stmt
.query_map([&like], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for r in mapped {
out.push(r.map_err(|e| e.to_string())?);
}
Ok(out)
})
.await
.map_err(|e| format!("find_sub_agent_conversations join 失败: {}", e))??;
// starts_with 精确校验:父 id 含 LIKE 通配字符时粗筛会误命中他父条目,以约定前缀为准剔除
Ok(rows
.into_iter()
.filter(|(id, _, _)| id.starts_with(&guard_prefix))
.map(|(conversation_id, title, created_at)| SubAgentRef { conversation_id, title, created_at })
.collect())
}
/// 按父会话反查子代理轨迹精简元数据(前端完成卡回补数据源,只读无副作用)。
#[tauri::command]
pub async fn ai_sub_agent_list(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<SubAgentRef>, String> {
find_sub_agent_conversations(&state.db, &conversation_id).await
}
/// 删除对话 /// 删除对话
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_delete( pub async fn ai_conversation_delete(
@@ -687,8 +776,10 @@ pub async fn ai_conversation_delete(
// spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点 // spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点
// 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。 // 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。
// 短临界区纯内存操作(无 await),不延长 session 持锁。 // 短临界区纯内存操作(无 await),不延长 session 持锁。
// Delete 形态:同锁内对 child 补 deleted_convs,出口 alive 判定放弃落库,防孤儿轨迹复活。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &conversation_id, &mut session, &state.sub_agent_registry, &conversation_id,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Delete,
); );
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。 // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
session.per_conv.remove(&conversation_id); session.per_conv.remove(&conversation_id);
@@ -805,6 +896,79 @@ pub async fn ai_update_conversation_goals(
Ok(()) Ok(())
} }
// ============================================================
// 无人值守任务边界 IPC(设计 §2.3):发起时一次拍板,存续期生效,随会话同亡。
// 无边界=现状逐次审批零倒退;set 传 None 清除=回退逐次审批(接管转人工语义)。
// ============================================================
/// 读取会话任务边界(发起卡回显/任务面板展示用)。
///
/// 内存优先(per_conv 即 live 真相),未加载(未切载/重启后未切)回退 DB 快照;
/// 两处皆无返回 None(幂等读,不报错不惰性建会话)。
#[tauri::command]
pub async fn ai_task_boundary_get(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<Option<crate::commands::ai::boundary::TaskBoundary>, String> {
{
let session = state.ai_session.lock().await;
if let Some(b) = session.conv_read(&conversation_id).and_then(|c| c.boundary.clone()) {
return Ok(Some(b));
}
}
let record = state.ai_conversations.get_by_id(&conversation_id).await.map_err(err_str)?;
Ok(record.and_then(|r| {
r.boundary_json.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| match serde_json::from_str(s) {
Ok(b) => Some(b),
Err(e) => {
tracing::warn!(
"读取对话 {} boundary_json 解析失败(按无边界返回): {}",
conversation_id, e
);
None
}
})
}))
}
/// 设置会话任务边界(发起时一次声明;中途改边界=新拍板,本 IPC 不拦重复调用——
/// 拍板点 8 修订:一次性约束的是 AI 不回头问用户,用户动自己的任务不受限)。
///
/// boundary=None 清除 → 回退逐次审批。set 即生效(per_conv 内存真相源),
/// DB 快照 best-effort 同步:对话未落库(懒创建)时 UPDATE 影响 0 行属预期,
/// 首条消息 save_conversation 首插会随行带上 boundary_json。
#[tauri::command]
pub async fn ai_task_boundary_set(
state: State<'_, AppState>,
conversation_id: String,
boundary: Option<crate::commands::ai::boundary::TaskBoundary>,
) -> Result<(), String> {
// 入口规范化:ceiling=High 钳到 Medium(拍板点 1,High 归 L3 不开放)
let normalized = boundary.map(|b| b.normalized());
{
let mut session = state.ai_session.lock().await;
session.conv(&conversation_id).boundary = normalized.clone();
}
match normalized.as_ref() {
Some(b) => {
let json = serde_json::to_string(b).map_err(|e| format!("序列化任务边界失败: {e}"))?;
if let Err(e) = state.ai_conversations.set_boundary_json(&conversation_id, Some(&json)).await {
tracing::warn!(
"写对话 {} boundary_json 失败(内存已生效,首插 save 随行补): {}",
conversation_id, e
);
}
}
None => {
// 清除:列写 NULL;未落库会话影响 0 行属预期,静默
let _ = state.ai_conversations.set_boundary_json(&conversation_id, None).await;
}
}
Ok(())
}
/// 导出对话为指定格式对话导出) /// 导出对话为指定格式对话导出)
/// ///
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) /// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
@@ -1271,4 +1435,69 @@ mod tests {
assert_eq!(got[1].id, "m_new"); assert_eq!(got[1].id, "m_new");
assert_eq!(got[1].content, "压缩后新提问"); assert_eq!(got[1].content, "压缩后新提问");
} }
/// ai_sub_agent_list 数据源:只列本父会话派生的子代理(origin='sub_agent' + id
/// 前缀约定),按 created_at 升序;LIKE 通配误命中与无子代理父不混入。
#[tokio::test]
async fn sub_agent_list_scopes_to_parent_prefix() {
use df_storage::crud::AiConversationRepo;
use df_storage::db::Database;
use df_storage::models::AiConversationRecord;
async fn insert_conv(
db: &Database,
id: &str,
title: Option<&str>,
origin: Option<&str>,
created_at: &str,
) {
AiConversationRepo::new(db)
.insert(AiConversationRecord {
id: id.to_string(),
title: title.map(|s| s.to_string()),
messages: "[]".to_string(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: created_at.to_string(),
updated_at: created_at.to_string(),
origin: origin.map(|s| s.to_string()),
boundary_json: None,
})
.await
.expect("测试会话插入应成功");
}
let db = Database::open_in_memory().await.expect("open_in_memory");
insert_conv(&db, "conv-p", Some("父会话"), None, "100").await;
insert_conv(&db, "conv-p::agent-1", Some("子一"), Some("sub_agent"), "300").await;
insert_conv(&db, "conv-p::agent-2", None, Some("sub_agent"), "200").await;
// 他父的子代理 / origin 形态不符 id 前缀的子代理行:均不得混入
insert_conv(&db, "conv-q::agent-1", Some("他父子"), Some("sub_agent"), "400").await;
insert_conv(&db, "conv-p::notagent", Some("形态不符"), Some("sub_agent"), "500").await;
// 父 id 含 LIKE 下划线通配:粗筛会误命中他父条目,须被 starts_with 精确校验剔除
insert_conv(&db, "p_1::agent-1", Some("真子"), Some("sub_agent"), "600").await;
insert_conv(&db, "pX1::agent-9", Some("通配误命中"), Some("sub_agent"), "700").await;
let out = find_sub_agent_conversations(&db, "conv-p").await.unwrap();
assert_eq!(out.len(), 2, "只列 conv-p 派生的子代理: {:?}", out);
assert_eq!(out[0].conversation_id, "conv-p::agent-2", "created_at 升序");
assert!(out[0].title.is_none());
assert_eq!(out[1].conversation_id, "conv-p::agent-1");
assert_eq!(out[1].title.as_deref(), Some("子一"));
assert_eq!(out[1].created_at, "300");
let out2 = find_sub_agent_conversations(&db, "p_1").await.unwrap();
assert_eq!(out2.len(), 1, "LIKE 下划线通配的误命中须被精确校验剔除: {:?}", out2);
assert_eq!(out2[0].conversation_id, "p_1::agent-1");
// 无子代理的父返空
assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty());
}
} }
+10 -1
View File
@@ -322,7 +322,8 @@ fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
/// 绝不进日志/返回值/错误信息) /// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调 /// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度) /// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig> /// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>;
/// probed 为空(远端空列表)时跳过写回,返回空列表(防清空用户模型池)
/// ///
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。 /// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
#[tauri::command] #[tauri::command]
@@ -345,6 +346,14 @@ pub async fn ai_fetch_models(
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// 守卫:远端 200 + 空列表(或探测结果全被过滤)时 probed 为空——若照常 merge 后
// update_full,空 merged 会覆盖 model_configs,用户模型池连同 weight/enabled/手动
// 标注静默清空。跳过写回保留现有配置,返回空列表作提示信号(前端据 length==0
// 提示「拉取到 0 个模型,已保留现有配置」)。对照 ai_test_connection 不写库无此问题。
if probed.is_empty() {
return Ok(Vec::new());
}
// 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/ // 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/
// context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。 // context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。
// 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router // 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router
+74 -12
View File
@@ -171,6 +171,18 @@ pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart])
} }
} }
/// save 元数据快照(P3-5):子代理出口落库显式携带父会话的 provider_id/created_at。
///
/// save 内部默认读 `session.active_*` 决定首插的 provider_id/created_at——这对主会话
/// 正确,对子代理错误:子代理后台长跑收尾时用户可能已切走会话,active 值已是别的会话的,
/// 子轨迹会继承错元数据。spawn 时刻快照、出口显式传入即可钉住。
/// 主会话路径不经过本类型(save_conversation 传 None),读法与历史版本一致。
#[derive(Debug, Clone, Default)]
pub(crate) struct SaveMetaOverride {
pub(crate) provider_id: Option<String>,
pub(crate) created_at: Option<String>,
}
/// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响) /// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响)
/// ///
/// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 + /// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 +
@@ -190,13 +202,40 @@ pub(crate) async fn save_conversation(
model: Option<&str>, model: Option<&str>,
touch_updated_at: bool, touch_updated_at: bool,
) { ) {
// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。 save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, None).await;
// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。 }
// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。 /// 子代理出口落库入口(P3-5):显式携带父会话 save 元数据快照的 save 变体。
/// 与 save_conversation 共用同一落库实现,仅首插的 provider_id/created_at 来源不同。
pub(crate) async fn save_conversation_meta(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>,
touch_updated_at: bool,
meta: SaveMetaOverride,
) {
save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, Some(meta)).await;
}
/// save 统一实现(meta=None 即主会话既有行为,Some 即显式元数据覆盖)。
/// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。
/// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。
/// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
/// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
async fn save_with_meta(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>,
touch_updated_at: bool,
meta: Option<SaveMetaOverride>,
) {
if let Err(_elapsed) = tokio::time::timeout( if let Err(_elapsed) = tokio::time::timeout(
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS), Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at), save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at, meta),
) )
.await .await
{ {
@@ -216,16 +255,17 @@ async fn save_conversation_serialized(
usage: Option<&df_ai::provider::TokenUsage>, usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>, model: Option<&str>,
touch_updated_at: bool, touch_updated_at: bool,
meta: Option<SaveMetaOverride>,
) { ) {
let save_lock = { let save_lock = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
session.save_lock(conv_id) session.save_lock(conv_id)
}; };
let _save_guard = save_lock.lock().await; let _save_guard = save_lock.lock().await;
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at).await; save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at, meta).await;
} }
/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。 /// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。
async fn save_conversation_inner( async fn save_conversation_inner(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>, db: &Arc<Database>,
@@ -233,6 +273,7 @@ async fn save_conversation_inner(
usage: Option<&df_ai::provider::TokenUsage>, usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>, model: Option<&str>,
touch_updated_at: bool, touch_updated_at: bool,
meta: Option<SaveMetaOverride>,
) { ) {
// 取 messages + 懒创建首次落库所需的 provider_id/created_at // 取 messages + 懒创建首次落库所需的 provider_id/created_at
// 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段 // 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
@@ -253,7 +294,7 @@ async fn save_conversation_inner(
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」, // BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。 // 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin) = { let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -268,8 +309,15 @@ async fn save_conversation_inner(
let __wait = __lock_start.elapsed(); let __wait = __lock_start.elapsed();
// 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow, // 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let provider_id = session.active_provider_id.clone(); // P3-5:显式快照优先(SubAgent 出口落库用),否则读 active_*(主会话历史读法不变)。
let created_at = session.active_conv_created_at.clone(); let provider_id = match &meta {
Some(m) => m.provider_id.clone(),
None => session.active_provider_id.clone(),
};
let created_at = match &meta {
Some(m) => m.created_at.clone(),
None => session.active_conv_created_at.clone(),
};
// 会话来源标记(V44 子轨迹落库):子代理 conv 首插时随 record 落 origin 列,主会话 None // 会话来源标记(V44 子轨迹落库):子代理 conv 首插时随 record 落 origin 列,主会话 None
let conv_origin = session.conv_origins.get(conv_id).cloned(); let conv_origin = session.conv_origins.get(conv_id).cloned();
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
@@ -284,6 +332,8 @@ async fn save_conversation_inner(
conv.messages.all_messages_clone() conv.messages.all_messages_clone()
}; };
let pinned_goals = conv.pinned_goals.clone(); let pinned_goals = conv.pinned_goals.clone();
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
let conv_boundary = conv.boundary.clone();
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
( (
provider_id, provider_id,
@@ -294,6 +344,7 @@ async fn save_conversation_inner(
needs_full_rewrite, needs_full_rewrite,
cloned, cloned,
conv_origin, conv_origin,
conv_boundary,
) )
}; };
let __lock_total = __lock_start.elapsed(); let __lock_total = __lock_start.elapsed();
@@ -403,6 +454,10 @@ async fn save_conversation_inner(
rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())); rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
// 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照 // 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
rec.pending_approvals = Some(pending_approvals_json.clone()); rec.pending_approvals = Some(pending_approvals_json.clone());
// 无人值守任务边界随行持久化:None(未声明)保持 NULL,不写 "null" 脏串
rec.boundary_json = conv_boundary.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_default())
.filter(|s| !s.is_empty());
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await { if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
@@ -473,6 +528,9 @@ async fn save_conversation_inner(
created_at: conv_created.clone(), created_at: conv_created.clone(),
updated_at: now, updated_at: now,
origin: conv_origin, origin: conv_origin,
boundary_json: conv_boundary.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_default())
.filter(|s| !s.is_empty()),
}; };
if let Err(e) = conv_repo.insert(rec).await { if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}"); tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -492,10 +550,14 @@ async fn save_conversation_inner(
// 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE // 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE
// 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。 // 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。
// 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用 // 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用
// max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。 // max 防回退;dirty 只在全量重写时清(增量/无变化路径写库期间新置的 dirty 如压缩 mark,
// 与本次 append 写库无关,误清会致改动滞留内存永不落库)。
if msgs_written_ok { if msgs_written_ok {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
session.conv(conv_id).messages.advance_persisted_count(total_len); session
.conv(conv_id)
.messages
.advance_persisted_count(total_len, needs_full_rewrite);
} }
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建) // 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
+176 -13
View File
@@ -29,6 +29,8 @@
pub mod agentic; pub mod agentic;
pub mod augmentation; pub mod augmentation;
pub mod audit; pub mod audit;
// 无人值守任务边界(TaskBoundary):结构 + 纯判定函数 + 单测(设计 §二/§三)
pub mod boundary;
pub mod code_intel; pub mod code_intel;
pub mod command_stream; pub mod command_stream;
pub mod commands; pub mod commands;
@@ -236,6 +238,9 @@ pub enum AiChatEvent {
/// - `status`:"completed" | "truncated" | "failed" /// - `status`:"completed" | "truncated" | "failed"
/// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0) /// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0)
/// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝 /// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝
/// - `stop_kind`:停止类别(snake_case 字符串,为前端备料):"cancelled"(用户停止级联)
/// | "wall_clock" | "max_rounds" | "token_budget" | "empty_convergence" | "error"
/// | "converged"(正常收敛);None=未分类(防御,当前所有路径均有类别)
AiAgentFinished { AiAgentFinished {
agent_id: String, agent_id: String,
conversation_id: Option<String>, conversation_id: Option<String>,
@@ -246,6 +251,9 @@ pub enum AiChatEvent {
/// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。 /// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。
/// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。 /// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。
rejected_tools: Option<Vec<String>>, rejected_tools: Option<Vec<String>>,
/// 停止类别(为前端备料):"cancelled" | "wall_clock" | "max_rounds" | "token_budget"
/// | "empty_convergence" | "error" | "converged"。前端不消费该字段也不破坏。
stop_kind: Option<String>,
}, },
/// 流式调用自动重试提示流前失败重试中) /// 流式调用自动重试提示流前失败重试中)
/// ///
@@ -425,6 +433,34 @@ pub enum AiChatEvent {
resolved_by: String, resolved_by: String,
conversation_id: Option<String>, conversation_id: Option<String>,
}, },
/// 无人值守安全停靠(设计 §四,挂起家族终局):五路非正常终局(审批超时取消/挂起后
/// 取消/熔断/预算耗尽/Fatal)统一出口。语义:现场已保存、generating 已复位(非死等)、
/// reason 即待办清单(停止原因+已拒绝操作提示),前端操作卡据此提供恢复入口
/// (既有 ai_chat_force_send/重发语义)。
///
/// 前端契约(钉死):事件名 `AiTaskParked`,字段
/// `{ conversation_id: string, reason: string, stop_kind?: string }`。
/// stop_kind 复用 AiAgentFinished 词表扩展: "approval_timeout" | "cancelled"
/// | "circuit_breaker" | "stall_breaker" | "max_rounds" | "fatal"。
AiTaskParked {
conversation_id: Option<String>,
reason: String,
stop_kind: Option<String>,
},
/// 无人值守进度可观测(设计 §4.3):主 loop 每 PROGRESS_EVERY_N_ROUNDS 轮 emit 一次,
/// 长任务静默期(子代理长跑/大工具执行/重入退避)前端可判断「在干活还是死了」。
/// 事件即逝不落库,终局(AiCompleted/AiTaskParked 等)不补发。
///
/// 前端契约(钉死):事件名 `AiTaskProgress`,字段
/// `{ conversation_id: string, round: number, max_rounds: number, tokens_used: number }`。
/// round 1 起算(对齐 AiAgentRound 口径);max_rounds=0 表示不限轮次,
/// tokens_used 为本 loop 累计 token。
AiTaskProgress {
conversation_id: Option<String>,
round: u32,
max_rounds: u32,
tokens_used: u32,
},
} }
/// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。 /// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。
@@ -662,19 +698,6 @@ pub struct AiSession {
} }
impl AiSession { impl AiSession {
pub fn new() -> Self {
Self {
active_provider_id: None,
active_conversation_id: None,
active_conv_created_at: None,
pending_approvals: HashMap::new(),
per_conv: HashMap::new(),
deleted_convs: HashSet::new(),
conv_origins: HashMap::new(),
save_locks: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
}
}
/// 推导指定会话读侧状态视图(只读,不写任何字段)。 /// 推导指定会话读侧状态视图(只读,不写任何字段)。
/// ///
@@ -738,6 +761,25 @@ impl AiSession {
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone() .clone()
} }
/// 新建顶层会话容器(单例字段全默认:无活跃对话/无挂起审批/per_conv 空)。
///
/// B 批4 后本结构仅剩容器字段(单会话态全部下沉 per_conv),构造极简;
/// 历史单例字段(messages/stop_flag/...)的初值语义由 [`PerConvState::new`] 承接
/// (调用方 session.conv(cid) 惰性创建时使用)。
pub fn new() -> Self {
Self {
active_provider_id: None,
active_conversation_id: None,
active_conv_created_at: None,
pending_approvals: HashMap::new(),
per_conv: HashMap::new(),
deleted_convs: HashSet::new(),
conv_origins: HashMap::new(),
save_locks: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1031,6 +1073,30 @@ pub struct PerConvState {
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后 /// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。 /// 位置漂移,内容不变)。随会话销毁不落库。
pub tool_view_decisions: std::collections::HashMap<u64, bool>, pub tool_view_decisions: std::collections::HashMap<u64, bool>,
/// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。
/// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
/// 子代理 per_conv 独立新建恒 None,不受父边界影响(拍板点 6 fail-closed)。
pub boundary: Option<boundary::TaskBoundary>,
/// 只读工具结果缓存的「写后失效」下界(消息索引,纯内存态,不落库)。
///
/// 治「只读结果缓存无写失效」缺陷:find_cached_readonly_result 通过反扫历史 messages
/// 命中旧 tool_result 回填缓存,但历史里 write_file/run_command 等写工具一旦执行过,
/// 其之前的只读结果(read_file/grep/list_directory)已可能过期——写后 LLM 重读会拿到
/// 改前内容,缓存把脏数据喂回 LLM 致其基于旧状态决策。
///
/// 语义:非只读工具(`is_readonly(name)==false`)执行成功回填后,本字段被推到当时
/// `messages.len()`(即该写结果 push 之后的位置)。缓存命中扫描仅认 `idx >= floor`
/// 的历史调用——写在前的只读结果全部失效(过界即脏),写在后的(写后新读)有效。
///
/// 脏失效范围必然覆盖「写工具影响的任何路径」:floor 是全量下界,不区分写的是哪个
/// 文件(精确按路径失效需解析工具语义,过度工程;宁可多失效少喂脏——失效只是
/// 多一次真执行,正确性无损)。
///
/// 索引漂移防御:floor 在锁内与 messages 同步读写;pop_last_assistant_round /
/// clear 会缩短 messages,此时 floor 若 > len 用 `min` 钳制(等于全失效后重新积累,
/// 保守方向安全)。floor 单调不减(取 max),避免写后多轮回退。
pub readonly_cache_floor: usize,
} }
/// 单个目标记录(含状态追踪)。 /// 单个目标记录(含状态追踪)。
@@ -1041,6 +1107,73 @@ pub struct GoalEntry {
pub status: GoalStatus, pub status: GoalStatus,
} }
#[cfg(test)]
mod tests_task_parked {
use super::*;
/// 停靠事件 serde 契约(前端钉死):事件名 type=AiTaskParked,字段
/// {conversation_id: string, reason: string, stop_kind?: string}。
/// 前端下批消费,本测试锁死后端不悄悄改协议。
#[test]
fn ai_task_parked_event_serde_contract() {
let ev = AiChatEvent::AiTaskParked {
conversation_id: Some("conv-1".to_string()),
reason: "审批超时(15分钟)自动取消,任务已安全停靠".to_string(),
stop_kind: Some("approval_timeout".to_string()),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "AiTaskParked", "事件名(前端契约)");
assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string");
assert_eq!(v["reason"], "审批超时(15分钟)自动取消,任务已安全停靠");
assert_eq!(v["stop_kind"], "approval_timeout");
// stop_kind 可选语义:None → null(前端 stop_kind? 可选消费)
let ev2 = AiChatEvent::AiTaskParked {
conversation_id: Some("c".to_string()),
reason: "r".to_string(),
stop_kind: None,
};
let v2 = serde_json::to_value(&ev2).unwrap();
assert!(v2["stop_kind"].is_null(), "stop_kind 缺省序列化为 null(可选字段)");
}
/// stop_kind 词表锁定(六路非正常终局):前端操作卡/图标按此分派,新增值须同步前端契约。
#[test]
fn ai_task_parked_stop_kind_vocabulary() {
const KINDS: [&str; 7] = [
"approval_timeout", "cancelled", "circuit_breaker",
"stall_breaker", "max_rounds", "fatal", "midstream",
];
for k in KINDS {
let ev = AiChatEvent::AiTaskParked {
conversation_id: Some("c".to_string()),
reason: "r".to_string(),
stop_kind: Some(k.to_string()),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["stop_kind"], k);
}
}
/// 进度事件 serde 契约(前端钉死):事件名 type=AiTaskProgress,字段
/// {conversation_id: string, round: number, max_rounds: number, tokens_used: number}。
/// max_rounds=0 语义=不限轮次(前端进度条按无上限处理),数值类型本测试锁死。
#[test]
fn ai_task_progress_event_serde_contract() {
let ev = AiChatEvent::AiTaskProgress {
conversation_id: Some("conv-1".to_string()),
round: 6,
max_rounds: 20,
tokens_used: 12345,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "AiTaskProgress", "事件名(前端契约)");
assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string");
assert_eq!(v["round"], 6, "round 序列化为 number(1 起算)");
assert_eq!(v["max_rounds"], 20, "max_rounds 序列化为 number(0=不限)");
assert_eq!(v["tokens_used"], 12345, "tokens_used 序列化为 number(loop 累计)");
}
}
/// 目标状态。 /// 目标状态。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -1096,6 +1229,8 @@ impl PerConvState {
workflow_dag_summary: None, workflow_dag_summary: None,
frozen_tool_names: None, frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(), tool_view_decisions: std::collections::HashMap::new(),
boundary: None,
readonly_cache_floor: 0,
} }
} }
} }
@@ -1253,3 +1388,31 @@ pub(crate) async fn execute_run_workflow_for_tool(
"note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。" "note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。"
})) }))
} }
// ============================================================
// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一事件+通知单点
// ============================================================
/// 发停靠事件 AiTaskParked + NeedAction 系统通知(托盘场景补位,复用 notify 基建)。
///
/// 调用方职责(本函数不做,保持单一只读语义):调用前完成「保存现场(落库)+
/// 不保持 generating(conv_state 复位)」两步——各终局路径现状已有
/// (teardown_round/stream_relay Fatal/ai_chat_stop),审批超时路径由本批补齐。
/// reason 即待办清单:把停止原因与恢复指引写成人话,前端操作卡直接展示。
pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &str, stop_kind: &str) {
let ev = AiChatEvent::AiTaskParked {
conversation_id: Some(conv_id.to_string()),
reason: reason.to_string(),
stop_kind: Some(stop_kind.to_string()),
};
event_bus::emit_ai_chat_event(app, ev);
// 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组
// (主窗聚焦跳过 + 5s 节流,重复终局不会刷屏)。
notify::notify_session_event(
app,
notify::NotifyKind::NeedAction,
conv_id,
"任务已停靠",
reason,
);
}
+124 -24
View File
@@ -81,10 +81,14 @@ fn env_profile_date_volatile() -> String {
format!("今天是 {today}") format!("今天是 {today}")
} }
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题) /// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)。
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) { ///
/// prefix 内的 read_symbol 优先与重复调用检测两条经 [`read_symbol_guidance`] /
/// [`duplicate_call_guidance`] 注入(与子代理工具纪律段同源,零复制)。
fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
match lang { match lang {
"en" => ( "en" => (
format!(
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\ "You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
Please respond in English.\n\n\ Please respond in English.\n\n\
## Capabilities\n\ ## Capabilities\n\
@@ -92,7 +96,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- Create/query projects, tasks, and ideas\n\ - Create/query projects, tasks, and ideas\n\
- Run workflows\n\ - Run workflows\n\
- Read file contents, list directories, create/write files\n\ - Read file contents, list directories, create/write files\n\
- To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found\n\ - {}\n\
- Get file metadata (size, line count, binary detection)\n\ - Get file metadata (size, line count, binary detection)\n\
- Append content to files, search files by name pattern\n\n\ - Append content to files, search files by name pattern\n\n\
## Guidelines\n\ ## Guidelines\n\
@@ -101,16 +105,20 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- Prefer using tools to complete actions rather than just describing steps\n\ - Prefer using tools to complete actions rather than just describing steps\n\
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\ - When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\ - **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
- **Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing\n\ - {}\n\
## Focus\n\ ## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
- Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\ - Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n", - Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
),
"\n## Current Projects\n", "\n## Current Projects\n",
"\n## Current Tasks\n", "\n## Current Tasks\n",
), ),
_ => ( _ => (
format!(
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\ "你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\ 必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
## 当前能力\n\ ## 当前能力\n\
@@ -118,7 +126,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 创建/查询项目、任务、灵感\n\ - 创建/查询项目、任务、灵感\n\
- 运行工作流\n\ - 运行工作流\n\
- 读取文件内容、列出目录、创建/写入文件\n\ - 读取文件内容、列出目录、创建/写入文件\n\
- 理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file\n\ - {}\n\
- 获取文件元信息(大小、行数、二进制检测)\n\ - 获取文件元信息(大小、行数、二进制检测)\n\
- 追加写入文件、按名称模式搜索文件\n\n\ - 追加写入文件、按名称模式搜索文件\n\n\
## 行为准则\n\ ## 行为准则\n\
@@ -126,12 +134,15 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 如果不确定用户意图,先提问\n\ - 如果不确定用户意图,先提问\n\
- 优先使用工具完成操作,而不是只描述步骤\n\ - 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\ - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
- **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\ - {}\n\
## 聚焦准则\n\ ## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\ - 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n", - 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
),
"\n## 当前项目\n", "\n## 当前项目\n",
"\n## 当前任务\n", "\n## 当前任务\n",
), ),
@@ -153,7 +164,56 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
/// AI 看到 hint 即自主提示用户「装了能 JS 渲染 SPA/反爬」,征得同意后用 run_command 帮装。 /// AI 看到 hint 即自主提示用户「装了能 JS 渲染 SPA/反爬」,征得同意后用 run_command 帮装。
/// ///
/// 仅段加在 prefix 末尾(聚焦段后),不破坏现有结构。 /// 仅段加在 prefix 末尾(聚焦段后),不破坏现有结构。
fn doc_exploration_strategy_section(lang: &str) -> &'static str { ///
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
fn doc_exploration_strategy_section(lang: &str) -> String {
format!(
"{}{}",
doc_strategy_core_section(lang),
doc_strategy_obscura_install_section(lang)
)
}
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
fn read_symbol_guidance(lang: &str) -> &'static str {
match lang {
"en" => "To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found",
_ => "理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file",
}
}
/// 重复调用检测指引(中/英,不含行首 "- ")。同源拼接见 read_symbol_guidance。
fn duplicate_call_guidance(lang: &str) -> &'static str {
match lang {
"en" => "**Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing",
_ => "**重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行",
}
}
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
format!(
"\n## 工具纪律\n\
- {}\n\
- {}\n{}{}",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
doc_strategy_core_section(lang),
app_config_query_guidance_section(lang),
)
}
/// 文档探索策略核心段(中/英):URL 三工具分工 + 嗅探→识别→提取→验证→应用流程 +
/// 禁臆造 URL + render=true 失败重试铁律。父会话与子代理同源拼接块。
fn doc_strategy_core_section(lang: &str) -> &'static str {
match lang { match lang {
"en" => "\n\ "en" => "\n\
## Documentation Exploration Strategy\n\ ## Documentation Exploration Strategy\n\
@@ -189,17 +249,7 @@ fn doc_exploration_strategy_section(lang: &str) -> &'static str {
1. **Switch after 2 failures**: if `render=true` fails twice in a row (any failure signal) → **immediately stop retrying that URL**. Pick an alternative: (a) use `http_request` to hit the API endpoint directly; (b) ask the user to provide content/screenshots manually; (c) work with the data you already have and produce output now — don't wait for a perfect render.\n\ 1. **Switch after 2 failures**: if `render=true` fails twice in a row (any failure signal) → **immediately stop retrying that URL**. Pick an alternative: (a) use `http_request` to hit the API endpoint directly; (b) ask the user to provide content/screenshots manually; (c) work with the data you already have and produce output now — don't wait for a perfect render.\n\
2. **Stop after the first success**: if the first `render=true` succeeds and returns markdown → **use the data you have immediately**; do NOT re-render the same URL to top up more content (what you got is what you use).\n\ 2. **Stop after the first success**: if the first `render=true` succeeds and returns markdown → **use the data you have immediately**; do NOT re-render the same URL to top up more content (what you got is what you use).\n\
3. **Never re-render the same URL**: once a URL's render result (success or failure) is known, never fire render=true at it again (history/cache is enough); for new content, switch URL or switch tactic.\n\ 3. **Never re-render the same URL**: once a URL's render result (success or failure) is known, never fire render=true at it again (history/cache is enough); for new content, switch URL or switch tactic.\n\
\n\ \n",
### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
_ => "\n\ _ => "\n\
## 文档探索策略\n\ ## 文档探索策略\n\
### 先选对 URL 工具(三者别混用)\n\ ### 先选对 URL 工具(三者别混用)\n\
@@ -234,8 +284,28 @@ fn doc_exploration_strategy_section(lang: &str) -> &'static str {
1. **失败 2 次必换方案**:`render=true` 连续 2 次失败(任意失败信号) → **立即停止重试同一 URL**,改走以下任一:(a) 改用 `http_request` 直接打 API 端点;(b) 提示用户手动提供内容/截图;(c) 用已拿到的数据先整理输出,别等完美渲染。\n\ 1. **失败 2 次必换方案**:`render=true` 连续 2 次失败(任意失败信号) → **立即停止重试同一 URL**,改走以下任一:(a) 改用 `http_request` 直接打 API 端点;(b) 提示用户手动提供内容/截图;(c) 用已拿到的数据先整理输出,别等完美渲染。\n\
2. **第 1 次成功即收手**:`render=true` 第 1 次成功拿到 markdown → **立即用已有数据整理输出**,不要重复 render 同 URL 去「补充」更多内容(已拿即用)。\n\ 2. **第 1 次成功即收手**:`render=true` 第 1 次成功拿到 markdown → **立即用已有数据整理输出**,不要重复 render 同 URL 去「补充」更多内容(已拿即用)。\n\
3. **同 URL 不重复 render=true**:同一 URL 的 render 结果(成功或失败)已知,后续不再对它重复发 render=true(缓存/历史即用);需要新内容请换 URL 或换方案。\n\ 3. **同 URL 不重复 render=true**:同一 URL 的 render 结果(成功或失败)已知,后续不再对它重复发 render=true(缓存/历史即用);需要新内容请换 URL 或换方案。\n\
\n\ \n",
### JS 渲染文档与 obscura 安装引导\n\ }
}
/// obscura 安装引导段(中/英,父侧专用):涉用户交互(征得同意帮装)与 run_command 高风险
/// 工具,子代理无用户交互面,不进子代理工具纪律段([`sub_agent_tool_discipline_section`])。
/// 与核心段拼接 = 原 [`doc_exploration_strategy_section`] 完整段(逐字节等价)。
fn doc_strategy_obscura_install_section(lang: &str) -> &'static str {
match lang {
// 注:核心段末尾已保留空行(…tactic.\n\n / …换方案。\n\n),本段自标题起拼,
// 拼接 = 原完整段逐字节等价,不再补前导换行。
"en" => "### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
_ => "### JS 渲染文档与 obscura 安装引导\n\
`fetch_url` 默认走静态抓取(不跑 JS)。遇到 SPA / 反爬 / JS 渲染的文档,用户(或你)可传 `render=true`。当 obscura(JS 渲染后端,github.com/h4ckf0r0day/obscura)**未安装**时,结果会静默回退静态,返回的 JSON 带 `render_mode: \"static_fallback\"` 和一个指向安装页的 `hint` 字段。请**重点看 `hint` 字段**(而非只看 `render_mode`)——它就是安装信号:\n\ `fetch_url` 默认走静态抓取(不跑 JS)。遇到 SPA / 反爬 / JS 渲染的文档,用户(或你)可传 `render=true`。当 obscura(JS 渲染后端,github.com/h4ckf0r0day/obscura)**未安装**时,结果会静默回退静态,返回的 JSON 带 `render_mode: \"static_fallback\"` 和一个指向安装页的 `hint` 字段。请**重点看 `hint` 字段**(而非只看 `render_mode`)——它就是安装信号:\n\
1. **察觉**:若 `fetch_url` 结果带 `hint`(或 `render_mode` 为 `static_fallback`),说明 obscura 缺失,页面可能没渲染完整。\n\ 1. **察觉**:若 `fetch_url` 结果带 `hint`(或 `render_mode` 为 `static_fallback`),说明 obscura 缺失,页面可能没渲染完整。\n\
2. **提议**:主动告诉用户——「obscura 未安装,装了之后 fetch_url 就能渲染 JS 重的 SPA / 反爬文档。要我帮你装吗?」当用户明显需要 JS 渲染时,不要默默用静态回退糊弄过去。\n\ 2. **提议**:主动告诉用户——「obscura 未安装,装了之后 fetch_url 就能渲染 JS 重的 SPA / 反爬文档。要我帮你装吗?」当用户明显需要 JS 渲染时,不要默默用静态回退糊弄过去。\n\
@@ -412,9 +482,9 @@ pub(crate) async fn build_system_prompt_with_excluded(
} }
let (prefix, _, _) = system_prompt_parts(lang); let (prefix, _, _) = system_prompt_parts(lang);
let mut prompt = env_profile_line(); let mut prompt = env_profile_line();
prompt.push_str(prefix); prompt.push_str(&prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试) // 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
prompt.push_str(doc_exploration_strategy_section(lang)); prompt.push_str(&doc_exploration_strategy_section(lang));
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败) // 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang)); prompt.push_str(app_config_query_guidance_section(lang));
@@ -556,8 +626,8 @@ pub(crate) async fn build_system_prompt_parts(
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。 // 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
let (prefix, _, _) = system_prompt_parts(lang); let (prefix, _, _) = system_prompt_parts(lang);
let mut stable = env_profile_stable_body(); let mut stable = env_profile_stable_body();
stable.push_str(prefix); stable.push_str(&prefix);
stable.push_str(doc_exploration_strategy_section(lang)); stable.push_str(&doc_exploration_strategy_section(lang));
stable.push_str(app_config_query_guidance_section(lang)); stable.push_str(app_config_query_guidance_section(lang));
stable.push_str(&build_custom_section(state).await); stable.push_str(&build_custom_section(state).await);
@@ -847,6 +917,36 @@ mod tests {
assert!(en.contains("Do **NOT**")); assert!(en.contains("Do **NOT**"));
} }
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
#[test]
fn sub_agent_tool_discipline_section_shares_parent_text() {
let zh = sub_agent_tool_discipline_section("zh-CN");
assert!(zh.contains("## 工具纪律"));
assert!(zh.contains(read_symbol_guidance("zh-CN")), "read_symbol 指引须与父会话同源");
assert!(zh.contains(duplicate_call_guidance("zh-CN")), "重复调用检测须与父会话同源");
assert!(zh.contains("## 文档探索策略"), "URL 纪律核心段须注入");
assert!(zh.contains("严禁臆造 URL"));
assert!(zh.contains("失败 2 次必换方案"));
assert!(zh.contains("get_app_config"));
assert!(
!zh.contains("要我帮你装吗"),
"obscura 安装引导为用户交互条款,子代理不得注入: {}",
zh
);
assert!(!zh.contains("## 聚焦准则"), "会话面准则不进子代理");
// 父会话 prefix 与共享函数同字同源(DRY 锁:改共享函数父侧同步生效)
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(prefix.contains(read_symbol_guidance("zh-CN")));
assert!(prefix.contains(duplicate_call_guidance("zh-CN")));
// 父侧完整文档探索段 = 核心 + obscura 引导(拆分不丢段)
let parent_doc = doc_exploration_strategy_section("zh-CN");
assert!(parent_doc.contains("失败 2 次必换方案"));
assert!(parent_doc.contains("要我帮你装吗"));
}
// 项目/任务清单注明语(中/英)存在 // 项目/任务清单注明语(中/英)存在
#[test] #[test]
fn listed_notes_present_zh_en() { fn listed_notes_present_zh_en() {
+1 -1
View File
@@ -51,7 +51,7 @@
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, State};
use crate::state::AppState; use crate::state::AppState;
+11
View File
@@ -102,6 +102,9 @@ pub(crate) enum StreamResult {
usage: df_ai::provider::TokenUsage, usage: df_ai::provider::TokenUsage,
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求) /// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
reasoning_content: Option<String>, reasoning_content: Option<String>,
/// 流因 provider max_tokens 上限截断(finish_reason=length / stop_reason=max_tokens)。
/// true=文本/tool_calls 可能不完整,调用方据此标记该轮产出截断,不冒充正常完成。
truncated_by_max_tokens: bool,
}, },
/// 流中途断(MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text /// 流中途断(MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text
/// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积), /// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积),
@@ -282,6 +285,9 @@ pub(crate) async fn stream_llm(
let mut finished_received = false; let mut finished_received = false;
let mut stopped = false; let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None; let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// max_tokens 截断标志跨帧 OR 累积(openai finish_reason=length / anthropic
// stop_reason=max_tokens 的帧先于终态帧到达),Complete 随旗标带出供上层标记截断。
let mut truncated_by_max_tokens = false;
// DeepSeek thinking 模式推理内容累积(多轮需回传) // DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = None; let mut reasoning_content_acc: Option<String> = None;
// per-conv delta 合批累加器。pending_delta 累积未 flush 的文本, // per-conv delta 合批累加器。pending_delta 累积未 flush 的文本,
@@ -419,6 +425,9 @@ pub(crate) async fn stream_llm(
if let Some(u) = &chunk.usage { if let Some(u) = &chunk.usage {
final_usage = Some(u.clone()); final_usage = Some(u.clone());
} }
if chunk.truncated_by_max_tokens {
truncated_by_max_tokens = true;
}
if let Some(ref rc) = chunk.reasoning_content { if let Some(ref rc) = chunk.reasoning_content {
reasoning_content_acc.get_or_insert_with(String::new).push_str(rc); reasoning_content_acc.get_or_insert_with(String::new).push_str(rc);
} }
@@ -540,6 +549,7 @@ pub(crate) async fn stream_llm(
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(), usage: final_usage.unwrap_or_default(),
reasoning_content: reasoning_content_acc, reasoning_content: reasoning_content_acc,
truncated_by_max_tokens,
}; };
} }
@@ -573,6 +583,7 @@ pub(crate) async fn stream_llm(
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(), usage: final_usage.unwrap_or_default(),
reasoning_content: reasoning_content_acc, reasoning_content: reasoning_content_acc,
truncated_by_max_tokens,
} }
} }
+6 -2
View File
@@ -156,9 +156,13 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
let mut patch_args = vec!["diff"]; let mut patch_args = vec!["diff"];
if staged { patch_args.push("--cached"); } if staged { patch_args.push("--cached"); }
let patch_raw = exec_git_exit(working_dir, &patch_args).await.0; let patch_raw = exec_git_exit(working_dir, &patch_args).await.0;
// 截断到 8000 字符(防大体量 diff) // 截断到 8000 字符(防大体量 diff);中文字节切片撞 UTF-8 边界会 panic,回退到字符边界
let patch_truncated = if patch_raw.len() > 8000 { let patch_truncated = if patch_raw.len() > 8000 {
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len()) let mut end = 8000;
while !patch_raw.is_char_boundary(end) {
end -= 1;
}
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..end], patch_raw.len())
} else { } else {
patch_raw patch_raw
}; };
+16 -1
View File
@@ -32,6 +32,7 @@ use crate::commands::ai::agentic::sub_agent::{
SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP, SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP,
}; };
use crate::commands::ai::AiSession; use crate::commands::ai::AiSession;
use crate::commands::ai::conversation::SaveMetaOverride;
use crate::state::LlmConcurrency; use crate::state::LlmConcurrency;
use df_storage::db::Database; use df_storage::db::Database;
@@ -253,7 +254,19 @@ async fn spawn_agent_handler(
let child_id = next_child_id(&parent_conv_id); let child_id = next_child_id(&parent_conv_id);
let task_prompt = build_task_prompt(&task, &allowed, &trimmed); let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
let system_prompt = sub_agent_system_prompt(); // P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds);
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
// 快照随子循环传入,出口经 save_conversation_meta 显式落库。
let parent_save_meta = {
let session = ctx.session.lock().await;
SaveMetaOverride {
provider_id: session.active_provider_id.clone(),
created_at: session.active_conv_created_at.clone(),
}
};
// 级联注册(父停止/强制发送/删除会遍历 children 级联取消) // 级联注册(父停止/强制发送/删除会遍历 children 级联取消)
sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id); sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id);
@@ -287,9 +300,11 @@ async fn spawn_agent_handler(
system_prompt, system_prompt,
task_prompt, task_prompt,
tool_defs, tool_defs,
allowed,
max_rounds, max_rounds,
child_model, child_model,
ctx.llm_concurrency.clone(), ctx.llm_concurrency.clone(),
parent_save_meta,
) )
.await; .await;
+2 -1
View File
@@ -313,7 +313,8 @@ pub async fn evaluate_idea(
/// ///
/// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。 /// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。
/// - provider 构造仅一次(批量场景减少重复初始化开销)。 /// - provider 构造仅一次(批量场景减少重复初始化开销)。
/// - 返回 BatchEvalResult { success, errors },前端据此展示部分成功/失败。 // 2026-10-04: 未在 lib.rs 注册 IPC(dead_code),保留作批量评估参考实现;复用下方 evaluate_single 共用内部函数。
#[allow(dead_code)]
#[tauri::command] #[tauri::command]
pub async fn evaluate_ideas_batch( pub async fn evaluate_ideas_batch(
state: State<'_, AppState>, state: State<'_, AppState>,
+5
View File
@@ -460,12 +460,17 @@ pub fn run() {
commands::ai::ai_conversation_switch, commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态) // S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek, commands::ai::ai_conversation_peek,
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
commands::ai::ai_sub_agent_list,
commands::ai::ai_conversation_load_more, commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete, commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename, commands::ai::ai_conversation_rename,
commands::ai::ai_conversation_archive, commands::ai::ai_conversation_archive,
commands::ai::ai_conversation_set_pinned, commands::ai::ai_conversation_set_pinned,
commands::ai::ai_update_conversation_goals, commands::ai::ai_update_conversation_goals,
// 无人值守任务边界(设计 §2.3):发起时一次拍板 / 回显;清除=回退逐次审批
commands::ai::ai_task_boundary_get,
commands::ai::ai_task_boundary_set,
commands::ai::ai_conversation_export, commands::ai::ai_conversation_export,
commands::ai::ai_list_skills, commands::ai::ai_list_skills,
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效) // 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
+2 -3
View File
@@ -18,8 +18,8 @@ mod llm_concurrency;
// 对外公共符号(pub use):外部 crate/模块经 `crate::state::X` 路径消费 // 对外公共符号(pub use):外部 crate/模块经 `crate::state::X` 路径消费
pub use allowed_dirs::{ pub use allowed_dirs::{
AllowedDirs, PathAuthDecision, check_path_authorization, is_in_system_blacklist, AllowedDirs, PathAuthDecision, best_effort_canonicalize, check_path_authorization,
paths_eq, strip_verbatim, is_in_system_blacklist, paths_eq, strip_verbatim,
}; };
pub use knowledge_config::{ pub use knowledge_config::{
ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY, ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY,
@@ -275,7 +275,6 @@ pub struct AppState {
/// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空, /// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空,
/// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。 /// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。
/// 已修复: /// 已修复:
/// - authz_debug → std::env::temp_dir() (chat.rs)
/// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs) /// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs)
fn workspace_root_path() -> PathBuf { fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+1 -1
View File
@@ -233,7 +233,7 @@ pub fn strip_verbatim(p: PathBuf) -> PathBuf {
/// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断) /// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断)
/// ///
/// 失败一律回退,绝不返回 Err——授权比对宁可降级匹配不可 panic/阻断合法访问。 /// 失败一律回退,绝不返回 Err——授权比对宁可降级匹配不可 panic/阻断合法访问。
pub(crate) fn best_effort_canonicalize(p: &Path) -> PathBuf { pub fn best_effort_canonicalize(p: &Path) -> PathBuf {
if let Ok(real) = std::fs::canonicalize(p) { if let Ok(real) = std::fs::canonicalize(p) {
return real; return real;
} }
+50 -6
View File
@@ -4,6 +4,9 @@
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束 //! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除 //! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
//! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime) //! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime)
//! - i18n:文案按系统 locale 取中/英双语静态映射(locale 判定一次缓存),无 Rust i18n 机制的最小实现
use std::sync::OnceLock;
use tauri::{ use tauri::{
AppHandle, Manager, AppHandle, Manager,
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
@@ -12,6 +15,45 @@ use tauri::{
use crate::state::AppState; use crate::state::AppState;
/// 托盘文案双语映射(系统 locale 判定一次,进程内缓存;判定失败回退中文——应用主语言)。
/// 菜单项与运行态文本分两处消费,映射同源防两处漂移。
struct TrayTexts {
status_idle: &'static str,
status_running: &'static str,
show: &'static str,
quit: &'static str,
tooltip_running: &'static str,
}
/// 按系统 locale 取托盘文案(判定一次缓存)。zh* → 中文,其余(含判定失败)→ 中文/英文
/// 各取其一:locale 明确非 zh 用英文,判定失败(None)回退中文(应用主语言)。
fn tray_texts() -> &'static TrayTexts {
static TEXTS: OnceLock<TrayTexts> = OnceLock::new();
TEXTS.get_or_init(|| {
let zh = sys_locale::get_locale()
.map(|l| l.to_ascii_lowercase().starts_with("zh"))
// None = 系统locale不可得,回退中文(应用默认语言),与首次安装体验一致
.unwrap_or(true);
if zh {
TrayTexts {
status_idle: "无运行中会话",
status_running: "运行中 {} 个会话",
show: "显示主窗口",
quit: "退出",
tooltip_running: "DevFlow (运行中 {} 个会话)",
}
} else {
TrayTexts {
status_idle: "No running sessions",
status_running: "{} session(s) running",
show: "Show Main Window",
quit: "Quit",
tooltip_running: "DevFlow ({} session(s) running)",
}
}
})
}
/// managed state 保活,防 GC 移除托盘图标。 /// managed state 保活,防 GC 移除托盘图标。
/// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip); /// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip);
/// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。 /// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。
@@ -24,10 +66,11 @@ pub struct TrayHandle {
/// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。 /// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let t = tray_texts();
// 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护 // 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护
let status = MenuItem::with_id(app, "status", "无运行中会话", false, None::<&str>)?; let status = MenuItem::with_id(app, "status", t.status_idle, false, None::<&str>)?;
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", t.show, true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", t.quit, true, None::<&str>)?;
let menu = Menu::with_items(app, &[&status, &show, &quit])?; let menu = Menu::with_items(app, &[&status, &show, &quit])?;
// 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置 // 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置
let icon = app let icon = app
@@ -60,14 +103,15 @@ pub fn update_tray_runtime(app: &AppHandle) {
else { else {
return; return;
}; };
let t = tray_texts();
let n = state.conv_states.active_convs().len(); let n = state.conv_states.active_convs().len();
let text = if n > 0 { let text = if n > 0 {
format!("运行中 {} 个会话", n) t.status_running.replace("{}", &n.to_string())
} else { } else {
"无运行中会话".to_string() t.status_idle.to_string()
}; };
let tooltip = if n > 0 { let tooltip = if n > 0 {
format!("DevFlow (运行中 {} 个会话)", n) t.tooltip_running.replace("{}", &n.to_string())
} else { } else {
"DevFlow (MCP server running)".to_string() "DevFlow (MCP server running)".to_string()
}; };
+4
View File
@@ -157,6 +157,10 @@ function initTheme() {
// UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰 // UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'i') { if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
// 输入焦点守卫(2026-10-04 回归修复):输入控件内 Ctrl+I 让路(打字/组合键不切面板),
// 对齐 AiChat onGlobalKeydown Esc 分支的 closest target 守卫写法
const el = e.target as HTMLElement | null
if (el?.closest?.('input, textarea, [contenteditable]')) return
e.preventDefault() e.preventDefault()
aiStore.cyclePanelMode() aiStore.cyclePanelMode()
} }
+23 -1
View File
@@ -2,7 +2,7 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo } from './types' import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types'
export const aiApi = { export const aiApi = {
/** /**
@@ -112,6 +112,19 @@ export const aiApi = {
return invoke('ai_pending_tool_calls', { conversationId }) return invoke('ai_pending_tool_calls', { conversationId })
}, },
// ── 无人值守任务边界(设计 §2.3):发起时一次授权,存续期生效,随会话同亡 ──
/** 读取会话任务边界(配置卡回显/侧栏徽标)。无边界返回 null(幂等读不报错) */
getTaskBoundary(conversationId: string): Promise<TaskBoundary | null> {
return invoke('ai_task_boundary_get', { conversationId })
},
/** 设置会话任务边界。boundary 传 null = 清除,回退逐次审批;
* 后端把 risk_ceiling=high 钳到 medium;对话未落库时内存即生效,首条消息落库随行 */
setTaskBoundary(conversationId: string, boundary: TaskBoundary | null): Promise<void> {
return invoke('ai_task_boundary_set', { conversationId, boundary })
},
/** 清空对话 */ /** 清空对话 */
clearChat(): Promise<void> { clearChat(): Promise<void> {
return invoke('ai_chat_clear') return invoke('ai_chat_clear')
@@ -378,6 +391,15 @@ export const aiApi = {
return invoke('ai_conversation_peek', { conversationId }) return invoke('ai_conversation_peek', { conversationId })
}, },
/**
* 按父会话反查子代理轨迹精简元数据(P2-2 完成卡回补数据源,只读无副作用)。
* 后端按 origin='sub_agent' + id 前缀约定过滤,created_at 升序(轨迹时间线序)。
* 重启/分离窗回收后内存完成卡清零,经此按父 conv 重建卡片入口。
*/
listSubAgents(conversationId: string): Promise<{ conversation_id: string; title: string | null; created_at: string }[]> {
return invoke('ai_sub_agent_list', { conversationId })
},
/** /**
* S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。 * S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。
* 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE), * 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE),
+37
View File
@@ -480,6 +480,19 @@ export type AiChatEvent = ({
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/ // 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。 // AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
type: 'AiHelpRequired'; reason: string; context: string; options: string[] type: 'AiHelpRequired'; reason: string; context: string; options: string[]
} | {
// 无人值守安全停靠(设计 §四,挂起家族终局):六路非正常终局(审批超时取消/挂起后取消/
// 熔断/预算耗尽/Fatal/流中断)统一出口。语义:现场已保存、generating 已复位(非死等),reason 即
// 待办清单(停止原因+被拒操作提示),前端渲染停靠操作卡(继续续跑/停止归档)。
// stop_kind 词表(后端契约钉死,复用 AiAgentFinished 词表扩展):approval_timeout / cancelled /
// circuit_breaker / stall_breaker / max_rounds / fatal / midstream;后端新增变体时前端未知值中性兜底。
type: 'AiTaskParked'; reason: string; stop_kind?: string
} | {
// 无人值守进度可观测(设计 §4.3):后端主 loop 每 5 轮 emit,长任务静默期(子代理长跑/
// 大工具执行/重入退避)可见「在干活」。round 1 起算;max_rounds=0=不限轮次(进度条只显轮次
// 与 token,不显分母)。事件即逝,前端 per-conv Map 存最近快照,终局(AiCompleted/AiError/
// AiHelpRequired/AiTaskParked)与会话删除时清理。
type: 'AiTaskProgress'; round: number; max_rounds: number; tokens_used: number
} | { } | {
// L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。 // L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。 // conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
@@ -806,6 +819,30 @@ export interface AiToolMeta {
risk: 'low' | 'medium' | 'high' risk: 'low' | 'medium' | 'high'
} }
// ============================================================
// 无人值守长任务(设计 docs/02-架构设计/专项设计/无人值守长任务-设计-2026-10-04.md §二/§2.3)
// 与后端 commands/ai/boundary.rs 严格对齐,snake_case 原样不做转译
// ============================================================
/** 任务预算(对齐后端 boundary::TaskBudget;null/缺省=不限,继承后端默认量纲) */
export interface TaskBudget {
max_rounds: number
max_tokens: number
wall_secs: number
}
/**
* 任务级授权边界(挂会话,随会话同亡;发起时一次声明,存续期内 AI 全自主)。
* risk_ceiling serde lowercase;入口只允许 low/medium(后端 set 把 high 钳到 medium)。
* forbid_destructive 恒 true 仅展示,后端判定不读本字段。
*/
export interface TaskBoundary {
risk_ceiling: 'low' | 'medium' | 'high'
dir_allowlist: string[]
forbid_destructive: boolean
budget: TaskBudget | null
}
// ============================================================ // ============================================================
// 知识库 // 知识库
// ============================================================ // ============================================================
+11
View File
@@ -46,6 +46,8 @@
<!-- Input --> <!-- Input -->
<div class="ai-input-area"> <div class="ai-input-area">
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化) <!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示; 仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。 单轮问答 agentRound 恒 0,不显示(避免误显)。
@@ -61,6 +63,8 @@
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。 断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 --> 机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" /> <HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
<TaskParkedCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。 <!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线—— path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载 path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
@@ -143,6 +147,8 @@ import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue' import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue' import MessageList from './ai/MessageList.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue' import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import TaskParkedCard from './ai/TaskParkedCard.vue'
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue' import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
@@ -326,6 +332,11 @@ function cancelEdit(): void {
// 注:Ctrl+K(UX-06 §3.1)已绑——Tauri WebView2 无浏览器地址栏,聚焦搜索框无冲突 // 注:Ctrl+K(UX-06 §3.1)已绑——Tauri WebView2 无浏览器地址栏,聚焦搜索框无冲突
function onGlobalKeydown(e: KeyboardEvent) { function onGlobalKeydown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey const ctrl = e.ctrlKey || e.metaKey
// 回归修复(2026-10-04 走查):全局 Ctrl 快捷键补输入焦点守卫——事件源在输入控件内时
// Ctrl+N/R/L/K/B 全部让路(输入框打字/组合键不误触新建对话/重生成/清空等)。
// 写法对齐下方 Esc 分支的 closest target 守卫;Esc 无 ctrl 不走此守卫(其分支自带仲裁)。
const el = e.target as HTMLElement | null
if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return
// Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突) // Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突)
if (ctrl && (e.key === 'n' || e.key === 'N')) { if (ctrl && (e.key === 'n' || e.key === 'N')) {
e.preventDefault() e.preventDefault()
+31
View File
@@ -15,6 +15,8 @@
/> />
<!-- F-260614-05 Phase 2b: 待发送图片预览 --> <!-- F-260614-05 Phase 2b: 待发送图片预览 -->
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" /> <ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
<!-- B1: 当前模型不看图时贴图警示(发送仍允许,后端温和降级丢图;chip 持续可见防误判网络问题) -->
<div v-if="showImageIgnoreWarn" class="ai-img-warn-chip">{{ t('aiChat.imageIgnoredWarn') }}</div>
<textarea <textarea
ref="inputEl" ref="inputEl"
v-model="inputText" v-model="inputText"
@@ -304,6 +306,22 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
return textActive ? 'stop' : 'idle' return textActive ? 'stop' : 'idle'
}) })
// ── B1: 非视觉模型贴图前置感知 ──
// 有效模型判定镜像 TopBar 口径(active provider → enabled 池 → override 命中优先,否则权重最高)。
// 池未知(未拉取/空)时保守视为支持(不误伤;后端 model_supports_vision 有 model_probe 兜底,
// 发图照旧温和降级)。发送仍允许(与现状兼容),仅以警示 chip 持续可见防「静默丢图」误判网络问题。
const currentModelSupportsVision = computed<boolean>(() => {
const p = store.state.providers.find(x => x.id === store.state.activeProvider)
|| store.state.providers.find(x => x.is_default)
|| store.state.providers[0]
if (!p?.model_configs?.length) return true
const models = p.model_configs.filter(m => m.enabled)
const chosen = (store.modelOverride.value ? models.find(m => m.model_id === store.modelOverride.value) : undefined) || models[0]
if (!chosen) return true
return (chosen.modalities ?? []).includes('vision')
})
const showImageIgnoreWarn = computed(() => pendingImages.value.length > 0 && !currentModelSupportsVision.value)
// ── 技能 `/` 联想 ── // ── 技能 `/` 联想 ──
const skillOpen = ref(false) const skillOpen = ref(false)
const skillIndex = ref(0) const skillIndex = ref(0)
@@ -849,6 +867,12 @@ async function handleSend() {
const skill = pendingSkill.value const skill = pendingSkill.value
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用) // 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)
if (!text && !skill && pendingImages.value.length === 0) return if (!text && !skill && pendingImages.value.length === 0) return
// B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮),
// 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状
if (!text && !skill && pendingImages.value.length > 0 && !currentModelSupportsVision.value) {
emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' })
return
}
// F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。 // F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的 // 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
// user 消息渲染多模态图,后端请求仍走 content 文本路径。 // user 消息渲染多模态图,后端请求仍走 content 文本路径。
@@ -1181,4 +1205,11 @@ defineExpose({
} }
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */ /* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
.ai-img-warn-chip {
font-size: 10.5px;
color: var(--df-warning);
padding: 1px 4px;
}
</style> </style>
+33
View File
@@ -71,6 +71,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -161,6 +165,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -215,6 +223,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -279,6 +291,9 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影) // V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
import { convPendingApprovals } from '../../composables/ai/aiShared' import { convPendingApprovals } from '../../composables/ai/aiShared'
// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪;
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
import { getConvBoundary } from '../../composables/ai/useTaskBoundary'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast // UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations' import { convOpFailNotice } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast' import { useToast } from '../../composables/useToast'
@@ -508,6 +523,11 @@ function pendingCountOf(convId: string): number {
return pendingCountByConv.value.get(convId) ?? 0 return pendingCountByConv.value.get(convId) ?? 0
} }
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
function hasBoundary(convId: string): boolean {
return getConvBoundary(convId) !== null
}
// ── 对话分组:今天 / 昨天 / 更早 ── // ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed<Record<string, string>>(() => ({ const bucketLabels = computed<Record<string, string>>(() => ({
today: t('aiChat.today'), today: t('aiChat.today'),
@@ -842,6 +862,19 @@ body.ai-sidebar-resizing * {
text-align: center; text-align: center;
cursor: help; cursor: help;
} }
/* 无人值守边界徽标(盾形,accent 色系与「已授权」语义一致;纯标记不计数) */
.ai-conv-boundary-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
color: var(--df-accent);
cursor: help;
}
.ai-conv-item-actions { .ai-conv-item-actions {
display: none; display: none;
align-items: center; align-items: center;
+30
View File
@@ -10,8 +10,25 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types' import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
import { useAiStore } from '../../stores/ai'
const { t } = useI18n() const { t } = useI18n()
const store = useAiStore()
/**
* 前导 /技能名 解析(问题3 技能调用留痕的渲染侧):
* 落库文本形如 `/skill-name 正文`(后端 chat.rs user_content 统一加前缀),命中本机
* 已安装技能名单才渲染为技能 chip——普通以 / 开头的文本(路径、markdown)不误判。
* 返回 null = 非技能调用,走原纯文本路径零回归。
*/
function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
if (msg.role !== 'user') return null
const m = /^\/([A-Za-z0-9_-]+)(?:\s([\s\S]*))?$/.exec(msg.content)
if (!m) return null
const known = store.state.skills.some((s) => s.name === m[1])
if (!known) return null
return { name: m[1], rest: m[2] ?? '' }
}
defineProps<{ defineProps<{
msg: AiMessage msg: AiMessage
@@ -89,10 +106,17 @@ function imgAlt(img: ImageContentPart): string {
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div> </div>
<div class="ai-msg-bubble ai-msg-bubble--user"> <div class="ai-msg-bubble ai-msg-bubble--user">
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
<template v-if="leadingSkill(msg)">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: leadingSkill(msg)!.name })">⚡ /{{ leadingSkill(msg)!.name }}</span>
<template v-if="leadingSkill(msg)!.rest">{{ ' ' + leadingSkill(msg)!.rest }}</template>
</template>
<template v-else>
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx"> <template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
<span v-if="seg.type === 'text'">{{ seg.text }}</span> <span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span> <span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
</template> </template>
</template>
<template v-if="msgImageParts(msg).length"> <template v-if="msgImageParts(msg).length">
<div class="ai-msg-images"> <div class="ai-msg-images">
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" /> <img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
@@ -153,6 +177,12 @@ function imgAlt(img: ImageContentPart): string {
background: var(--df-accent-bg, rgba(108,99,255,0.1)); background: var(--df-accent-bg, rgba(108,99,255,0.1));
color: var(--df-accent, #6c63ff); color: var(--df-accent, #6c63ff);
} }
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
.ai-msg-chip--skill {
background: var(--df-accent-bg, rgba(108,99,255,0.18));
color: var(--df-accent, #6c63ff);
font-weight: 600;
}
/* 图片 */ /* 图片 */
.ai-msg-images { .ai-msg-images {
display: flex; display: flex;
+240 -18
View File
@@ -690,7 +690,8 @@ function canOpenSettings(msg: AiMessage): boolean {
* 错误是否可重试(显隐「重试」按钮)。 * 错误是否可重试(显隐「重试」按钮)。
* Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败, * Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败,
* 隐藏重试按钮,引导用户走「去设置」。network/timeout/unknown 为瞬时错误,可重试。 * 隐藏重试按钮,引导用户走「去设置」。network/timeout/unknown 为瞬时错误,可重试。
* retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。 * error_type 由后端 emit 点分类(2026-10-04 起 emit_fatal_error 对模型不存在/令牌无权/
* 参数拒绝类 400 归 provider_config);后端判不了的真 Unknown 仍可重试,维持现状。
*/ */
function canRetry(msg: AiMessage): boolean { function canRetry(msg: AiMessage): boolean {
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮) // 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
@@ -1077,11 +1078,22 @@ const renderItems = computed<RenderItem[]>(() => {
} }
} }
flushBucket() flushBucket()
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容; // S1 子代理完成卡:按完成时刻插到所属轮次桶尾,不恒贴消息流最底部——恒贴时用户继续对话,
// 早前轮次的卡被顶到后面内容之下,与所属轮次脱钩。归桶判据:finishedAt 落在哪桶的跨度内
// (trace.startTs ≤ finishedAt < 下一桶),卡即插到该桶 trace 卡之后与其同层(spawn 就发生
// 在这个桶里;桶内末条答复的落库时刻必晚于 finishedAt,时间比较天然落在这里)。
// 无时间语义的项(step/timesep/sep/agentFin)不作锚点,防插进时间线分隔的语义缝隙。
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。 // 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。 // 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
for (const card of getConvAgentFinished(store.state.activeConversationId)) { const cards = [...getConvAgentFinished(store.state.activeConversationId)].sort((a, b) => a.finishedAt - b.finishedAt)
items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card }) for (const card of cards) {
let at = items.length
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
const ts = it.kind === 'trace' ? it.startTs : it.kind === 'msg' ? it.msg.timestamp : 0
if (ts && ts <= card.finishedAt) { at = i + 1; break }
}
items.splice(at, 0, { kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
} }
return items return items
}) })
@@ -1094,11 +1106,35 @@ function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err
if (status === 'failed') return 'err' if (status === 'failed') return 'err'
return 'ok' return 'ok'
} }
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应 // 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应。
function agentFinStatusText(status: AgentFinishedCard['status']): string { // stop_kind 结构化优先(根治启发式):后端终态枚举直达语义,截断三细分各自透出;
if (status === 'truncated') return t('ai.agentFinished.truncated') // 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
if (status === 'failed') return t('ai.agentFinished.failed') // 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处
// 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。
function agentFinStatusText(card: AgentFinishedCard): string {
const k = card.stop_kind
if (k) {
if (k === 'cancelled') return t('ai.agentFinished.cancelled')
if (k === 'max_rounds') return t('ai.agentFinished.truncMaxRounds')
if (k === 'wall_clock') return t('ai.agentFinished.truncWallClock')
if (k === 'token_budget') return t('ai.agentFinished.truncTokenBudget')
if (k === 'empty_convergence') return t('ai.agentFinished.noReport')
if (k === 'error') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed') return t('ai.agentFinished.completed')
}
if (card.status === 'truncated') {
return card.summary.includes('已被取消')
? t('ai.agentFinished.cancelled')
: t('ai.agentFinished.truncated')
}
if (card.status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
// 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库,
// 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败
// 也至少有一轮落库记录,可正常 peek)。
function agentFinNoTrace(card: AgentFinishedCard): boolean {
return card.status === 'failed' && card.rounds === 0
} }
// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版), // 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
// 全文经 title hover 兜底不丢信息。 // 全文经 title hover 兜底不丢信息。
@@ -1106,6 +1142,13 @@ function agentFinSummary(summary: string): string {
const s = (summary || '').replace(/\s+/g, ' ').trim() const s = (summary || '').replace(/\s+/g, ' ').trim()
return s.length > 160 ? s.slice(0, 160) + '…' : s return s.length > 160 ? s.slice(0, 160) + '…' : s
} }
// 被拒工具 chip(P2-1):前 2 枚枚举 + 「+N」收尾防长清单撑爆细条,完整清单经 hover title 查看
const AGENT_FIN_REJECTED_MAX = 2
function agentFinRejectedText(list: string[]): string {
const shown = list.slice(0, AGENT_FIN_REJECTED_MAX).join(' / ')
const rest = list.length - AGENT_FIN_REJECTED_MAX
return t('ai.agentFinished.rejected', { tools: rest > 0 ? `${shown} +${rest}` : shown })
}
// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ── // ── S3 子代理轨迹浮层(完成卡点击展开,只读) ──
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留), // 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
@@ -1114,10 +1157,17 @@ interface AgentPeekMsg {
role: 'user' | 'assistant' | 'tool' | 'system' role: 'user' | 'assistant' | 'tool' | 'system'
text: string text: string
toolNames: string[] toolNames: string[]
// deny envelope 消息标记:命中子代理拒绝信封的 tool 结果,渲染为红字人话而非原始 JSON
denied?: boolean
} }
const agentPeekVisible = ref(false) const agentPeekVisible = ref(false)
const agentPeekLoading = ref(false) const agentPeekLoading = ref(false)
// 加载中的卡 id:spinner 挂在具体完成卡上而非浮层(浮层此时尚未打开)
const agentPeekLoadingId = ref<string | null>(null)
const agentPeekMsgs = ref<AgentPeekMsg[]>([]) const agentPeekMsgs = ref<AgentPeekMsg[]>([])
// 浮层头身份标识:agent-{n} 尾段 + 子会话标题(IPC title,子代理常为空则不显)+ 任务书首行,
// 多代理场景辨认「在看谁」;关浮层即弃
const agentPeekSubject = ref({ agent: '', convTitle: '', task: '', taskFull: '' })
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底, // 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
// 完整数据仍在库,不做全文渲染(不要求富文本)。 // 完整数据仍在库,不做全文渲染(不要求富文本)。
const AGENT_PEEK_TEXT_MAX = 4000 const AGENT_PEEK_TEXT_MAX = 4000
@@ -1125,40 +1175,104 @@ const AGENT_PEEK_TEXT_MAX = 4000
/** /**
* 完成卡点击:peek 子会话轨迹并开浮层。 * 完成卡点击:peek 子会话轨迹并开浮层。
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。 * agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
* 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。 * 启动失败无轨迹卡直接忽略(agentFinNoTrace);加载中/已开时忽略重复点击(防连点重复 IPC);
* 失败走既有 toast 通道给可见反馈,不开空浮层。
*/ */
async function openAgentTrace(card: AgentFinishedCard): Promise<void> { async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
if (agentFinNoTrace(card)) return
if (agentPeekVisible.value || agentPeekLoading.value) return if (agentPeekVisible.value || agentPeekLoading.value) return
agentPeekLoading.value = true agentPeekLoading.value = true
agentPeekLoadingId.value = card.agentId
try { try {
const res = await aiApi.peekConversation(card.agentId) const res = await aiApi.peekConversation(card.agentId)
const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || []) const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || [])
// tool_call_id → 工具名映射:tool 消息只带 id,名字在前条 assistant.tool_calls 里
const toolNameById = new Map<string, string>()
for (const m of raw) {
if (Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
if (tc?.id && tc?.function?.name) toolNameById.set(String(tc.id), String(tc.function.name))
}
}
}
agentPeekMsgs.value = raw.map((m) => { agentPeekMsgs.value = raw.map((m) => {
const text = String(m.content || '') const rawText = String(m.content || '')
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system' const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
? m.role ? m.role
: 'assistant' : 'assistant'
return { const msg: AgentPeekMsg = {
role, role,
text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text, text: '',
toolNames: Array.isArray(m.tool_calls) toolNames: Array.isArray(m.tool_calls)
? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean) ? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean)
: [], : [],
} }
if (role === 'tool') {
// 回显工具名 chip(与前条 assistant 消息的调用 chip 对得上,行内可读)
const callName = toolNameById.get(String(m.tool_call_id || ''))
if (callName) msg.toolNames.push(callName)
// deny envelope 先解析后截断(信封很小不会被截),命中转人话不走原文
const deny = parseDenyEnvelope(rawText)
if (deny) {
msg.denied = true
const risk = agentPeekRiskText(deny.risk_level)
msg.text = t('ai.agentTrace.deniedTool', { tool: deny.tool_name || callName || '?' })
+ (risk ? `(${risk})` : '')
+ (deny.message ? ` · ${deny.message}` : '')
return msg
}
}
msg.text = rawText.length > AGENT_PEEK_TEXT_MAX ? rawText.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : rawText
return msg
}) })
// 任务行取子会话首条 user 消息(即任务书)首行,取不到回退卡摘要;title 挂全文
const firstUser = raw.find((m) => m.role === 'user')
const taskLine = String(firstUser?.content || '').split('\n').map((s: string) => s.trim()).filter(Boolean)[0] || ''
const full = taskLine || (card.summary || '').replace(/\s+/g, ' ').trim()
agentPeekSubject.value = {
agent: card.agentId.split('::').pop() || card.agentId,
convTitle: String(res.title || '').trim(),
task: full.length > 120 ? full.slice(0, 120) + '…' : full,
taskFull: full,
}
agentPeekVisible.value = true agentPeekVisible.value = true
} catch (e) { } catch (e) {
console.error('[AI] 拉取子代理轨迹失败:', e) console.error('[AI] 拉取子代理轨迹失败:', e)
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' }) emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
} finally { } finally {
agentPeekLoading.value = false agentPeekLoading.value = false
agentPeekLoadingId.value = null
} }
} }
/** deny envelope 识别:与后端 is_sub_agent_deny_envelope 同构(status=rejected + reason=sub_agent_denied) */
function parseDenyEnvelope(text: string): { tool_name?: string; risk_level?: string; message?: string } | null {
const s = text.trim()
if (!s.startsWith('{')) return null
try {
const v = JSON.parse(s)
if (v?.status === 'rejected' && v?.reason === 'sub_agent_denied') {
return { tool_name: v.tool_name, risk_level: v.risk_level, message: v.message }
}
} catch {
// 截断/非 JSON 内容即非信封,按原文渲染
}
return null
}
/** 风险级别人话化(后端 RiskLevel serde lowercase,字面量 low/medium/high) */
function agentPeekRiskText(level: unknown): string {
if (level === 'low') return t('ai.agentTrace.riskLow')
if (level === 'medium') return t('ai.agentTrace.riskMedium')
if (level === 'high') return t('ai.agentTrace.riskHigh')
return ''
}
/** 关浮层即弃:清空局部消息态,不缓存轨迹 */ /** 关浮层即弃:清空局部消息态,不缓存轨迹 */
function closeAgentTrace(): void { function closeAgentTrace(): void {
agentPeekVisible.value = false agentPeekVisible.value = false
agentPeekMsgs.value = [] agentPeekMsgs.value = []
agentPeekSubject.value = { agent: '', convTitle: '', task: '', taskFull: '' }
} }
/** role → 本地化角色名(简版渲染,无头像气泡) */ /** role → 本地化角色名(简版渲染,无头像气泡) */
@@ -1187,6 +1301,8 @@ watch(() => store.state.activeConversationId, () => {
setFollowing(true) setFollowing(true)
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开) // 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
expandedTraceIds.value = new Set() expandedTraceIds.value = new Set()
// 切会话关子代理轨迹浮层(浮层是组件级单例,不关则旧会话轨迹叠在新会话上)
closeAgentTrace()
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue // BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。 // 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations) // 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
@@ -1211,6 +1327,7 @@ watch(() => store.state.activeConversationId, () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelPendingRaf() cancelPendingRaf()
document.removeEventListener('click', closeTokenPopoverOnOutsideClick) document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
document.removeEventListener('keydown', closeAgentTraceOnEsc)
}) })
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部) // token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
@@ -1219,6 +1336,12 @@ function closeTokenPopoverOnOutsideClick(): void {
} }
document.addEventListener('click', closeTokenPopoverOnOutsideClick) document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键)
function closeAgentTraceOnEsc(e: KeyboardEvent): void {
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace()
}
document.addEventListener('keydown', closeAgentTraceOnEsc)
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例, // Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。 // 多次调用安全,确保子组件挂载即预热)。
loadMarkdown() loadMarkdown()
@@ -1321,16 +1444,26 @@ defineExpose({
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot"> <div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div <div
class="ai-agent-fin" class="ai-agent-fin"
:class="'ai-agent-fin--' + agentFinTone(item.card.status)" :class="['ai-agent-fin--' + agentFinTone(item.card.status), { 'ai-agent-fin--notrace': agentFinNoTrace(item.card) }]"
role="button" role="button"
:title="$t('ai.agentFinished.viewHint')" :title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)" @click="openAgentTrace(item.card)"
> >
<span class="ai-agent-fin-dot"></span> <span class="ai-agent-fin-dot"></span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span> <span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span> <span class="ai-agent-fin-status">{{ agentFinStatusText(item.card) }}</span>
<!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系;
单枚直显,多枚前 2 枚枚举 + N,完整清单 hover title -->
<span
v-if="item.card.rejected_tools?.length"
class="ai-agent-fin-rejected"
:title="item.card.rejected_tools.join('\n')"
>{{ agentFinRejectedText(item.card.rejected_tools) }}</span>
<span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span> <span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span>
<span class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span> <!-- 回补卡 rounds/tokens 未知为 0,双零隐藏 meta 防「0 轮 · 0 tokens」噪音 -->
<span v-if="item.card.rounds || item.card.tokens" class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
<!-- peek 加载微标:IPC 期间转圈,防「点了没反应」;挂在对应卡上,多卡各自反馈 -->
<span v-if="agentPeekLoadingId === item.card.agentId" class="ai-agent-fin-spin" aria-hidden="true"></span>
</div> </div>
</div> </div>
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) --> <!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
@@ -1584,19 +1717,28 @@ defineExpose({
<div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace"> <div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace">
<div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')"> <div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
<div class="ai-agent-peek-head"> <div class="ai-agent-peek-head">
<div class="ai-agent-peek-head-text">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span> <span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<!-- 身份标识行:agent-{n} + 子会话标题 + 任务书首行,多代理场景辨认「在看谁」 -->
<div class="ai-agent-peek-subject" :title="agentPeekSubject.taskFull">
<span class="ai-agent-peek-agent">{{ agentPeekSubject.agent }}</span>
<span v-if="agentPeekSubject.convTitle" class="ai-agent-peek-conv">{{ agentPeekSubject.convTitle }}</span>
<span v-if="agentPeekSubject.task" class="ai-agent-peek-task">{{ agentPeekSubject.task }}</span>
</div>
</div>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace"> <button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
<div class="ai-agent-peek-body"> <div class="ai-agent-peek-body">
<div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div> <div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div>
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染) --> <!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染);
deny envelope 消息(denied)红字人话,不再直出原始 JSON -->
<div <div
v-for="(m, i) in agentPeekMsgs" v-for="(m, i) in agentPeekMsgs"
:key="i" :key="i"
class="ai-agent-peek-msg" class="ai-agent-peek-msg"
:class="'ai-agent-peek-msg--' + m.role" :class="['ai-agent-peek-msg--' + m.role, { 'ai-agent-peek-msg--denied': m.denied }]"
> >
<div class="ai-agent-peek-role"> <div class="ai-agent-peek-role">
<span>{{ agentPeekRoleText(m.role) }}</span> <span>{{ agentPeekRoleText(m.role) }}</span>
@@ -2450,6 +2592,40 @@ defineExpose({
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10.5px; font-size: 10.5px;
} }
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
.ai-agent-fin-rejected {
flex-shrink: 0;
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 6px;
border-radius: 999px;
font-size: 10.5px;
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent);
color: var(--df-warning, #f0c75e);
}
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
.ai-agent-fin--notrace {
cursor: default;
opacity: 0.72;
}
.ai-agent-fin--notrace:hover {
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
}
/* peek 拉取中的加载微标(挂在具体卡右端) */
.ai-agent-fin-spin {
flex-shrink: 0;
width: 10px;
height: 10px;
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
border-top-color: var(--df-text-secondary);
border-radius: 50%;
animation: ai-agent-fin-spin 0.8s linear infinite;
}
@keyframes ai-agent-fin-spin {
to { transform: rotate(360deg); }
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */ /* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
.ai-agent-peek-mask { .ai-agent-peek-mask {
@@ -2552,4 +2728,50 @@ defineExpose({
color: var(--df-text-secondary); color: var(--df-text-secondary);
font-size: 11.5px; font-size: 11.5px;
} }
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
.ai-agent-peek-head-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-agent-peek-subject {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-agent-peek-agent {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-conv {
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text);
}
.ai-agent-peek-task {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text-dim);
}
/* deny envelope 消息:红字人话,置于 tool/system 弱化规则之后以覆盖其色 */
.ai-agent-peek-msg--denied {
border-left: 2px solid var(--df-danger);
}
.ai-agent-peek-msg--denied .ai-agent-peek-text {
color: var(--df-danger);
}
</style> </style>
+530
View File
@@ -0,0 +1,530 @@
<template>
<!-- 无人值守边界配置卡(设计 §2.2):发起时一次性授权,存续期生效,随会话同亡。
默认收起(现状零变化);展开后对当前活跃会话 ai_task_boundary_set 一次拍板。
折叠行在有边界时只读回显摘要(数据源镜像,拉取见下);任务 generating 中锁编辑
(中途改边界=边跑边扩权,设计明令禁止)。不引入对话框,最小折叠卡。 -->
<div class="ai-boundary">
<button type="button" class="ai-boundary-toggle" @click="toggleOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span class="ai-boundary-toggle-label">{{ t('ai.taskBoundary.toggle') }}</span>
<span v-if="current" class="ai-boundary-echo">{{ echoSummary }}</span>
</button>
<div v-if="open" class="ai-boundary-body">
<div v-if="locked" class="ai-boundary-locked">{{ t('ai.taskBoundary.lockedGenerating') }}</div>
<!-- 拉取失败 ≠ 无边界:失败态禁「一次授权」(防默认空表单覆盖原授权),保留表单现状供重试 -->
<div v-if="loadFailed" class="ai-boundary-loadfail">
<span>{{ t('ai.taskBoundary.loadFailed') }}</span>
<button type="button" class="ai-boundary-mini-btn" @click="retryHydrate">{{ t('ai.taskBoundary.retryLoad') }}</button>
</div>
<div class="ai-boundary-row">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.riskCeiling') }}</span>
<label class="ai-boundary-radio" :class="{ 'is-checked': risk === 'low' }">
<input v-model="risk" type="radio" value="low" :disabled="locked" />
<span>{{ t('ai.taskBoundary.riskLow') }}</span>
</label>
<label class="ai-boundary-radio" :class="{ 'is-checked': risk === 'medium' }">
<input v-model="risk" type="radio" value="medium" :disabled="locked" />
<span>{{ t('ai.taskBoundary.riskMedium') }}</span>
</label>
</div>
<div class="ai-boundary-row ai-boundary-row--col">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.dirs') }}</span>
<div v-if="dirs.length" class="ai-boundary-dirs">
<span v-for="d in dirs" :key="d" class="ai-boundary-dir">
<span class="ai-boundary-dir-path" :title="d">{{ d }}</span>
<button type="button" class="ai-boundary-dir-x" :disabled="locked" @click="removeDir(d)">×</button>
</span>
</div>
<span v-else class="ai-boundary-dim">{{ t('ai.taskBoundary.dirEmpty') }}</span>
<div class="ai-boundary-dir-add">
<input
v-model="dirInput"
class="ai-boundary-input"
type="text"
:placeholder="t('ai.taskBoundary.dirPlaceholder')"
:disabled="locked"
@keydown="onDirInputKeydown"
/>
<button type="button" class="ai-boundary-mini-btn" :disabled="locked || !dirInput.trim()" @click="addDir">{{ t('ai.taskBoundary.dirAdd') }}</button>
</div>
<!-- A2: 坏输入就近红字(非法字符/相对路径),不弹窗;存在性不阻断(白名单目录可尚不存在) -->
<span v-if="dirError" class="ai-boundary-dir-err">{{ dirError === 'illegal' ? t('ai.taskBoundary.dirErrIllegal') : t('ai.taskBoundary.dirErrRelative') }}</span>
</div>
<div class="ai-boundary-row">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.forbidDestructive') }}</span>
</div>
<button type="button" class="ai-boundary-budget-toggle" @click="budgetOpen = !budgetOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': budgetOpen }" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
{{ t('ai.taskBoundary.budgetToggle') }}
</button>
<div v-if="budgetOpen" class="ai-boundary-budget">
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetTokens') }}</span>
<input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<span class="ai-boundary-dim">{{ t('ai.taskBoundary.budgetHint') }}</span>
</div>
<div class="ai-boundary-actions">
<button type="button" class="ai-boundary-confirm" :disabled="locked || loadFailed" @click="confirmSet">{{ t('ai.taskBoundary.confirmSet') }}</button>
<!-- A3: 清除=收权(安全向),generating 中保持可用,与字段编辑锁分离;有 confirm 二次确认 -->
<button v-if="current" type="button" class="ai-boundary-clear" @click="clearBoundary">{{ t('ai.taskBoundary.clearBoundary') }}</button>
</div>
</div>
<!-- 清除边界二次确认(运行中收权=后续工具回到逐次审批,须用户拍板;useConfirm 每实例独立 ref) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="t('ai.taskBoundary.clearBoundary')" @result="answerConfirm" />
</div>
</template>
<script setup lang="ts">
// 无人值守边界配置卡:字段宁少勿预留(设计 §2.2)。目录白名单用简化交互(文本添加+删除),
// 不复刻设置页完整目录选择器;预算三项折叠,空=不限(不传 budget)。
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { fetchConvBoundary, getConvBoundary, saveConvBoundary, isConvBoundaryFetchFailed } from '../../composables/ai/useTaskBoundary'
import { useConfirm } from '../../composables/useConfirm'
import ConfirmDialog from '../ConfirmDialog.vue'
import type { TaskBoundary } from '../../api/types'
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' | 'success' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 清除边界二次确认(A3);useConfirm 每调用独立 ref,不与 AiChat 的实例串台
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 收起为默认态(未展开 = 现状逐次审批,零行为变化)
const open = ref(false)
const risk = ref<'low' | 'medium'>('low')
const dirs = ref<string[]>([])
const dirInput = ref('')
// A2: 目录输入词法校验结果(空串=无错;illegal=含非法字符;relative=非绝对路径)
const dirError = ref<'' | 'illegal' | 'relative'>('')
// A1: 边界拉取失败态(≠ 无边界;禁「一次授权」防默认空表单覆盖原授权)
const loadFailed = ref(false)
const budgetOpen = ref(false)
const rounds = ref('')
const tokens = ref('')
const wallSecs = ref('')
const activeId = computed(() => store.state.activeConversationId)
// 生成中锁编辑:防「边跑边扩权」。isGenerating 派生自 convStates reactive Map,computed 内可追踪
const locked = computed(() => store.isGenerating(activeId.value))
// 当前会话已授权边界(镜像;折叠行回显摘要数据源)
const current = computed(() => getConvBoundary(activeId.value))
function resetForm(): void {
risk.value = 'low'
dirs.value = []
dirInput.value = ''
dirError.value = ''
budgetOpen.value = false
rounds.value = ''
tokens.value = ''
wallSecs.value = ''
}
/** 从镜像回填表单(展开或切会话时调);镜像 miss 先拉一次再回填。
* A1: 拉取失败与「确认无边界」分流——失败态置 loadFailed 禁授权,表单保留现状不清空 */
async function hydrate(): Promise<void> {
const id = activeId.value
if (!id) {
resetForm()
loadFailed.value = false
return
}
await fetchConvBoundary(id)
if (activeId.value !== id) return
if (isConvBoundaryFetchFailed(id)) {
loadFailed.value = true
return
}
loadFailed.value = false
const b = getConvBoundary(id)
if (!b) {
resetForm()
return
}
// 单选只提供 low/medium(后端把 high 钳到 medium);万一存量 high 回显按 medium 呈现
risk.value = b.risk_ceiling === 'medium' || b.risk_ceiling === 'high' ? 'medium' : 'low'
dirs.value = [...b.dir_allowlist]
rounds.value = b.budget ? String(b.budget.max_rounds) : ''
tokens.value = b.budget ? String(b.budget.max_tokens) : ''
wallSecs.value = b.budget ? String(b.budget.wall_secs) : ''
budgetOpen.value = !!b.budget
}
/** 失败态重试(错误条按钮):重走拉取+回填 */
function retryHydrate(): void {
void hydrate()
}
function toggleOpen(): void {
open.value = !open.value
if (open.value) void hydrate()
}
// 切会话:预拉边界喂侧栏徽标镜像;展开态走 hydrate(内含拉取)重填表单,防上一会话表单串台
watch(activeId, (id) => {
if (!id) return
if (open.value) void hydrate()
else void fetchConvBoundary(id)
})
onMounted(() => {
if (activeId.value) void fetchConvBoundary(activeId.value)
})
/** 目录输入词法校验(A2,只做本地词法;存在性不阻断——白名单目录可尚不存在,后端
* 运行期 canonicalize 本就是 best-effort)。非法字符:Windows 保留集(冒号仅盘符位合法);
* 相对路径:须盘符/UNC/POSIX 根开头。返回空串=合法 */
function validateDirInput(d: string): '' | 'illegal' | 'relative' {
const body = /^[a-zA-Z]:/.test(d) ? d.slice(2) : d
if (/[<>:"|?*]/.test(body) || /[\u0000-\u001f]/.test(d)) return 'illegal'
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(d) || d.startsWith('\\\\') || d.startsWith('/')
if (!isAbsolute) return 'relative'
return ''
}
function addDir(): void {
const d = dirInput.value.trim()
if (!d) return
const err = validateDirInput(d)
if (err) {
dirError.value = err
return
}
dirError.value = ''
if (!dirs.value.includes(d)) dirs.value.push(d)
dirInput.value = ''
}
// 输入变化即清红字(改一个字就该有机会过下一轮校验,红字只描述当前输入的上一态)
watch(dirInput, () => {
if (dirError.value) dirError.value = ''
})
/** 目录输入回车即添加;IME 组词中的回车属选词非提交,放行给输入法 */
function onDirInputKeydown(e: KeyboardEvent): void {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
addDir()
}
}
function removeDir(d: string): void {
dirs.value = dirs.value.filter(x => x !== d)
}
/** 预算三项解析:全空=undefined(不限);任一非空但非正整数视为无效返回 null */
function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: number } | undefined | null {
const fields = [
{ v: rounds.value.trim(), min: 1 },
{ v: tokens.value.trim(), min: 1 },
{ v: wallSecs.value.trim(), min: 1 },
]
if (fields.every(f => !f.v)) return undefined
const nums: number[] = []
for (const f of fields) {
if (!f.v) {
nums.push(0)
continue
}
const n = Number(f.v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return null
nums.push(n)
}
return { max_rounds: nums[0], max_tokens: nums[1], wall_secs: nums[2] }
}
/** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行 */
async function confirmSet(): Promise<void> {
const id = activeId.value
if (!id || locked.value || loadFailed.value) return
const budget = parseBudget()
if (budget === null) {
emit('toast', { msg: t('ai.taskBoundary.budgetInvalid'), type: 'warning' })
return
}
const boundary: TaskBoundary = {
risk_ceiling: risk.value,
dir_allowlist: dirs.value.slice(),
forbid_destructive: true,
budget: budget ?? null,
}
try {
await saveConvBoundary(id, boundary)
open.value = false
emit('toast', { msg: t('ai.taskBoundary.setOk'), type: 'success' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
}
}
/** 清除边界:set null,回退逐次审批(接管转人工语义),可随时重新授权。
* A3: 收权是安全向,不受 generating 编辑锁限制(任务跑偏时可立即收回授权,唯一出路
* 不该是停掉整个任务);因运行中收权影响后续工具审批方式,加 confirm 二次确认 */
async function clearBoundary(): Promise<void> {
const id = activeId.value
if (!id) return
if (!await confirmDialog(t('ai.taskBoundary.clearConfirm'))) return
try {
await saveConvBoundary(id, null)
resetForm()
emit('toast', { msg: t('ai.taskBoundary.clearOk'), type: 'info' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
}
}
// 折叠行只读回显摘要:「已授权 · 风险上限 中 · 2 个目录 · 预算不限」
const echoSummary = computed(() => {
const b = current.value
if (!b) return ''
const riskWord = b.risk_ceiling === 'low'
? t('ai.taskBoundary.riskLow')
: b.risk_ceiling === 'medium'
? t('ai.taskBoundary.riskMedium')
: t('ai.agentTrace.riskHigh') // 存量 high 边界回显(单选已不产出 high,兜底转 i18n 不直出裸串)
const parts = [
t('ai.taskBoundary.echoRisk', { risk: riskWord }),
t('ai.taskBoundary.echoDirs', { n: b.dir_allowlist.length }),
b.budget
? t('ai.taskBoundary.echoBudget', { rounds: b.budget.max_rounds, tokens: b.budget.max_tokens, secs: b.budget.wall_secs })
: t('ai.taskBoundary.echoNoBudget'),
]
return parts.join(' · ')
})
</script>
<style scoped>
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */
.ai-boundary {
margin-bottom: 4px;
font-size: 11px;
color: var(--df-text-dim);
}
.ai-boundary-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 4px;
border: none;
background: none;
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
border-radius: var(--df-radius-sm, 4px);
max-width: 100%;
}
.ai-boundary-toggle:hover { color: var(--df-text); }
.ai-boundary-chevron {
transition: transform 0.15s var(--df-ease);
flex-shrink: 0;
}
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
.ai-boundary-toggle-label { font-weight: 600; }
.ai-boundary-echo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-accent);
opacity: 0.85;
}
.ai-boundary-body {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 7px;
padding: 8px;
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
border-radius: var(--df-radius);
}
.ai-boundary-locked {
font-size: 10.5px;
color: var(--df-warning);
}
.ai-boundary-loadfail {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
color: var(--df-danger);
}
.ai-boundary-dir-err {
font-size: 10.5px;
color: var(--df-danger);
}
.ai-boundary-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ai-boundary-row--col {
flex-direction: column;
align-items: stretch;
gap: 5px;
}
.ai-boundary-label {
font-weight: 600;
color: var(--df-text);
}
.ai-boundary-radio {
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
color: var(--df-text-dim);
}
.ai-boundary-radio.is-checked { color: var(--df-accent); }
.ai-boundary-radio input { accent-color: var(--df-accent); margin: 0; }
.ai-boundary-radio:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
.ai-boundary-dirs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ai-boundary-dir {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: calc(var(--df-radius) - 2px);
font-size: 10.5px;
color: var(--df-text);
max-width: 100%;
}
.ai-boundary-dir-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.ai-boundary-dir-x {
border: none;
background: none;
color: var(--df-text-dim);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0 2px;
}
.ai-boundary-dir-x:hover:not(:disabled) { color: var(--df-danger); }
.ai-boundary-dir-x:disabled { cursor: not-allowed; opacity: 0.5; }
.ai-boundary-dir-add {
display: flex;
gap: 4px;
}
.ai-boundary-input {
flex: 1;
min-width: 0;
font-size: 11px;
padding: 2px 6px;
background: var(--df-bg-card);
color: var(--df-text);
border: 0.5px solid var(--df-border);
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); }
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-input--num { flex: 0 0 90px; }
.ai-boundary-mini-btn {
border: 0.5px solid var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
flex-shrink: 0;
}
.ai-boundary-mini-btn:hover:not(:disabled) { color: var(--df-text); }
.ai-boundary-mini-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.ai-boundary-budget-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: none;
padding: 0;
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
align-self: flex-start;
}
.ai-boundary-budget-toggle:hover { color: var(--df-text); }
.ai-boundary-budget {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ai-boundary-field {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--df-text-dim);
white-space: nowrap;
}
.ai-boundary-dim {
font-size: 10px;
opacity: 0.7;
}
.ai-boundary-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ai-boundary-confirm {
border: none;
background: var(--df-accent);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
transition: background 0.15s var(--df-ease);
}
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); }
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-clear {
border: 0.5px solid var(--df-border);
background: transparent;
color: var(--df-text-dim);
font-size: 11px;
padding: 4px 10px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
}
.ai-boundary-clear:hover:not(:disabled) { color: var(--df-danger); border-color: var(--df-danger); }
.ai-boundary-clear:disabled { cursor: not-allowed; opacity: 0.5; }
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<!-- 无人值守安全停靠卡(设计 §四):后端非正常终局(六路,stop_kind 词表见 i18n)emit AiTaskParked(已保存现场并复位
generating,非死等),useAiEvents/useAiLifecycleEvents 登记 pendingTaskParked(per-conv Map)
后本卡渲染。复用 HelpRequiredCard 模式:per-conv 守卫(挂起会话===当前展示会话)+ 行为后摘卡。
卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 -->
<div v-if="parkedActive" ref="cardRef" class="ai-task-parked">
<div class="ai-task-parked-head">
<span class="ai-task-parked-tag" :class="kindClass">{{ kindLabel }}</span>
<span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span>
</div>
<span class="ai-task-parked-reason">{{ parkedActive.reason }}</span>
<div class="ai-task-parked-actions">
<!-- cancelled(审批挂起中被用户主动取消)不显「继续」:用户刚点过停止,首位按钮再诱导续跑与意图相逆 -->
<button v-if="parkedActive.stopKind !== 'cancelled'" class="ai-task-parked-btn" :disabled="busy" @click="handleContinue">{{ t('aiChat.continueLoop') }}</button>
<button class="ai-task-parked-btn ai-task-parked-btn--muted" :disabled="busy" @click="handleStopArchive">{{ t('ai.taskParked.stopArchive') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api'
import { pendingTaskParked } from '../../composables/ai/useAiPendingState'
// toast 经 emit 转父(保持单一 toast 源,与 HelpRequiredCard 一致)
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 当前展示会话的停靠挂起(无则不渲染)。后端停靠已复位 generating,与 HelpRequiredCard 同理
// 不依赖 streaming 判断显隐。Map 键控 per-conv,多会话各自停靠互不覆盖。
const parkedActive = computed(() => {
const id = store.state.activeConversationId
if (!id) return null
return pendingTaskParked.get(id) ?? null
})
// stop_kind 词表(后端契约钉死):命中词表取对应文案与色级,未知值中性兜底(向后兼容后端新增变体)
const KNOWN_KINDS = new Set(['approval_timeout', 'cancelled', 'circuit_breaker', 'stall_breaker', 'max_rounds', 'fatal', 'midstream'])
const kindLabel = computed(() => {
const k = parkedActive.value?.stopKind
if (k && KNOWN_KINDS.has(k)) return t(`ai.taskParked.stopKind.${k}`)
return t('ai.taskParked.unknownKind')
})
// fatal 走 danger 红色(连续失败重入预算耗尽,警示级最高;语义上仍有「继续」路),其余 warning 琥珀
const kindClass = computed(() => (parkedActive.value?.stopKind === 'fatal' ? 'ai-task-parked-tag--fatal' : ''))
// 卡片根 ref。停靠常发生在多轮迭代后消息流已长,卡显时 scrollIntoView 让操作按钮立即可见
const cardRef = ref<HTMLElement | null>(null)
watch(parkedActive, async (p) => {
if (!p) return
await nextTick()
cardRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
// 操作进行中防重复点击(继续/停止都是 IPC 往返,连点会重复触发续跑/停止)
const busy = ref(false)
/** 继续:ai_continue_loop 重新授权续跑(预算重计=用户再授权语义);成功即摘卡 */
async function handleContinue(): Promise<void> {
const convId = parkedActive.value?.conversationId
if (!convId || busy.value) return
busy.value = true
try {
await aiApi.continueLoop(convId)
pendingTaskParked.delete(convId)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.continueLoopFailed', { msg }), type: 'error' })
} finally {
busy.value = false
}
}
/** 停止归档:既有 ai_chat_stop 停止路径;停靠态 loop 已复位,此调用确保任何残留终局并落库,
* 成功后摘卡(用户不再续跑)。会话归档沿用侧栏既有入口,此处不代做避免不可逆误触 */
async function handleStopArchive(): Promise<void> {
const convId = parkedActive.value?.conversationId
if (!convId || busy.value) return
busy.value = true
try {
await aiApi.stopChat(convId)
pendingTaskParked.delete(convId)
emit('toast', { msg: t('ai.taskParked.stoppedToast'), type: 'info' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskParked.archiveFailed', { msg }), type: 'error' })
} finally {
busy.value = false
}
}
</script>
<style scoped>
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */
.ai-task-parked {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
border-radius: var(--df-radius);
}
.ai-task-parked-head {
display: flex;
align-items: center;
gap: 6px;
}
.ai-task-parked-tag {
flex-shrink: 0;
padding: 0 6px;
border-radius: calc(var(--df-radius) - 2px);
background: var(--df-warning);
color: #000;
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.ai-task-parked-tag--fatal {
background: var(--df-danger);
color: #fff;
}
.ai-task-parked-title {
font-size: 11.5px;
font-weight: 600;
color: var(--df-warning);
}
.ai-task-parked-reason {
font-size: 11px;
color: var(--df-text-dim);
opacity: 0.9;
word-break: break-word;
white-space: pre-wrap;
}
.ai-task-parked-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.ai-task-parked-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
background: var(--df-warning);
color: #000;
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
}
.ai-task-parked-btn:hover { filter: brightness(1.1); }
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-task-parked-btn--muted {
background: transparent;
color: var(--df-text-dim);
border-color: var(--df-border);
}
.ai-task-parked-btn--muted:hover { color: var(--df-text); }
</style>
+80 -1
View File
@@ -119,6 +119,20 @@
<div class="ai-bottom-tools" ref="goalsRef"> <div class="ai-bottom-tools" ref="goalsRef">
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span> <span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
<div class="ai-bottom-tools-right"> <div class="ai-bottom-tools-right">
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。 -->
<div v-if="taskProgress" class="ai-task-progress">
<span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span
v-if="taskProgress.maxRounds > 0"
class="ai-task-progress-bar"
:title="$t('ai.taskProgress.barHint', { round: taskProgress.round, max: taskProgress.maxRounds })"
>
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
</span>
</div>
<!-- 目标计数 --> <!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline"> <div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge ai-tool-badge" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')"> <span class="ai-goals-inline-badge ai-tool-badge" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
@@ -217,6 +231,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai' import { aiApi } from '../../api/ai'
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types' import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
defineProps<{ defineProps<{
@@ -356,6 +371,28 @@ const currentConvTitle = computed(() => {
return conv?.title || '' return conv?.title || ''
}) })
// ── 无人值守任务进度(设计 §4.3,AiTaskProgress per-conv 快照)──
// 消费 useAiEvents 归集的 pendingTaskProgress(per-conv Map,终局即清):无快照不渲染。
// 切会话自动跟随(Map 按 activeId 取),多会话并发各显各的,不串台。
const taskProgress = computed(() => {
const id = store.state.activeConversationId
if (!id) return null
return pendingTaskProgress.get(id) ?? null
})
const taskProgressText = computed(() => {
const p = taskProgress.value
if (!p) return ''
const tokens = p.tokensUsed >= 1000 ? `${(p.tokensUsed / 1000).toFixed(1)}k` : String(p.tokensUsed)
return p.maxRounds > 0
? t('ai.taskProgress.running', { round: p.round, max: p.maxRounds, tokens })
: t('ai.taskProgress.unlimited', { round: p.round, tokens })
})
const taskProgressPct = computed(() => {
const p = taskProgress.value
if (!p || p.maxRounds <= 0) return 0
return Math.min(100, Math.round((p.round / p.maxRounds) * 100))
})
// 🎯 对话目标面板(对话透明化 L1:Goal visibility) // 🎯 对话目标面板(对话透明化 L1:Goal visibility)
const goals = ref<GoalEntry[]>([]) const goals = ref<GoalEntry[]>([])
const goalsExpanded = ref(false) const goalsExpanded = ref(false)
@@ -744,7 +781,10 @@ onBeforeUnmount(() => {
color: var(--df-text-secondary); color: var(--df-text-secondary);
min-width: 0; min-width: 0;
margin-right: auto; margin-right: auto;
/* 长标题单行截断(防换行撑高工具行;右侧徽章族 flex-shrink:0,分离窄窗口更挤) */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.ai-bottom-tools-right { .ai-bottom-tools-right {
display: flex; display: flex;
@@ -753,6 +793,45 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-left: auto;
} }
/* 无人值守任务进度(设计 §4.3):绿点+轮次/token 文本+细进度条,与右侧徽章族同排 */
.ai-task-progress {
display: inline-flex;
align-items: center;
gap: 5px;
margin-right: 4px;
white-space: nowrap;
}
.ai-task-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-success);
flex-shrink: 0;
animation: ai-task-progress-pulse 1.6s ease-in-out infinite;
}
.ai-task-progress-text {
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-task-progress-bar {
display: inline-block;
width: 48px;
height: 3px;
border-radius: 2px;
background: color-mix(in srgb, var(--df-text-dim) 25%, transparent);
overflow: hidden;
}
.ai-task-progress-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--df-success);
transition: width .4s var(--df-ease);
}
@keyframes ai-task-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ═══ Provider Bar (header 中间区) ═══ */ /* ═══ Provider Bar (header 中间区) ═══ */
.ai-provider-bar { .ai-provider-bar {
display: inline-flex; display: inline-flex;
+38 -2
View File
@@ -34,7 +34,8 @@
<section class="panel" v-if="connForm.visible"> <section class="panel" v-if="connForm.visible">
<div class="panel-header"> <div class="panel-header">
<h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2> <h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">{{ $t('common.cancel') }}</button> <!-- L19 对齐: 取消走 closeConnForm,脏表单二次确认后再关(防误弃改动) -->
<button class="btn btn-ghost btn-sm" @click="closeConnForm">{{ $t('common.cancel') }}</button>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div class="form-field"> <div class="form-field">
@@ -77,7 +78,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, onMounted } from 'vue' import { reactive, ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
@@ -120,6 +121,8 @@ const connForm = reactive({
port: DEFAULT_PORTS.mysql, port: DEFAULT_PORTS.mysql,
user: '', user: '',
password: '', password: '',
// L19 对齐: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isConnDirty 比对(同 ProviderPanel 范式)
savedSnapshot: '' as string,
}) })
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮 // 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
@@ -146,6 +149,34 @@ function typeLabel(type: string) {
return map[type] || type return map[type] || type
} }
// L19 对齐: 脏表单二次确认——关闭前与已落库快照比对,有改动先 confirmDialog 防误关丢配置
// (password 计入快照:输入了未保存的密钥同样算脏)
function connBusinessSnapshot(): string {
return JSON.stringify({
name: connForm.name,
type: connForm.type,
host: connForm.host,
port: connForm.port,
user: connForm.user,
password: connForm.password,
})
}
const isConnDirty = computed(() => {
if (!connForm.visible) return false
return connBusinessSnapshot() !== connForm.savedSnapshot
})
/** 关闭表单入口(模板「取消」用):脏表单二次确认后再关,防误弃改动 */
async function closeConnForm() {
if (isConnDirty.value) {
const ok = await props.confirmDialog(t('settings.confirmDiscardUnsaved'))
if (!ok) return // 用户取消关闭,继续编辑
}
connForm.visible = false
connForm.savedSnapshot = ''
}
function openConnForm(conn?: ConnRecord) { function openConnForm(conn?: ConnRecord) {
if (conn) { if (conn) {
connForm.editId = conn.id connForm.editId = conn.id
@@ -165,6 +196,8 @@ function openConnForm(conn?: ConnRecord) {
connForm.password = '' connForm.password = ''
} }
connForm.visible = true connForm.visible = true
// 记初始快照(visible=true、字段回填完成后),isConnDirty 据此比对
connForm.savedSnapshot = connBusinessSnapshot()
} }
/** 用户手动切换连接类型时,端口自动重置为该类型默认端口。 /** 用户手动切换连接类型时,端口自动重置为该类型默认端口。
@@ -202,7 +235,10 @@ function saveConn() {
connections.value.push(record) connections.value.push(record)
} }
persistConnections() persistConnections()
// 成功明确反馈:关表单 + 清快照(刚落库无脏,直接关不走二次确认)+ 绿色「已保存」toast(对齐 ProviderPanel)
connForm.visible = false connForm.visible = false
connForm.savedSnapshot = ''
emit('toast', t('settings.toastSaved'), 'success')
} finally { } finally {
submitting.value = false submitting.value = false
} }
+23 -1
View File
@@ -521,6 +521,12 @@ async function fetchModels() {
apiKey: providerForm.apiKey, apiKey: providerForm.apiKey,
providerId: providerForm.editId || null, providerId: providerForm.editId || null,
}) })
// 空列表守卫:拉到 0 个模型时保留现有模型表格(不覆盖),仅提示——
// 与后端 ai_fetch_models 空守卫同语义(空结果不清用户配置),防误清后顺手保存落库
if (models.length === 0) {
emit('toast', t('settings.toastFetchEmpty'), 'warning')
return
}
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖 // 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。 // (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重 // enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
@@ -599,6 +605,20 @@ async function saveProvider() {
} else { } else {
providerForm.temperature = null providerForm.temperature = null
} }
// 模型权重钳制 [0,100](同温度范式:原生 min/max 只约束步进按钮,手输越界照存)。
// 越界/小数 → clamp+取整;清空残留非数值(v-model.number 得 '')→ 归一 50(对齐负载池 NaN 兜底)。
// 多行聚合一次 toast,逐行报会刷屏。
let weightClamped = false
for (const m of providerForm.models) {
if (typeof m.weight === 'number' && Number.isFinite(m.weight)) {
const c = Math.min(100, Math.max(0, Math.trunc(m.weight)))
if (c !== m.weight) { m.weight = c; weightClamped = true }
} else {
m.weight = 50
weightClamped = true
}
}
if (weightClamped) emit('toast', t('settings.toastWeightClamped'), 'warning')
providerForm.saving = true providerForm.saving = true
try { try {
await aiApi.saveProvider({ await aiApi.saveProvider({
@@ -672,7 +692,9 @@ async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled const prev = p.enabled
p.enabled = enabled p.enabled = enabled
try { try {
await aiApi.updateProviderPool(p.id, enabled, p.weight ?? 50) // 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info') emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) { } catch (e) {
p.enabled = prev // revert p.enabled = prev // revert
+25 -3
View File
@@ -9,6 +9,8 @@
@close="$emit('close')" @close="$emit('close')"
header-style="display:none" header-style="display:none"
> >
<!-- 关联会话跳转反馈(P3-7):命中已删会话时抽屉保持打开,toast 就地提示 -->
<div v-if="toast.visible" class="drawer-toast" :class="'drawer-toast--' + toast.type">{{ toast.msg }}</div>
<div class="task-drawer" v-if="task"> <div class="task-drawer" v-if="task">
<!-- 标题行:标题 + 状态 + 优先级 + 推进按钮 --> <!-- 标题行:标题 + 状态 + 优先级 + 推进按钮 -->
<div class="dr-header"> <div class="dr-header">
@@ -205,7 +207,7 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const store = useProjectStore() const store = useProjectStore()
const { showToast } = useToast() const { toast, showToast } = useToast()
const { openConversation } = useAiConversations() const { openConversation } = useAiConversations()
// 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏) // 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏)
@@ -299,10 +301,15 @@ async function loadTaskConversations(tid: string) {
} catch { taskConversations.value = [] } } catch { taskConversations.value = [] }
} }
/** 点击关联会话:先关抽屉(面板在抽屉下层,不关会被遮挡),再走 openConversation 跳转 */ /** 点击关联会话:跳转成功才关抽屉(面板在抽屉下层,不关会被遮挡);命中已删会话时留原地
* 并 toast(UX走查 P3-7:原实现先关抽屉且静默兜底新建,用户落进空白对话无解释) */
async function handleOpenConversation(convId: string) { async function handleOpenConversation(convId: string) {
const res = await openConversation(convId)
if (res === 'not_found') {
showToast(t('ai.convDeleted'), 'error')
return
}
emit('close') emit('close')
await openConversation(convId)
} }
async function loadChildren() { async function loadChildren() {
@@ -402,4 +409,19 @@ async function confirmSubtask() {
word-break: break-all; word-break: break-all;
} }
.conv-link:hover { text-decoration: underline; } .conv-link:hover { text-decoration: underline; }
/* 抽屉内 toast(P3-7):fixed + 高 z-index 盖过 Arco Drawer 遮罩(内容被 teleport 到 body,作用域样式仍生效) */
.drawer-toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 3000;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 80%;
overflow-wrap: anywhere;
}
.drawer-toast--error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
</style> </style>
+54
View File
@@ -624,6 +624,14 @@ export interface AgentFinishedCard {
status: AgentFinishedStatus status: AgentFinishedStatus
rounds: number rounds: number
tokens: number tokens: number
/** 被拒工具清单("工具名×次数",如 "write_file×3",后端 format_rejected_tools 产出)。
* 字段名保持事件 wire 格式 snake_case(summary 是后端拼好的全文,结构化清单只此一份,
* 卡面 160 字截断裁掉 summary 尾部时渲染端仍可扫描被拒事实);缺省=零拒绝。 */
rejected_tools?: string[]
/** 终止原因结构化字段(后端 AiAgentFinished.stop_kind,cancelled/wall_clock/max_rounds/
* token_budget/empty_convergence/error/converged)。缺省=旧后端未发,渲染端回退
* summary 启发式(向后兼容);实时事件到达时以此覆盖启发式判断。 */
stop_kind?: string
finishedAt: number finishedAt: number
} }
@@ -661,6 +669,52 @@ export function clearConvAgentFinished(convId: string | null | undefined): void
convAgentFinished.delete(convId) convAgentFinished.delete(convId)
} }
/** ai_sub_agent_list 的单条轨迹元数据(wire shape 归口 api 层返回类型,此处只投影用) */
type SubAgentRef = Awaited<ReturnType<typeof aiApi.listSubAgents>>[number]
/**
* P2-2 完成卡回补:重启/分离窗回收后内存 Map 清零,子代理轨迹成无 UI 入口孤岛。
* 按父 conv 经 ai_sub_agent_list 反查子代理轨迹,重建已完成卡(轨迹存在即视为 completed;
* rejected_tools/rounds/tokens 后端该 IPC 不回,缺省=零拒绝/0,卡面隐藏 0 值 meta)。
*
* 幂等/去重/降级三保证:
* - 幂等:挂载恢复与每次 switchConversation 都会触发,agentId 键控 insert-only,重复调用收敛;
* - 去重:同 agentId 已有卡(实时事件先到/上轮回补过)跳过,不覆盖已有数据;
* 实时事件走 addConvAgentFinished 的 splice 覆盖语义,晚到仍会覆盖回补卡(实时数据优先)。
* - 降级:IPC 失败仅 console.warn 返回,调用方 fire-and-forget,不阻塞会话加载。
*/
export async function backfillConvAgentFinished(convId: string | null | undefined): Promise<void> {
if (!convId) return
let refs: SubAgentRef[]
try {
refs = await aiApi.listSubAgents(convId)
} catch (e) {
console.warn('[AI] 完成卡回补失败(静默降级):', convId, e)
return
}
if (refs.length === 0) return
let arr = convAgentFinished.get(convId)
if (!arr) {
arr = []
convAgentFinished.set(convId, arr)
}
for (const ref of refs) {
const agentId = ref.conversation_id
// agentId 即子会话 conv id({parent}::agent-{n} 全形,与实时事件 agent_id 同源),
// 与既有卡天然同键;无 id 的脏行跳过防空键卡
if (!agentId || arr.some(c => c.agentId === agentId)) continue
arr.push({
agentId,
// title 懒创建常为 None,回退显 agent-{n} 尾段(对齐轨迹浮层身份行切分逻辑)
summary: (ref.title || '').trim() || agentId.split('::').pop() || agentId,
status: 'completed',
rounds: 0,
tokens: 0,
finishedAt: Number(ref.created_at) || Date.now(),
})
}
}
// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ── // ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ──
// //
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages() // 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
+71 -24
View File
@@ -10,9 +10,10 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { persistUiState } from './useAiPanel' import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals } from './aiShared' import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished } from './aiShared'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types' import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -154,6 +155,12 @@ function getLoadMoreState() {
return loadMoreCursor return loadMoreCursor
} }
/** 会话是否处于在途生成态(非终止三态;switchConversation/loadMoreHistory 回载口径一致) */
function isConvGenerating(convId: string): boolean {
const cs = getConvState(convId)
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
}
/** /**
* 后端 ChatMessage[] → 前端 AiMessage[](switch + load_more 共用)。 * 后端 ChatMessage[] → 前端 AiMessage[](switch + load_more 共用)。
* 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、 * 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、
@@ -161,8 +168,12 @@ function getLoadMoreState() {
* id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}` * id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}`
* (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'), * (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'),
* 防两条 id 方案在 prepend 后冲突。 * 防两条 id 方案在 prepend 后冲突。
* convGenerating:目标会话是否在途生成。主 loop 在工具执行前已落库带 tool_calls 的 assistant
* 消息,回载时无 tool_result 的 tool_call 不能恒标 completed(UX走查 P1-2:运行期切回父会话
* 见「已完成」幽灵态);在途时标 running 由后续 AiToolCallCompleted 按 id 推进终态。
* 会话不在生成(后端崩溃残留等)无更好真相,维持 completed 原行为。
*/ */
function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] { function parseConvMessages(rawMsgs: any[], fallbackPrefix: string, convGenerating = false): AiMessage[] {
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果 // 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
const toolResultMap = new Map<string, string>() const toolResultMap = new Map<string, string>()
for (const m of rawMsgs) { for (const m of rawMsgs) {
@@ -227,7 +238,9 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
id: tc.id, id: tc.id,
name: tc.function?.name || '', name: tc.function?.name || '',
args, args,
status: 'completed' as const, status: (toolResultMap.has(tc.id) || !convGenerating
? 'completed'
: 'running') as AiToolCallStatus,
result: toolResultMap.get(tc.id), result: toolResultMap.get(tc.id),
} }
}), }),
@@ -248,7 +261,7 @@ async function loadMoreHistory(): Promise<boolean> {
// 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图 // 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图
if (cur.convId !== state.activeConversationId) return false if (cur.convId !== state.activeConversationId) return false
const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages
const older = parseConvMessages(rawMsgs, 'older') const older = parseConvMessages(rawMsgs, 'older', isConvGenerating(cur.convId))
// 按 id 去重(防御:游标边界变化 / 与已加载消息重复) // 按 id 去重(防御:游标边界变化 / 与已加载消息重复)
const existing = new Set(state.messages.map(m => m.id)) const existing = new Set(state.messages.map(m => m.id))
const fresh = older.filter(m => !existing.has(m.id)) const fresh = older.filter(m => !existing.has(m.id))
@@ -272,12 +285,16 @@ async function loadMoreHistory(): Promise<boolean> {
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) /** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复)
* @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue * @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */ * onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。
export async function switchConversation(id: string, force = false) { * @param opts.noCreateFallback true=「对话不存在」时不再兜底新建会话,返回 'not_found'
* 交调用方反馈(UX走查 P3-7:程序化跳转命中已删会话,静默新建落空白对话)。
* @returns 'not_found' 仅在 noCreateFallback 且目标会话不存在时;其余路径(含瞬态失败,
* 已保留原视图并推气泡)一律 'ok'。 */
export async function switchConversation(id: string, force = false, opts?: { noCreateFallback?: boolean }): Promise<'ok' | 'not_found'> {
// 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse + // 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse +
// pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留 // pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留
// in-flight 流式文本。程序化刷新(force=true)不受影响。 // in-flight 流式文本。程序化刷新(force=true)不受影响。
if (!force && id === state.activeConversationId) return if (!force && id === state.activeConversationId) return 'ok'
// 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。 // 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。
// 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。 // 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。
clearAllApprovalTimers() clearAllApprovalTimers()
@@ -294,9 +311,14 @@ export async function switchConversation(id: string, force = false) {
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。 // 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
const errMsg = e instanceof Error ? e.message : String(e) const errMsg = e instanceof Error ? e.message : String(e)
if (errMsg.includes('对话不存在')) { if (errMsg.includes('对话不存在')) {
// 程序化跳转(P3-7):目标已删不静默新建,留原视图由调用方就地反馈
if (opts?.noCreateFallback) {
console.warn('[AI] switchConversation: 会话不存在(已删除),保持原视图:', id)
return 'not_found'
}
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id) console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
const created = await aiApi.createConversation() const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
void appSettings.set('df-ai-active-conv', created.id) void appSettings.set('df-ai-active-conv', created.id)
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留 // 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由 // 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
@@ -313,15 +335,15 @@ export async function switchConversation(id: string, force = false) {
setConvPendingApprovals(created.id, []) setConvPendingApprovals(created.id, [])
notifyConversationChanged() notifyConversationChanged()
scheduleConversationsRefresh() scheduleConversationsRefresh()
return return 'ok'
} }
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。 // 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
// 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。 // 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e) console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
pushConvOpFail('switchConvFail') pushConvOpFail('switchConvFail')
scheduleConversationsRefresh() scheduleConversationsRefresh()
return return 'ok'
} finally { } finally {
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。 // 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
// 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃, // 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃,
@@ -331,7 +353,7 @@ export async function switchConversation(id: string, force = false) {
setConvCurrentText(id, '') setConvCurrentText(id, '')
} }
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
// 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。 // 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。 // 后端在 generating/空对话等场景返 false/null,此处默认兜底。
const switchDetailAny = detail as any const switchDetailAny = detail as any
@@ -343,8 +365,7 @@ export async function switchConversation(id: string, force = false) {
// (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的 // (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的
// 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生, // 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生,
// 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。 // 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。
const targetCs = getConvState(id) const targetGen = isConvGenerating(id)
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
// 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值, // 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值,
// 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而 // 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而
@@ -356,7 +377,8 @@ export async function switchConversation(id: string, force = false) {
// 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。 // 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id // 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。 // (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
state.messages = parseConvMessages(rawMsgs, 'loaded') // targetGen 透传(UX走查 P1-2):在途回载时无结果的 tool_call 标 running。
state.messages = parseConvMessages(rawMsgs, 'loaded', targetGen)
// parse 成功才置 active(保证 active 与 messages 一致) // parse 成功才置 active(保证 active 与 messages 一致)
state.activeConversationId = id state.activeConversationId = id
// 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位 // 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
@@ -393,12 +415,17 @@ export async function switchConversation(id: string, force = false) {
isError: true, isError: true,
timestamp: Date.now(), timestamp: Date.now(),
} as AiMessage) } as AiMessage)
return return 'ok'
} }
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv) // 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
void appSettings.set('df-ai-active-conv', id) void appSettings.set('df-ai-active-conv', id)
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' }) setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
// 启动恢复也走本函数(loadConversations 的 switchConversation(pending)),一处挂载全覆盖;
// fire-and-forget 不阻塞切换,幂等去重与静默降级见 backfillConvAgentFinished。
void backfillConvAgentFinished(id)
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值 // 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
const conv = state.conversations.find(c => c.id === id) const conv = state.conversations.find(c => c.id === id)
if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) { if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) {
@@ -429,7 +456,7 @@ export async function switchConversation(id: string, force = false) {
try { try {
const pending = await aiApi.pendingToolCalls(id) const pending = await aiApi.pendingToolCalls(id)
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals // 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
if (pending.length) { if (pending.length) {
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染 // kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind])) const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
@@ -478,6 +505,7 @@ export async function switchConversation(id: string, force = false) {
state.pendingApprovals = [] state.pendingApprovals = []
} }
} }
return 'ok'
} }
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。 /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。
@@ -541,13 +569,16 @@ async function deleteConversation(id: string) {
notifyConversationChanged({ deletedConvId: id }) notifyConversationChanged({ deletedConvId: id })
} }
/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */ /** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败
* + convDeleted 关联会话跳转命中已删会话) */
type ConvOpFailKey = type ConvOpFailKey =
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail' | 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail' | 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
| 'convDeleted'
/** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象, /** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象,
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */ * ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。
* convDeleted(P3-7/P1-1 分离窗路径)也走此通道:反馈须落在承接会话的窗口内。 */
export const convOpFailNotice = ref<{ export const convOpFailNotice = ref<{
key: ConvOpFailKey key: ConvOpFailKey
params?: Record<string, string | number> params?: Record<string, string | number>
@@ -657,17 +688,33 @@ function toggleSidebar() {
} }
/** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。 /** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,只切会话不动面板。 */ * 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,emit 跨窗事件由
async function openConversation(id: string) { * 分离窗切会话并聚焦(UX走查 P1-1:原实现只切主窗内存态,分离窗不跟随不聚焦,感知「点了没反应」)。
* 跳转属程序化导航:命中已删会话不静默新建(P3-7),返回 'not_found' 由调用方就地反馈;
* detached 路径的实际切换结果落在分离窗,本窗一律返回 'ok'。 */
async function openConversation(id: string): Promise<'ok' | 'not_found'> {
if (state.detached) {
await emitDetachedOpenConversation(id)
return 'ok'
}
// 已展开不重复写 persistUiState(省一次 debounced SQLite 写) // 已展开不重复写 persistUiState(省一次 debounced SQLite 写)
if (!state.detached && !state.panelOpen) { if (!state.panelOpen) {
state.panelOpen = true state.panelOpen = true
persistUiState() persistUiState()
} }
await switchConversation(id) return switchConversation(id, false, { noCreateFallback: true })
}
/** 分离窗消费跨窗切会话事件(P1-1):本地切会话;命中已删会话经 convOpFailNotice 由本窗
* 侧栏弹 toast(事件发起方在主窗口,反馈必须落在承接会话的分离窗内)。 */
async function handleDetachedOpenConversation(convId: string) {
const res = await switchConversation(convId, false, { noCreateFallback: true })
if (res === 'not_found') convOpFailNotice.value = { key: 'convDeleted' }
} }
export function useAiConversations() { export function useAiConversations() {
// 跨窗切会话监听注册(幂等;label 非分离窗不实际挂 listener,主窗口为事件发起方)
registerDetachedOpenConvListener(handleDetachedOpenConversation)
return { return {
loadConversations, loadConversations,
scheduleConversationsRefresh, scheduleConversationsRefresh,
+85 -5
View File
@@ -12,6 +12,7 @@
//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。 //! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。
import { listen, emit } from '@tauri-apps/api/event' import { listen, emit } from '@tauri-apps/api/event'
import { t } from '@/i18n/i18n-helpers'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
@@ -19,9 +20,11 @@ import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream' import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh } from './useAiConversations' import { scheduleConversationsRefresh } from './useAiConversations'
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearConvBoundaryCache } from './useTaskBoundary'
import { handleStreamingEvent } from './useAiStreamingEvents' import { handleStreamingEvent } from './useAiStreamingEvents'
import { handleToolEvent } from './useAiToolEvents' import { handleToolEvent } from './useAiToolEvents'
import { handleLifecycleEvent } from './useAiLifecycleEvents' import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents'
import type { AiChatEvent, MessageId } from '@/api/types' import type { AiChatEvent, MessageId } from '@/api/types'
// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变) // 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变)
@@ -40,9 +43,12 @@ let _startPromise: Promise<void> | null = null
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助由各自 case 内 clear)。 // 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助/停靠由各自 case 内 clear)。
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。 // 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired']) const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired', 'AiTaskParked'])
// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」)
const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
/** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产, /** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产,
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv * 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
@@ -73,6 +79,8 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
status: AgentFinishedStatus status: AgentFinishedStatus
rounds: number rounds: number
tokens: number tokens: number
rejected_tools?: string[] | null
stop_kind?: string | null
} }
const convId = event.conversation_id const convId = event.conversation_id
if (!convId || !ev.agent_id) return true if (!convId || !ev.agent_id) return true
@@ -82,13 +90,73 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
status: ev.status, status: ev.status,
rounds: ev.rounds, rounds: ev.rounds,
tokens: ev.tokens, tokens: ev.tokens,
// 后端 Option→null/缺省,卡片语义统一为缺省=零拒绝
rejected_tools: ev.rejected_tools ?? undefined,
// 结构化终止原因(可选,旧后端不发):存卡供卡面状态词优先消费,替代 summary 启发式
stop_kind: ev.stop_kind ?? undefined,
}) })
// P3-6:后台会话完成轻提醒(当前会话卡已在消息流可见,不弹)。
// composable 无组件上下文,复用 ai-toast 事件总线先例(ai-tool-slow-toast),AiChat listen 弹本地 toast;
// 多个子代理连发时 toast 单槽自然合并,无风暴面。
if (convId !== state.activeConversationId) {
// 未知 status 兜底按 completed 语气(防 t 回显键名);failed 用 warning 色与完成区分
const statusKey = ev.status === 'failed' ? 'failed' : ev.status === 'truncated' ? 'truncated' : 'completed'
const collapsed = ev.summary.replace(/\s+/g, ' ').trim()
const summary = collapsed.length > AGENT_FINISHED_TOAST_SUMMARY_MAX
? collapsed.slice(0, AGENT_FINISHED_TOAST_SUMMARY_MAX) + '…'
: collapsed
void emit('ai-toast', {
msg: t('ai.agentFinishedToast', { status: t(`ai.agentFinished.${statusKey}`), summary }),
type: ev.status === 'failed' ? 'warning' : 'info',
})
}
// 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到 // 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到
// 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。 // 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。
resetStreamWatchdog(convId) resetStreamWatchdog(convId)
return true return true
} }
/** 无人值守停靠域:消费 AiTaskParked(per-conv 落卡 + 后台会话收敛,挂起家族终局)。
* 返回 true=已处理。仅拦截后台会话:当前会话放行到 lifecycle 域统一收尾(看门狗/流式复位
* 在 cleanupTerminatedConversation,后台分支此处的收敛集合与其对齐但不重复触发两次)。
* 停靠卡必须 per-conv 落卡:多会话并发各自可停靠,后台会话的卡丢了用户切回前无从得知。 */
function handleTaskParkedEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiTaskParked') return false
const convId = event.conversation_id || state.activeConversationId
if (convId && convId !== state.activeConversationId) {
recordTaskParked(convId, event.reason, event.stop_kind ?? undefined)
// 对齐 AiCompleted 家族后台分支的收敛:删状态项回不在生成、清 per-conv 流式态/去重记录/
// 挂起审批集合/自动续跑计数/进度快照,再刷新侧栏(防抖调度)
convStates.delete(convId)
clearConvStreamState(convId)
clearLastDelta(convId)
discardConvPendingApprovals(convId)
clearResumeAttempts(convId || undefined)
pendingTaskProgress.delete(convId)
scheduleConversationsRefresh()
return true
}
return false
}
/** 无人值守进度域:消费 AiTaskProgress(per-conv 快照,静默期「在干活」可见)。
* 返回 true=已处理。isCurrent 守卫之前归集(同停靠卡范式):后台会话的进度也要落 Map,
* 切回仍可见。进度事件即活体信号,顺带 resetStreamWatchdog(子代理长跑/重入退避期间
* 后端心跳之外的第二重存活证明,防看门狗误判停滞)。 */
function handleTaskProgressEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiTaskProgress') return false
const convId = event.conversation_id || state.activeConversationId
if (!convId) return true
pendingTaskProgress.set(convId, {
round: event.round,
maxRounds: event.max_rounds,
tokensUsed: event.tokens_used,
updatedAt: Date.now(),
})
resetStreamWatchdog(convId)
return true
}
/** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。 /** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。
* 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */ * 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */
function handleUserMessageEvent(event: AiChatEvent): boolean { function handleUserMessageEvent(event: AiChatEvent): boolean {
@@ -128,6 +196,10 @@ export function handleEvent(event: AiChatEvent) {
if (handleUserMessageEvent(event)) return if (handleUserMessageEvent(event)) return
// S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡) // S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
if (handleAgentFinishedEvent(event)) return if (handleAgentFinishedEvent(event)) return
// 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
if (handleTaskParkedEvent(event)) return
// 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度)
if (handleTaskProgressEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏; // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏;
// 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派 // 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派
const isCurrent = !convId || convId === state.activeConversationId const isCurrent = !convId || convId === state.activeConversationId
@@ -145,6 +217,8 @@ export function handleEvent(event: AiChatEvent) {
clearLastDelta(convId || '') clearLastDelta(convId || '')
discardConvPendingApprovals(convId || '') discardConvPendingApprovals(convId || '')
clearResumeAttempts(convId || undefined) clearResumeAttempts(convId || undefined)
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
pendingTaskProgress.delete(convId || '')
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh() scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
@@ -168,8 +242,10 @@ export function handleEvent(event: AiChatEvent) {
// 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入 // 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入
if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return
// 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口; // 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口;
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用) // 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') { // 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与
// 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') {
setConvState(convId, 'generating') setConvState(convId, 'generating')
} }
// 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时) // 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时)
@@ -206,6 +282,10 @@ export async function startListener() {
// S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图) // S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图)
if (deleted) { if (deleted) {
clearConvAgentFinished(deleted) clearConvAgentFinished(deleted)
// 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二)
pendingTaskParked.delete(deleted)
pendingTaskProgress.delete(deleted)
clearConvBoundaryCache(deleted)
} }
}) })
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
+42 -5
View File
@@ -1,6 +1,6 @@
//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation //! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation
//! //!
//! 处理:AiCompleted / AiError / AiHelpRequired + 上下文生命周期事件(AiCompressing/ //! 处理:AiCompleted / AiError / AiHelpRequired / AiTaskParked + 上下文生命周期事件(AiCompressing/
//! AiManualCompressed/AiAutoCompressed/AiContextCleared)。 //! AiManualCompressed/AiAutoCompressed/AiContextCleared)。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard / //! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard /
//! useAiConversations,不导入任何同级子 composable。 //! useAiConversations,不导入任何同级子 composable。
@@ -10,7 +10,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared' import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared'
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
@@ -43,14 +43,16 @@ function isShowTokenUsage(): boolean {
} }
/** /**
* 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired 共用):清看门狗/计时器/流式态/挂起。 * 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。
* *
* 语义差异(调用方自行处理): * 语义差异(调用方自行处理):
* - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
* - AiError: 调用后追加错误气泡 + 清 queue * - AiError: 调用后追加错误气泡 + 清 queue
* - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡 * - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡
* - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp,
* 熔断并存场景见下方 pendingHelp 分支注释)
*/ */
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired' | 'AiTaskParked') {
clearStreamWatchdog(convId || undefined) clearStreamWatchdog(convId || undefined)
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId // 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta // 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
@@ -83,8 +85,16 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
pendingMaxRounds.value = null pendingMaxRounds.value = null
} }
if (pendingHelp.value && pendingHelp.value.conversationId === convId) { if (pendingHelp.value && pendingHelp.value.conversationId === convId) {
pendingHelp.value = null // 熔断场景后端并存发求助卡+停靠卡(agentic/mod.rs 熔断分支先 emit 求助卡再「补发」
// AiTaskParked,注释自称并存意图):停靠收尾不清求助卡,help options 三选项不丢,
// 两卡并存供用户任选入口。其余终态(AiCompleted/AiError/AiHelpRequired)照旧清。
if (reason !== 'AiTaskParked') pendingHelp.value = null
} }
// 无人值守停靠卡:会话有任何终态(含停靠本身)即摘旧卡;停靠 case 在收尾后重新登记,
// 其余终态(AiCompleted/AiError,点继续续跑后的正常收敛/出错)摘卡即消失,语义对齐 pendingHelp
pendingTaskParked.delete(convId)
// 无人值守进度快照:终态即清(事件即逝态,终止后不留陈旧「进行中」;下次 loop 每 5 轮重建)
pendingTaskProgress.delete(convId)
// 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留) // 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId) pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响; // 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
@@ -104,6 +114,21 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
localStorage.removeItem('df-ai-text') localStorage.removeItem('df-ai-text')
} }
/**
* 登记无人值守停靠卡(幂等,convId 键控覆盖)。lifecycle 主路径与 useAiEvents 后台会话
* 分支共用唯一写入口,防止两处字段口径漂移。convId 缺失兜底当前活跃会话(优于丢卡)。
*/
export function recordTaskParked(convId: string | null | undefined, reason: string, stopKind?: string): void {
const id = convId || state.activeConversationId
if (!id) return
pendingTaskParked.set(id, {
reason,
stopKind: stopKind || undefined,
conversationId: id,
parkedAt: Date.now(),
})
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */ /** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */
export function handleLifecycleEvent(event: AiChatEvent): boolean { export function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) { switch (event.type) {
@@ -215,6 +240,18 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
return true return true
} }
case 'AiTaskParked': {
// 无人值守安全停靠(设计 §四):五路非正常终局统一出口,后端已保存现场并复位
// generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡,
// 改登记 pendingTaskParked 驱动停靠卡(reason + stop_kind 标签 + 继续/停止归档)。
// 队列不动:停靠等用户拍板,排队消息不自动续发不静默清(继续/停止由用户决定)。
cleanupTerminatedConversation(event.conversation_id || '', 'AiTaskParked')
recordTaskParked(event.conversation_id, event.reason, event.stop_kind ?? undefined)
// FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。
notifyConversationChanged()
return true
}
case 'AiCompressing': case 'AiCompressing':
_contextCallbacks.onCompressing?.() _contextCallbacks.onCompressing?.()
return true return true
+27 -1
View File
@@ -5,7 +5,7 @@
//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/ //! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/
//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。 //! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。
import { ref } from 'vue' import { reactive, ref } from 'vue'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -75,3 +75,29 @@ export interface PendingHelp {
conversationId: string | null conversationId: string | null
} }
export const pendingHelp = ref<PendingHelp | null>(null) export const pendingHelp = ref<PendingHelp | null>(null)
// 无人值守停靠挂起(后端 AiTaskParked 置):convId 键控 Map 而非单 ref。
// 与 pendingHelp 单 ref 的差异原因:停靠是无人值守长任务的常态终局,多会话并发下
// 多个会话可各自停靠,单 ref 后到覆盖先到会丢卡(待审批 pendingDirAuths 同款教训)。
// 消费方(TaskParkedCard)按 activeConversationId 取卡,per-conversation 归位不串台。
// 幂等:同会话重复停靠按键覆盖(后端单出口单发,覆盖仅防监听重放双卡)。
export interface PendingTaskParked {
reason: string
/** 后端结构化停止原因(词表 approval_timeout/cancelled/circuit_breaker/stall_breaker/max_rounds/fatal/midstream);旧后端不发为 undefined */
stopKind?: string
conversationId: string
parkedAt: number
}
export const pendingTaskParked = reactive(new Map<string, PendingTaskParked>())
// 无人值守进度快照(后端 AiTaskProgress 每 5 轮置,设计 §4.3):convId 键控 Map(停靠卡同范式,
// 多会话并发各自有进度互不覆盖)。只存最近一次快照(事件即逝,后端不落库);消费方(TopBar
// 进度条)按 activeConversationId 取,静默期(子代理长跑/重入退避)凭最后快照可见「在干活」。
// 终局(AiCompleted/AiError/AiHelpRequired/AiTaskParked)与会话删除时清理。maxRounds=0=不限轮次。
export interface PendingTaskProgress {
round: number
maxRounds: number
tokensUsed: number
updatedAt: number
}
export const pendingTaskProgress = reactive(new Map<string, PendingTaskProgress>())
+3 -1
View File
@@ -90,7 +90,9 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
state.messages.push({ state.messages.push({
id: userMsgId as MessageId, id: userMsgId as MessageId,
role: 'user', role: 'user',
content: text.trim(), // 技能调用留痕(问题3,与后端 chat.rs user_content 同规则):带技能时本地气泡即刻补
// /技能名 前缀,与落库文本一致——否则发送瞬间无痕、切回会话才见前缀,前后显示不一。
content: skill ? `/${skill} ${text.trim()}` : text.trim(),
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归) // 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
parts: parts && parts.length > 0 ? parts : undefined, parts: parts && parts.length > 0 ? parts : undefined,
// Input Augmentation: 仅在非空时挂 mentionSpans(供消息列表 chip 渲染;空则 undefined 零回归) // Input Augmentation: 仅在非空时挂 mentionSpans(供消息列表 chip 渲染;空则 undefined 零回归)
+51 -1
View File
@@ -1,7 +1,9 @@
//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain //! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain
//! + 跨窗切会话(emitDetachedOpenConversation/registerDetachedOpenConvListener)
//! //!
//! 模块级私有(不进 reactive): //! 模块级私有(不进 reactive):
//! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener //! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener
//! - _openConvListenerRegistered: 跨窗切会话监听注册标记(幂等)
//! //!
//! 耦合: //! 耦合:
//! - resumeInDetached / restoreGeneratingState 调 aiShared.nextMsgId 占位 ai 气泡 + //! - resumeInDetached / restoreGeneratingState 调 aiShared.nextMsgId 占位 ai 气泡 +
@@ -35,6 +37,54 @@ const TEXT_KEY_PREFIX = 'df-ai-text-'
function detachedLabel(convId: string): string { function detachedLabel(convId: string): string {
return DETACHED_LABEL_PREFIX + convId return DETACHED_LABEL_PREFIX + convId
} }
/** 分离窗口 label 判定(含旧版单 label 'ai-detached' 兼容) */
function isDetachedLabel(label: string): boolean {
return label === 'ai-detached' || label.startsWith(DETACHED_LABEL_PREFIX)
}
// ── 跨窗切会话(UX走查 2026-10-03 P1-1)──
// 主/分离窗口是独立 JS context(state 互不同步),主窗口点「关联会话」跳转时分离窗不跟随。
// 约定:发起方 emit 全局事件广播到全部 webview,仅分离窗(按 label 判定)注册消费,
// 主窗口 label 'main' 不注册,不会自发自收。
const OPEN_CONV_EVENT = 'ai-open-conversation'
/** 发起方(主窗口)侧:广播跨窗切会话事件;无分离窗存活时事件落空 */
export async function emitDetachedOpenConversation(convId: string): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(OPEN_CONV_EVENT, { convId })
} catch (e) {
console.error('[AI] 跨窗切会话事件发送失败:', e)
}
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
let _openConvListenerRegistered = false
export function registerDetachedOpenConvListener(handler: (convId: string) => Promise<void>): void {
if (_openConvListenerRegistered) return
_openConvListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (!isDetachedLabel(getCurrentWebviewWindow().label)) return
await listen<{ convId?: string }>(OPEN_CONV_EVENT, async (ev) => {
const convId = ev.payload?.convId
if (!convId) return
await handler(convId)
try { await getCurrentWebviewWindow().setFocus() } catch { /* 窗口关闭竞态忽略 */ }
})
} catch (e) {
_openConvListenerRegistered = false
console.error('[AI] 注册跨窗切会话监听失败:', e)
}
})()
}
function genKey(convId: string): string { function genKey(convId: string): string {
return GEN_KEY_PREFIX + convId return GEN_KEY_PREFIX + convId
} }
@@ -125,7 +175,7 @@ async function reattachPanel() {
const all = await WebviewWindow.getAll() const all = await WebviewWindow.getAll()
for (const w of all) { for (const w of all) {
const lbl = w.label const lbl = w.label
if (lbl === 'ai-detached' || lbl.startsWith(DETACHED_LABEL_PREFIX)) { if (isDetachedLabel(lbl)) {
try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ } try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ }
} }
} }
+61
View File
@@ -0,0 +1,61 @@
//! 无人值守任务边界(per-conv 边界缓存 + IPC 读写)
//!
//! 后端真相源是 per_conv.boundary 内存态 + boundary_json 落库快照(ai_task_boundary_get/set)。
//! 前端这里维护一份只读镜像 Map,服务两个消费面:
//! - ConversationSidebar 徽标:批量 IPC 不在契约内,镜像按「访问过/设置过」渐进填充
//! (切会话时配置卡拉取、设置卡写入),未访问会话不显徽标属已接受取舍;
//! - TaskBoundaryCard 回显:打开卡/切会话时读镜像,miss 时拉一次。
//!
//! 叶子定位:仅依赖 vue + api 层,不导入任何 composable,无环风险。
import { reactive } from 'vue'
import { aiApi } from '@/api'
import type { TaskBoundary } from '@/api/types'
/** convId → 当前边界镜像(无键=无边界或未拉取)。后端 set/get 单发,前端不做节流 */
export const convBoundaries = reactive(new Map<string, TaskBoundary>())
/** 拉取失败会话集合(镜像 miss 的两种语义须区分:确认无边界 vs 拉取失败。
* 失败态配置卡禁「一次授权」,防默认空表单静默覆盖库中原授权) */
export const convBoundaryFetchFailed = reactive(new Set<string>())
/** 某会话边界最近一次拉取是否失败(失败态镜像内容不可信) */
export function isConvBoundaryFetchFailed(convId: string | null | undefined): boolean {
if (!convId) return false
return convBoundaryFetchFailed.has(convId)
}
/** 读某会话边界镜像;无记录返回 null */
export function getConvBoundary(convId: string | null | undefined): TaskBoundary | null {
if (!convId) return null
return convBoundaries.get(convId) ?? null
}
/** 拉取某会话边界并刷新镜像(切会话/打开配置卡时调)。侧栏徽标消费侧保持静默降级;
* 配置卡经 isConvBoundaryFetchFailed 读取失败态(成功即清标记,失败置标记) */
export async function fetchConvBoundary(convId: string | null | undefined): Promise<void> {
if (!convId) return
try {
const b = await aiApi.getTaskBoundary(convId)
convBoundaryFetchFailed.delete(convId)
if (b) convBoundaries.set(convId, b)
else convBoundaries.delete(convId)
} catch (e) {
convBoundaryFetchFailed.add(convId)
console.warn('[TaskBoundary] 读取边界失败(徽标静默降级,配置卡显失败态):', convId, e)
}
}
/** 写入某会话边界(boundary 传 null 清除),IPC 成功后才更新镜像(失败不撒谎) */
export async function saveConvBoundary(convId: string | null | undefined, boundary: TaskBoundary | null): Promise<void> {
if (!convId) return
await aiApi.setTaskBoundary(convId, boundary)
if (boundary) convBoundaries.set(convId, boundary)
else convBoundaries.delete(convId)
}
/** 清某会话镜像(会话删除时调,防 Map 持已删会话条目) */
export function clearConvBoundaryCache(convId: string | null | undefined): void {
if (!convId) return
convBoundaries.delete(convId)
}
+5
View File
@@ -112,6 +112,11 @@ export function toolDisplayName(tc: AiToolCallInfo): string {
const host = httpHost(url) const host = httpHost(url)
return host ? `${tGlobal('aiTool.fetchUrlPrefix')} ${host}` : tGlobal('aiTool.fetchUrlFallback') return host ? `${tGlobal('aiTool.fetchUrlPrefix')} ${host}` : tGlobal('aiTool.fetchUrlFallback')
} }
case 'spawn_agent': {
// 任务书截断展示(对齐后端 G1 GoalMeta「派发子代理」Truncate 25 字;shortCmd 顺带折叠多行任务书为单行)
const task = argString(tc.args, 'task')
return task ? tGlobal('aiTool.spawnAgentPrefix', { task: shortCmd(task, 25) }) : formatToolName(tc.name)
}
default: return formatToolName(tc.name) default: return formatToolName(tc.name)
} }
} }
+79
View File
@@ -49,6 +49,8 @@ export default {
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent) // conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.', loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
switchConvFail: 'Failed to switch conversation. Please retry.', switchConvFail: 'Failed to switch conversation. Please retry.',
// UX走查 P3-7/P1-1: linked-conversation jump hit a deleted conversation (was silently creating a blank one / dead click in detached window)
convDeleted: 'Conversation deleted',
// M30: rename/archive/pin IPC failure feedback (was no try/catch, exceptions leaked to unhandledrejection) // M30: rename/archive/pin IPC failure feedback (was no try/catch, exceptions leaked to unhandledrejection)
renameConvFail: 'Failed to rename. Please retry.', renameConvFail: 'Failed to rename. Please retry.',
archiveConvFail: 'Failed to {action}. Please retry.', archiveConvFail: 'Failed to {action}. Please retry.',
@@ -87,11 +89,23 @@ export default {
title: 'Sub-agent', title: 'Sub-agent',
completed: 'completed', completed: 'completed',
truncated: 'truncated', truncated: 'truncated',
cancelled: 'cancelled',
failed: 'failed', failed: 'failed',
// stop_kind structured stop reasons (replaces summary heuristics; truncation sub-kinds surfaced)
truncMaxRounds: 'max rounds reached',
truncWallClock: 'timed out',
truncTokenBudget: 'token budget reached',
noReport: 'no report produced',
// Rejected-tools chip ({tools} = "tool×count" items, first 2 shown then +N)
rejected: 'rejected {tools}',
// Startup-failed sub-agents have no persisted trace; card is click-disabled
noTrace: 'No trace (failed to start)',
meta: '{rounds} rounds · {tokens} tokens', meta: '{rounds} rounds · {tokens} tokens',
// S3: finished card is clickable to open the read-only trace (hover hint) // S3: finished card is clickable to open the read-only trace (hover hint)
viewHint: 'Click to view execution trace', viewHint: 'Click to view execution trace',
}, },
// P3-6 background-conversation sub-agent finished light toast ({status} reuses agentFinished status words above, {summary} truncated to 40 chars)
agentFinishedToast: 'Sub-agent {status}: {summary}',
// S3 sub-agent trace overlay (opened from the finished card, read-only simple render, // S3 sub-agent trace overlay (opened from the finished card, read-only simple render,
// discarded on close) // discarded on close)
agentTrace: { agentTrace: {
@@ -102,6 +116,71 @@ export default {
roleAssistant: 'Assistant', roleAssistant: 'Assistant',
roleTool: 'Tool result', roleTool: 'Tool result',
roleSystem: 'System', roleSystem: 'System',
deniedTool: 'Denied: {tool}',
riskLow: 'low risk',
riskMedium: 'medium risk',
riskHigh: 'high risk',
},
// Unattended task parked card (AiTaskParked, terminal of the pending family):
// Continue re-authorizes the loop, Stop archives without resuming
taskParked: {
title: 'Task parked',
// stop_kind structured stop reason (vocabulary pinned by backend contract; unknown values fall back to unknownKind)
stopKind: {
approval_timeout: 'Approval timed out',
cancelled: 'Cancelled',
circuit_breaker: 'Circuit breaker',
stall_breaker: 'Stall breaker',
max_rounds: 'Max rounds reached',
fatal: 'Repeated failures (retry budget exhausted)',
midstream: 'Stream interrupted',
},
unknownKind: 'Parked',
stopArchive: 'Stop & Archive',
stoppedToast: 'Task stopped, will not resume',
archiveFailed: 'Stop failed: {msg}',
},
// Unattended task progress (AiTaskProgress, design §4.3): TopBar lightweight progress,
// visible as "still working" during silent periods
taskProgress: {
running: 'Running · round {round}/{max} · {tokens} tokens',
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)
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',
confirmSet: 'Authorize once, no interruptions for the whole task',
clearBoundary: 'Clear boundary',
setOk: 'Unattended boundary authorized',
clearOk: 'Boundary cleared, back to per-action approval',
setFail: 'Failed to set boundary: {msg}',
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',
echoNoBudget: 'no budget limit',
}, },
}, },
} }
+3
View File
@@ -110,6 +110,7 @@ export default {
// ── Skill ── // ── Skill ──
clearSkill: 'Clear skill', clearSkill: 'Clear skill',
skillBadgeTip: 'Injected full skill playbook: {name}',
placeholderWithSkill: 'Skill selected. Type arguments and press Enter', placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)", placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)",
skillNoMatch: 'No matching skill', skillNoMatch: 'No matching skill',
@@ -141,6 +142,8 @@ export default {
imageTooLarge: 'Image too large (max 10MB), skipped', imageTooLarge: 'Image too large (max 10MB), skipped',
imageLimit: 'Up to {n} images, extras ignored', imageLimit: 'Up to {n} images, extras ignored',
imageReadFailed: 'Failed to read image', imageReadFailed: 'Failed to read image',
imageIgnoredWarn: 'Current model does not support images; they will be ignored on send',
imageOnlyBlocked: 'Current model does not support images; switch to a vision model or add some text',
// ── Confirm dialog ── // ── Confirm dialog ──
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.', confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
+2
View File
@@ -117,6 +117,8 @@ export default {
// fetch_url header display name (useToolCardHeader): host-shortened prefix + fallback when no url // fetch_url header display name (useToolCardHeader): host-shortened prefix + fallback when no url
fetchUrlPrefix: 'Fetch', fetchUrlPrefix: 'Fetch',
fetchUrlFallback: 'Fetch URL', fetchUrlFallback: 'Fetch URL',
// spawn_agent header display name (P3-3: task truncated to 25 chars, consumes backend "dispatch sub-agent" goal metadata)
spawnAgentPrefix: 'Spawn agent: {task}',
installObscura: 'Install obscura via AI', installObscura: 'Install obscura via AI',
obscuraInstallPrompt: 'Please install obscura (a Rust headless browser, used by fetch_url render=true for JS rendering). Try `cargo install obscura` first; if cargo is unavailable, download the prebuilt binary for this platform from the GitHub releases (github.com/h4ckf0r0day/obscura) and place it on PATH. After installing, verify with `obscura --version` and tell me I can retry the web fetch.', obscuraInstallPrompt: 'Please install obscura (a Rust headless browser, used by fetch_url render=true for JS rendering). Try `cargo install obscura` first; if cargo is unavailable, download the prebuilt binary for this platform from the GitHub releases (github.com/h4ckf0r0day/obscura) and place it on PATH. After installing, verify with `obscura --version` and tell me I can retry the web fetch.',
// grep cross-file content search results per output_mode phrasing // grep cross-file content search results per output_mode phrasing
+2
View File
@@ -23,6 +23,7 @@ export default {
// Out-of-range clamp notices (concurrency 1-64 + per-conv ≤ global; temperature reports the corrected value) // Out-of-range clamp notices (concurrency 1-64 + per-conv ≤ global; temperature reports the corrected value)
toastConcurrencyClamped: 'Value out of range, corrected automatically (concurrency 1-{max}; per-conversation capped at global)', toastConcurrencyClamped: 'Value out of range, corrected automatically (concurrency 1-{max}; per-conversation capped at global)',
toastTempClamped: 'Sampling temperature must be within 0-2, corrected to {v}', toastTempClamped: 'Sampling temperature must be within 0-2, corrected to {v}',
toastWeightClamped: 'Model weight must be within 0-100; out-of-range values corrected (cleared inputs default to 50)',
// Prompt caching toggle save feedback (previously silent) // Prompt caching toggle save feedback (previously silent)
toastAnthropicCacheSaved: 'Prompt caching setting saved', toastAnthropicCacheSaved: 'Prompt caching setting saved',
toastAnthropicCacheFail: 'Failed to save the prompt caching setting', toastAnthropicCacheFail: 'Failed to save the prompt caching setting',
@@ -88,6 +89,7 @@ export default {
saveModels: 'Save model config', saveModels: 'Save model config',
modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them', modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them',
toastFetchOk: 'Fetched {count} models', toastFetchOk: 'Fetched {count} models',
toastFetchEmpty: 'Fetched 0 models — existing config kept',
toastFetchFail: 'Fetch failed: {msg}', toastFetchFail: 'Fetch failed: {msg}',
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}', fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
fetchFailedAuth: 'Authentication failed, check your API Key: {msg}', fetchFailedAuth: 'Authentication failed, check your API Key: {msg}',
+76
View File
@@ -49,6 +49,8 @@ export default {
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈) // 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接', loadConvFail: '对话列表加载失败,请检查网络或后端连接',
switchConvFail: '对话切换失败,请重试', switchConvFail: '对话切换失败,请重试',
// UX走查 P3-7/P1-1: 关联会话跳转命中已删会话(原静默新建空会话/分离窗死点击)
convDeleted: '会话已删除',
// M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感) // M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感)
renameConvFail: '重命名失败,请重试', renameConvFail: '重命名失败,请重试',
archiveConvFail: '{action}失败,请重试', archiveConvFail: '{action}失败,请重试',
@@ -86,11 +88,23 @@ export default {
title: '子代理', title: '子代理',
completed: '已完成', completed: '已完成',
truncated: '已截断', truncated: '已截断',
cancelled: '已取消',
failed: '失败', failed: '失败',
// stop_kind 结构化终止原因细分(根治 summary 启发式;截断三态各自透出)
truncMaxRounds: '已达轮次上限',
truncWallClock: '超时截断',
truncTokenBudget: 'token 预算截断',
noReport: '未产出报告',
// 被拒工具 chip({tools} 为「工具名×次数」枚举,多枚前 2 枚 + N)
rejected: '被拒 {tools}',
// 启动失败型子代理未落库无轨迹,卡禁点(hover 说明替代 viewHint)
noTrace: '无轨迹(启动失败)',
meta: '{rounds} 轮 · {tokens} tokens', meta: '{rounds} 轮 · {tokens} tokens',
// S3:完成卡可点击查看只读轨迹(hover 提示) // S3:完成卡可点击查看只读轨迹(hover 提示)
viewHint: '点击查看执行轨迹', viewHint: '点击查看执行轨迹',
}, },
// P3-6 后台会话子代理完成轻提醒({status} 复用上方 agentFinished 状态词,{summary} 截前 40 字)
agentFinishedToast: '子代理{status}: {summary}',
// S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃) // S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃)
agentTrace: { agentTrace: {
title: '子代理执行轨迹', title: '子代理执行轨迹',
@@ -100,6 +114,68 @@ export default {
roleAssistant: '助手', roleAssistant: '助手',
roleTool: '工具结果', roleTool: '工具结果',
roleSystem: '系统', roleSystem: '系统',
deniedTool: '被拒:{tool}',
riskLow: '低风险',
riskMedium: '中风险',
riskHigh: '高风险',
},
// 无人值守安全停靠卡(AiTaskParked,挂起家族终局):继续=重新授权续跑,停止归档=不再续跑
taskParked: {
title: '任务已停靠',
// stop_kind 结构化停止原因(词表后端契约钉死,未知值走 unknownKind 中性兜底)
stopKind: {
approval_timeout: '审批超时',
cancelled: '已取消',
circuit_breaker: '熔断保护',
stall_breaker: '停滞保护',
max_rounds: '已达轮次上限',
fatal: '连续失败(重入预算耗尽)',
midstream: '响应流中断',
},
unknownKind: '任务停靠',
stopArchive: '停止归档',
stoppedToast: '任务已停止,不再续跑',
archiveFailed: '停止失败:{msg}',
},
// 无人值守任务进度(AiTaskProgress,设计 §4.3):TopBar 轻量进度条,静默期可见「在干活」
taskProgress: {
running: '进行中 · 第 {round}/{max} 轮 · {tokens} tokens',
unlimited: '进行中 · 第 {round} 轮 · {tokens} tokens',
barHint: '第 {round}/{max} 轮',
},
// 无人值守边界配置卡(发起时一次授权,存续期生效,随会话同亡)
taskBoundary: {
toggle: '无人值守',
badge: '无人值守边界已授权',
riskCeiling: '风险上限',
riskLow: '低',
riskMedium: '中',
dirs: '目录白名单',
dirPlaceholder: '输入目录绝对路径',
dirAdd: '添加',
dirEmpty: '尚未添加目录',
forbidDestructive: '破坏性命令恒禁止',
budgetToggle: '预算(高级)',
budgetRounds: '最大轮次',
budgetTokens: '最大 tokens',
budgetWallSecs: '挂钟秒数',
budgetHint: '留空表示不限',
budgetInvalid: '预算需为正整数',
confirmSet: '一次授权,任务期内不再打扰',
clearBoundary: '清除边界',
setOk: '已授权无人值守边界',
clearOk: '已清除边界,回到逐次审批',
setFail: '边界设置失败:{msg}',
lockedGenerating: '任务运行中,仅支持清除边界(收权),字段不可修改',
loadFailed: '边界读取失败,当前表单不可信',
retryLoad: '重试',
dirErrIllegal: '路径含非法字符 < > : " | ? *',
dirErrRelative: '请输入绝对路径(如 D:\\projects 或 /home/user)',
clearConfirm: '清除边界将立即收回无人值守授权,运行中的任务后续工具调用回到逐次审批。确定清除?',
echoRisk: '风险上限 {risk}',
echoDirs: '{n} 个目录',
echoBudget: '预算 {rounds} 轮 / {tokens} tokens / {secs}s',
echoNoBudget: '预算不限',
}, },
}, },
} }
+3
View File
@@ -110,6 +110,7 @@ export default {
// ── 技能 ── // ── 技能 ──
clearSkill: '取消技能', clearSkill: '取消技能',
skillBadgeTip: '已注入技能「{name}」的完整规程',
placeholderWithSkill: '已选技能,输入参数后回车', placeholderWithSkill: '已选技能,输入参数后回车',
placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)", placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)",
skillNoMatch: '无匹配技能', skillNoMatch: '无匹配技能',
@@ -142,6 +143,8 @@ export default {
imageTooLarge: '图片过大(上限 10MB),已跳过', imageTooLarge: '图片过大(上限 10MB),已跳过',
imageLimit: '最多 {n} 张图片,已忽略多余的', imageLimit: '最多 {n} 张图片,已忽略多余的',
imageReadFailed: '图片读取失败', imageReadFailed: '图片读取失败',
imageIgnoredWarn: '当前模型不支持图片,发送时图片将被忽略',
imageOnlyBlocked: '当前模型不支持图片,请改用视觉模型或补充文字说明',
// ── 确认弹层 ── // ── 确认弹层 ──
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。', confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
+2
View File
@@ -118,6 +118,8 @@ export default {
// fetch_url 头部显示名(useToolCardHeader):host 精简前缀 + 无 url 时兜底 // fetch_url 头部显示名(useToolCardHeader):host 精简前缀 + 无 url 时兜底
fetchUrlPrefix: '获取', fetchUrlPrefix: '获取',
fetchUrlFallback: '获取网页', fetchUrlFallback: '获取网页',
// spawn_agent 头部显示名(P3-3:任务书截 25 字,消费后端「派发子代理」目标元数据语义)
spawnAgentPrefix: '派发子代理: {task}',
installObscura: 'AI 帮装 obscura', installObscura: 'AI 帮装 obscura',
obscuraInstallPrompt: '请帮我安装 obscura(Rust 无头浏览器,用于 fetch_url 的 render=true JS 渲染)。优先尝试 `cargo install obscura`,若 cargo 不可用则从 GitHub releases(github.com/h4ckf0r0day/obscura)下载对应平台预编译二进制并放入 PATH。安装完成后用 `obscura --version` 验证,然后告诉我可以重试网页抓取了。', obscuraInstallPrompt: '请帮我安装 obscura(Rust 无头浏览器,用于 fetch_url 的 render=true JS 渲染)。优先尝试 `cargo install obscura`,若 cargo 不可用则从 GitHub releases(github.com/h4ckf0r0day/obscura)下载对应平台预编译二进制并放入 PATH。安装完成后用 `obscura --version` 验证,然后告诉我可以重试网页抓取了。',
// grep 跨文件内容搜索结果按 output_mode 分文案 // grep 跨文件内容搜索结果按 output_mode 分文案
+2
View File
@@ -23,6 +23,7 @@ export default {
// 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值) // 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值)
toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)', toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)',
toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}', toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}',
toastWeightClamped: '模型权重须在 0-100 范围内,越界值已修正(清空视为 50)',
// 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈) // 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈)
toastAnthropicCacheSaved: 'Prompt 缓存设置已保存', toastAnthropicCacheSaved: 'Prompt 缓存设置已保存',
toastAnthropicCacheFail: 'Prompt 缓存设置保存失败', toastAnthropicCacheFail: 'Prompt 缓存设置保存失败',
@@ -87,6 +88,7 @@ export default {
saveModels: '保存模型配置', saveModels: '保存模型配置',
modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃', modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃',
toastFetchOk: '已拉取 {count} 个模型', toastFetchOk: '已拉取 {count} 个模型',
toastFetchEmpty: '拉取到 0 个模型,已保留现有配置',
toastFetchFail: '拉取模型失败:{msg}', toastFetchFail: '拉取模型失败:{msg}',
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}', fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
fetchFailedAuth: '鉴权失败,请检查 API Key:{msg}', fetchFailedAuth: '鉴权失败,请检查 API Key:{msg}',
+42 -7
View File
@@ -3,6 +3,7 @@
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责: //! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读 //! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写 //! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
//! - 落库失败保留 pending 脏值待下次写入重试,经 lastWriteError 透出(见 set/flush)
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key //! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
//! //!
//! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 —— //! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 ——
@@ -24,6 +25,11 @@ const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照) /// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
const pendingValues = new Map<string, unknown>() const pendingValues = new Map<string, unknown>()
/// 最近一次落库失败(key + 错误),成功落库同一 key 后清 null。
/// store 层无组件上下文,不直接弹 toast,对齐 knowledge.ts 的 state.error 范式,
/// 上层 watch 此状态做行内红字/toast 通报。
const lastWriteError = ref<{ key: string; error: unknown } | null>(null)
/** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */ /** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */
async function loadAll(): Promise<void> { async function loadAll(): Promise<void> {
const all = await settingsApi.getAll() const all = await settingsApi.getAll()
@@ -41,6 +47,9 @@ function get<T>(key: string, defaultValue: T): T {
/** /**
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。 * 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。 * 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
*
* promise 契约:resolve 仅代表"已调度落库",不代表已落库;本 promise 永不
* reject(调用方 void/await 均安全),落库成败经 lastWriteError 透出。
*/ */
async function set(key: string, value: unknown): Promise<void> { async function set(key: string, value: unknown): Promise<void> {
cache[key] = value cache[key] = value
@@ -51,18 +60,35 @@ async function set(key: string, value: unknown): Promise<void> {
const timer = setTimeout(() => { const timer = setTimeout(() => {
pendingTimers.delete(key) pendingTimers.delete(key)
const latest = pendingValues.get(key) void flush(key)
pendingValues.delete(key)
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
settingsApi
.set(key, JSON.stringify(latest))
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
}, SET_DEBOUNCE_MS) }, SET_DEBOUNCE_MS)
pendingTimers.set(key, timer) pendingTimers.set(key, timer)
} }
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除 */ /**
* 把 pending 中该 key 的最新值落库。失败时保留 pending 脏值不清除,
* 下次该 key 任何 set 会合并进同一队列自然重试;cache 保持乐观值不回跳
* (避免落库失败把用户已输入的视图打回去),失败经 lastWriteError 与
* console.error 双通道透出,状态不再"假成功"。
*/
async function flush(key: string): Promise<void> {
const latest = pendingValues.get(key)
if (latest === undefined) return
try {
await settingsApi.set(key, JSON.stringify(latest))
// 成功才清 pending;若落库期间又有新写合并进来,保留新值交由新定时器落库
if (Object.is(pendingValues.get(key), latest)) pendingValues.delete(key)
if (lastWriteError.value?.key === key) lastWriteError.value = null
} catch (e) {
console.error(`[appSettings] 写入 ${key} 失败,已标记待重试(下次改动该 key 时重试):`, e)
lastWriteError.value = { key, error: e }
}
}
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除;删除失败恢复内存态后原样上抛 */
async function remove(key: string): Promise<void> { async function remove(key: string): Promise<void> {
const prevCache = cache[key]
const prevPending = pendingValues.get(key)
delete cache[key] delete cache[key]
pendingValues.delete(key) pendingValues.delete(key)
const t = pendingTimers.get(key) const t = pendingTimers.get(key)
@@ -70,7 +96,15 @@ async function remove(key: string): Promise<void> {
clearTimeout(t) clearTimeout(t)
pendingTimers.delete(key) pendingTimers.delete(key)
} }
try {
await settingsApi.delete(key) await settingsApi.delete(key)
} catch (e) {
// 删除失败:磁盘仍有该值,恢复内存态保持两端一致,避免重启后该 key 幽灵复活而视图已丢
if (prevCache !== undefined) cache[key] = prevCache
if (prevPending !== undefined) pendingValues.set(key, prevPending)
lastWriteError.value = { key, error: e }
throw e
}
} }
/** /**
@@ -149,6 +183,7 @@ function parse(raw: string): unknown {
export function useAppSettingsStore() { export function useAppSettingsStore() {
return { return {
cache, cache,
lastWriteError,
loadAll, loadAll,
get, get,
set, set,