新增: 执行中轨迹卡展开与占用期消息必达
- 执行中 step 卡支持点击展开(复用轨迹展开态, 实时过程工具卡回看) - 占用期消息改落待发箱自动续投, 不再拒绝让用户重发(959) - 待发项派发后完成结果回推来源飞书会话(35min 弃守) - 961 轮次语义/960 聚合防抖设计要点登记
This commit is contained in:
+4
-2
@@ -740,5 +740,7 @@
|
||||
958. [x] **飞书通道图片入站能力(2026-10-11 用户发图实测,回「暂仅支持文本」提示卡)**:用户给 DevFlow 飞书 bot 发图,只收到降级提示——**勘察结论(AI-Ops 10-11,链路+可行性已核)**:非故障,系有意兜底(feishu_channel.rs:387,#25 静默丢修复);桌面端 ai-chat 图片链路**已完整**(ChatInput 粘贴/上传 → ContentPart::Image → chat.rs:920 has_image 自动转 vision 端点,anthropic 侧 url→base64 预拉已有),缺口**仅在飞书入站**。修(四件):①`feishu_channel.rs::handle_message` 增 image 分支(现 `message_type != "text"` 一刀切回提示卡);②拉图:飞书 im/v1/messages/{message_id}/resources/{file_key}?type=image 下载(file_key 从 image 消息 content JSON 取),SDK 无现成方法则手写 GET(租户 access_token 复用既有 client);③组装:字节→base64→ContentPart::Image+可选 caption 文本拼 user 消息,走既有 spawn 会话链路(vision 路由零改动);④开放平台侧:应用补 im:resource 只读权限。**注意**:①权限申领是对外变更(飞书后台),先拍板;②大图限流(base64 膨胀,建议 >10MB 拒收提示);③文件/PDF 等其他媒体不在本条范围。验收:发图给 bot,AI 能看到图并按图回答;非图媒体仍回现有提示卡。**【10-11 AI-Ops 实施完成(用户令「直接推进」),待换装+权限开通】**:①②③已全部落地 feishu_channel.rs 单文件——类型三分流(text/image/其余提示卡,image_key 取自 SDK 归一化 msg.resources 零手解析)/`download_feishu_image`(走 channel_messaging().download_message_resource 便捷方法,与 reply/send 同风格)/魔数判 media_type(sniff_image_mime 四格式,未知拒收防端点 400)+10MB 限流/引导词 IMAGE_INBOUND_PROMPT 喂模型(飞书 image 独立类型无法带文字)/白名单后下载(未授权不烧流量)+占位发票前失败早退/注入旁路排除图片(inject 只收文本,诚实落受理守卫)。验证:cargo check -p devflow 0 error + feishu_channel 测试 23 passed(含新 image_mime_sniff_four_formats)。**待两件**:①换装生效(重启应用,等令);②飞书开放平台给应用补「获取与上传图片或文件资源」权限(im:message.resource:readonly,后台操作,未开通时下载失败会回可读错误卡)。**【10-11 追加:引用化存储改造(用户指令「下载或转文件存储引用,信息存储只存一个引用,模型使用按需转换」)】**:base64 内嵌版升级为落盘引用版——图片字节落盘 `%APPDATA%/top.1216.devflow/media/<chat_id>/<uuid>.<ext>`(save_media_file,按会话分目录,不主动 GC),消息只存 `file://` 引用(ContentPart.url,base64 空),发送请求时 df-ai 新模块 image_ref.rs 单点展开(`inline_local_image_part`:读盘→base64;缺失降级 Text「[图片文件缺失]」不炸请求)——openai_compat(map 链插入)+anthropic_compat(content_part_to_block 入口)两臂接入,provider 转换层单点覆盖全部请求路径(主会话/子代理/标题/压缩+历史重放),零契约变更(url 字段语义扩展)。桌面粘贴图 base64 内嵌同族膨胀,后续批接同一机制(登记待办)。验证:df-ai 全量 **578 passed 0 failed**(新增 image_ref 3 测+openai/anthropic file:// 端到端各 1)+devflow check 0 error+feishu_channel 23 passed。**【10-11 第二轮:媒体域全面重构完成(用户令「推」+补1-补7),全链落地】**:①media_store.rs 新模块(媒体域单一归属,防臃肿)——hash 去重落盘(sha256 前 16hex,同内容同文件)+阿里云 OSS 双写(PUT Header 签名手写,失败降级 file:// 不阻塞)+文件名=`<hash16>[_<清洗原始名>]<ext>`(u-claw safeAttachName 模式:hash 保去重+原始名保可读,路径穿越清洗);②下载防线(对齐 u-claw):3 次指数退避+120s 超时+半写残件不留盘;③spawn offload 统一收口(send+resend 两处,vision 判定后):桌面粘贴图 base64→落盘引用,SQLite 不进大 base64;④消息溯源 V52(ai_messages.source 列,MessageSource{channel,message_id,chat_id,sender}),spawn 第 11 参穿透,飞书传真实 msg_id(可跳原消息追溯,多端混发可区分);⑤出站 [交付] 回传(u-claw replyLocalFiles 同模式):报告写 [交付] 路径行→自动上传飞书(图片→图片消息/其余→文件消息)+敏感名拦截(.env/id_rsa/token 等 8 特征);⑥引用统一走 url 字段(https=OSS 跨设备可解析/file://=本机 image_ref 展开),不自造 scheme。验证:cargo check --workspace 0 error+df-storage 214+12 绿/df-ai 578 绿/devflow 832 绿(新增 media_store 4+delivery 3+V52 迁移)。**待**:换装生效+飞书后台 im:resource 权限+media-oss-config KV 手配(设置面板后续批)。
|
||||
|
||||
959. [ ] **占用期消息必达·收件箱续投(2026-10-11 u-claw 对照,补6)**:用户在 bot 任务占用期发的消息,devflow 现状=QUEUE_BUSY_MSG 拒绝+用户须重发;u-claw 已进化为 pending 收件箱落盘+scanner 自动续投+卡片原地刷新「已进入待投队列(无需重发)」——重试责任不在用户。修:受理守卫拒绝路径改落盘收件箱(ai_outbox 表 V51 已有,复用)+原地 PATCH 提示;空闲自动续投+结果回推。验收:任务占用期发消息不丢,任务完成自动处理并回推结果。
|
||||
960. [ ] **连续消息聚合防抖「已收 N 条无补充即处理」(2026-10-11 u-claw 对照,补6)**:u-claw 连发消息聚合「📥 已收 %d 条,%ds 内无补充即开始处理」防抖后再统一处理;devflow 每条立即受理(连续消息第二条起被占用守卫拒)。修:飞书入站短窗防抖(聚合期间到达的同 chat 消息合并为一次任务,文本拼接+附件清单合并),配合 959 收件箱。设计先行(与队列/注入语义联动,防双头)。
|
||||
961. [ ] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。涉及:MessageList 折叠模型+轮次归属模型(压缩边界 vs 发起边界)+编号生成规则。设计先行,拆批实施。
|
||||
960. [ ] **连续消息聚合防抖「已收 N 条无补充即处理」(2026-10-11 u-claw 对照,补6)**:u-claw 连发消息聚合「📥 已收 %d 条,%ds 内无补充即开始处理」防抖后再统一处理;devflow 每条立即受理(连续消息第二条起被占用守卫拒)。修:飞书入站短窗防抖(聚合期间到达的同 chat 消息合并为一次任务,文本拼接+附件清单合并),配合 959 收件箱。**设计要点**:防抖窗 1.5~2s(per-chat 首条到达起算,窗内消息合并:文本以空行拼接+image 附件合并为多图 parts);窗口期回 ACK 卡「已收 N 条,Ns 内无补充即处理」原地更新;窗结束按 959 口径受理(空闲即 spawn/占用即收件箱)。风险位:与 per-chat 锁/注入旁路/审批决议拦截的先后序,设计先行防双头。
|
||||
961. [~] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。
|
||||
- ✅ **①已修(2026-10-11)**:执行中 step 卡支持点击展开(复用 expandedTraceIds,key 与完成态 trace 同构,执行中展开→完成转 trace 态不丢;展开区实时渲染该轮过程工具卡)。
|
||||
- **②③设计要点(实施前拍板)**:轮边界判定锚点从「压缩摘要」改为「用户发起消息」——发起=桌面直发/飞书入站的 user 消息,补充=inject_runtime_message 写入的消息(需来源标记,建议复用 V52 source:{channel:"inject"} 与发起区分,归属判定 O(1));轮号=conv 内第 N 条「发起消息」序(数据派生,天然稳定,不随压缩/视图漂移),轨迹卡头/导航条/进度徽标统一取该口径(单一真值来源)。涉及:轮桶分桶逻辑(MessageList genBucketItems 上游)+轮号计算函数收敛+inject 消息 source 标记。
|
||||
|
||||
@@ -242,6 +242,9 @@ async fn dispatch_claimed(
|
||||
emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
|
||||
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
|
||||
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
|
||||
// 959 结果回推:待发项派发后完成结果推回来源渠道(飞书 chat 映射)。
|
||||
// 桌面端在看的会话重复可见无害;未绑 chat 时 push_to_chat 内部静默跳过
|
||||
spawn_completion_push(app.clone(), conv.clone());
|
||||
Ok(conv_id)
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -444,6 +447,19 @@ pub async fn ai_outbox_enqueue(
|
||||
skill: Option<String>,
|
||||
model_override: Option<String>,
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
enqueue_internal(&app, &state, text, due_at, skill, model_override, conversation_id).await
|
||||
}
|
||||
|
||||
/// 入队内部口(#959):前端 command 与飞书受理守卫拒绝路径共用,单点校验/唤醒/事件。
|
||||
pub(crate) async fn enqueue_internal(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
text: String,
|
||||
due_at: Option<i64>,
|
||||
skill: Option<String>,
|
||||
model_override: Option<String>,
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
validate_text(&text)?;
|
||||
validate_conv_id(&conversation_id)?;
|
||||
@@ -477,7 +493,7 @@ pub async fn ai_outbox_enqueue(
|
||||
.map_err(|e| format!("待发项写入失败: {e}"))?;
|
||||
// 尽快项/到期项即时唤醒调度器(周期 tick 仅兜底);事件供各窗口视图刷新。
|
||||
notify().notify_one();
|
||||
emit(&app, "ai-outbox-changed", &id, &conversation_id, None, None);
|
||||
emit(app, "ai-outbox-changed", &id, &conversation_id, None, None);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -731,3 +747,43 @@ mod tests {
|
||||
assert!(ensure_cancellable(STATUS_CANCELED).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// 959 结果回推任务:订阅事件总线等该会话完成(AiAgentFinished),终版报告推回
|
||||
/// 来源渠道(push_to_chat 按 conv 反查飞书 chat 映射)。35min 超时弃守(对齐渠道
|
||||
/// watcher 量级;引擎仍在跑则结果在桌面端可见,不阻塞)。
|
||||
fn spawn_completion_push(app: AppHandle, conv: String) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
let mut rx = state.ai_event_bus.subscribe();
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(35 * 60);
|
||||
loop {
|
||||
let ev = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
match ev {
|
||||
Err(_) => {
|
||||
tracing::warn!("[outbox] 完成回推超时弃守 conv={conv}");
|
||||
return;
|
||||
}
|
||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return,
|
||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
|
||||
Ok(Ok(v)) => {
|
||||
if v.get("type").and_then(|t| t.as_str()) != Some("AiAgentFinished") {
|
||||
continue;
|
||||
}
|
||||
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let summary = v
|
||||
.get("summary")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if summary.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::feishu_channel::push_to_chat(&app, &summary, Some(&conv)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -764,11 +764,40 @@ async fn handle_message(
|
||||
|
||||
let state = ctx.app.state::<crate::state::AppState>();
|
||||
// #20 受理守卫:发票(受理卡)前先过引擎读侧闸。不可启动(前任务占用/压缩暂存/停止中)
|
||||
// 直接回实况话术,不发票、不 spawn、不注册桥与流式任务,堵「受理卡已发后续零通知」
|
||||
// 的伪受理断层(13:38 实证形态)
|
||||
// 不发票、不 spawn、不注册桥与流式任务,堵「受理卡已发后续零通知」的伪受理断层。
|
||||
// 959 必达改造(对齐 u-claw 收件箱续投):拒绝路径改落入待发箱(queue 尽快项),
|
||||
// 前序任务完成调度器自动派发+结果回推本 chat——重试责任不在用户,不再让用户重发。
|
||||
// 入队失败(写库异常等)降级回原占用话术,消息不丢语义不倒退
|
||||
if !state.conv_states.can_accept_request(&conv_id) {
|
||||
feishu_log(&format!("受理守卫拒绝 chat={chat_id} conv={conv_id}"));
|
||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, QUEUE_BUSY_MSG).await;
|
||||
match crate::commands::ai::outbox::enqueue_internal(
|
||||
&ctx.app,
|
||||
&state,
|
||||
text.clone(),
|
||||
None, // 尽快队列项:前序任务空闲即发
|
||||
None,
|
||||
None,
|
||||
conv_id.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
feishu_log(&format!("已入待发箱 chat={chat_id} conv={conv_id}"));
|
||||
deliver_result(
|
||||
&ctx.client,
|
||||
&placeholder_id,
|
||||
&message_id,
|
||||
&chat_id,
|
||||
"📥 已登记待发:前序任务完成后将自动发出,无需重发(期间可继续追加,均按补充登记)。",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
feishu_log(&format!("待发箱入队失败 chat={chat_id}: {e}"));
|
||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, QUEUE_BUSY_MSG)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 通知桥注册(#14):oneshot 接收端有意 drop——两段式下终版走事件广播
|
||||
|
||||
@@ -1032,7 +1032,7 @@ type RenderItem =
|
||||
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要;
|
||||
// round=轨迹序号(卡头「第 N 轮」,与执行中步骤行、轮次导航条同一计数来源)
|
||||
| { kind: 'trace'; key: string; round: number; stats: TurnCallStats; startTs: number; endTs: number; tokens: number; traceMsgs: AiMessage[]; expanded: boolean }
|
||||
| { kind: 'step'; key: string; round: number; stats: TurnCallStats }
|
||||
| { kind: 'step'; key: string; round: number; stats: TurnCallStats; traceMsgs: AiMessage[]; expanded: boolean }
|
||||
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
|
||||
| { kind: 'timesep'; key: string; ts: number }
|
||||
// S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
|
||||
@@ -1121,7 +1121,12 @@ function genBucketItems(
|
||||
pushSep(msgs[0].timestamp)
|
||||
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
|
||||
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
|
||||
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs) })
|
||||
// 961①: step 卡可展开——key 与完成态 trace 同构,复用 expandedTraceIds,
|
||||
// 执行中展开看实时过程,完成后切换 trace 态展开不丢
|
||||
items.push({
|
||||
kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs),
|
||||
traceMsgs: prev, expanded: expandedTraceIds.value.has('trace-' + prev[0].id),
|
||||
})
|
||||
} else {
|
||||
pushSep(cur.timestamp)
|
||||
}
|
||||
@@ -1676,12 +1681,35 @@ defineExpose({
|
||||
骨架与完成态 .ai-trace-card 完全同构(卡头结构一致),仅 chevron→脉冲点、右侧→「进行中」,
|
||||
使 step→trace 切换时只 patch 内部文字,无高度跳变/闪烁 -->
|
||||
<div v-else-if="item.kind === 'step'" class="ai-msg-slot ai-msg-slot--proc">
|
||||
<div class="ai-trace-card ai-trace-card--running">
|
||||
<div class="ai-trace-head">
|
||||
<div class="ai-trace-card ai-trace-card--running" :class="{ 'ai-trace-card--open': item.expanded }">
|
||||
<div
|
||||
class="ai-trace-head"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="item.expanded"
|
||||
@click="toggleTrace(item.key)"
|
||||
@keydown.enter.prevent="toggleTrace(item.key)"
|
||||
@keydown.space.prevent="toggleTrace(item.key)"
|
||||
>
|
||||
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
|
||||
<span class="ai-step-dot"></span>
|
||||
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
|
||||
<span class="ai-trace-agg">{{ stepText(item.stats, item.round) }}</span>
|
||||
<span class="ai-trace-toggle ai-trace-toggle--running">{{ $t('ai.trace.running') }}</span>
|
||||
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.running') }}</span>
|
||||
</div>
|
||||
<!-- 961① 执行中展开:实时过程回看(每条过程消息的工具卡),与完成态 trace 展开同诉求 -->
|
||||
<div v-if="item.expanded" class="ai-trace-body">
|
||||
<template v-for="m in item.traceMsgs" :key="m.id">
|
||||
<div v-if="m.toolCalls && m.toolCalls.length > 0" class="ai-trace-round">
|
||||
<ToolCardList
|
||||
:toolCalls="m.toolCalls"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user