Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91773da602 | ||
|
|
b994cf2c54 | ||
|
|
08e9d0292d | ||
|
|
d9e0ce73d1 | ||
|
|
7aa0da32f2 | ||
|
|
01d231c39d | ||
|
|
b2d44f85e9 |
Generated
+10
@@ -869,6 +869,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
@@ -4422,6 +4423,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* df-relay WS 客户端(uni-app 跨端适配)
|
||||
* u-relay WS 客户端(uni-app 跨端适配)
|
||||
*
|
||||
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/miniapp`
|
||||
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
|
||||
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
|
||||
* 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/commander`
|
||||
* 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时)
|
||||
* 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close
|
||||
* (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed)
|
||||
*
|
||||
* 跨端适配:
|
||||
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
|
||||
@@ -176,10 +177,10 @@ class WsClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Command(miniapp → device)
|
||||
* 发送 Command(commander → agent)
|
||||
*
|
||||
* 不包装 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 {
|
||||
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
||||
@@ -314,8 +315,8 @@ class WsClient {
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
kind: 'miniapp',
|
||||
device_id: cfg.deviceId,
|
||||
kind: 'commander',
|
||||
channel_id: cfg.channelId,
|
||||
token: cfg.token,
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
|
||||
* miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填)
|
||||
*
|
||||
* 这些值在真实部署前是占位:
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
|
||||
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
|
||||
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址
|
||||
* - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键)
|
||||
* - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3)
|
||||
*/
|
||||
|
||||
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
|
||||
/** 配对绑定的设备 ID */
|
||||
deviceId: string
|
||||
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
|
||||
/** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */
|
||||
channelId: string
|
||||
/** 配对 token(对齐 u-relay U_RELAY_TOKEN) */
|
||||
token: string
|
||||
/** 心跳间隔(ms) */
|
||||
heartbeatInterval: number
|
||||
@@ -29,17 +29,17 @@ export interface MiniappConfig {
|
||||
/**
|
||||
* 默认配置(占位值,需替换)
|
||||
*
|
||||
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
|
||||
* 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。
|
||||
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
|
||||
* 这符合预期,提示用户配置)。
|
||||
*/
|
||||
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 合法域名白名单(微信公众平台→开发设置→服务器域名)
|
||||
relayHost: 'wss://u-work.1216.top/ws/miniapp',
|
||||
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
|
||||
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
|
||||
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
|
||||
relayHost: 'wss://u-work.1216.top/ws/commander',
|
||||
// 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端)
|
||||
// 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程)
|
||||
channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
|
||||
token: 'devflow-relay-default-token',
|
||||
heartbeatInterval: 30000,
|
||||
reconnectBaseDelay: 1000,
|
||||
@@ -54,6 +54,23 @@ const STORAGE_KEY = 'df-miniapp-config'
|
||||
/** 单例配置(null 表示尚未从 storage 加载) */
|
||||
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 读取并合并默认配置
|
||||
*
|
||||
@@ -64,9 +81,10 @@ function loadFromStorage(): MiniappConfig {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (!raw) return { ...defaultConfig }
|
||||
const parsed = JSON.parse(raw) as Partial<MiniappConfig>
|
||||
// 合并默认值,避免旧版本字段缺失导致 undefined
|
||||
return { ...defaultConfig, ...parsed }
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
// 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined
|
||||
const migrated = migrateLegacyFields(parsed)
|
||||
return { ...defaultConfig, ...migrated }
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] load config from storage failed:', e)
|
||||
return { ...defaultConfig }
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAiChat } from '@/composables/useAiChat'
|
||||
/**
|
||||
* 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。
|
||||
*
|
||||
* 默认查看模式(只读展示 relayHost/deviceId/token,token 脱敏);点「编辑」进入编辑模式,
|
||||
* 默认查看模式(只读展示 relayHost/channelId/token,token 脱敏);点「编辑」进入编辑模式,
|
||||
* 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」,
|
||||
* 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。
|
||||
*
|
||||
@@ -82,13 +82,13 @@ function onConnOp(): void {
|
||||
/** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */
|
||||
const editing = ref(false)
|
||||
const draftRelayHost = ref('')
|
||||
const draftDeviceId = ref('')
|
||||
const draftChannelId = ref('')
|
||||
const draftToken = ref('')
|
||||
|
||||
/** 进入编辑模式:以当前配置填充草稿 */
|
||||
function enterEdit(): void {
|
||||
draftRelayHost.value = cfg.value.relayHost
|
||||
draftDeviceId.value = cfg.value.deviceId
|
||||
draftChannelId.value = cfg.value.channelId
|
||||
draftToken.value = cfg.value.token
|
||||
editing.value = true
|
||||
}
|
||||
@@ -101,12 +101,12 @@ function cancelEdit(): void {
|
||||
/** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */
|
||||
function saveEdit(): void {
|
||||
const relayHost = draftRelayHost.value.trim()
|
||||
const deviceId = draftDeviceId.value.trim()
|
||||
if (!relayHost || !deviceId) {
|
||||
uni.showToast({ title: '中继地址和设备 ID 不能为空', icon: 'none' })
|
||||
const channelId = draftChannelId.value.trim()
|
||||
if (!relayHost || !channelId) {
|
||||
uni.showToast({ title: '中继地址和通道 ID 不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setConfig({ relayHost, deviceId, token: draftToken.value.trim() })
|
||||
setConfig({ relayHost, channelId, token: draftToken.value.trim() })
|
||||
editing.value = false
|
||||
uni.showToast({ title: '已保存,正在重连…', icon: 'none' })
|
||||
ai.resumeIfDisconnected()
|
||||
@@ -139,7 +139,7 @@ function saveEdit(): void {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/deviceId/token) -->
|
||||
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/channelId/token) -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">连接配置</text>
|
||||
@@ -151,13 +151,13 @@ function saveEdit(): void {
|
||||
</view>
|
||||
<view class="row">
|
||||
<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>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">设备 ID</text>
|
||||
<input v-if="editing" v-model="draftDeviceId" class="row-input" placeholder="桌面端设备 ID" placeholder-class="row-ph" />
|
||||
<text v-else class="row-value mono">{{ cfg.deviceId }}</text>
|
||||
<text class="row-label">通道 ID</text>
|
||||
<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.channelId }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">配对 Token</text>
|
||||
|
||||
@@ -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
|
||||
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
|
||||
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
|
||||
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
|
||||
* - ClientKind / MessageKind:u-relay/src/broadcast.rs
|
||||
* (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token)
|
||||
* kind 用 ClientKindWire,序列化为 'agent'/'commander'
|
||||
* - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端)
|
||||
*
|
||||
* 关键协议点(relay.rs:332-365 handle_inbound_text):
|
||||
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
|
||||
* 关键协议点(relay.rs handle_inbound_text):
|
||||
* relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。
|
||||
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
|
||||
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 再从 payload 取 AiChatEvent。
|
||||
*
|
||||
* 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线,
|
||||
* 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。
|
||||
*/
|
||||
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
|
||||
export type ClientKind = 'device' | 'miniapp'
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */
|
||||
export type ClientKind = 'agent' | 'commander'
|
||||
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */
|
||||
export type MessageKind = 'event' | 'command' | 'control'
|
||||
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */
|
||||
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
|
||||
|
||||
/**
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage)
|
||||
*
|
||||
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
|
||||
*/
|
||||
export interface BroadcastMessage {
|
||||
/** 配对绑定的设备 ID */
|
||||
device_id: string
|
||||
/** 配对绑定的通道 ID */
|
||||
channel_id: string
|
||||
/** 消息语义 */
|
||||
kind: MessageKind
|
||||
/** 消息来源连接 ID(relay 分配,u64) */
|
||||
/** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */
|
||||
source: ConnId
|
||||
/** 来源客户端类型 */
|
||||
from: ClientKind
|
||||
@@ -49,36 +52,36 @@ export interface BroadcastMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello)
|
||||
*
|
||||
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
|
||||
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
|
||||
* 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。
|
||||
* kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。
|
||||
*/
|
||||
export interface Hello {
|
||||
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
|
||||
kind: 'miniapp' | 'device'
|
||||
/** 配对绑定的设备 ID(用户首次配置时生成) */
|
||||
device_id: string
|
||||
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
|
||||
/** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */
|
||||
kind: 'commander'
|
||||
/** 配对绑定的通道 ID(非空,≤128B,无控制字符) */
|
||||
channel_id: string
|
||||
/** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */
|
||||
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 =
|
||||
| { control_kind: 'pair'; device_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; device_id: string }
|
||||
| { control_kind: 'presence'; device_id: string; state: PresenceState }
|
||||
| { control_kind: 'pair'; channel_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; channel_id: string }
|
||||
| { control_kind: 'presence'; channel_id: string; state: PresenceState }
|
||||
| { control_kind: 'ping' }
|
||||
| { control_kind: 'pong' }
|
||||
|
||||
/**
|
||||
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
|
||||
* 小程序(控制端)→ 桌面端(执行端)Command 协议
|
||||
*
|
||||
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 字段:
|
||||
* - cmd:命令名(对齐 src-tauri command 函数名)
|
||||
* - args:命令参数(对齐各 command 的参数)
|
||||
@@ -227,6 +230,6 @@ export interface ContinueLoopArgs {
|
||||
|
||||
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
|
||||
export interface StopLoopArgs {
|
||||
/** 霷停止循环的会话 id */
|
||||
/** 需停止循环的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
@@ -138,6 +138,14 @@ pub struct ModelConfig {
|
||||
#[serde(default = "default_context_window")]
|
||||
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)──
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub probe_source: Option<ProbeSource>,
|
||||
@@ -173,6 +181,9 @@ impl ModelConfig {
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: default_weight(),
|
||||
context_window: default_context_window(),
|
||||
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
|
||||
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
|
||||
temperature_supported: true,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
@@ -339,6 +350,7 @@ mod tests {
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
temperature_supported: true,
|
||||
probe_source: Some(ProbeSource::PresetTable),
|
||||
};
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
|
||||
@@ -472,6 +472,7 @@ mod tests {
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: Some("thought".to_string()),
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
@@ -350,6 +350,11 @@ pub struct StreamChunk {
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
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 模式推理内容增量
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
|
||||
@@ -133,7 +133,8 @@
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 262144
|
||||
"context_window": 262144,
|
||||
"temperature_supported": false
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-v4-flash",
|
||||
|
||||
@@ -262,12 +262,23 @@ impl AnthropicCompatProvider {
|
||||
.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 {
|
||||
model,
|
||||
messages,
|
||||
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
|
||||
system,
|
||||
temperature: req.temperature,
|
||||
temperature,
|
||||
stream: req.stream,
|
||||
tools,
|
||||
tool_choice: req.tool_choice,
|
||||
@@ -927,6 +938,21 @@ mod tests {
|
||||
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
|
||||
#[test]
|
||||
fn anthropic_message_delta_output_is_cumulative_not_incremental() {
|
||||
|
||||
@@ -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) {
|
||||
Ok(v) => v,
|
||||
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("");
|
||||
@@ -169,7 +169,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
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
|
||||
"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.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,对称 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" => {
|
||||
@@ -190,16 +193,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
|
||||
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();
|
||||
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 回传
|
||||
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
|
||||
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") {
|
||||
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") {
|
||||
@@ -217,10 +220,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: None,
|
||||
error: 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
|
||||
"content_block_start" => {
|
||||
@@ -253,10 +257,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: None,
|
||||
error: 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
|
||||
"message_stop" => StreamChunk {
|
||||
@@ -266,15 +271,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
},
|
||||
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
|
||||
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
|
||||
"error" => {
|
||||
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 流式错误事件");
|
||||
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
|
||||
_ => 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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,12 +841,12 @@ fn phase1_push_only_keeps_clean_no_dirty_flag() {
|
||||
|
||||
#[test]
|
||||
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));
|
||||
mgr.push(ChatMessage::user("x"));
|
||||
mgr.push(ChatMessage::user("y"));
|
||||
// 模拟 save 成功后推进
|
||||
mgr.advance_persisted_count(2);
|
||||
// 模拟 save(全量重写)成功后推进
|
||||
mgr.advance_persisted_count(2, true);
|
||||
assert_eq!(mgr.persisted_msg_count(), 2);
|
||||
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() {
|
||||
// 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.advance_persisted_count(5);
|
||||
mgr.advance_persisted_count(3); // 误传更小
|
||||
mgr.advance_persisted_count(5, true);
|
||||
mgr.advance_persisted_count(3, true); // 误传更小
|
||||
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]
|
||||
fn phase1_compress_old_messages_sets_dirty() {
|
||||
// compress 改既有消息 status(active→compressed) → 必须全量重写收敛
|
||||
@@ -881,7 +896,7 @@ fn phase1_compress_no_op_does_not_set_dirty() {
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
let _ = mgr.compress_old_messages(1);
|
||||
assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty");
|
||||
mgr.advance_persisted_count(1); // 模拟 save 后清 dirty
|
||||
mgr.advance_persisted_count(1, true); // 模拟 save(全量)后清 dirty
|
||||
// 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty
|
||||
let compressed = mgr.compress_old_messages(1);
|
||||
assert!(compressed.is_empty());
|
||||
@@ -974,7 +989,7 @@ fn phase1_clear_resets_baseline_and_sets_dirty() {
|
||||
// clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
mgr.advance_persisted_count(1);
|
||||
mgr.advance_persisted_count(1, true);
|
||||
assert_eq!(mgr.persisted_msg_count(), 1);
|
||||
mgr.clear();
|
||||
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")); // 追加
|
||||
assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty");
|
||||
// 模拟 save(全量重写)成功后推进 + 清
|
||||
mgr.advance_persisted_count(2);
|
||||
mgr.advance_persisted_count(2, true);
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
}
|
||||
|
||||
|
||||
@@ -528,11 +528,15 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
|
||||
/// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。
|
||||
pub fn advance_persisted_count(&mut self, new_count: usize) {
|
||||
/// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。
|
||||
/// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty
|
||||
/// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。
|
||||
pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) {
|
||||
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
|
||||
self.persisted_msg_count = self.persisted_msg_count.max(new_count);
|
||||
self.needs_full_rewrite = false;
|
||||
if full_rewrite_applied {
|
||||
self.needs_full_rewrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
|
||||
|
||||
@@ -159,6 +159,24 @@ mod tests {
|
||||
assert!(m.modalities.contains(&Modality::Text));
|
||||
// context_window 应为预设的 256K
|
||||
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]
|
||||
|
||||
@@ -74,6 +74,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: temperature_supported_by_name(&name),
|
||||
probe_source: None,
|
||||
};
|
||||
}
|
||||
@@ -106,6 +107,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: temperature_supported_by_name(&name),
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
@@ -120,3 +122,11 @@ pub(super) fn has_vision_token(name: &str) -> bool {
|
||||
|| name.contains("vl")
|
||||
|| 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")
|
||||
}
|
||||
|
||||
@@ -209,10 +209,21 @@ impl OpenAICompatProvider {
|
||||
.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 {
|
||||
model,
|
||||
messages,
|
||||
temperature: req.temperature,
|
||||
temperature,
|
||||
max_tokens: req.max_tokens,
|
||||
stream: req.stream,
|
||||
tools,
|
||||
|
||||
@@ -274,6 +274,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
usage: usage_accum.take(),
|
||||
error: 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,
|
||||
error: Some(msg),
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
// 提取 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(),
|
||||
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| {
|
||||
// 缺 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(),
|
||||
error: None,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
truncated_by_max_tokens,
|
||||
}
|
||||
} else {
|
||||
// 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(),
|
||||
error: 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,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,4 +511,32 @@ mod tests {
|
||||
);
|
||||
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 不置截断标志");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
|
||||
//!
|
||||
//! 策略:
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
|
||||
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
|
||||
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
|
||||
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
|
||||
@@ -34,6 +34,11 @@ const BASE_BACKOFF_SECS: u64 = 1;
|
||||
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
|
||||
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 的 ±比例)。避免重试风暴对齐。
|
||||
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=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)
|
||||
}
|
||||
|
||||
/// 退避倍率 `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 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
|
||||
///
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
|
||||
///
|
||||
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
|
||||
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
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 时长的浮动比例
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -160,12 +190,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 {
|
||||
let d = backoff_delay(attempt);
|
||||
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000;
|
||||
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64;
|
||||
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64;
|
||||
let base_ms = base_backoff_ms(attempt);
|
||||
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
|
||||
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
|
||||
assert!(
|
||||
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
|
||||
"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]
|
||||
fn status_classification() {
|
||||
assert!(is_status_retryable(429));
|
||||
|
||||
@@ -163,6 +163,7 @@ mod tests {
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: true,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
# 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 不再接受功能演进(防双份漂移)
|
||||
- 本目录暂保留:现役生产部署(容器 :9180)仍从 devflow 侧构建,待 devflow 客户端(df-tunnel / 小程序 ws.ts)迁移 u-relay 后整体下线(设计 Q7)
|
||||
- u-relay 协议为 df-relay 超集:hello / device_id / Event / Command 帧原样兼容,迁移只需切 host
|
||||
- 本目录暂保留:现役生产部署(容器 :9180)仍从 devflow 侧构建,待 devflow 客户端(df-tunnel / df-miniapp ws.ts)迁移 u-relay 后整体下线(设计 Q7)
|
||||
|
||||
## ⚠️ 协议兼容性(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`
|
||||
|
||||
@@ -108,6 +108,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
// origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式):
|
||||
// 迁移未跑/极端情况防御,NULL=主会话。
|
||||
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),
|
||||
insert => |conn, rec| {
|
||||
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)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
"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, ?16)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
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| {
|
||||
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![
|
||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
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)?
|
||||
}
|
||||
|
||||
/// 无人值守任务边界快照写入(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 子行(单事务原子)。
|
||||
///
|
||||
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联
|
||||
|
||||
@@ -51,7 +51,9 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。
|
||||
// V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库,
|
||||
// 桌面侧栏/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),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -96,6 +98,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(42, migrate_v42),
|
||||
(43, migrate_v43),
|
||||
(44, migrate_v44),
|
||||
(45, migrate_v45),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -1334,6 +1337,23 @@ fn migrate_v44(conn: &Connection) -> Result<()> {
|
||||
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 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -2338,7 +2358,7 @@ mod tests {
|
||||
"tasks.module_id 列缺失(V41 加)"
|
||||
);
|
||||
|
||||
// 3. schema_version 应推进到 43(全量迁移成功落版本号)
|
||||
// 3. schema_version 应推进到 45(全量迁移成功落版本号)
|
||||
let max_version: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
||||
@@ -2347,8 +2367,8 @@ mod tests {
|
||||
)
|
||||
.expect("查 schema_version 应成功");
|
||||
assert_eq!(
|
||||
max_version, 44,
|
||||
"全量迁移后 schema_version 应为 44(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
max_version, 45,
|
||||
"全量迁移后 schema_version 应为 45(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
max_version
|
||||
);
|
||||
|
||||
@@ -2425,7 +2445,7 @@ mod tests {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v, 44, "首轮应推进到 44");
|
||||
assert_eq!(max_v, 45, "首轮应推进到 45");
|
||||
|
||||
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
||||
conn.execute("DELETE FROM schema_version", []).unwrap();
|
||||
@@ -2437,7 +2457,7 @@ mod tests {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v2, 44, "重跑后应重新推进到 44");
|
||||
assert_eq!(max_v2, 45, "重跑后应重新推进到 45");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -2510,4 +2530,37 @@ mod tests {
|
||||
.unwrap();
|
||||
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 应可写可读");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +374,9 @@ pub struct AiConversationRecord {
|
||||
/// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库),
|
||||
/// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。
|
||||
pub origin: Option<String>,
|
||||
/// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
|
||||
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
|
||||
pub boundary_json: Option<String>,
|
||||
}
|
||||
|
||||
/// AI 工具执行审计记录
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -11,41 +11,51 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 握手身份宣告帧(对齐 df-relay::relay::Hello)
|
||||
/// 握手身份宣告帧(对齐 u-relay::relay::Hello)
|
||||
///
|
||||
/// 桌面端 WS 连 `/ws/device` 后,首条消息发此 JSON,relay 校验 kind 与 token。
|
||||
/// 字段名/serde rename 与 df-relay 严格一致(`#[serde(rename_all="snake_case")]`),
|
||||
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
|
||||
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
|
||||
/// 否则 relay 反序列化失败 → 握手被拒。
|
||||
///
|
||||
/// 注意:本 crate 不依赖 df-relay(避免 lib→server 反向依赖),自行镜像协议结构。
|
||||
/// 协议字段如有变更须同步 df-relay::relay::Hello 与本类型。
|
||||
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
|
||||
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
|
||||
///
|
||||
/// 历史:df-relay 一代协议用 device_id + kind:"device",已在 u-relay 现行版全部下线
|
||||
/// (端点 /ws/agent,kind:"agent",配对键 channel_id,旧名握手期直接拒绝)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// 客户端类型("device" / "miniapp"),桌面端恒为 Device
|
||||
/// 客户端类型("agent" / "commander"),桌面端恒为 Agent
|
||||
pub kind: HelloKind,
|
||||
/// 配对绑定的设备 ID
|
||||
pub device_id: String,
|
||||
/// 配对 token(env DF_RELAY_TOKEN 同源)
|
||||
/// 配对绑定的通道 ID(非空,≤128B,无控制字符)
|
||||
pub channel_id: String,
|
||||
/// 配对 token(env U_RELAY_TOKEN 同源)
|
||||
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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HelloKind {
|
||||
Device,
|
||||
Miniapp,
|
||||
Agent,
|
||||
Commander,
|
||||
}
|
||||
|
||||
/// relay 控制面错误帧(握手失败时 relay 发送)
|
||||
///
|
||||
/// relay 在校验失败/连接异常时发 `{"kind":"control","error":"<reason>"}` 文本帧。
|
||||
/// 桌面端据此识别握手失败原因(不依赖 error 帧亦可:连接被 Close 即视为失败)。
|
||||
///
|
||||
/// 现行 u-relay 错误码:handshake_failed / kind_mismatch / invalid_channel_id /
|
||||
/// server_misconfigured / auth_failed。历史 `invalid_device_id` 已下线。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayControlError {
|
||||
/// 固定 "control"(用于与正常 BroadcastMessage 区分,relay 错误帧简化为两字段)
|
||||
pub kind: String,
|
||||
/// 错误原因(handshake_failed / kind_mismatch / auth_failed)
|
||||
/// 错误原因(handshake_failed / kind_mismatch / invalid_channel_id /
|
||||
/// server_misconfigured / auth_failed)
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
@@ -112,10 +122,11 @@ impl TunnelEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 云后端 → 桌面端的操作指令(小程序远程操作映射)
|
||||
/// 云后端 → 桌面端的操作指令(控制端远程操作映射)
|
||||
///
|
||||
/// 对齐设计文档「Layer1 指令路由」:
|
||||
/// send/stop/approve/regenerate/switch 五类操作。
|
||||
/// 由控制端(df-miniapp 等 Commander)发出,经 u-relay 路由到本桌面端 Agent。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TunnelCommand {
|
||||
@@ -133,8 +144,8 @@ pub enum TunnelCommand {
|
||||
|
||||
/// 隧道传输的消息包装(双向)
|
||||
///
|
||||
/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent)
|
||||
/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。
|
||||
/// 区分「桌面端 → u-relay → 控制端」的事件流(TunnelEvent)
|
||||
/// 与「控制端 → u-relay → 桌面端」的指令流(TunnelCommand)。
|
||||
/// 同一 WS 连接复用,靠 kind 标签区分。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "dir", rename_all = "snake_case")]
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。
|
||||
//!
|
||||
//! ## 职责
|
||||
//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射)
|
||||
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序
|
||||
//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command
|
||||
//! - 主动连接云后端 u-relay(出站穿 NAT,无需端口映射)
|
||||
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 控制端(df-miniapp 等)
|
||||
//! - 指令路由:接收控制端远程操作 → 调对应 Tauri command
|
||||
//!
|
||||
//! ## 边界
|
||||
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
|
||||
|
||||
@@ -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 握手 + 收发循环。
|
||||
//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处):
|
||||
//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。
|
||||
//!
|
||||
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
|
||||
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
|
||||
//! - relay 双因子校验(kind == expected_device + token 匹配),失败发
|
||||
//! `{"kind":"control","error":"..."}` + Close,成功则静默进入收发循环(无显式 ack)
|
||||
//! - 握手成功信号:**未收到 error 帧 且 socket 保持打开**(connect 用 timeout 探测首帧)
|
||||
//! - 入站消息:relay 把对端 Miniapp 的文本帧包成 BroadcastMessage 转发,桌面端收到的
|
||||
//! 是完整 BroadcastMessage JSON,payload 是 miniapp 指令原样 JSON(Phase3 纯透传,不解析)
|
||||
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 BroadcastMessage)
|
||||
//! ## 协议对齐(u-relay 现行,2026-10-04 起)
|
||||
//! - 桌面端连 `/ws/agent`,首帧发 `Hello { kind:"agent", channel_id, token }`
|
||||
//! - relay 双因子校验(kind == agent + token 匹配 + channel_id 格式校验),
|
||||
//! 失败发 `{"kind":"control","error":"..."}` + Close,
|
||||
//! 成功立即回 `{"kind":"control","payload":{"control_kind":"hello_ack"}}` 显式 ack
|
||||
//! - 握手成功信号:**收到 hello_ack 控制帧 或 收到任何非 error 首帧**(u-relay 有显式 ack)
|
||||
//! - 入站消息:relay 把对端 Commander 的文本帧包成 BroadcastMessage 转发,桌面端收到的
|
||||
//! 是完整 BroadcastMessage JSON,payload 是 commander 指令原样 JSON(纯透传,不解析)
|
||||
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 Event 广播)
|
||||
//! - 心跳:relay 本地回 pong(`{"control_kind":"ping"}` → pong 不经对端),路径短延迟低
|
||||
//!
|
||||
//! ## AiChatEvent 透传原则
|
||||
//! payload 用 serde_json::Value 透传,本 crate 不依赖 src-tauri/df-types(避跨 crate 强耦合)。
|
||||
@@ -21,7 +23,7 @@
|
||||
//! ## 并发模型
|
||||
//! 内部 spawn 收发循环 task,通过 mpsc 解耦 send_event(调用方)与 socket 写入。
|
||||
//! 收到 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 借用。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -38,7 +40,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use crate::error::{Result, TunnelError};
|
||||
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 强类型,
|
||||
/// 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 {
|
||||
/// 主动连接云后端 + 注册指令回调
|
||||
///
|
||||
/// `url` 形如 `wss://host/ws/device`(query 不带 token,token 走 Hello 帧)。
|
||||
/// `url` 形如 `wss://host/ws/agent`(query 不带 token,token 走 Hello 帧)。
|
||||
/// 成功后进入「已连接」状态并 spawn 收发循环。失败返回 TunnelError::Connect/Auth。
|
||||
/// `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(
|
||||
&self,
|
||||
url: &str,
|
||||
device_id: &str,
|
||||
channel_id: &str,
|
||||
token: &str,
|
||||
on_command: CommandHandler,
|
||||
) -> Result<()>;
|
||||
@@ -79,12 +81,12 @@ pub trait TunnelClient: Send + Sync {
|
||||
/// 是否处于已连接状态(轻量查询,不阻塞,不发心跳)
|
||||
fn is_connected(&self) -> bool;
|
||||
|
||||
/// 发送事件(桌面端 → 云后端 → 小程序)
|
||||
/// 发送事件(桌面端 → 云后端 → 控制端)
|
||||
///
|
||||
/// 非阻塞入队,实际 socket 写入异步完成。连接断开时返回 NotConnected。
|
||||
async fn send_event(&self, event: TunnelEvent) -> Result<()>;
|
||||
|
||||
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value
|
||||
/// 发送原始事件(桌面端 → 云后端 → 控制端),payload 为 AiChatEvent 序列化 JSON Value
|
||||
///
|
||||
/// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法
|
||||
/// 透传(不经 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<()>;
|
||||
}
|
||||
|
||||
/// 握手探测窗口(relay 校验通过后静默进入收发循环,无 ack 帧;
|
||||
/// 握手探测窗口(u-relay 校验通过会立即回 hello_ack 控制帧;
|
||||
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
|
||||
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);
|
||||
|
||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
||||
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||
|
||||
/// 重连初始退避
|
||||
@@ -136,7 +139,7 @@ pub struct WsTunnelClient {
|
||||
/// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
|
||||
/// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。
|
||||
server_url: Mutex<Option<String>>,
|
||||
device_id: Mutex<Option<String>>,
|
||||
channel_id: Mutex<Option<String>>,
|
||||
token: Mutex<Option<String>>,
|
||||
/// 当前活跃连接状态(None = 未连接)
|
||||
conn: Mutex<Option<ConnState>>,
|
||||
@@ -150,19 +153,19 @@ impl WsTunnelClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
server_url: Mutex::new(None),
|
||||
device_id: Mutex::new(None),
|
||||
channel_id: Mutex::new(None),
|
||||
token: Mutex::new(None),
|
||||
conn: Mutex::new(None),
|
||||
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)> {
|
||||
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()?;
|
||||
Some((url, device_id, token))
|
||||
Some((url, channel_id, token))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ impl TunnelClient for WsTunnelClient {
|
||||
async fn connect(
|
||||
&self,
|
||||
url: &str,
|
||||
device_id: &str,
|
||||
channel_id: &str,
|
||||
token: &str,
|
||||
on_command: CommandHandler,
|
||||
) -> Result<()> {
|
||||
@@ -204,8 +207,8 @@ impl TunnelClient for WsTunnelClient {
|
||||
|
||||
// 2. 发 Hello 握手帧
|
||||
let hello = Hello {
|
||||
kind: HelloKind::Device,
|
||||
device_id: device_id.to_string(),
|
||||
kind: HelloKind::Agent,
|
||||
channel_id: channel_id.to_string(),
|
||||
token: token.to_string(),
|
||||
};
|
||||
let hello_json = serde_json::to_string(&hello)?;
|
||||
@@ -216,13 +219,14 @@ impl TunnelClient for WsTunnelClient {
|
||||
// 3. 探测握手结果:HANDSHAKE_PROBE 窗口内
|
||||
// - 收到 error 帧 → relay 拒绝(返回 Auth 错误,附原因)
|
||||
// - 收到 Close / EOF → 连接被关(返回 Connect 错误)
|
||||
// - 收到首条正常消息 / 超时无负帧 → 视为握手成功(relay 静默进入收发循环)
|
||||
// - 收到 hello_ack / 首条正常消息 / 超时无负帧 → 视为握手成功(u-relay 显式 ack)
|
||||
// - Ping/Pong 协议帧:忽略继续等
|
||||
let mut first_frame_pending: Option<WsMessage> = None;
|
||||
let probe = tokio::time::timeout(HANDSHAKE_PROBE, stream.next()).await;
|
||||
match probe {
|
||||
Err(_) => {
|
||||
// 超时无负帧:relay 静默通过(常见路径)
|
||||
// 超时无负帧:hello_ack 错过探测窗(可能性低)或 relay 异常,
|
||||
// 仍按成功处理(后续帧会进 run_loop 正常分派)
|
||||
tracing::debug!("握手探测窗口无负帧,视为成功");
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -253,7 +257,7 @@ impl TunnelClient for WsTunnelClient {
|
||||
|
||||
// 4. 握手成功:保存参数 + 建发送队列 + spawn 收发循环
|
||||
*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());
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel::<OutFrame>();
|
||||
@@ -271,7 +275,7 @@ impl TunnelClient for WsTunnelClient {
|
||||
));
|
||||
|
||||
*self.conn.lock().await = Some(ConnState { tx, task });
|
||||
tracing::info!(%url, device_id, "WsTunnelClient 连接就绪,收发循环已启动");
|
||||
tracing::info!(%url, channel_id, "WsTunnelClient 连接就绪,收发循环已启动");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -332,7 +336,7 @@ impl WsTunnelClient {
|
||||
}
|
||||
}
|
||||
*self.server_url.lock().await = None;
|
||||
*self.device_id.lock().await = None;
|
||||
*self.channel_id.lock().await = None;
|
||||
*self.token.lock().await = None;
|
||||
self.connected.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
| [多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) | 📐 测试 | 各测试用例的落地方式(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 集成可行性研究
|
||||
|
||||
> 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 能力」
|
||||
|
||||
## 一、定性结论
|
||||
|
||||
@@ -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
@@ -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 视图裁剪补滚动摘要 ✅已修(9b89610 淘汰补偿摘要插视图首部,user 角色保缓存指纹,纯视图不落库,eviction 11 测试全绿,2026-10-02 核验)
|
||||
- [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 复核)
|
||||
- [ ] 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 侧接入。
|
||||
> 详情:[02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md](./02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md)
|
||||
|
||||
- [ ] devflow/u-claw 两侧细勘察补全(接入点 trait/WS 渠道能力边界/多轮会话锚定)
|
||||
- [ ] 接入实施:WS 客户端(移植 u-ppt relay.ts 或并入 df-tunnel)+ aichat u-claw 渠道 + 配置页(默认关,对齐 cfg.WS==nil 模式)
|
||||
- [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 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)
|
||||
|
||||
@@ -289,7 +289,7 @@ graph TD
|
||||
|
||||
**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`**。以下为可执行登记,开工以现场源码为准。
|
||||
|
||||
### 批次2(后端高危)并入项
|
||||
- [ ] P0 压缩 conv_id 分裂【已亲核实锤】:`commands/chat.rs:1734` 起 cid 切 active——压缩后台会话时目标 conv compressing 永久卡死 + active 会话被插入他人摘要入库。修::1734 改 `cid = conversation_id.clone()`(约 4 行,首位)
|
||||
- [ ] P0 V21 迁移 COUNT 幂等窗口:`df-storage/migrations.rs:481-488,570` 批次中途崩溃→剩余对话永不迁移但版本号补齐。修:按对话进度标记替代全局 COUNT
|
||||
- [ ] P1 conv delete 不停 in-flight loop:`commands/conversation.rs:608-640` 无 stop_flag/epoch bump——zombie 复活+审批吊死。**与上方「conv 删除族 6 处」同域合并做**
|
||||
- [ ] P1 只读结果缓存无写失效:`audit/cache.rs` 写后读回过期内容。修:per_conv 脏标记,写后整体失效
|
||||
- [ ] P1 OpenAI 流式 error 帧被 choices 必填整帧丢弃:`df-ai/openai_helpers.rs:193-203`(无声截断)+ 同批 serde 结构错误判 Retryable(`anthropic_compat.rs:629`/`openai_compat.rs:527`)
|
||||
- [ ] P1 sanitize 合并连续同 role 丢多模态 parts:`df-ai/context/sanitize.rs:406-425` 带图消息图对 LLM 隐身。修:合并分支 extend parts
|
||||
- [ ] P2-2 maybe_auto_compress protect_start 跨锁过期切片 panic:`context_lifecycle.rs:118` 加 `min(len)` 钳制(一行,与 F2 前移同域)
|
||||
- [ ] P2-3 try_continue_agent_loop 裸 emit 绕过 emit_ai_completed_once:`agentic/helpers.rs:100/:171` 占位 tool_result 与 pending 残留
|
||||
- [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 引用,注释含修复说明)
|
||||
- [x] P0 V21 迁移 COUNT 幂等窗口:`df-storage/migrations.rs:481-488,570` 批次中途崩溃→剩余对话永不迁移但版本号补齐。修:按对话进度标记替代全局 COUNT ✅已修(2026-10-04 核验:已改逐对话进度判定——批事务保证对话级原子,已落库数=JSON 消息数即跳过,重跑只补未迁对话)
|
||||
- [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 守卫天然防重复))
|
||||
- [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 个新增锁定测试)
|
||||
- [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 防确定性失败白烧重试预算)
|
||||
- [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 随合并迁移,图不再隐身)
|
||||
- [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 内部钳制口径一致)
|
||||
- [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(前端体验)并入项
|
||||
- [ ] P1 后台审批/授权事件被 isCurrent 丢弃→会话挂死:`useAiEvents.ts:94-123` !isCurrent 分支对 ApprovalRequired/DirAuthRequired 放行(与 P2-5 审批数组 per-conv 合并同域)
|
||||
- [ ] P1 switchConversation「对话不存在」兜底漏置 activeId:`useAiConversations.ts:294-309` 补一行(视图/高亮/发送目标三重错配)
|
||||
- [ ] P2 看门狗 legacy/per-conv 双轨三错配:useAiSend.ts:169/:241/:512 统一迁移传 convId + `_resumeAttempts` 收尾清(债务本就要还)
|
||||
- [ ] P2 _lastDeltas 双路径漏清:useAiEvents.ts:96-115/:154-161 补 clearLastDelta
|
||||
- [ ] P2 removeGoal 不回写 store/不回滚:TopBar.vue:530-539 对齐 withConvOp 模式
|
||||
- [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 待审批集合)
|
||||
- [x] P1 switchConversation「对话不存在」兜底漏置 activeId:`useAiConversations.ts:294-309` 补一行(视图/高亮/发送目标三重错配) ✅已修(2026-10-04 核验:兜底新建路径已含 `state.activeConversationId = created.id` 置位+持久化,注释记载旧 id 残留致 isCurrent 误路由;另附 P3-7 noCreateFallback 程序化跳转分支)
|
||||
- [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」)
|
||||
- [x] P2 _lastDeltas 双路径漏清:useAiEvents.ts:96-115/:154-161 补 clearLastDelta ✅已修(2026-10-04 核验:后台终止分支(:217)与停靠卡归集分支(:131)均补 clearLastDelta,前台收尾 cleanupTerminatedConversation 亦有显式清理,注释记载防残留记录误丢下轮同内容 delta)
|
||||
- [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 修复同家族)
|
||||
|
||||
### 机械批(可独立小批)
|
||||
@@ -878,8 +878,54 @@ graph TD
|
||||
- [ ] i18n searchFilesBlocked 死键裸 @(zh-CN/en aiChat.ts:198/197)转义或删
|
||||
|
||||
### 登记观察(定级前先核)
|
||||
- [ ] model_fetch 空列表 Ok([]) 上层写回清空模型池(df-ai 侧已核验,src-tauri provider.rs 写回点待核后定级)
|
||||
- [ ] save_conversation 需核 needs_full_rewrite 检查(advance_persisted_count 无条件清 dirty);secret.rs 未走查(权限拒绝)
|
||||
- [x] model_fetch 空列表 Ok([]) 上层写回清空模型池 ✅已修(2026-10-03 N1):provider.rs 空守卫跳过写回+前端 toastFetchEmpty 同语义守卫
|
||||
- [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 用户口述,不分先后)
|
||||
|
||||
@@ -894,3 +940,97 @@ graph TD
|
||||
7. UX 体验交互体验(批次3 及后续 UX 批持续线)
|
||||
8. 内置数据库、远程服务器连接能力(与代理工具生态 mysql-proxy/ssh-proxy 等整合)
|
||||
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` 回滚到重整前
|
||||
|
||||
@@ -39,6 +39,9 @@ async-trait = { workspace = true }
|
||||
# 零新依赖树)。rust-work 改代理端口只改网关映射表,工具代码不感知端口。
|
||||
axum = { version = "0.7" }
|
||||
|
||||
# 托盘菜单 i18n:系统 locale 判定(零传递依赖,判定一次缓存)
|
||||
sys-locale = "0.3"
|
||||
|
||||
# 后端 crate
|
||||
df-types = { path = "../crates/df-types" }
|
||||
df-storage = { path = "../crates/df-storage" }
|
||||
|
||||
@@ -86,6 +86,18 @@ pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
|
||||
/// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,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
|
||||
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
|
||||
///
|
||||
@@ -154,10 +166,15 @@ pub const MAX_GOALS: usize = 5;
|
||||
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
|
||||
pub const SKILL_LIST_INJECT_ENABLED: bool = true;
|
||||
|
||||
/// 技能清单注入最大条数(默认 20)。
|
||||
/// 技能清单注入最大条数(默认 120)。
|
||||
///
|
||||
/// 防本机大量技能全量进 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 字符)。
|
||||
///
|
||||
@@ -1039,6 +1056,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。
|
||||
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/收敛/审批退出(下方达上限暂停分支不触发)
|
||||
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 不生效(尽力兜底)。
|
||||
let outcome = unwind_to_result(async {
|
||||
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/心跳)
|
||||
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
|
||||
// + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ──
|
||||
@@ -1070,29 +1105,81 @@ pub(crate) async fn run_agentic_loop(
|
||||
PreparedRound::Exit => return,
|
||||
};
|
||||
|
||||
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)──
|
||||
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文 + 跨轮重入)──
|
||||
// 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续;
|
||||
// StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/
|
||||
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。
|
||||
let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round(
|
||||
&session_arc, &db, &conv_id,
|
||||
&messages, &tool_defs, &app_handle,
|
||||
&stop_flag, ¬ify,
|
||||
max_retries, &model_override, &agentic_req, estimated_prompt,
|
||||
&mut last_round_estimated, &mut last_reasoning_content,
|
||||
&mut resolved_model, &mut provider_config,
|
||||
&mut provider_saturation,
|
||||
&mut tokens, &mut saved_token_snapshot, &mut guard,
|
||||
&candidates, &llm_concurrency,
|
||||
&pinned_goals_snapshot,
|
||||
&loop_epoch_arc, my_epoch,
|
||||
).await {
|
||||
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
|
||||
(full_text, tool_calls, round_usage, round_is_estimated)
|
||||
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)。
|
||||
// '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,
|
||||
&messages, &tool_defs, &app_handle,
|
||||
&stop_flag, ¬ify,
|
||||
max_retries, &model_override, &agentic_req, estimated_prompt,
|
||||
&mut last_round_estimated, &mut last_reasoning_content,
|
||||
&mut resolved_model, &mut provider_config,
|
||||
&mut provider_saturation,
|
||||
&mut tokens, &mut saved_token_snapshot, &mut guard,
|
||||
&candidates, &llm_concurrency,
|
||||
&pinned_goals_snapshot,
|
||||
&loop_epoch_arc, my_epoch,
|
||||
).await {
|
||||
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens } => {
|
||||
// 本轮成功:连续失败计数归零(重入只认连续失败)。
|
||||
consecutive_round_failures = 0;
|
||||
break (full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens);
|
||||
}
|
||||
RoundOutcome::StaleAfterStream => return,
|
||||
RoundOutcome::MidStreamSaved => {
|
||||
// 安全停靠(设计 §四):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;
|
||||
}
|
||||
}
|
||||
RoundOutcome::StaleAfterStream => return,
|
||||
RoundOutcome::MidStreamSaved => return,
|
||||
RoundOutcome::Fatal => return,
|
||||
};
|
||||
// global/per_conv permit 已上移 loop 入口整 loop 持有,
|
||||
// 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,
|
||||
&last_reasoning_content,
|
||||
&mut goal_verify_rounds,
|
||||
round_truncated,
|
||||
).await {
|
||||
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
|
||||
RoundEpilogue::RunTools => {}
|
||||
@@ -1182,6 +1270,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
&loop_epoch_arc, my_epoch,
|
||||
false,
|
||||
).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;
|
||||
}
|
||||
|
||||
@@ -1661,6 +1757,8 @@ async fn finalize_round_output(
|
||||
round_is_estimated: bool,
|
||||
last_reasoning_content: &Option<String>,
|
||||
goal_verify_rounds: &mut u32,
|
||||
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
|
||||
round_truncated_by_max_tokens: bool,
|
||||
) -> RoundEpilogue {
|
||||
// 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。
|
||||
// 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 +
|
||||
@@ -1773,6 +1871,20 @@ async fn finalize_round_output(
|
||||
);
|
||||
}
|
||||
// 正常收敛(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;
|
||||
}
|
||||
}
|
||||
@@ -1817,7 +1929,7 @@ async fn process_round_tools(
|
||||
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
|
||||
let round_tool_call_count = tool_calls_acc.len();
|
||||
// 处理工具调用(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,
|
||||
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
||||
tracing::debug!(
|
||||
@@ -1864,6 +1976,13 @@ async fn process_round_tools(
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1881,6 +2000,12 @@ async fn process_round_tools(
|
||||
*loop_warned = new_loop_warned;
|
||||
if tripped {
|
||||
guard.reset().await;
|
||||
// 安全停靠(设计 §四):探索熔断从「等用户」改「存档+通知」,语义对齐五路统一出口。
|
||||
crate::commands::ai::emit_task_parked(
|
||||
app_handle, conv_id,
|
||||
"探索熔断(连续无进展的重复探索),任务安全停靠;已完成进度已保存,补充新方向后重发消息可继续",
|
||||
"stall_breaker",
|
||||
);
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
}
|
||||
@@ -2309,13 +2434,47 @@ async fn insert_tool_call_over_limit_warning(
|
||||
.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_ai_chat_event。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类);
|
||||
// 两端统一 Network(原 publish 端漏传 None 属漂移,2026-10-01 顺带修)。
|
||||
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
|
||||
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
|
||||
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 {
|
||||
error: error.to_string(),
|
||||
error_type: Some(ErrorType::Network),
|
||||
error_type: Some(error_type),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
|
||||
@@ -2584,8 +2743,9 @@ async fn build_runtime_prompt(
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
system_prompt = format!(
|
||||
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}",
|
||||
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述,已去重,上限 {} 条)。**自动触发规则**:用户请求(含普通任务指令)与某技能用途匹配时,你应先调 read_skill 工具读取该技能规程全文,按其流程直接执行,无需等待用户显式输入 /技能名;仅当技能涉及高风险操作或不确定用户意图时,才提示用户输入 /技能名 确认。未列出的技能也可用 read_skill 按名查询:\n{}",
|
||||
system_prompt,
|
||||
SKILL_LIST_MAX_ITEMS,
|
||||
lines.join("\n")
|
||||
);
|
||||
tracing::debug!(
|
||||
@@ -2974,3 +3134,24 @@ mod panic_guard_tests {
|
||||
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,
|
||||
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
|
||||
reasoning_content: Option<String>,
|
||||
/// 流因 provider max_tokens 上限截断(finish_reason=length / stop_reason=max_tokens),
|
||||
/// 本轮文本/tool_calls 可能不完整,调用方据此标记该轮产出截断。
|
||||
truncated: bool,
|
||||
},
|
||||
/// error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。
|
||||
InitFailedExhausted { error: String },
|
||||
@@ -175,10 +178,11 @@ async fn stream_with_retry(
|
||||
).await;
|
||||
|
||||
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 {
|
||||
text, tool_calls, usage, incomplete: false,
|
||||
resolved_model: resolved_model.to_string(), reasoning_content,
|
||||
truncated: truncated_by_max_tokens,
|
||||
};
|
||||
}
|
||||
StreamResult::Partial { text, tool_calls, usage, reasoning_content } => {
|
||||
@@ -192,6 +196,8 @@ async fn stream_with_retry(
|
||||
return StreamOutcome::Success {
|
||||
text, tool_calls, usage, incomplete: true,
|
||||
resolved_model: resolved_model.to_string(), reasoning_content,
|
||||
// MidStream 已按不完整处理(incomplete=true),截断标志不再叠加
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
StreamResult::InitFailed { retryable, error } => {
|
||||
@@ -257,6 +263,9 @@ pub(crate) enum RoundOutcome {
|
||||
/// 本轮 prompt 是否估算兜底(= 进 stream_round 时的 round_usage.prompt_tokens == 0),
|
||||
/// 供 loop 主体 push 消息级 is_estimated,与 round_usage 同点派生防漂移。
|
||||
round_is_estimated: bool,
|
||||
/// 本轮流因 provider max_tokens 上限截断(P2-1),文本/tool_calls 可能不完整。
|
||||
/// 子循环据此把收敛报告标 Truncated,不冒充 Completed;主循环据此提示用户。
|
||||
truncated_by_max_tokens: bool,
|
||||
},
|
||||
/// 流返回后 stale(epoch 已变,被新 loop 接管):guard 已 disarm,
|
||||
/// loop 应直接 return(不再 push/save/执行工具)。
|
||||
@@ -265,8 +274,9 @@ pub(crate) enum RoundOutcome {
|
||||
/// teardown_round save/reset/emit)→ loop 直接 return。
|
||||
MidStreamSaved,
|
||||
/// Fatal(候选 Fatal 或全候选耗尽):guard.reset + epoch 检查 + emit AiError +
|
||||
/// save 已完成 → loop 直接 return。
|
||||
Fatal,
|
||||
/// save 已完成 → loop 直接 return。`error` 为最后一条诊断文本(P2-4:子循环据此
|
||||
/// 在出口报告写明失败原因,父会话可针对性补救,不再拿到无因失败)。
|
||||
Fatal { error: String },
|
||||
}
|
||||
|
||||
/// 单轮流式调用:抽自 `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 不写入。
|
||||
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。
|
||||
// Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功,
|
||||
// emit 会留残留气泡)。
|
||||
@@ -418,14 +428,14 @@ pub(crate) async fn stream_round(
|
||||
).await;
|
||||
|
||||
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/标题)。
|
||||
// mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」
|
||||
// 而非主 candidate。compress 已在本轮 stream 之前用过 primary,不受影响。
|
||||
*resolved_model = m;
|
||||
*provider_config = candidate.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;
|
||||
}
|
||||
StreamOutcome::InitFailedExhausted { error } => {
|
||||
@@ -449,14 +459,14 @@ pub(crate) async fn stream_round(
|
||||
guard.reset().await;
|
||||
// F1:旧 loop(被新 loop 接管)不 emit 错误气泡(owner loop 负责呈现)。
|
||||
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;
|
||||
// G1.1(2026-08-05):Fatal 退出也落库 user 消息(镜像下方 Exhausted 分支),
|
||||
// 否则重启/切会话从 DB 恢复时末条 user 丢失(与 Exhausted 同根因不对称)。
|
||||
// epoch 校验通过后、return 前补 save(owner loop 才写,防 owner 已转移仍写)。
|
||||
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 候选流前失败重试耗尽,放弃本轮",
|
||||
);
|
||||
guard.reset().await;
|
||||
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
|
||||
return RoundOutcome::Fatal;
|
||||
}
|
||||
let err_msg = last_exhausted_error
|
||||
.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;
|
||||
// 全 provider 失败也需落库 user 消息(已 push 内存),
|
||||
// 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。
|
||||
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,
|
||||
round_usage: round_usage_full,
|
||||
round_is_estimated,
|
||||
truncated_by_max_tokens: round_truncated,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
//! 统一出口(`run_sub_agent_loop`)摘除拦截后调一次 `save_conversation`,整条子轨迹经
|
||||
//! 既有 save 链路持久化,origin='sub_agent' 随首插落 `ai_conversations.origin`(V44),
|
||||
//! 桌面侧栏/miniapp 列表按此过滤。Truncated/Failed 轨迹同样落库(调试与轨迹树数据源);
|
||||
//! 父删除级联命中(deleted_convs 被级联重打/per_conv 已清)时放弃落库,防空壳复活。
|
||||
//! 父删除级联命中(deleted_convs 被级联重打,见 cascade_stop_children_locked Delete 形态)
|
||||
//! 时放弃落库,防空壳复活。
|
||||
//!
|
||||
//! ## 循环拼装(全部复用既有积木,不另起炉灶)
|
||||
//! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链;
|
||||
@@ -33,9 +34,12 @@
|
||||
//!
|
||||
//! ## 事件与展示边界
|
||||
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
|
||||
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留:其 Fatal 分支
|
||||
//! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦,
|
||||
//! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。
|
||||
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常
|
||||
//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此):
|
||||
//! - 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::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -58,9 +62,11 @@ use crate::commands::ai::agentic::{
|
||||
STALL_BREAKER_ENABLED,
|
||||
};
|
||||
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::AiChatEvent;
|
||||
use crate::commands::ai::{AiChatEvent, ToolCallDraft};
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
use super::guard::GeneratingGuard;
|
||||
@@ -94,6 +100,17 @@ pub(crate) const SUB_AGENT_MAX_TOTAL_TOKENS: u64 = 200_000;
|
||||
/// 外层超时对 spawn_agent 取同值,保证外层不先于子循环自腰斩。
|
||||
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)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SubAgentOutcome {
|
||||
@@ -129,6 +181,8 @@ pub(crate) struct SubAgentOutcome {
|
||||
/// 运行期被拒工具汇总(工具名→次数,BTreeMap 序确定性)。S2 报告协议:父会话须能
|
||||
/// 看见子代理被拒了什么,而非只拿到一份不知缺口的报告;空 Vec=本次零拒绝。
|
||||
pub(crate) rejected_tools: Vec<(String, u32)>,
|
||||
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
|
||||
pub(crate) stop_kind: Option<SubAgentStopKind>,
|
||||
}
|
||||
|
||||
impl SubAgentOutcome {
|
||||
@@ -140,6 +194,7 @@ impl SubAgentOutcome {
|
||||
rounds: 0,
|
||||
tokens: 0,
|
||||
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()
|
||||
}
|
||||
|
||||
/// 级联取消形态(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。
|
||||
///
|
||||
/// 调用方(ai_chat_stop / ai_chat_force_send / ai_conversation_delete)须已持 session 锁,
|
||||
/// 本函数短临界区纯内存操作(无 await),与「禁止持 session 锁跨 await」纪律兼容。
|
||||
/// 仅触碰已存在的子会话条目(不惰性重建,防取消动作反而制造空条目残留);
|
||||
/// 子循环停止检查点读 own stop_flag 即退出,父侧条目消失/父 stop 是兜底判据。
|
||||
/// Delete 形态的 deleted_convs 标记对 child 不回收(与直接删除的标记同生命周期):
|
||||
/// 防在途 save 于出口清理之后复活空壳,单条 ~40B 量级可忽略。
|
||||
pub(crate) fn cascade_stop_children_locked(
|
||||
session: &mut crate::commands::ai::AiSession,
|
||||
reg: &SubAgentRegistry,
|
||||
parent: &str,
|
||||
form: CascadeStopForm,
|
||||
) {
|
||||
for child in sub_agents_children(reg, parent) {
|
||||
if let Some(conv) = session.per_conv.get_mut(&child) {
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
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)
|
||||
}
|
||||
|
||||
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some(原因) 即应截断退出,None 继续。
|
||||
/// 纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限)。
|
||||
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some((原因, 停止类别)) 即应截断退出,
|
||||
/// None 继续。纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限);
|
||||
/// 停止类别随原因同点产出,供 AiAgentFinished.stop_kind 结构化透出。
|
||||
pub(crate) fn round_budget_stop(
|
||||
round: u32,
|
||||
max_rounds: u32,
|
||||
tokens_total: u64,
|
||||
deadline: tokio::time::Instant,
|
||||
now: tokio::time::Instant,
|
||||
) -> Option<String> {
|
||||
) -> Option<(String, SubAgentStopKind)> {
|
||||
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 {
|
||||
return Some(format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS));
|
||||
return Some((
|
||||
format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS),
|
||||
SubAgentStopKind::TokenBudget,
|
||||
));
|
||||
}
|
||||
if round >= max_rounds {
|
||||
return Some(format!("达最大轮次上限({})", max_rounds));
|
||||
return Some((
|
||||
format!("达最大轮次上限({})", max_rounds),
|
||||
SubAgentStopKind::MaxRounds,
|
||||
));
|
||||
}
|
||||
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 报告协议解析核心,纯函数可单测)。
|
||||
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
|
||||
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
|
||||
@@ -314,19 +455,35 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
|
||||
}
|
||||
|
||||
/// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。
|
||||
pub(crate) fn sub_agent_system_prompt() -> String {
|
||||
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
|
||||
## 定位\n\
|
||||
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
|
||||
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
|
||||
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
|
||||
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
|
||||
- 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\
|
||||
## 输出要求\n\
|
||||
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
|
||||
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
|
||||
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式"
|
||||
.to_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\
|
||||
## 定位\n\
|
||||
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
|
||||
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
|
||||
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
|
||||
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
|
||||
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
|
||||
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
|
||||
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
|
||||
## 输出要求\n\
|
||||
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
|
||||
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
|
||||
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
|
||||
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,
|
||||
task_prompt: String,
|
||||
tool_defs: Vec<ToolDefinition>,
|
||||
// 执行期工具白名单(P2-1):spawn 入口钳制产物,随子循环传入 process_tool_calls,
|
||||
// 幻觉调用被裁剪面外的工具(含 Low)在执行期被就地拒绝,而不仅在 prompt 层承诺。
|
||||
allowed: Vec<String>,
|
||||
max_rounds: u32,
|
||||
model_override: Option<String>,
|
||||
llm_concurrency: LlmConcurrency,
|
||||
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
|
||||
// provider/created_at。
|
||||
parent_save_meta: SaveMetaOverride,
|
||||
) -> SubAgentOutcome {
|
||||
let outcome = run_sub_agent_loop_inner(
|
||||
session_arc.clone(), tools_arc, db.clone(), app_handle.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(),
|
||||
).await;
|
||||
|
||||
@@ -373,6 +536,8 @@ pub(crate) async fn run_sub_agent_loop(
|
||||
} else {
|
||||
Some(format_rejected_tools(&outcome.rejected_tools))
|
||||
},
|
||||
// 停止类别(结构化枚举,前端按「为什么停」差异化展示;正常收敛=converged)
|
||||
stop_kind: outcome.stop_kind.map(|k| k.as_str().to_string()),
|
||||
});
|
||||
|
||||
// 子轨迹落库(二期池①):摘除零落库拦截,整条轨迹经既有 save 链路持久化。
|
||||
@@ -388,7 +553,9 @@ pub(crate) async fn run_sub_agent_loop(
|
||||
alive
|
||||
};
|
||||
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 一并回收)
|
||||
@@ -415,6 +582,7 @@ async fn run_sub_agent_loop_inner(
|
||||
system_prompt: String,
|
||||
task_prompt: String,
|
||||
tool_defs: Vec<ToolDefinition>,
|
||||
allowed: Vec<String>,
|
||||
max_rounds: u32,
|
||||
model_override: Option<String>,
|
||||
llm_concurrency: LlmConcurrency,
|
||||
@@ -423,6 +591,16 @@ async fn run_sub_agent_loop_inner(
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ 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
|
||||
// (子代理不阻塞等待,防占满父 loop 的执行槽与全局配额)
|
||||
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 rounds_done: u32 = 0;
|
||||
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写
|
||||
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写。
|
||||
// stop_kind 缺省 MaxRounds 与默认截断原因(达最大轮次上限)同点对齐。
|
||||
let mut status = SubAgentStatus::Truncated;
|
||||
let mut stop_reason = String::new();
|
||||
let mut stop_kind = Some(SubAgentStopKind::MaxRounds);
|
||||
let mut report = String::new();
|
||||
// S2 报告协议:被拒工具累计(工具名→次数,BTreeMap 序确定性),出口汇总进报告尾+outcome
|
||||
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 {
|
||||
status = SubAgentStatus::Truncated;
|
||||
stop_reason = format!("已被取消({})", reason);
|
||||
stop_kind = Some(SubAgentStopKind::Cancelled);
|
||||
break;
|
||||
}
|
||||
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;
|
||||
stop_reason = reason;
|
||||
stop_kind = Some(kind);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -544,6 +726,7 @@ async fn run_sub_agent_loop_inner(
|
||||
let Some(conv) = session.conv_read(&child_conv_id) else {
|
||||
status = SubAgentStatus::Failed;
|
||||
stop_reason = "子会话条目已消失".to_string();
|
||||
stop_kind = Some(SubAgentStopKind::Error);
|
||||
break;
|
||||
};
|
||||
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,
|
||||
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,
|
||||
&messages, &tool_defs, &app_handle,
|
||||
&child_stop, &child_notify,
|
||||
@@ -594,8 +783,8 @@ async fn run_sub_agent_loop_inner(
|
||||
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
|
||||
&[],
|
||||
&child_epoch, my_epoch,
|
||||
).await {
|
||||
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
|
||||
)).await {
|
||||
Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
|
||||
rounds_done += 1;
|
||||
tokens.add_usage(&round_usage);
|
||||
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 {
|
||||
push_cancelled_tool_results(&session_arc, &child_conv_id, &tool_calls).await;
|
||||
status = SubAgentStatus::Truncated;
|
||||
stop_reason = format!("已被取消({})", reason);
|
||||
stop_kind = Some(SubAgentStopKind::Cancelled);
|
||||
break;
|
||||
}
|
||||
if !has_tool_calls {
|
||||
// 收敛轮:末轮 full_text 即报告自摘要
|
||||
status = SubAgentStatus::Completed;
|
||||
report = full_text;
|
||||
// 收敛轮:末轮 full_text 即报告自摘要(P3-7 空白降级 / P2-1 截断标记)
|
||||
let (st, rep, extra_reason, kind) = convergence_round(full_text, truncated_by_max_tokens);
|
||||
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;
|
||||
}
|
||||
// ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)──
|
||||
@@ -634,6 +845,8 @@ async fn run_sub_agent_loop_inner(
|
||||
let pending = process_tool_calls(
|
||||
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
|
||||
ToolExecPolicy::SubAgent,
|
||||
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
|
||||
Some(&allowed),
|
||||
).await;
|
||||
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
|
||||
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 策略下不应产生挂起;出现即终止(子代理无审批恢复通道)
|
||||
status = SubAgentStatus::Failed;
|
||||
stop_reason = format!("子代理策略下出现意外审批挂起({} 项),终止", pending);
|
||||
stop_kind = Some(SubAgentStopKind::Error);
|
||||
break;
|
||||
}
|
||||
// L1 失败断路器(读子会话尾部消息,机制与主 loop 一致)
|
||||
@@ -652,6 +866,7 @@ async fn run_sub_agent_loop_inner(
|
||||
"连续同类工具失败 {} 次(如 {}),已熔断",
|
||||
max_count, sample_key
|
||||
);
|
||||
stop_kind = Some(SubAgentStopKind::Error);
|
||||
break;
|
||||
}
|
||||
// G2 探索熔断 + 写删回环熔断(读子会话尾部签名,机制与主 loop 一致)
|
||||
@@ -666,23 +881,38 @@ async fn run_sub_agent_loop_inner(
|
||||
if STALL_BREAKER_ENABLED && tripped {
|
||||
status = SubAgentStatus::Failed;
|
||||
stop_reason = "重复工具调用/写删回环探索熔断".to_string();
|
||||
stop_kind = Some(SubAgentStopKind::Error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)
|
||||
RoundOutcome::Fatal => {
|
||||
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)。
|
||||
// P2-4:Fatal 携带最后诊断文本,出口报告写明真实原因(封顶防超长污染)。
|
||||
Ok(RoundOutcome::Fatal { error }) => {
|
||||
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;
|
||||
}
|
||||
RoundOutcome::MidStreamSaved => {
|
||||
Ok(RoundOutcome::MidStreamSaved) => {
|
||||
status = SubAgentStatus::Truncated;
|
||||
stop_reason = "流中途中断,已保文".to_string();
|
||||
stop_kind = Some(SubAgentStopKind::Error);
|
||||
break;
|
||||
}
|
||||
RoundOutcome::StaleAfterStream => {
|
||||
Ok(RoundOutcome::StaleAfterStream) => {
|
||||
status = SubAgentStatus::Failed;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -734,6 +964,37 @@ async fn run_sub_agent_loop_inner(
|
||||
rounds: rounds_done,
|
||||
tokens: total_tokens,
|
||||
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(), "缺省请求不产生裁剪尾注");
|
||||
}
|
||||
|
||||
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因;预算内返 None。
|
||||
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因与结构化停止类别;预算内返 None。
|
||||
#[test]
|
||||
fn round_budget_trips_on_rounds_tokens_and_deadline() {
|
||||
let now = tokio::time::Instant::now();
|
||||
@@ -865,15 +1126,18 @@ mod tests {
|
||||
// 预算内:继续
|
||||
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_eq!(kind, SubAgentStopKind::MaxRounds);
|
||||
// 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_eq!(kind, SubAgentStopKind::TokenBudget);
|
||||
// 挂钟超时熔断(优先级最高)
|
||||
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_eq!(kind, SubAgentStopKind::WallClock);
|
||||
}
|
||||
|
||||
/// registry 级联:注册幂等追加、children 可查、注销移除且父条目空时整条清掉。
|
||||
@@ -913,14 +1177,200 @@ mod tests {
|
||||
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(®, "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, ®, "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(®2, "p2", &child2);
|
||||
let mut session2 = crate::commands::ai::AiSession::new();
|
||||
let _ = session2.conv(&child2);
|
||||
cascade_stop_children_locked(&mut session2, ®2, "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]
|
||||
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("工具权限受限"), "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/正常结果/其他拒绝不误计。
|
||||
|
||||
@@ -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),
|
||||
/// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。
|
||||
///
|
||||
/// **写后失效**(治「只读结果缓存无写失效」缺陷):会话内 write_file/run_command 等
|
||||
/// 非只读工具成功执行后,历史只读结果已可能过期(读的是改前世界)。本函数扫描时按
|
||||
/// per_conv.readonly_cache_floor 过滤——floor 之后的索引才算可信,之前的旧调用全部
|
||||
/// 跳过(过界即脏),写后 LLM 重读同参工具必然 miss 缓存走真执行拿到新内容。
|
||||
pub(crate) async fn find_cached_readonly_result(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
@@ -281,7 +286,11 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
let cached: Option<(String, String, u32)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
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,
|
||||
// 同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。
|
||||
@@ -342,6 +351,29 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
/// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5):
|
||||
/// 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 对象的键做字典序排序(就地),数组成员也递归排序。
|
||||
fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
match v {
|
||||
@@ -545,7 +577,53 @@ mod tests {
|
||||
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]
|
||||
fn listing_command_detects_powershell_and_unix() {
|
||||
|
||||
@@ -31,7 +31,7 @@ mod diff;
|
||||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
|
||||
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 参数,经此跨模块读进程级标志
|
||||
pub(super) use path_auth::is_blacklist_bypassed;
|
||||
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
|
||||
@@ -78,7 +78,7 @@ pub(crate) use finalize::audit_finalize;
|
||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
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 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。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;
|
||||
}
|
||||
|
||||
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 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 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
///
|
||||
/// `allowed`(P2-1):SubAgent 策略的执行期工具白名单(spawn 入口钳制产物)。
|
||||
/// Low 风险工具不在名单同样就地拒绝(layer=whitelist-clamp);主会话调用方传 None,语义不变。
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||||
///
|
||||
@@ -481,6 +542,33 @@ async fn cancel_expired_approval(
|
||||
.await;
|
||||
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
||||
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(
|
||||
@@ -491,6 +579,7 @@ pub(crate) async fn process_tool_calls(
|
||||
app_handle: &AppHandle,
|
||||
conv_id: &str,
|
||||
policy: ToolExecPolicy,
|
||||
allowed: Option<&[String]>,
|
||||
) -> usize {
|
||||
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
|
||||
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
|
||||
@@ -598,6 +687,18 @@ pub(crate) async fn process_tool_calls(
|
||||
.await
|
||||
.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)
|
||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||
@@ -623,6 +724,14 @@ pub(crate) async fn process_tool_calls(
|
||||
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
|
||||
// 每轮工具执行前刷新,与下方 check_file_tool_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 循环)
|
||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = 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(系统黑名单)分开标注——
|
||||
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
|
||||
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();
|
||||
for (idx, draft, args) in drafts {
|
||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||
// 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::NeedsAuth(req) => {
|
||||
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
|
||||
@@ -642,6 +753,21 @@ pub(crate) async fn process_tool_calls(
|
||||
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
|
||||
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 门同语义。
|
||||
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
|
||||
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
|
||||
@@ -702,6 +828,15 @@ pub(crate) async fn process_tool_calls(
|
||||
).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)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// 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
|
||||
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||||
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 {
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
|
||||
@@ -825,6 +966,39 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
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 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
||||
@@ -893,6 +1067,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
})).await;
|
||||
|
||||
|
||||
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
||||
for (draft, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
@@ -914,6 +1089,12 @@ pub(crate) async fn process_tool_calls(
|
||||
content.clone()
|
||||
};
|
||||
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),留痕可追溯
|
||||
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()
|
||||
};
|
||||
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 才进)。
|
||||
// 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));
|
||||
}
|
||||
dupe_audit_records.push(build_audit_record(
|
||||
conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level,
|
||||
Some(warned_content), Some("cache_hit"), current_message_id,
|
||||
conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"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() {
|
||||
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 {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id,
|
||||
batch = dup_len,
|
||||
"[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}",
|
||||
dup_len,
|
||||
batch = batch_len,
|
||||
"[ai] insert_batch(轮内重复)批量写审计记录失败(已回滚,共 {} 条): {}",
|
||||
batch_len,
|
||||
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 区分)。
|
||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||
let (path_count, risk_count, pending_approvals_len) = {
|
||||
@@ -1195,7 +1448,7 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default();
|
||||
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 {
|
||||
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
|
||||
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
|
||||
@@ -1203,7 +1456,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// 对照: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 {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
|
||||
@@ -1219,7 +1472,7 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||
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 {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
|
||||
@@ -1234,4 +1487,39 @@ mod tests {
|
||||
let paths = extract_file_tool_paths("grep", &args);
|
||||
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=禁用超时不会到点,防御)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,22 +59,38 @@ pub(crate) enum FileToolAuthOutcome {
|
||||
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`:
|
||||
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
||||
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
||||
/// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied
|
||||
/// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
|
||||
/// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入;
|
||||
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价,
|
||||
/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。
|
||||
/// - `bypass=All`(「信任所有目录」总开关,主会话):未授权目录不再产生 NeedsAuth,黑名单
|
||||
/// Denied 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
|
||||
/// 随 NeedsAuth 消失一并解除)。`bypass=KeepBlacklist`(子代理):只豁免 NeedsAuth,
|
||||
/// 黑名单硬拒保持。bypass 由调用方 process_tool_calls 读 KV 后按策略分流传入;
|
||||
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(主会话语义,与 All 同源等价,
|
||||
/// audit 层每轮刷新保证两处拦截点一致;子代理文件工具在预校验层已硬拒,handler 级标志不生效)。
|
||||
/// 本函数同步签名,不自取异步 settings。
|
||||
pub(crate) fn check_file_tool_auth(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
allowed: &crate::state::AllowedDirs,
|
||||
bypass: bool,
|
||||
bypass: PathBypass,
|
||||
) -> FileToolAuthOutcome {
|
||||
use crate::state::{check_path_authorization, PathAuthDecision};
|
||||
let paths = extract_file_tool_paths(tool_name, args);
|
||||
@@ -88,16 +104,17 @@ pub(crate) fn check_file_tool_auth(
|
||||
for p in &paths {
|
||||
match check_path_authorization(p, allowed) {
|
||||
PathAuthDecision::Denied { reason } => {
|
||||
// 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。
|
||||
// 总开关=All 时黑名单一并放行(产品拍板:体验优先,用户知情选择,仅主会话);
|
||||
// 关闭与 KeepBlacklist(子代理)时硬拒不变——黑名单是安全线。
|
||||
// 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。
|
||||
if bypass {
|
||||
if matches!(bypass, PathBypass::All) {
|
||||
continue;
|
||||
}
|
||||
return FileToolAuthOutcome::Denied(reason);
|
||||
}
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
// 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理)
|
||||
if bypass {
|
||||
// 总开关开启(All 或 KeepBlacklist):未授权目录直接放行(黑名单的放行仅在 All 分支)
|
||||
if !matches!(bypass, PathBypass::Off) {
|
||||
continue;
|
||||
}
|
||||
if !pending_dirs.contains(&dir) {
|
||||
@@ -136,17 +153,43 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default();
|
||||
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
|
||||
// 关(基线,行为不变):NeedsAuth
|
||||
match check_file_tool_auth("read_file", &args, &allowed, false) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
|
||||
FileToolAuthOutcome::NeedsAuth(_) => {}
|
||||
_ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"),
|
||||
}
|
||||
// 开:直接 Authorized,不挂起
|
||||
match check_file_tool_auth("read_file", &args, &allowed, true) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::All) {
|
||||
FileToolAuthOutcome::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 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。
|
||||
/// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。
|
||||
#[test]
|
||||
@@ -157,7 +200,7 @@ mod tests {
|
||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||
let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
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
|
||||
);
|
||||
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 allowed = crate::state::AllowedDirs::default_with_root();
|
||||
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(_) => {}
|
||||
_ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"),
|
||||
}
|
||||
|
||||
@@ -296,15 +296,23 @@ fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) ->
|
||||
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
|
||||
/// 是 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 倒序,首见=最近一次);② 子代理会话
|
||||
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
|
||||
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
|
||||
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行,
|
||||
/// 更早的关联对"跳回最近上下文"场景无意义。
|
||||
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢。
|
||||
pub(crate) async fn find_task_conversations(
|
||||
db: &Database,
|
||||
task_id: &str,
|
||||
) -> Result<Vec<TaskConversationRef>, String> {
|
||||
let pattern = format!("%\"id\":\"{}\"%", task_id);
|
||||
let conn = db.conn();
|
||||
let rows: Vec<(String, String, Option<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 \
|
||||
WHERE tool_name IN ('create_task','update_task','advance_task') \
|
||||
AND conversation_id IS NOT NULL \
|
||||
AND result LIKE ?1 \
|
||||
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mapped = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map([&pattern], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
@@ -590,6 +599,7 @@ mod tests {
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
origin: origin.map(|s| s.to_string()),
|
||||
boundary_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("测试会话插入应成功");
|
||||
@@ -637,6 +647,36 @@ mod tests {
|
||||
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 不等 均不命中,精确判等才命中。
|
||||
#[test]
|
||||
fn match_task_audit_row_truth_table() {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! `resolve_all` 按 [`MentionRef::kind_str`] 分发到对应 Resolver,单条 resolve 失败
|
||||
//! 收集进 errors 列表不阻断整批注入(返回 `(Vec<Augmentation>, Vec<ResolveError>)`)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_types::augmentation::{Augmentation, MentionRef, ResolveError};
|
||||
@@ -45,6 +45,10 @@ impl ResolverRegistry {
|
||||
/// - 失败(Resolver 返 Err / kind 无对应 Resolver)→ push 进 `errors`,**不中断**
|
||||
/// 后续 mention(整批注入最大化保留可用上下文)。
|
||||
///
|
||||
/// 同一引用去重(先到先得):`/技能` 前导与 @mention span 可指向同一技能,按
|
||||
/// `kind + ref_id`(skill 的 ref_id 即 name)跳过重复,避免同一正文重复 resolve
|
||||
/// 并在 prompt 注入两份。首现位置决定保留哪条,顺序不变。
|
||||
///
|
||||
/// 返回 `(成功列表, 失败列表)`,调用方据 errors 数决定是否提示用户某 mention 跳过。
|
||||
pub async fn resolve_all(
|
||||
&self,
|
||||
@@ -53,7 +57,12 @@ impl ResolverRegistry {
|
||||
) -> (Vec<Augmentation>, Vec<ResolveError>) {
|
||||
let mut augs = Vec::with_capacity(refs.len());
|
||||
let mut errors = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::with_capacity(refs.len());
|
||||
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();
|
||||
match self.resolvers.get(kind) {
|
||||
Some(resolver) => match resolver.resolve(rf, loc).await {
|
||||
@@ -139,4 +148,48 @@ mod tests {
|
||||
assert!(augs.is_empty(), "两条 mention kind 均≠test 应全 KindMismatch");
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(), "新会话默认无边界(无边界=现状逐次审批零倒退)");
|
||||
}
|
||||
}
|
||||
@@ -724,6 +724,14 @@ pub(crate) async fn spawn_agentic_session(
|
||||
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 真并发上线):
|
||||
// - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
|
||||
@@ -790,12 +798,12 @@ pub(crate) async fn spawn_agentic_session(
|
||||
} else {
|
||||
format!("{} [图片已忽略:当前模型不支持图片]", base)
|
||||
}
|
||||
} else if message.trim().is_empty() {
|
||||
if let Some(ref skill_name) = skill {
|
||||
format!("/{}", skill_name)
|
||||
} else {
|
||||
message.clone()
|
||||
}
|
||||
} else if let Some(ref skill_name) = skill {
|
||||
// 技能调用留痕(问题3):带文本的技能调用落库加 /技能名 前缀,否则技能名只在
|
||||
// system prompt 注入、会话历史零痕迹(用户无从得知是否用了技能)。前缀同时是
|
||||
// regenerate/edit 的 backtrack_leading_skill 回溯依据(非空文本此前无前缀,
|
||||
// 重生成时回溯不到即丢技能注入——本修复顺带治好该链)。
|
||||
format!("/{} {}", skill_name, message)
|
||||
} else {
|
||||
message.clone()
|
||||
};
|
||||
@@ -1135,6 +1143,12 @@ pub async fn ai_approve(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
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 {
|
||||
@@ -1287,7 +1301,7 @@ pub async fn ai_authorize_dir(
|
||||
decision: String,
|
||||
) -> Result<String, String> {
|
||||
// F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
|
||||
// 避免污染 stderr(用户可见);authz_debug 文件日志保留(读 authz-debug.log 定位,排障仍可用)。
|
||||
// 避免污染 stderr(用户可见)。
|
||||
tracing::debug!(
|
||||
tool_call_id = %tool_call_id,
|
||||
decision = %decision,
|
||||
@@ -2014,6 +2028,12 @@ pub async fn ai_chat_force_send(
|
||||
other => (other, false),
|
||||
};
|
||||
|
||||
// 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
|
||||
// 置于锁与消息 push 之前,拒绝零副作用。
|
||||
if degraded && message.trim().is_empty() && skill.is_none() {
|
||||
return Err("当前模型不支持图片,无法发送纯图片消息".to_string());
|
||||
}
|
||||
|
||||
// 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
|
||||
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
|
||||
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
|
||||
@@ -2072,8 +2092,10 @@ pub async fn ai_chat_force_send(
|
||||
// spawn_agent 级联取消:强制发送复位父 loop,其派生的子代理同步置停。
|
||||
// force_send 随后会把父 stop_flag 复位 false(新 loop 起跑),子循环不能依赖父
|
||||
// flag 长期置位,own stop_flag 是子循环主判据(父条目消失为兜底)。
|
||||
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
|
||||
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
|
||||
&mut session, &state.sub_agent_registry, &target,
|
||||
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
|
||||
);
|
||||
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
|
||||
// 这是"强制"语义本身,前面已主动复位)
|
||||
@@ -2102,12 +2124,11 @@ pub async fn ai_chat_force_send(
|
||||
} else {
|
||||
format!("{} [图片已忽略:当前模型不支持图片]", base)
|
||||
}
|
||||
} else if message.trim().is_empty() {
|
||||
if let Some(ref skill_name) = skill {
|
||||
format!("/{}", skill_name)
|
||||
} else {
|
||||
message.clone()
|
||||
}
|
||||
} else if let Some(ref skill_name) = skill {
|
||||
// 技能调用留痕(同 ai_chat_send,问题3):带文本的技能调用落库加 /技能名 前缀,
|
||||
// 会话历史可见技能使用痕迹 + regenerate/edit 回溯依据。非空文本此前无前缀,
|
||||
// 强制重发后重生成时回溯不到即丢技能注入。
|
||||
format!("/{} {}", skill_name, message)
|
||||
} else {
|
||||
message.clone()
|
||||
};
|
||||
@@ -2279,10 +2300,17 @@ pub async fn ai_chat_stop(
|
||||
is_estimated: false,
|
||||
incomplete: None,
|
||||
incomplete_reason: None,
|
||||
conversation_id: Some(target),
|
||||
conversation_id: Some(target.clone()),
|
||||
pinned_goals,
|
||||
};
|
||||
emit_ai_chat_event(&app, ev);
|
||||
// 安全停靠(设计 §四):挂起后取消(审批等待态用户主动停止)= 五路非正常终局之一。
|
||||
// 上方 AiCompleted 收敛前端流式态,此处补 AiTaskParked 留停靠记录(挂起家族统一出口)。
|
||||
crate::commands::ai::emit_task_parked(
|
||||
&app, &target,
|
||||
"任务已停止(审批挂起中被取消):现场已保存,重发消息或补充指令可继续",
|
||||
"cancelled",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// 流式生成中:置位让目标 conv 的 loop 自行收尾
|
||||
@@ -2294,8 +2322,10 @@ pub async fn ai_chat_stop(
|
||||
// spawn_agent 级联取消:父 loop 收到停止,其派生的子代理同步置停
|
||||
// (子循环停止检查点读 own stop_flag 即退出,不等挂钟超时)。
|
||||
// 短临界区纯内存操作(无 await),不延长 session 持锁。
|
||||
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
|
||||
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
|
||||
&mut session, &state.sub_agent_registry, &target,
|
||||
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
|
||||
);
|
||||
// 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!,
|
||||
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
|
||||
|
||||
@@ -400,6 +400,21 @@ pub async fn ai_conversation_switch(
|
||||
};
|
||||
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 {
|
||||
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]
|
||||
pub async fn ai_conversation_delete(
|
||||
@@ -687,8 +776,10 @@ pub async fn ai_conversation_delete(
|
||||
// spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点
|
||||
// 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。
|
||||
// 短临界区纯内存操作(无 await),不延长 session 持锁。
|
||||
// Delete 形态:同锁内对 child 补 deleted_convs,出口 alive 判定放弃落库,防孤儿轨迹复活。
|
||||
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
|
||||
&mut session, &state.sub_agent_registry, &conversation_id,
|
||||
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Delete,
|
||||
);
|
||||
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
|
||||
session.per_conv.remove(&conversation_id);
|
||||
@@ -805,6 +896,79 @@ pub async fn ai_update_conversation_goals(
|
||||
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 不读(可能被切走/未落库)
|
||||
@@ -1271,4 +1435,69 @@ mod tests {
|
||||
assert_eq!(got[1].id, "m_new");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +322,8 @@ fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
|
||||
/// 绝不进日志/返回值/错误信息)
|
||||
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
|
||||
/// 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 闭环)。
|
||||
#[tauri::command]
|
||||
@@ -345,6 +346,14 @@ pub async fn ai_fetch_models(
|
||||
.await
|
||||
.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/
|
||||
// context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。
|
||||
// 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router
|
||||
|
||||
@@ -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 切换影响)
|
||||
///
|
||||
/// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 +
|
||||
@@ -190,13 +202,40 @@ pub(crate) async fn save_conversation(
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
) {
|
||||
// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。
|
||||
// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。
|
||||
// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
|
||||
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
|
||||
save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, None).await;
|
||||
}
|
||||
|
||||
/// 子代理出口落库入口(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(
|
||||
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
|
||||
{
|
||||
@@ -216,16 +255,17 @@ async fn save_conversation_serialized(
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
meta: Option<SaveMetaOverride>,
|
||||
) {
|
||||
let save_lock = {
|
||||
let mut session = session_arc.lock().await;
|
||||
session.save_lock(conv_id)
|
||||
};
|
||||
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(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
db: &Arc<Database>,
|
||||
@@ -233,6 +273,7 @@ async fn save_conversation_inner(
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
meta: Option<SaveMetaOverride>,
|
||||
) {
|
||||
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
|
||||
// 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
|
||||
@@ -253,7 +294,7 @@ async fn save_conversation_inner(
|
||||
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
||||
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
|
||||
// 无变化则不 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;
|
||||
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
|
||||
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
|
||||
@@ -268,8 +309,15 @@ async fn save_conversation_inner(
|
||||
let __wait = __lock_start.elapsed();
|
||||
// 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow,
|
||||
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
||||
let provider_id = session.active_provider_id.clone();
|
||||
let created_at = session.active_conv_created_at.clone();
|
||||
// P3-5:显式快照优先(SubAgent 出口落库用),否则读 active_*(主会话历史读法不变)。
|
||||
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
|
||||
let conv_origin = session.conv_origins.get(conv_id).cloned();
|
||||
let conv = session.conv(conv_id);
|
||||
@@ -284,6 +332,8 @@ async fn save_conversation_inner(
|
||||
conv.messages.all_messages_clone()
|
||||
};
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
|
||||
let conv_boundary = conv.boundary.clone();
|
||||
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
|
||||
(
|
||||
provider_id,
|
||||
@@ -294,6 +344,7 @@ async fn save_conversation_inner(
|
||||
needs_full_rewrite,
|
||||
cloned,
|
||||
conv_origin,
|
||||
conv_boundary,
|
||||
)
|
||||
};
|
||||
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()));
|
||||
// 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
|
||||
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
|
||||
if let Err(e) = conv_repo.update_full(&rec).await {
|
||||
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
|
||||
@@ -473,6 +528,9 @@ async fn save_conversation_inner(
|
||||
created_at: conv_created.clone(),
|
||||
updated_at: now,
|
||||
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 {
|
||||
tracing::warn!("落库对话失败 {conv_id}: {e}");
|
||||
@@ -492,10 +550,14 @@ async fn save_conversation_inner(
|
||||
// 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE
|
||||
// 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。
|
||||
// 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用
|
||||
// max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。
|
||||
// max 防回退;dirty 只在全量重写时清(增量/无变化路径写库期间新置的 dirty 如压缩 mark,
|
||||
// 与本次 append 写库无关,误清会致改动滞留内存永不落库)。
|
||||
if msgs_written_ok {
|
||||
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 时创建)
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
pub mod agentic;
|
||||
pub mod augmentation;
|
||||
pub mod audit;
|
||||
// 无人值守任务边界(TaskBoundary):结构 + 纯判定函数 + 单测(设计 §二/§三)
|
||||
pub mod boundary;
|
||||
pub mod code_intel;
|
||||
pub mod command_stream;
|
||||
pub mod commands;
|
||||
@@ -236,6 +238,9 @@ pub enum AiChatEvent {
|
||||
/// - `status`:"completed" | "truncated" | "failed"
|
||||
/// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0)
|
||||
/// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝
|
||||
/// - `stop_kind`:停止类别(snake_case 字符串,为前端备料):"cancelled"(用户停止级联)
|
||||
/// | "wall_clock" | "max_rounds" | "token_budget" | "empty_convergence" | "error"
|
||||
/// | "converged"(正常收敛);None=未分类(防御,当前所有路径均有类别)
|
||||
AiAgentFinished {
|
||||
agent_id: String,
|
||||
conversation_id: Option<String>,
|
||||
@@ -246,6 +251,9 @@ pub enum AiChatEvent {
|
||||
/// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。
|
||||
/// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。
|
||||
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,
|
||||
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 字段)。
|
||||
@@ -662,19 +698,6 @@ pub struct 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(())))
|
||||
.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)]
|
||||
@@ -1031,6 +1073,30 @@ pub struct PerConvState {
|
||||
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
|
||||
/// 位置漂移,内容不变)。随会话销毁不落库。
|
||||
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,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1096,6 +1229,8 @@ impl PerConvState {
|
||||
workflow_dag_summary: None,
|
||||
frozen_tool_names: None,
|
||||
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 跟踪进度。"
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一事件+通知单点
|
||||
// ============================================================
|
||||
|
||||
/// 发停靠事件 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,57 +81,68 @@ fn env_profile_date_volatile() -> String {
|
||||
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 {
|
||||
"en" => (
|
||||
"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\
|
||||
## Capabilities\n\
|
||||
You can perform the following actions via tool calls:\n\
|
||||
- Create/query projects, tasks, and ideas\n\
|
||||
- Run workflows\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\
|
||||
- Get file metadata (size, line count, binary detection)\n\
|
||||
- Append content to files, search files by name pattern\n\n\
|
||||
## Guidelines\n\
|
||||
- Briefly explain your intent before executing actions\n\
|
||||
- Ask for clarification if the user's intent is unclear\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\
|
||||
- **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\
|
||||
## 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\
|
||||
- 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\
|
||||
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
|
||||
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\
|
||||
Please respond in English.\n\n\
|
||||
## Capabilities\n\
|
||||
You can perform the following actions via tool calls:\n\
|
||||
- Create/query projects, tasks, and ideas\n\
|
||||
- Run workflows\n\
|
||||
- Read file contents, list directories, create/write files\n\
|
||||
- {}\n\
|
||||
- Get file metadata (size, line count, binary detection)\n\
|
||||
- Append content to files, search files by name pattern\n\n\
|
||||
## Guidelines\n\
|
||||
- Briefly explain your intent before executing actions\n\
|
||||
- Ask for clarification if the user's intent is unclear\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\
|
||||
- **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\
|
||||
- {}\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\
|
||||
- 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\
|
||||
- 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 Tasks\n",
|
||||
),
|
||||
_ => (
|
||||
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
|
||||
必须使用简体中文回复,禁止使用繁体中文字符。\n\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\
|
||||
- **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\
|
||||
## 聚焦准则\n\
|
||||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||||
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
|
||||
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
|
||||
format!(
|
||||
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
|
||||
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
|
||||
## 当前能力\n\
|
||||
你可以通过工具调用执行以下操作:\n\
|
||||
- 创建/查询项目、任务、灵感\n\
|
||||
- 运行工作流\n\
|
||||
- 读取文件内容、列出目录、创建/写入文件\n\
|
||||
- {}\n\
|
||||
- 获取文件元信息(大小、行数、二进制检测)\n\
|
||||
- 追加写入文件、按名称模式搜索文件\n\n\
|
||||
## 行为准则\n\
|
||||
- 执行操作前简要说明你的意图\n\
|
||||
- 如果不确定用户意图,先提问\n\
|
||||
- 优先使用工具完成操作,而不是只描述步骤\n\
|
||||
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
|
||||
- {}\n\
|
||||
## 聚焦准则\n\
|
||||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||||
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
|
||||
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
|
||||
read_symbol_guidance(lang),
|
||||
duplicate_call_guidance(lang),
|
||||
),
|
||||
"\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 帮装。
|
||||
///
|
||||
/// 仅段加在 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 {
|
||||
"en" => "\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\
|
||||
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\
|
||||
\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\
|
||||
### 先选对 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\
|
||||
2. **第 1 次成功即收手**:`render=true` 第 1 次成功拿到 markdown → **立即用已有数据整理输出**,不要重复 render 同 URL 去「补充」更多内容(已拿即用)。\n\
|
||||
3. **同 URL 不重复 render=true**:同一 URL 的 render 结果(成功或失败)已知,后续不再对它重复发 render=true(缓存/历史即用);需要新内容请换 URL 或换方案。\n\
|
||||
\n\
|
||||
### JS 渲染文档与 obscura 安装引导\n\
|
||||
\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\
|
||||
1. **察觉**:若 `fetch_url` 结果带 `hint`(或 `render_mode` 为 `static_fallback`),说明 obscura 缺失,页面可能没渲染完整。\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 mut prompt = env_profile_line();
|
||||
prompt.push_str(prefix);
|
||||
prompt.push_str(&prefix);
|
||||
// 文档探索策略段(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 内联脚本引号嵌套失败)
|
||||
prompt.push_str(app_config_query_guidance_section(lang));
|
||||
|
||||
@@ -556,8 +626,8 @@ pub(crate) async fn build_system_prompt_parts(
|
||||
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
|
||||
let (prefix, _, _) = system_prompt_parts(lang);
|
||||
let mut stable = env_profile_stable_body();
|
||||
stable.push_str(prefix);
|
||||
stable.push_str(doc_exploration_strategy_section(lang));
|
||||
stable.push_str(&prefix);
|
||||
stable.push_str(&doc_exploration_strategy_section(lang));
|
||||
stable.push_str(app_config_query_guidance_section(lang));
|
||||
stable.push_str(&build_custom_section(state).await);
|
||||
|
||||
@@ -847,6 +917,36 @@ mod tests {
|
||||
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]
|
||||
fn listed_notes_present_zh_en() {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ pub(crate) enum StreamResult {
|
||||
usage: df_ai::provider::TokenUsage,
|
||||
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
|
||||
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
|
||||
/// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积),
|
||||
@@ -282,6 +285,9 @@ pub(crate) async fn stream_llm(
|
||||
let mut finished_received = false;
|
||||
let mut stopped = false;
|
||||
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 模式推理内容累积(多轮需回传)
|
||||
let mut reasoning_content_acc: Option<String> = None;
|
||||
// per-conv delta 合批累加器。pending_delta 累积未 flush 的文本,
|
||||
@@ -419,6 +425,9 @@ pub(crate) async fn stream_llm(
|
||||
if let Some(u) = &chunk.usage {
|
||||
final_usage = Some(u.clone());
|
||||
}
|
||||
if chunk.truncated_by_max_tokens {
|
||||
truncated_by_max_tokens = true;
|
||||
}
|
||||
if let Some(ref rc) = chunk.reasoning_content {
|
||||
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,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
truncated_by_max_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -573,6 +583,7 @@ pub(crate) async fn stream_llm(
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
truncated_by_max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -156,9 +156,13 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
|
||||
let mut patch_args = vec!["diff"];
|
||||
if staged { patch_args.push("--cached"); }
|
||||
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 {
|
||||
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 {
|
||||
patch_raw
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ use crate::commands::ai::agentic::sub_agent::{
|
||||
SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP,
|
||||
};
|
||||
use crate::commands::ai::AiSession;
|
||||
use crate::commands::ai::conversation::SaveMetaOverride;
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
use df_storage::db::Database;
|
||||
@@ -253,7 +254,19 @@ async fn spawn_agent_handler(
|
||||
|
||||
let child_id = next_child_id(&parent_conv_id);
|
||||
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 级联取消)
|
||||
sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id);
|
||||
@@ -287,9 +300,11 @@ async fn spawn_agent_handler(
|
||||
system_prompt,
|
||||
task_prompt,
|
||||
tool_defs,
|
||||
allowed,
|
||||
max_rounds,
|
||||
child_model,
|
||||
ctx.llm_concurrency.clone(),
|
||||
parent_save_meta,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -313,7 +313,8 @@ pub async fn evaluate_idea(
|
||||
///
|
||||
/// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。
|
||||
/// - provider 构造仅一次(批量场景减少重复初始化开销)。
|
||||
/// - 返回 BatchEvalResult { success, errors },前端据此展示部分成功/失败。
|
||||
// 2026-10-04: 未在 lib.rs 注册 IPC(dead_code),保留作批量评估参考实现;复用下方 evaluate_single 共用内部函数。
|
||||
#[allow(dead_code)]
|
||||
#[tauri::command]
|
||||
pub async fn evaluate_ideas_batch(
|
||||
state: State<'_, AppState>,
|
||||
|
||||
@@ -460,12 +460,17 @@ pub fn run() {
|
||||
commands::ai::ai_conversation_switch,
|
||||
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
|
||||
commands::ai::ai_conversation_peek,
|
||||
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
|
||||
commands::ai::ai_sub_agent_list,
|
||||
commands::ai::ai_conversation_load_more,
|
||||
commands::ai::ai_conversation_delete,
|
||||
commands::ai::ai_conversation_rename,
|
||||
commands::ai::ai_conversation_archive,
|
||||
commands::ai::ai_conversation_set_pinned,
|
||||
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_list_skills,
|
||||
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
|
||||
|
||||
@@ -18,8 +18,8 @@ mod llm_concurrency;
|
||||
|
||||
// 对外公共符号(pub use):外部 crate/模块经 `crate::state::X` 路径消费
|
||||
pub use allowed_dirs::{
|
||||
AllowedDirs, PathAuthDecision, check_path_authorization, is_in_system_blacklist,
|
||||
paths_eq, strip_verbatim,
|
||||
AllowedDirs, PathAuthDecision, best_effort_canonicalize, check_path_authorization,
|
||||
is_in_system_blacklist, paths_eq, strip_verbatim,
|
||||
};
|
||||
pub use knowledge_config::{
|
||||
ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY,
|
||||
@@ -275,7 +275,6 @@ pub struct AppState {
|
||||
/// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空,
|
||||
/// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。
|
||||
/// 已修复:
|
||||
/// - authz_debug → std::env::temp_dir() (chat.rs)
|
||||
/// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs)
|
||||
fn workspace_root_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -233,7 +233,7 @@ pub fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
/// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断)
|
||||
///
|
||||
/// 失败一律回退,绝不返回 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) {
|
||||
return real;
|
||||
}
|
||||
|
||||
+50
-6
@@ -4,6 +4,9 @@
|
||||
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
|
||||
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
|
||||
//! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime)
|
||||
//! - i18n:文案按系统 locale 取中/英双语静态映射(locale 判定一次缓存),无 Rust i18n 机制的最小实现
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use tauri::{
|
||||
AppHandle, Manager,
|
||||
menu::{Menu, MenuItem},
|
||||
@@ -12,6 +15,45 @@ use tauri::{
|
||||
|
||||
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 移除托盘图标。
|
||||
/// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip);
|
||||
/// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。
|
||||
@@ -24,10 +66,11 @@ pub struct TrayHandle {
|
||||
|
||||
/// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。
|
||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let t = tray_texts();
|
||||
// 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护
|
||||
let status = MenuItem::with_id(app, "status", "无运行中会话", false, None::<&str>)?;
|
||||
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
let status = MenuItem::with_id(app, "status", t.status_idle, false, None::<&str>)?;
|
||||
let show = MenuItem::with_id(app, "show", t.show, true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", t.quit, true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&status, &show, &quit])?;
|
||||
// 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置
|
||||
let icon = app
|
||||
@@ -60,14 +103,15 @@ pub fn update_tray_runtime(app: &AppHandle) {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let t = tray_texts();
|
||||
let n = state.conv_states.active_convs().len();
|
||||
let text = if n > 0 {
|
||||
format!("运行中 {} 个会话", n)
|
||||
t.status_running.replace("{}", &n.to_string())
|
||||
} else {
|
||||
"无运行中会话".to_string()
|
||||
t.status_idle.to_string()
|
||||
};
|
||||
let tooltip = if n > 0 {
|
||||
format!("DevFlow (运行中 {} 个会话)", n)
|
||||
t.tooltip_running.replace("{}", &n.to_string())
|
||||
} else {
|
||||
"DevFlow (MCP server running)".to_string()
|
||||
};
|
||||
|
||||
@@ -157,6 +157,10 @@ function initTheme() {
|
||||
// UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
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()
|
||||
aiStore.cyclePanelMode()
|
||||
}
|
||||
|
||||
+23
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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 = {
|
||||
/**
|
||||
@@ -112,6 +112,19 @@ export const aiApi = {
|
||||
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> {
|
||||
return invoke('ai_chat_clear')
|
||||
@@ -378,6 +391,15 @@ export const aiApi = {
|
||||
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 的会话。
|
||||
* 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE),
|
||||
|
||||
@@ -480,6 +480,19 @@ export type AiChatEvent = ({
|
||||
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
|
||||
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
|
||||
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 消费。
|
||||
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
|
||||
@@ -806,6 +819,30 @@ export interface AiToolMeta {
|
||||
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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识库
|
||||
// ============================================================
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
|
||||
<!-- Input -->
|
||||
<div class="ai-input-area">
|
||||
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
|
||||
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
|
||||
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
||||
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
||||
@@ -61,6 +63,8 @@
|
||||
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
|
||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
|
||||
<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)。
|
||||
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
|
||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
||||
@@ -143,6 +147,8 @@ import ChatInput from './ai/ChatInput.vue'
|
||||
import TopBar from './ai/TopBar.vue'
|
||||
import MessageList from './ai/MessageList.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 { useConfirm } from '../composables/useConfirm'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -326,6 +332,11 @@ function cancelEdit(): void {
|
||||
// 注:Ctrl+K(UX-06 §3.1)已绑——Tauri WebView2 无浏览器地址栏,聚焦搜索框无冲突
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
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 可靠,无浏览器新窗口冲突)
|
||||
if (ctrl && (e.key === 'n' || e.key === 'N')) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
/>
|
||||
<!-- F-260614-05 Phase 2b: 待发送图片预览 -->
|
||||
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
|
||||
<!-- B1: 当前模型不看图时贴图警示(发送仍允许,后端温和降级丢图;chip 持续可见防误判网络问题) -->
|
||||
<div v-if="showImageIgnoreWarn" class="ai-img-warn-chip">{{ t('aiChat.imageIgnoredWarn') }}</div>
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="inputText"
|
||||
@@ -304,6 +306,22 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||
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 skillIndex = ref(0)
|
||||
@@ -849,6 +867,12 @@ async function handleSend() {
|
||||
const skill = pendingSkill.value
|
||||
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)
|
||||
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...])。
|
||||
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
|
||||
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
|
||||
@@ -1181,4 +1205,11 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
|
||||
|
||||
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
|
||||
.ai-img-warn-chip {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-warning);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- 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="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 class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -161,6 +165,10 @@
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- 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="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 class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -215,6 +223,10 @@
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- 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="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 class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -279,6 +291,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
|
||||
import { convPendingApprovals } from '../../composables/ai/aiShared'
|
||||
// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪;
|
||||
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
|
||||
import { getConvBoundary } from '../../composables/ai/useTaskBoundary'
|
||||
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
|
||||
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
@@ -508,6 +523,11 @@ function pendingCountOf(convId: string): number {
|
||||
return pendingCountByConv.value.get(convId) ?? 0
|
||||
}
|
||||
|
||||
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
|
||||
function hasBoundary(convId: string): boolean {
|
||||
return getConvBoundary(convId) !== null
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
const bucketLabels = computed<Record<string, string>>(() => ({
|
||||
today: t('aiChat.today'),
|
||||
@@ -842,6 +862,19 @@ body.ai-sidebar-resizing * {
|
||||
text-align: center;
|
||||
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 {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
||||
@@ -10,8 +10,25 @@
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
|
||||
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<{
|
||||
msg: AiMessage
|
||||
@@ -89,9 +106,16 @@ 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>
|
||||
</div>
|
||||
<div class="ai-msg-bubble ai-msg-bubble--user">
|
||||
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
|
||||
<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>
|
||||
<!-- 问题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">
|
||||
<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>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="msgImageParts(msg).length">
|
||||
<div class="ai-msg-images">
|
||||
@@ -153,6 +177,12 @@ function imgAlt(img: ImageContentPart): string {
|
||||
background: var(--df-accent-bg, rgba(108,99,255,0.1));
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -690,7 +690,8 @@ function canOpenSettings(msg: AiMessage): boolean {
|
||||
* 错误是否可重试(显隐「重试」按钮)。
|
||||
* Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败,
|
||||
* 隐藏重试按钮,引导用户走「去设置」。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 {
|
||||
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
|
||||
@@ -1077,11 +1078,22 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
}
|
||||
}
|
||||
flushBucket()
|
||||
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
|
||||
// S1 子代理完成卡:按完成时刻插到所属轮次桶尾,不恒贴消息流最底部——恒贴时用户继续对话,
|
||||
// 早前轮次的卡被顶到后面内容之下,与所属轮次脱钩。归桶判据:finishedAt 落在哪桶的跨度内
|
||||
// (trace.startTs ≤ finishedAt < 下一桶),卡即插到该桶 trace 卡之后与其同层(spawn 就发生
|
||||
// 在这个桶里;桶内末条答复的落库时刻必晚于 finishedAt,时间比较天然落在这里)。
|
||||
// 无时间语义的项(step/timesep/sep/agentFin)不作锚点,防插进时间线分隔的语义缝隙。
|
||||
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
|
||||
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
|
||||
for (const card of getConvAgentFinished(store.state.activeConversationId)) {
|
||||
items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
|
||||
const cards = [...getConvAgentFinished(store.state.activeConversationId)].sort((a, b) => a.finishedAt - b.finishedAt)
|
||||
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
|
||||
})
|
||||
@@ -1094,18 +1106,49 @@ function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err
|
||||
if (status === 'failed') return 'err'
|
||||
return 'ok'
|
||||
}
|
||||
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应
|
||||
function agentFinStatusText(status: AgentFinishedCard['status']): string {
|
||||
if (status === 'truncated') return t('ai.agentFinished.truncated')
|
||||
if (status === 'failed') return t('ai.agentFinished.failed')
|
||||
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应。
|
||||
// stop_kind 结构化优先(根治启发式):后端终态枚举直达语义,截断三细分各自透出;
|
||||
// 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
|
||||
// 级联取消子代理时 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')
|
||||
}
|
||||
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 字口径的轻量版),
|
||||
// 全文经 title hover 兜底不丢信息。
|
||||
function agentFinSummary(summary: string): string {
|
||||
const s = (summary || '').replace(/\s+/g, ' ').trim()
|
||||
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 子代理轨迹浮层(完成卡点击展开,只读) ──
|
||||
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
|
||||
@@ -1114,10 +1157,17 @@ interface AgentPeekMsg {
|
||||
role: 'user' | 'assistant' | 'tool' | 'system'
|
||||
text: string
|
||||
toolNames: string[]
|
||||
// deny envelope 消息标记:命中子代理拒绝信封的 tool 结果,渲染为红字人话而非原始 JSON
|
||||
denied?: boolean
|
||||
}
|
||||
const agentPeekVisible = ref(false)
|
||||
const agentPeekLoading = ref(false)
|
||||
// 加载中的卡 id:spinner 挂在具体完成卡上而非浮层(浮层此时尚未打开)
|
||||
const agentPeekLoadingId = ref<string | null>(null)
|
||||
const agentPeekMsgs = ref<AgentPeekMsg[]>([])
|
||||
// 浮层头身份标识:agent-{n} 尾段 + 子会话标题(IPC title,子代理常为空则不显)+ 任务书首行,
|
||||
// 多代理场景辨认「在看谁」;关浮层即弃
|
||||
const agentPeekSubject = ref({ agent: '', convTitle: '', task: '', taskFull: '' })
|
||||
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
|
||||
// 完整数据仍在库,不做全文渲染(不要求富文本)。
|
||||
const AGENT_PEEK_TEXT_MAX = 4000
|
||||
@@ -1125,40 +1175,104 @@ const AGENT_PEEK_TEXT_MAX = 4000
|
||||
/**
|
||||
* 完成卡点击:peek 子会话轨迹并开浮层。
|
||||
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
|
||||
* 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。
|
||||
* 启动失败无轨迹卡直接忽略(agentFinNoTrace);加载中/已开时忽略重复点击(防连点重复 IPC);
|
||||
* 失败走既有 toast 通道给可见反馈,不开空浮层。
|
||||
*/
|
||||
async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
|
||||
if (agentFinNoTrace(card)) return
|
||||
if (agentPeekVisible.value || agentPeekLoading.value) return
|
||||
agentPeekLoading.value = true
|
||||
agentPeekLoadingId.value = card.agentId
|
||||
try {
|
||||
const res = await aiApi.peekConversation(card.agentId)
|
||||
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) => {
|
||||
const text = String(m.content || '')
|
||||
const rawText = String(m.content || '')
|
||||
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
|
||||
? m.role
|
||||
: 'assistant'
|
||||
return {
|
||||
const msg: AgentPeekMsg = {
|
||||
role,
|
||||
text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text,
|
||||
text: '',
|
||||
toolNames: Array.isArray(m.tool_calls)
|
||||
? 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
|
||||
} catch (e) {
|
||||
console.error('[AI] 拉取子代理轨迹失败:', e)
|
||||
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
|
||||
} finally {
|
||||
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 {
|
||||
agentPeekVisible.value = false
|
||||
agentPeekMsgs.value = []
|
||||
agentPeekSubject.value = { agent: '', convTitle: '', task: '', taskFull: '' }
|
||||
}
|
||||
|
||||
/** role → 本地化角色名(简版渲染,无头像气泡) */
|
||||
@@ -1187,6 +1301,8 @@ watch(() => store.state.activeConversationId, () => {
|
||||
setFollowing(true)
|
||||
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
|
||||
expandedTraceIds.value = new Set()
|
||||
// 切会话关子代理轨迹浮层(浮层是组件级单例,不关则旧会话轨迹叠在新会话上)
|
||||
closeAgentTrace()
|
||||
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
|
||||
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
|
||||
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
|
||||
@@ -1211,6 +1327,7 @@ watch(() => store.state.activeConversationId, () => {
|
||||
onBeforeUnmount(() => {
|
||||
cancelPendingRaf()
|
||||
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
|
||||
document.removeEventListener('keydown', closeAgentTraceOnEsc)
|
||||
})
|
||||
|
||||
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
|
||||
@@ -1219,6 +1336,12 @@ function closeTokenPopoverOnOutsideClick(): void {
|
||||
}
|
||||
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 单例,
|
||||
// 多次调用安全,确保子组件挂载即预热)。
|
||||
loadMarkdown()
|
||||
@@ -1321,16 +1444,26 @@ defineExpose({
|
||||
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
|
||||
<div
|
||||
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"
|
||||
:title="$t('ai.agentFinished.viewHint')"
|
||||
:title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')"
|
||||
@click="openAgentTrace(item.card)"
|
||||
>
|
||||
<span class="ai-agent-fin-dot"></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-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>
|
||||
<!-- 居中时间分隔线(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 class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
|
||||
<div class="ai-agent-peek-head">
|
||||
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
|
||||
<div class="ai-agent-peek-head-text">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="ai-agent-peek-body">
|
||||
<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
|
||||
v-for="(m, i) in agentPeekMsgs"
|
||||
:key="i"
|
||||
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">
|
||||
<span>{{ agentPeekRoleText(m.role) }}</span>
|
||||
@@ -2450,6 +2592,40 @@ defineExpose({
|
||||
font-family: var(--df-font-mono);
|
||||
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 子代理轨迹浮层(只读,关即弃) ═══ */
|
||||
.ai-agent-peek-mask {
|
||||
@@ -2552,4 +2728,50 @@ defineExpose({
|
||||
color: var(--df-text-secondary);
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -119,6 +119,20 @@
|
||||
<div class="ai-bottom-tools" ref="goalsRef">
|
||||
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
|
||||
<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">
|
||||
<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 { useAiStore } from '../../stores/ai'
|
||||
import { aiApi } from '../../api/ai'
|
||||
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
|
||||
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
|
||||
|
||||
defineProps<{
|
||||
@@ -356,6 +371,28 @@ const currentConvTitle = computed(() => {
|
||||
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)
|
||||
const goals = ref<GoalEntry[]>([])
|
||||
const goalsExpanded = ref(false)
|
||||
@@ -744,7 +781,10 @@ onBeforeUnmount(() => {
|
||||
color: var(--df-text-secondary);
|
||||
min-width: 0;
|
||||
margin-right: auto;
|
||||
|
||||
/* 长标题单行截断(防换行撑高工具行;右侧徽章族 flex-shrink:0,分离窄窗口更挤) */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-bottom-tools-right {
|
||||
display: flex;
|
||||
@@ -753,6 +793,45 @@ onBeforeUnmount(() => {
|
||||
flex-shrink: 0;
|
||||
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 中间区) ═══ */
|
||||
.ai-provider-bar {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
<section class="panel" v-if="connForm.visible">
|
||||
<div class="panel-header">
|
||||
<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 class="form-grid">
|
||||
<div class="form-field">
|
||||
@@ -77,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
@@ -120,6 +121,8 @@ const connForm = reactive({
|
||||
port: DEFAULT_PORTS.mysql,
|
||||
user: '',
|
||||
password: '',
|
||||
// L19 对齐: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isConnDirty 比对(同 ProviderPanel 范式)
|
||||
savedSnapshot: '' as string,
|
||||
})
|
||||
|
||||
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
|
||||
@@ -146,6 +149,34 @@ function typeLabel(type: string) {
|
||||
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) {
|
||||
if (conn) {
|
||||
connForm.editId = conn.id
|
||||
@@ -165,6 +196,8 @@ function openConnForm(conn?: ConnRecord) {
|
||||
connForm.password = ''
|
||||
}
|
||||
connForm.visible = true
|
||||
// 记初始快照(visible=true、字段回填完成后),isConnDirty 据此比对
|
||||
connForm.savedSnapshot = connBusinessSnapshot()
|
||||
}
|
||||
|
||||
/** 用户手动切换连接类型时,端口自动重置为该类型默认端口。
|
||||
@@ -202,7 +235,10 @@ function saveConn() {
|
||||
connections.value.push(record)
|
||||
}
|
||||
persistConnections()
|
||||
// 成功明确反馈:关表单 + 清快照(刚落库无脏,直接关不走二次确认)+ 绿色「已保存」toast(对齐 ProviderPanel)
|
||||
connForm.visible = false
|
||||
connForm.savedSnapshot = ''
|
||||
emit('toast', t('settings.toastSaved'), 'success')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -521,6 +521,12 @@ async function fetchModels() {
|
||||
apiKey: providerForm.apiKey,
|
||||
providerId: providerForm.editId || null,
|
||||
})
|
||||
// 空列表守卫:拉到 0 个模型时保留现有模型表格(不覆盖),仅提示——
|
||||
// 与后端 ai_fetch_models 空守卫同语义(空结果不清用户配置),防误清后顺手保存落库
|
||||
if (models.length === 0) {
|
||||
emit('toast', t('settings.toastFetchEmpty'), 'warning')
|
||||
return
|
||||
}
|
||||
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
|
||||
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
|
||||
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
|
||||
@@ -599,6 +605,20 @@ async function saveProvider() {
|
||||
} else {
|
||||
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
|
||||
try {
|
||||
await aiApi.saveProvider({
|
||||
@@ -672,7 +692,9 @@ async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
|
||||
const prev = p.enabled
|
||||
p.enabled = enabled
|
||||
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')
|
||||
} catch (e) {
|
||||
p.enabled = prev // revert
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
@close="$emit('close')"
|
||||
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="dr-header">
|
||||
@@ -205,7 +207,7 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
const { showToast } = useToast()
|
||||
const { toast, showToast } = useToast()
|
||||
const { openConversation } = useAiConversations()
|
||||
|
||||
// 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏)
|
||||
@@ -299,10 +301,15 @@ async function loadTaskConversations(tid: string) {
|
||||
} catch { taskConversations.value = [] }
|
||||
}
|
||||
|
||||
/** 点击关联会话:先关抽屉(面板在抽屉下层,不关会被遮挡),再走 openConversation 跳转 */
|
||||
/** 点击关联会话:跳转成功才关抽屉(面板在抽屉下层,不关会被遮挡);命中已删会话时留原地
|
||||
* 并 toast(UX走查 P3-7:原实现先关抽屉且静默兜底新建,用户落进空白对话无解释) */
|
||||
async function handleOpenConversation(convId: string) {
|
||||
const res = await openConversation(convId)
|
||||
if (res === 'not_found') {
|
||||
showToast(t('ai.convDeleted'), 'error')
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
await openConversation(convId)
|
||||
}
|
||||
|
||||
async function loadChildren() {
|
||||
@@ -402,4 +409,19 @@ async function confirmSubtask() {
|
||||
word-break: break-all;
|
||||
}
|
||||
.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>
|
||||
@@ -624,6 +624,14 @@ export interface AgentFinishedCard {
|
||||
status: AgentFinishedStatus
|
||||
rounds: 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
|
||||
}
|
||||
|
||||
@@ -661,6 +669,52 @@ export function clearConvAgentFinished(convId: string | null | undefined): void
|
||||
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 守卫 ──
|
||||
//
|
||||
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
|
||||
|
||||
@@ -10,9 +10,10 @@ import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
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 type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
@@ -154,6 +155,12 @@ function getLoadMoreState() {
|
||||
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 共用)。
|
||||
* 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、
|
||||
@@ -161,8 +168,12 @@ function getLoadMoreState() {
|
||||
* id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}`
|
||||
* (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'),
|
||||
* 防两条 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 映射,用于回填工具执行结果
|
||||
const toolResultMap = new Map<string, string>()
|
||||
for (const m of rawMsgs) {
|
||||
@@ -227,7 +238,9 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
|
||||
id: tc.id,
|
||||
name: tc.function?.name || '',
|
||||
args,
|
||||
status: 'completed' as const,
|
||||
status: (toolResultMap.has(tc.id) || !convGenerating
|
||||
? 'completed'
|
||||
: 'running') as AiToolCallStatus,
|
||||
result: toolResultMap.get(tc.id),
|
||||
}
|
||||
}),
|
||||
@@ -248,7 +261,7 @@ async function loadMoreHistory(): Promise<boolean> {
|
||||
// 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图
|
||||
if (cur.convId !== state.activeConversationId) return false
|
||||
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 去重(防御:游标边界变化 / 与已加载消息重复)
|
||||
const existing = new Set(state.messages.map(m => 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 审批恢复)
|
||||
* @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue
|
||||
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */
|
||||
export async function switchConversation(id: string, force = false) {
|
||||
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。
|
||||
* @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 +
|
||||
// pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留
|
||||
// in-flight 流式文本。程序化刷新(force=true)不受影响。
|
||||
if (!force && id === state.activeConversationId) return
|
||||
if (!force && id === state.activeConversationId) return 'ok'
|
||||
// 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。
|
||||
// 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。
|
||||
clearAllApprovalTimers()
|
||||
@@ -294,9 +311,14 @@ export async function switchConversation(id: string, force = false) {
|
||||
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
if (errMsg.includes('对话不存在')) {
|
||||
// 程序化跳转(P3-7):目标已删不静默新建,留原视图由调用方就地反馈
|
||||
if (opts?.noCreateFallback) {
|
||||
console.warn('[AI] switchConversation: 会话不存在(已删除),保持原视图:', id)
|
||||
return 'not_found'
|
||||
}
|
||||
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
|
||||
const created = await aiApi.createConversation()
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
void appSettings.set('df-ai-active-conv', created.id)
|
||||
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
|
||||
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
|
||||
@@ -313,15 +335,15 @@ export async function switchConversation(id: string, force = false) {
|
||||
setConvPendingApprovals(created.id, [])
|
||||
notifyConversationChanged()
|
||||
scheduleConversationsRefresh()
|
||||
return
|
||||
return 'ok'
|
||||
}
|
||||
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
|
||||
// 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
|
||||
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
pushConvOpFail('switchConvFail')
|
||||
scheduleConversationsRefresh()
|
||||
return
|
||||
return 'ok'
|
||||
} finally {
|
||||
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
|
||||
// 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃,
|
||||
@@ -331,7 +353,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
setConvCurrentText(id, '')
|
||||
}
|
||||
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
// 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
|
||||
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。
|
||||
const switchDetailAny = detail as any
|
||||
@@ -343,8 +365,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
// (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的
|
||||
// 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生,
|
||||
// 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。
|
||||
const targetCs = getConvState(id)
|
||||
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
|
||||
const targetGen = isConvGenerating(id)
|
||||
|
||||
// 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值,
|
||||
// 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而
|
||||
@@ -356,7 +377,8 @@ export async function switchConversation(id: string, force = false) {
|
||||
// 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。
|
||||
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
|
||||
// (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 一致)
|
||||
state.activeConversationId = id
|
||||
// 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
|
||||
@@ -393,12 +415,17 @@ export async function switchConversation(id: string, force = false) {
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
return
|
||||
return 'ok'
|
||||
}
|
||||
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||||
|
||||
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
|
||||
// 启动恢复也走本函数(loadConversations 的 switchConversation(pending)),一处挂载全覆盖;
|
||||
// fire-and-forget 不阻塞切换,幂等去重与静默降级见 backfillConvAgentFinished。
|
||||
void backfillConvAgentFinished(id)
|
||||
|
||||
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) {
|
||||
@@ -429,7 +456,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
try {
|
||||
const pending = await aiApi.pendingToolCalls(id)
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
if (pending.length) {
|
||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。
|
||||
@@ -541,13 +569,16 @@ async function deleteConversation(id: string) {
|
||||
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 =
|
||||
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
|
||||
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
|
||||
| 'convDeleted'
|
||||
|
||||
/** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象,
|
||||
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */
|
||||
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。
|
||||
* convDeleted(P3-7/P1-1 分离窗路径)也走此通道:反馈须落在承接会话的窗口内。 */
|
||||
export const convOpFailNotice = ref<{
|
||||
key: ConvOpFailKey
|
||||
params?: Record<string, string | number>
|
||||
@@ -657,17 +688,33 @@ function toggleSidebar() {
|
||||
}
|
||||
|
||||
/** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。
|
||||
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,只切会话不动面板。 */
|
||||
async function openConversation(id: string) {
|
||||
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,emit 跨窗事件由
|
||||
* 分离窗切会话并聚焦(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 写)
|
||||
if (!state.detached && !state.panelOpen) {
|
||||
if (!state.panelOpen) {
|
||||
state.panelOpen = true
|
||||
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() {
|
||||
// 跨窗切会话监听注册(幂等;label 非分离窗不实际挂 listener,主窗口为事件发起方)
|
||||
registerDetachedOpenConvListener(handleDetachedOpenConversation)
|
||||
return {
|
||||
loadConversations,
|
||||
scheduleConversationsRefresh,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。
|
||||
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
@@ -19,9 +20,11 @@ import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState
|
||||
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { scheduleConversationsRefresh } from './useAiConversations'
|
||||
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearConvBoundaryCache } from './useTaskBoundary'
|
||||
import { handleStreamingEvent } from './useAiStreamingEvents'
|
||||
import { handleToolEvent } from './useAiToolEvents'
|
||||
import { handleLifecycleEvent } from './useAiLifecycleEvents'
|
||||
import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents'
|
||||
import type { AiChatEvent, MessageId } from '@/api/types'
|
||||
|
||||
// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变)
|
||||
@@ -40,9 +43,12 @@ let _startPromise: Promise<void> | null = null
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助由各自 case 内 clear)。
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助/停靠由各自 case 内 clear)。
|
||||
// 模块级 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
|
||||
@@ -73,6 +79,8 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
|
||||
status: AgentFinishedStatus
|
||||
rounds: number
|
||||
tokens: number
|
||||
rejected_tools?: string[] | null
|
||||
stop_kind?: string | null
|
||||
}
|
||||
const convId = event.conversation_id
|
||||
if (!convId || !ev.agent_id) return true
|
||||
@@ -82,13 +90,73 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
|
||||
status: ev.status,
|
||||
rounds: ev.rounds,
|
||||
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 内等待):此前该事件穿透到
|
||||
// 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。
|
||||
resetStreamWatchdog(convId)
|
||||
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)。
|
||||
* 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */
|
||||
function handleUserMessageEvent(event: AiChatEvent): boolean {
|
||||
@@ -128,6 +196,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
if (handleUserMessageEvent(event)) return
|
||||
// S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
|
||||
if (handleAgentFinishedEvent(event)) return
|
||||
// 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
|
||||
if (handleTaskParkedEvent(event)) return
|
||||
// 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度)
|
||||
if (handleTaskProgressEvent(event)) return
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏;
|
||||
// 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
@@ -145,6 +217,8 @@ export function handleEvent(event: AiChatEvent) {
|
||||
clearLastDelta(convId || '')
|
||||
discardConvPendingApprovals(convId || '')
|
||||
clearResumeAttempts(convId || undefined)
|
||||
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
|
||||
pendingTaskProgress.delete(convId || '')
|
||||
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
|
||||
scheduleConversationsRefresh()
|
||||
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
|
||||
@@ -168,8 +242,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
// 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入
|
||||
if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return
|
||||
// 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口;
|
||||
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
|
||||
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。
|
||||
// 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与
|
||||
// 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') {
|
||||
setConvState(convId, 'generating')
|
||||
}
|
||||
// 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时)
|
||||
@@ -206,6 +282,10 @@ export async function startListener() {
|
||||
// S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图)
|
||||
if (deleted) {
|
||||
clearConvAgentFinished(deleted)
|
||||
// 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二)
|
||||
pendingTaskParked.delete(deleted)
|
||||
pendingTaskProgress.delete(deleted)
|
||||
clearConvBoundaryCache(deleted)
|
||||
}
|
||||
})
|
||||
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation
|
||||
//!
|
||||
//! 处理:AiCompleted / AiError / AiHelpRequired + 上下文生命周期事件(AiCompressing/
|
||||
//! 处理:AiCompleted / AiError / AiHelpRequired / AiTaskParked + 上下文生命周期事件(AiCompressing/
|
||||
//! AiManualCompressed/AiAutoCompressed/AiContextCleared)。
|
||||
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard /
|
||||
//! useAiConversations,不导入任何同级子 composable。
|
||||
@@ -10,7 +10,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
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 { setStreaming } from './streamingGuard'
|
||||
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
|
||||
* - AiError: 调用后追加错误气泡 + 清 queue
|
||||
* - 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)
|
||||
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
|
||||
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
|
||||
@@ -83,8 +85,16 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
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(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
|
||||
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
|
||||
@@ -104,6 +114,21 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
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=已处理。 */
|
||||
export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
@@ -215,6 +240,18 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
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':
|
||||
_contextCallbacks.onCompressing?.()
|
||||
return true
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/
|
||||
//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
@@ -75,3 +75,29 @@ export interface PendingHelp {
|
||||
conversationId: string | 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>())
|
||||
|
||||
@@ -90,7 +90,9 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
state.messages.push({
|
||||
id: userMsgId as MessageId,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
// 技能调用留痕(问题3,与后端 chat.rs user_content 同规则):带技能时本地气泡即刻补
|
||||
// /技能名 前缀,与落库文本一致——否则发送瞬间无痕、切回会话才见前缀,前后显示不一。
|
||||
content: skill ? `/${skill} ${text.trim()}` : text.trim(),
|
||||
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
|
||||
parts: parts && parts.length > 0 ? parts : undefined,
|
||||
// Input Augmentation: 仅在非空时挂 mentionSpans(供消息列表 chip 渲染;空则 undefined 零回归)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain
|
||||
//! + 跨窗切会话(emitDetachedOpenConversation/registerDetachedOpenConvListener)
|
||||
//!
|
||||
//! 模块级私有(不进 reactive):
|
||||
//! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener
|
||||
//! - _openConvListenerRegistered: 跨窗切会话监听注册标记(幂等)
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - resumeInDetached / restoreGeneratingState 调 aiShared.nextMsgId 占位 ai 气泡 +
|
||||
@@ -35,6 +37,54 @@ const TEXT_KEY_PREFIX = 'df-ai-text-'
|
||||
function detachedLabel(convId: string): string {
|
||||
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 {
|
||||
return GEN_KEY_PREFIX + convId
|
||||
}
|
||||
@@ -125,7 +175,7 @@ async function reattachPanel() {
|
||||
const all = await WebviewWindow.getAll()
|
||||
for (const w of all) {
|
||||
const lbl = w.label
|
||||
if (lbl === 'ai-detached' || lbl.startsWith(DETACHED_LABEL_PREFIX)) {
|
||||
if (isDetachedLabel(lbl)) {
|
||||
try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -112,6 +112,11 @@ export function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
const host = httpHost(url)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ export default {
|
||||
// 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.',
|
||||
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)
|
||||
renameConvFail: 'Failed to rename. Please retry.',
|
||||
archiveConvFail: 'Failed to {action}. Please retry.',
|
||||
@@ -87,11 +89,23 @@ export default {
|
||||
title: 'Sub-agent',
|
||||
completed: 'completed',
|
||||
truncated: 'truncated',
|
||||
cancelled: 'cancelled',
|
||||
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',
|
||||
// S3: finished card is clickable to open the read-only trace (hover hint)
|
||||
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,
|
||||
// discarded on close)
|
||||
agentTrace: {
|
||||
@@ -102,6 +116,71 @@ export default {
|
||||
roleAssistant: 'Assistant',
|
||||
roleTool: 'Tool result',
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export default {
|
||||
|
||||
// ── Skill ──
|
||||
clearSkill: 'Clear skill',
|
||||
skillBadgeTip: 'Injected full skill playbook: {name}',
|
||||
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
|
||||
placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)",
|
||||
skillNoMatch: 'No matching skill',
|
||||
@@ -141,6 +142,8 @@ export default {
|
||||
imageTooLarge: 'Image too large (max 10MB), skipped',
|
||||
imageLimit: 'Up to {n} images, extras ignored',
|
||||
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 ──
|
||||
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
|
||||
|
||||
@@ -117,6 +117,8 @@ export default {
|
||||
// fetch_url header display name (useToolCardHeader): host-shortened prefix + fallback when no url
|
||||
fetchUrlPrefix: 'Fetch',
|
||||
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',
|
||||
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
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
// 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)',
|
||||
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)
|
||||
toastAnthropicCacheSaved: 'Prompt caching setting saved',
|
||||
toastAnthropicCacheFail: 'Failed to save the prompt caching setting',
|
||||
@@ -88,6 +89,7 @@ export default {
|
||||
saveModels: 'Save model config',
|
||||
modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them',
|
||||
toastFetchOk: 'Fetched {count} models',
|
||||
toastFetchEmpty: 'Fetched 0 models — existing config kept',
|
||||
toastFetchFail: 'Fetch failed: {msg}',
|
||||
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
|
||||
fetchFailedAuth: 'Authentication failed, check your API Key: {msg}',
|
||||
|
||||
@@ -49,6 +49,8 @@ export default {
|
||||
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
|
||||
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
|
||||
switchConvFail: '对话切换失败,请重试',
|
||||
// UX走查 P3-7/P1-1: 关联会话跳转命中已删会话(原静默新建空会话/分离窗死点击)
|
||||
convDeleted: '会话已删除',
|
||||
// M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感)
|
||||
renameConvFail: '重命名失败,请重试',
|
||||
archiveConvFail: '{action}失败,请重试',
|
||||
@@ -86,11 +88,23 @@ export default {
|
||||
title: '子代理',
|
||||
completed: '已完成',
|
||||
truncated: '已截断',
|
||||
cancelled: '已取消',
|
||||
failed: '失败',
|
||||
// stop_kind 结构化终止原因细分(根治 summary 启发式;截断三态各自透出)
|
||||
truncMaxRounds: '已达轮次上限',
|
||||
truncWallClock: '超时截断',
|
||||
truncTokenBudget: 'token 预算截断',
|
||||
noReport: '未产出报告',
|
||||
// 被拒工具 chip({tools} 为「工具名×次数」枚举,多枚前 2 枚 + N)
|
||||
rejected: '被拒 {tools}',
|
||||
// 启动失败型子代理未落库无轨迹,卡禁点(hover 说明替代 viewHint)
|
||||
noTrace: '无轨迹(启动失败)',
|
||||
meta: '{rounds} 轮 · {tokens} tokens',
|
||||
// S3:完成卡可点击查看只读轨迹(hover 提示)
|
||||
viewHint: '点击查看执行轨迹',
|
||||
},
|
||||
// P3-6 后台会话子代理完成轻提醒({status} 复用上方 agentFinished 状态词,{summary} 截前 40 字)
|
||||
agentFinishedToast: '子代理{status}: {summary}',
|
||||
// S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃)
|
||||
agentTrace: {
|
||||
title: '子代理执行轨迹',
|
||||
@@ -100,6 +114,68 @@ export default {
|
||||
roleAssistant: '助手',
|
||||
roleTool: '工具结果',
|
||||
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: '预算不限',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export default {
|
||||
|
||||
// ── 技能 ──
|
||||
clearSkill: '取消技能',
|
||||
skillBadgeTip: '已注入技能「{name}」的完整规程',
|
||||
placeholderWithSkill: '已选技能,输入参数后回车',
|
||||
placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)",
|
||||
skillNoMatch: '无匹配技能',
|
||||
@@ -142,6 +143,8 @@ export default {
|
||||
imageTooLarge: '图片过大(上限 10MB),已跳过',
|
||||
imageLimit: '最多 {n} 张图片,已忽略多余的',
|
||||
imageReadFailed: '图片读取失败',
|
||||
imageIgnoredWarn: '当前模型不支持图片,发送时图片将被忽略',
|
||||
imageOnlyBlocked: '当前模型不支持图片,请改用视觉模型或补充文字说明',
|
||||
|
||||
// ── 确认弹层 ──
|
||||
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
|
||||
|
||||
@@ -118,6 +118,8 @@ export default {
|
||||
// fetch_url 头部显示名(useToolCardHeader):host 精简前缀 + 无 url 时兜底
|
||||
fetchUrlPrefix: '获取',
|
||||
fetchUrlFallback: '获取网页',
|
||||
// spawn_agent 头部显示名(P3-3:任务书截 25 字,消费后端「派发子代理」目标元数据语义)
|
||||
spawnAgentPrefix: '派发子代理: {task}',
|
||||
installObscura: 'AI 帮装 obscura',
|
||||
obscuraInstallPrompt: '请帮我安装 obscura(Rust 无头浏览器,用于 fetch_url 的 render=true JS 渲染)。优先尝试 `cargo install obscura`,若 cargo 不可用则从 GitHub releases(github.com/h4ckf0r0day/obscura)下载对应平台预编译二进制并放入 PATH。安装完成后用 `obscura --version` 验证,然后告诉我可以重试网页抓取了。',
|
||||
// grep 跨文件内容搜索结果按 output_mode 分文案
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
// 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值)
|
||||
toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)',
|
||||
toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}',
|
||||
toastWeightClamped: '模型权重须在 0-100 范围内,越界值已修正(清空视为 50)',
|
||||
// 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈)
|
||||
toastAnthropicCacheSaved: 'Prompt 缓存设置已保存',
|
||||
toastAnthropicCacheFail: 'Prompt 缓存设置保存失败',
|
||||
@@ -87,6 +88,7 @@ export default {
|
||||
saveModels: '保存模型配置',
|
||||
modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃',
|
||||
toastFetchOk: '已拉取 {count} 个模型',
|
||||
toastFetchEmpty: '拉取到 0 个模型,已保留现有配置',
|
||||
toastFetchFail: '拉取模型失败:{msg}',
|
||||
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
|
||||
fetchFailedAuth: '鉴权失败,请检查 API Key:{msg}',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
|
||||
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
|
||||
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
|
||||
//! - 落库失败保留 pending 脏值待下次写入重试,经 lastWriteError 透出(见 set/flush)
|
||||
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
|
||||
//!
|
||||
//! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 ——
|
||||
@@ -24,6 +25,11 @@ const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
|
||||
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 调用) */
|
||||
async function loadAll(): Promise<void> {
|
||||
const all = await settingsApi.getAll()
|
||||
@@ -41,6 +47,9 @@ function get<T>(key: string, defaultValue: T): T {
|
||||
/**
|
||||
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
|
||||
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
|
||||
*
|
||||
* promise 契约:resolve 仅代表"已调度落库",不代表已落库;本 promise 永不
|
||||
* reject(调用方 void/await 均安全),落库成败经 lastWriteError 透出。
|
||||
*/
|
||||
async function set(key: string, value: unknown): Promise<void> {
|
||||
cache[key] = value
|
||||
@@ -51,18 +60,35 @@ async function set(key: string, value: unknown): Promise<void> {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingTimers.delete(key)
|
||||
const latest = pendingValues.get(key)
|
||||
pendingValues.delete(key)
|
||||
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
|
||||
settingsApi
|
||||
.set(key, JSON.stringify(latest))
|
||||
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
|
||||
void flush(key)
|
||||
}, SET_DEBOUNCE_MS)
|
||||
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> {
|
||||
const prevCache = cache[key]
|
||||
const prevPending = pendingValues.get(key)
|
||||
delete cache[key]
|
||||
pendingValues.delete(key)
|
||||
const t = pendingTimers.get(key)
|
||||
@@ -70,7 +96,15 @@ async function remove(key: string): Promise<void> {
|
||||
clearTimeout(t)
|
||||
pendingTimers.delete(key)
|
||||
}
|
||||
await settingsApi.delete(key)
|
||||
try {
|
||||
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() {
|
||||
return {
|
||||
cache,
|
||||
lastWriteError,
|
||||
loadAll,
|
||||
get,
|
||||
set,
|
||||
|
||||
Reference in New Issue
Block a user