diff --git a/docs/todo.md b/docs/todo.md index 6343170..da3bbe4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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//.`(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:// 不阻塞)+文件名=`[_<清洗原始名>]`(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 标记。 diff --git a/src-tauri/src/commands/ai/outbox.rs b/src-tauri/src/commands/ai/outbox.rs index d6faaed..82b5f96 100644 --- a/src-tauri/src/commands/ai/outbox.rs +++ b/src-tauri/src/commands/ai/outbox.rs @@ -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, model_override: Option, conversation_id: String, +) -> Result { + 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, + skill: Option, + model_override: Option, + conversation_id: String, ) -> Result { 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::(); + 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; + } + } + } + }); +} diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs index 4f24c41..51a6591 100644 --- a/src-tauri/src/feishu_channel.rs +++ b/src-tauri/src/feishu_channel.rs @@ -764,11 +764,40 @@ async fn handle_message( let state = ctx.app.state::(); // #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——两段式下终版走事件广播 diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index d7a76dc..315649a 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -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 内部文字,无高度跳变/闪烁 -->
-
-
+
+
+ ▸ {{ $t('ai.trace.roundN', { n: item.round }) }} {{ stepText(item.stats, item.round) }} - {{ $t('ai.trace.running') }} + {{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.running') }} +
+ +
+