Compare commits

..
3 Commits
Author SHA1 Message Date
lxy 3bb662f092 修复: 工具外置集成测试夹具失效
- 夹具误用错误行信号(.rs:)做普通大结果,错误行全量保留致压缩断言先天不成立,改中性行
- 生产行为缺陷(错误行洪泛击穿压缩,grep/rustc 类输出为主要受害者)登记看板待修
2026-10-15 14:27:33 +08:00
lxy a644e93604 修复: 项目详情页三态分离与反馈一致性
- notFound/已归档/加载失败三态分离:单查兜底+归档恢复入口+加载失败重试钮
- 反馈一致性六项:同步守卫与完成提示/首屏并行加载/筛选空态文案/计数改过滤口径/路径勾选 seq 守卫/删除确认危险态
- 文件列表滚动条对齐全局范式(scrollbar-width:thin 致 Chromium 忽略定制样式)
- 删除失败不再跳转(store 返布尔)/幽灵勾选清理/折叠阈值与 CSS 对齐
2026-10-15 14:27:32 +08:00
lxy 795935bf6a 新增: 飞书文件消息入站与补充注入链完善
- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
2026-10-15 14:27:22 +08:00
20 changed files with 570 additions and 151 deletions
+3
View File
@@ -310,6 +310,7 @@ mod tests {
role: MessageRole::User,
content: "字面量构造".into(),
parts: None,
source: None,
tool_call_id: None,
tool_calls: None,
model: None,
@@ -368,6 +369,7 @@ mod tests {
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
source: None,
tool_call_id: None,
tool_calls: None,
model: None,
@@ -429,6 +431,7 @@ mod tests {
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
source: None,
tool_call_id: None,
tool_calls: None,
model: None,
+3 -1
View File
@@ -68,7 +68,9 @@ fn non_read_file_large_result_also_gets_handle() {
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.rs:{i}\n")).collect();
// 中性行夹具:不得含 error/panic/.rs: 等错误行信号——错误行保留策略会全量保留
// 命中行,用信号行做夹具会让「应被压缩」断言先天不成立(测的是普通大结果外置)
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.txt:{i}\n")).collect();
assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)");
let out = extract_key_info(&body, "grep");
assert!(out.len() < body.len(), "应被压缩");
+5
View File
@@ -353,6 +353,11 @@
### 附:F2 兜底前移(可选)
- `ai_chat_stop` 的心跳 60s stale 判死+emit AiCompleted 兜底只在用户点停止时运行;可在 loop spawn 时自带一份,panic 死 loop 无需用户点停止也能收敛。
### 🔍 工具结果压缩·错误行洪泛缺陷(2026-10-15 发现,未修)
- **现象**:`extract_key_info_inner` 的错误行保留策略无上限——结果里每个含 `error`/`panic`/`.rs:`/「失败」的行都全量保留并加 `[行 N]` 前缀,grep/rustc 类输出(恰是压缩的主要对象)错误行占多数时输出≥原文,压缩失效且无落盘句柄(`compressed.len() < content.len()` 门不过)。
- **佐证**:`crates/df-ai/tests/context_offload.rs` 的 `non_read_file_large_result_also_gets_handle` 用 `src/foo.rs:N` 做夹具,先天必挂——该集成测试历来未被 `--lib` 门禁覆盖, until 2026-10-15 全量 workspace 门禁才暴露(夹具已改中性行修绿,生产行为未动)。
- **修法方向**:错误行保留加封顶(如最多 N 条+「另有 M 条错误行省略」),超限走正常省略段;配套恢复信号行夹具的鉴别力测试。
# aichat 三路走查增量待办(2026-10-02 深夜)
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
+63 -1
View File
@@ -527,6 +527,31 @@ pub(crate) async fn run_agentic_loop(
// 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。
conv.loop_max_rounds = max_iterations;
conv.round_budget_extra = 0;
// 真目标源(2026-10-15 目标语义重构):pinned_goals 从「工具推理流水账」改为「用户任务原文」
// ——流水账模式下用户真实意图从不进目标体系,压缩/重启后 AI 即失忆(2026-10-12/15
// 两次丢目标事故同根)。首条任务型 user 消息写入首位;已有目标不覆盖(尊重
// UPDATE_GOALS 显式修正);[系统恢复] 引导与超短碎片不入(递归套娃教训)。
if conv.pinned_goals.is_empty() {
let task_text = conv
.messages
.iter()
.find(|m| {
matches!(m.role, df_ai::provider::MessageRole::User)
&& !m.content.starts_with("[系统恢复]")
&& m.content.chars().count() >= 4
})
.map(|m| {
let mut t: String = m.content.chars().take(GOAL_MAX_CHARS).collect();
if m.content.chars().count() > GOAL_MAX_CHARS {
t.push('…');
}
t
});
if let Some(t) = task_text {
conv.pinned_goals.push(super::GoalEntry::new(t));
tracing::info!(conv_id = %conv_id, "[ai] 会话目标取首条任务消息(目标语义重构)");
}
}
(
conv.stop_flag.clone(),
conv.notify.clone(),
@@ -2013,6 +2038,9 @@ async fn prepare_round_inputs(
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(conv_id).is_some() {
session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
// 本轮请求已含截至此刻的全部消息,注入计数清零(=已消化);此后到收敛前
// 再到的注入会计数,收敛出口凭 >0 续一轮消化(见收敛分支守卫)。
session.conv(conv_id).pending_injects = 0;
}
let conv = match session.conv_read(conv_id) {
Some(c) => c,
@@ -2218,7 +2246,10 @@ async fn finalize_round_output(
})
.collect();
} else {
update_pinned_goals(&mut session, conv_id, tool_calls_acc);
// 工具推理流水账停写(2026-10-15 目标语义重构):「执行 xxx/查看 xxx」是手段记录
// 不是用户目标,入 pinned_goals 挤占真目标且压缩/重启后即失忆;工具轨迹已由
// 轨迹卡与审计表完整覆盖。目标来源收敛为:首条任务消息(入口写入)+UPDATE_GOALS
// 显式修正(上方分支,LLM 显式目标管理继续有效)。
}
}
}
@@ -2345,6 +2376,35 @@ async fn finalize_round_output(
// 正常收敛(loop 主体置 converged 后 break)
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
// 注入未消化守卫(2026-10-14 实测):本轮组装后到达的补充指令,纯问答轮一轮收敛即
// 无人消费(本轮上下文已在生成中定型)。计数>0 → 插提示续一轮,下轮
// build_for_request 自然带上补充内容;计数已 take 清零,同批注入不重复触发。
let pending_injects = {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
std::mem::take(&mut session.conv(conv_id).pending_injects)
} else {
0
}
};
if pending_injects > 0 {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut tip = ChatMessage::system(format!(
"用户在回答生成期间补充了 {pending_injects} 条消息(见对话记录中的补充指令);\
请结合补充内容继续或修正上面的回答,勿忽略。"
));
tip.model = Some(resolved_model.to_string());
conv.messages.push(tip);
}
tracing::info!(
conv_id = %conv_id, pending = pending_injects,
"[ai] 收敛时检出未消化注入,插提示续一轮消化"
);
// 不 return Converged:掉到函数尾部 RunTools(本轮无工具空转),
// loop 顶部正常续轮,补充指令获得完整消费轮。
} else {
if round_truncated_by_max_tokens {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
@@ -2360,6 +2420,7 @@ async fn finalize_round_output(
return RoundEpilogue::Converged;
}
}
}
// 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。
// 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里,
@@ -3602,6 +3663,7 @@ fn push_assistant_message(
// ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ──
// 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。
// 入参 session 需外层调用方持锁;纯内存 mutate,无 await。
#[allow(dead_code)]
fn update_pinned_goals(
session: &mut AiSession,
conv_id: &str,
@@ -1197,6 +1197,9 @@ pub(crate) async fn inject_runtime_message(
if extended {
conv.round_budget_extra += 1;
}
// 未消化计数(2026-10-14 实测):纯问答轮一轮收敛无下一轮,末轮扩轮机制(loop_max>0 才生效)
// 兜不住;收敛出口凭此计数感知「组装后有注入到达」并续一轮消化。
conv.pending_injects = conv.pending_injects.saturating_add(1);
drop(session);
// 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
@@ -1204,6 +1207,20 @@ pub(crate) async fn inject_runtime_message(
Ok(true)
}
/// 桌面端补充注入入口(桌面 AiChat 运行中发消息的 u-claw 语义:补充并进当前回答,不开新轮)。
/// 把 inject_runtime_message 暴露为 Tauri command,与飞书渠道(feishu_channel.rs)同源同一实现,
/// 锁纪律/末轮顺延语义收敛在 inject_runtime_message 单一真值,本 command 纯透传零附加逻辑。
/// 返回契约:Ok(true)=已注入(前端不入队,提示已并入当前回答);Ok(false)=会话不存在
/// (前端按普通新消息入队);Err=非活跃/Stopping(前端零破坏回退常规入队)。
#[tauri::command]
pub async fn ai_inject_runtime(
state: State<'_, AppState>,
conversation_id: String,
message: String,
) -> Result<bool, String> {
inject_runtime_message(&state, &conversation_id, &message).await
}
/// 批准/拒绝挂起的工具调用
#[tauri::command]
pub async fn ai_approve(
+5
View File
@@ -1065,6 +1065,10 @@ pub struct PerConvState {
/// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。
/// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。
pub round_budget_extra: usize,
/// 未消化注入计数(2026-10-14 实测):inject_runtime_message 每次 +1;每轮 build_for_request
/// 组装后清零(消息已进本轮请求=消化);收敛出口查 >0 则不收敛续一轮,治「纯问答轮
/// 一轮收敛,生成期间到达的补充指令无人消费只能等下次请求」的丢失。内存态不落库。
pub pending_injects: u32,
/// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>,
/// 用户指定模型 override(主对话专用,语义同 AiSession.model_override)
@@ -1266,6 +1270,7 @@ impl PerConvState {
iteration_used: 0,
loop_max_rounds: 0,
round_budget_extra: 0,
pending_injects: 0,
agent_language: None,
model_override: None,
session_trust: HashSet::new(),
+216 -61
View File
@@ -384,6 +384,46 @@ const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查
/// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
/// 文件入站大小上限:文件不经 base64 内嵌(模型经 read_file 按需读盘),上限只防
/// 恶意超大件烧下载流量与磁盘,20MB 覆盖常规文档/报表/压缩包
const MAX_FILE_BYTES: usize = 20 * 1024 * 1024;
/// 飞书消息资源下载带防线(图片/文件共用,对齐 u-claw fetchResource 经验):3 次指数
/// 退避(1s/4s)+单次 120s 超时——飞书资源接口偶发抖动,无重试时用户收到一次性失败;
/// 超时防接口挂起拖死受理链。空响应与错误同待遇进重试(可能为服务端瞬时异常),
/// 耗尽后报最后一次原因。resource_type=image/file,同一资源接口
async fn download_message_resource_with_retry(
client: &LarkClient,
message_id: &str,
file_key: &str,
resource_type: &str,
label: &str,
) -> Result<Vec<u8>, String> {
let mut last_err = String::from("下载失败: 未知错误");
for attempt in 0u32..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1 << (attempt - 1))).await;
}
let dl = tokio::time::timeout(
std::time::Duration::from_secs(120),
client.channel_messaging().download_message_resource(
message_id,
file_key,
resource_type,
&RequestOption::default(),
),
)
.await;
match dl {
Ok(Ok(r)) if !r.data.is_empty() => return Ok(r.data),
Ok(Ok(_)) => last_err = format!("{label}内容为空"),
Ok(Err(e)) => last_err = format!("下载失败: {e}"),
Err(_) => last_err = "下载超时(120s)".into(),
}
}
Err(last_err)
}
/// 下载飞书图片消息资源并落盘,返回本地引用 ContentPart(#958 引用化存储)。
/// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀),
/// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。
@@ -396,53 +436,20 @@ async fn download_feishu_image(
message_id: &str,
file_key: &str,
) -> Result<ContentPart, String> {
// 下载带防线(对齐 u-claw fetchResource 经验):3 次指数退避(1s/4s)+单次 120s 超时
// ——飞书资源接口偶发抖动,无重试时用户收到一次性失败;超时防接口挂起拖死受理链。
// 空响应与错误同待遇进重试(可能为服务端瞬时异常),耗尽后报最后一次原因
let resp = {
let mut last_err = String::from("下载失败: 未知错误");
let mut out = None;
for attempt in 0u32..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1 << (attempt - 1))).await;
}
let dl = tokio::time::timeout(
std::time::Duration::from_secs(120),
client.channel_messaging().download_message_resource(
message_id,
file_key,
"image",
&RequestOption::default(),
),
)
.await;
match dl {
Ok(Ok(r)) if !r.data.is_empty() => {
out = Some(r);
break;
}
Ok(Ok(_)) => last_err = "图片内容为空".into(),
Ok(Err(e)) => last_err = format!("下载失败: {e}"),
Err(_) => last_err = "下载超时(120s)".into(),
}
}
match out {
Some(r) => r,
None => return Err(last_err),
}
};
if resp.data.len() > MAX_IMAGE_BYTES {
let data = download_message_resource_with_retry(client, message_id, file_key, "image", "图片")
.await?;
if data.len() > MAX_IMAGE_BYTES {
return Err(format!(
"图片超过 10MB 上限(约 {}MB),请压缩后重发",
resp.data.len() / 1024 / 1024
data.len() / 1024 / 1024
));
}
let media_type = sniff_image_mime(&resp.data)
let media_type = sniff_image_mime(&data)
.ok_or_else(|| "不支持的图片格式(仅支持 png/jpeg/webp/gif)".to_string())?;
// 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析),
// 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。
// 飞书 image 消息无原始文件名(None=纯 hash 名)
let url = store.save(&resp.data, media_type, None).await?;
let url = store.save(&data, media_type, None).await?;
Ok(ContentPart::Image {
url: Some(url),
base64: None,
@@ -451,6 +458,67 @@ async fn download_feishu_image(
})
}
/// 文件消息喂模型的任务语境:告知文件名与本机落盘路径,引导用 read_file 按需读取;
/// 二进制/超大文件的读取局限如实交底,防模型谎称已完整处理。落盘前(无路径)给
/// 占位版,仅供空判与命令/审批判定,不喂模型不落卡
fn file_inbound_prompt(file_name: &str, path: Option<&str>) -> String {
match path {
Some(p) => format!(
"(用户通过飞书发来文件「{file_name}」,已保存到本地路径 {p};请按用户需求用 read_file 工具读取该文件处理。二进制/超大文件可能无法完整读取,如实告知用户)"
),
None => format!("(用户通过飞书发来文件「{file_name}」,保存中)"),
}
}
/// 文件超限判定:超 20MB 上限时给可读错误话术(提示压缩/拆分),宁拒不落盘
fn file_size_over_limit_error(len: usize) -> Option<String> {
(len > MAX_FILE_BYTES).then(|| {
format!(
"文件超过 20MB 上限(约 {}MB),请压缩或拆分后重发",
len / 1024 / 1024
)
})
}
/// 文件展示名裁决:SDK 归一化 file_name 优先(保真,清洗首尾空白);缺失回退
/// `file_<内容 hash 前 8>`(与落盘 hash 命名同源,可对账辨认)
fn file_display_name(raw: &str, bytes: &[u8]) -> String {
let raw = raw.trim();
if raw.is_empty() {
format!("file_{}", &crate::media_store::content_hash(bytes)[..8])
} else {
raw.to_string()
}
}
/// 下载飞书文件消息资源并落盘,返回 (展示名, 本机绝对路径)。
/// 与图片同一资源接口(resource_type=file)与下载防线;落盘走 media_store 通用入口
/// (hash 去重+OSS 双写+失败降级,原文件名缀后保目录可读)。返回本机路径喂模型
/// read_file(OSS https 引用对本机读取无意义,不作返回);超 20MB 拒收回可读错误
async fn download_feishu_file(
client: &LarkClient,
store: &crate::media_store::MediaStore,
message_id: &str,
res: &larksuite_oapi_sdk_rs::channel::ChannelResource,
) -> Result<(String, String), String> {
let data =
download_message_resource_with_retry(client, message_id, &res.file_key, "file", "文件")
.await?;
if let Some(err) = file_size_over_limit_error(data.len()) {
return Err(err);
}
let display_name = file_display_name(&res.file_name, &data);
let original = res.file_name.trim();
let (_, path) = store
.save_with_path(
&data,
"application/octet-stream",
(!original.is_empty()).then_some(original),
)
.await?;
Ok((display_name, path.to_string_lossy().into_owned()))
}
/// 读取媒体 OSS 配置(KV `media-oss-config`;未配置/解析失败 → None=纯本地模式)
async fn read_media_oss_config(
db: &df_storage::db::Database,
@@ -588,12 +656,21 @@ async fn handle_message(
) {
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
ctx.last_alive.store(now_secs(), Ordering::SeqCst);
// 类型分流(#958 图片入站):text 原样;image 走下载装配(vision ContentPart);
// 其余类型维持 #25 提示卡。image 消息 SDK 归一化已把 image_key 放入
// msg.resources(ChannelResource),无需自行解析 content JSON。
let image_key = if msg.message_type == "image" {
// 群聊响应门控对齐 u-claw 模式(feishu_events.go mentionedBot 四条):①@自己响应
// ②@所有人响应 ③@别的bot不响应 ④无mention静默旁观;私聊恒响应。SDK 的
// mentioned_bot/mention_all 已承载①②③的精确判定。门控必须在类型分流之前——
// 分流段对语音等未支持类型会回提示卡,门控放后面时未艾特的群消息也会得到回复
// (2026-10-15 用户实测「群里没艾特也处理」根因)。
if msg.chat_type == "group" && !msg.mentioned_bot && !msg.mention_all {
return;
}
// 类型分流(#958 图片入站,文件入站同源扩展):text 原样;image 走下载装配(vision
// ContentPart);file 走下载落盘(read_file 本机路径);语音等其余类型维持提示卡。
// image/file 消息 SDK 归一化已把 file_key/file_name 放入 msg.resources(ChannelResource),
// 无需自行解析 content JSON。
let (image_key, file_res) = if msg.message_type == "image" {
match msg.resources.iter().find(|r| r.resource_type == "image") {
Some(r) => Some(r.file_key.clone()),
Some(r) => (Some(r.file_key.clone()), None),
None => {
let _ = reply_markdown_card(
&ctx.client,
@@ -604,26 +681,39 @@ async fn handle_message(
return;
}
}
} else if msg.message_type != "text" {
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。
// 补替代路径引导:用户发文件等往往是想让 AI 处理内容,直接告知改用文字描述
} else if msg.message_type == "file" {
match msg.resources.iter().find(|r| r.resource_type == "file") {
Some(r) => (None, Some(r.clone())),
None => {
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"📎 暂支持文本与图片消息,文件/语音等其他类型规划中。\n\n如需处理文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。",
"⚠️ 文件消息缺少 file_key,无法处理。",
)
.await;
return;
}
}
} else if msg.message_type != "text" {
// #25 非文本消息静默丢修复:回提示卡,不再零反馈。文本/图片/文件已支持,
// 余下语音等类型如实告知规划中
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"📎 暂支持文本、图片与文件消息,语音等其他类型规划中。",
)
.await;
return;
} else {
None
(None, None)
};
if msg.chat_type == "group" && !msg.mentioned_bot {
return;
}
// 图片消息不带文字(飞书 image 为独立消息类型),喂固定引导词交代任务语境;
// msg.text 的 markdown 图链([图片](image_key))对模型无意义,不喂
let text = if image_key.is_some() {
// 附件消息不带文字(飞书 image/file 均为独立消息类型),msg.text 的图链/文件标签
// 对模型无意义,不喂:图片喂固定引导词交代任务语境;文件语境需落盘路径,先预填
// 文件名占位版(命令/审批判定恒不命中),下载点覆写为含路径终版
let mut text = if image_key.is_some() {
IMAGE_INBOUND_PROMPT.to_string()
} else if let Some(res) = &file_res {
file_inbound_prompt(res.file_name.trim(), None)
} else {
let text = msg.text.clone().unwrap_or_default().trim().to_string();
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
@@ -685,6 +775,30 @@ async fn handle_message(
None => None,
};
// 文件入站:下载消息资源→落盘拿本机路径→覆写任务语境终版(含路径,模型 read_file
// 按需读取)。与图片同段白名单后下载(未授权不烧流量)、占位发票前早退(失败回
// 可读错误卡,超 20MB 话术在下载错误内)
if let Some(res) = &file_res {
let state = ctx.app.state::<crate::state::AppState>();
let oss_cfg = read_media_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
match download_feishu_file(&ctx.client, &store, &message_id, res).await {
Ok((name, path)) => {
text = file_inbound_prompt(&name, Some(&path));
}
Err(e) => {
feishu_log(&format!("文件下载失败 chat={chat_id}: {e}"));
let _ = reply_markdown_card(
&ctx.client,
&message_id,
&format!("⚠️ 文件下载失败:{e}\n\n请重发,或直接用文字描述你的需求。"),
)
.await;
return;
}
}
}
// 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。
// 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充
// 指令注入任务;未知命令词落回常规任务路径,零行为变更
@@ -836,9 +950,10 @@ async fn handle_message(
let state = ctx.app.state::<crate::state::AppState>();
let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned();
if let Some(cid) = conv_id_now {
// 图片消息不注入:inject_runtime_message 只收文本,注入则图被丢、模型只见
// 引导词空跑;落常规路径由受理守卫拦,语义诚实(任务占用中,稍后重发)
if image_key.is_none() && state.conv_states.is_active(&cid) {
// 附件消息(图片/文件)不注入,走与图片相同的后续链:inject_runtime_message
// 只收文本,图片注入则图被丢、模型只见引导词空跑;文件作为独立新任务语义
// 同样应过受理/防抖/发票链,conv 占用时受理守卫如实回告,不并入运行中任务
if image_key.is_none() && file_res.is_none() && state.conv_states.is_active(&cid) {
match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await {
Ok(true) => {
let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await;
@@ -2402,9 +2517,10 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
mod tests {
use super::{
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
heartbeat_due, is_terminal_push, md_to_lark_friendly, merge_debounce_parts,
merge_debounce_text, msg_after_acceptance, parse_command, sniff_image_mime,
strip_mention_placeholders, stream_preview,
file_display_name, file_inbound_prompt, file_size_over_limit_error, heartbeat_due,
is_terminal_push, md_to_lark_friendly, merge_debounce_parts, merge_debounce_text,
msg_after_acceptance, parse_command, sniff_image_mime, strip_mention_placeholders,
stream_preview,
};
#[test]
@@ -2453,6 +2569,45 @@ mod tests {
assert_eq!(sniff_image_mime(b"RIFF\x24\x08\x00\x00AVI "), None);
}
// ── 文件消息入站:任务语境构造/大小限流/展示名回退 ──
#[test]
fn file_prompt_carries_name_path_and_read_guidance() {
// 落盘后终版:文件名+本机路径+read_file 引导+读取局限交底,四要素齐备
let full = file_inbound_prompt("报表.xlsx", Some("E:\\data\\media\\ab12_报表.xlsx"));
assert!(full.contains("「报表.xlsx」"), "缺文件名: {full}");
assert!(full.contains("本地路径 E:\\data\\media\\ab12_报表.xlsx"), "缺路径: {full}");
assert!(full.contains("read_file"), "缺读取引导: {full}");
assert!(full.contains("如实告知用户"), "缺读取局限交底: {full}");
// 落盘前占位版:不含路径(不喂模型不落卡,仅供空判与命令/审批判定)
let pending = file_inbound_prompt("报表.xlsx", None);
assert!(!pending.contains("本地路径"), "占位版不该有路径: {pending}");
assert!(pending.contains("「报表.xlsx」"));
}
#[test]
fn file_size_limit_rejects_over_20mb_only() {
// 恰 20MB 放行(> 判定),超限给可读错误话术(含实际大小,引导压缩/拆分)
assert_eq!(file_size_over_limit_error(20 * 1024 * 1024), None);
assert_eq!(file_size_over_limit_error(1024), None);
let err = file_size_over_limit_error(21 * 1024 * 1024).unwrap();
assert!(err.contains("20MB") && err.contains("21MB"), "话术缺限值/实际值: {err}");
}
#[test]
fn file_display_name_prefers_original_and_hash_fallback() {
// 原始名保真(清洗首尾空白);缺失回退 file_<hash 前 8>,不同内容回退名不同
assert_eq!(file_display_name(" 报表.xlsx ", b"abc"), "报表.xlsx");
let fallback = file_display_name("", b"abc");
assert!(fallback.starts_with("file_"), "回退名形态: {fallback}");
assert_eq!(fallback.len(), "file_".len() + 8, "回退名取 hash 前 8: {fallback}");
assert_ne!(
fallback,
file_display_name(" ", b"abcd"),
"不同内容的回退名应可区分"
);
}
#[test]
fn command_case_and_tail_tolerance() {
// 30:/new 大小写与附言容错,附言不烧 LLM
+2
View File
@@ -496,6 +496,8 @@ pub fn run() {
commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear,
commands::ai::ai_is_generating,
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
commands::ai::ai_inject_runtime,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context,
+47 -7
View File
@@ -98,6 +98,20 @@ impl MediaStore {
media_type: &str,
original_name: Option<&str>,
) -> Result<String, String> {
self.save_with_path(bytes, media_type, original_name)
.await
.map(|(url, _)| url)
}
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
pub async fn save_with_path(
&self,
bytes: &[u8],
media_type: &str,
original_name: Option<&str>,
) -> Result<(String, std::path::PathBuf), String> {
let key = content_key(bytes, media_type, original_name);
let dir = self.data_dir.join("media");
tokio::fs::create_dir_all(&dir)
@@ -112,26 +126,33 @@ impl MediaStore {
return Err(format!("媒体写入失败: {e}"));
}
}
match &self.oss {
let url = match &self.oss {
Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await {
Ok(()) => Ok(public_url(cfg, &key)),
Ok(()) => public_url(cfg, &key),
Err(e) => {
tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://");
Ok(local_ref(&path))
local_ref(&path)
}
},
None => Ok(local_ref(&path)),
}
None => local_ref(&path),
};
Ok((url, path))
}
}
/// 内容 sha256 全量 hex。落盘键与展示名回退共用同一 hash 口径(飞书文件入站无原始名
/// 时展示名取 `file_<hash 前 8>`,与落盘 hash 命名同源可对账)
pub fn content_hash(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(bytes))
}
/// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName
/// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。
/// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话
/// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说
fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String {
use sha2::Digest as _;
let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]);
let digest = content_hash(bytes)[..16].to_string();
// 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化
let name = original_name.map(sanitize_name).filter(|s| !s.is_empty());
let ext = name
@@ -280,6 +301,25 @@ mod tests {
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[tokio::test]
async fn save_with_path_returns_readable_local_path() {
// 文件消息入站契约:引用 url 之外另给本机路径(模型 read_file 只认本机路径),
// 通用文件落盘字节无损可读
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let (url, path) = store
.save_with_path(b"doc-bytes", "application/octet-stream", Some("合同.pdf"))
.await
.unwrap();
assert!(url.starts_with("file://"));
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
format!("{}_合同.pdf", &content_hash(b"doc-bytes")[..16])
);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"doc-bytes");
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[test]
fn content_key_stable_and_ext_aware() {
let k1 = content_key(b"abc", "image/png", None);
+2
View File
@@ -104,6 +104,8 @@ export interface ProjectRecord {
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
/** 归档标记(归档功能 P2,V48)。true=归档态,退出主列表仅归档箱可见;详情页据此区分「已归档」与「不存在」 */
archived?: boolean
created_at: string
updated_at: string
/**
+5 -38
View File
@@ -809,7 +809,9 @@ async function onRemoveModule() {
overflow-y: auto;
overflow-x: hidden;
padding: 4px 4px 4px 0;
scrollbar-width: thin;
/* 滚动条样式走全局 ::-webkit-scrollbar(5px + var(--df-scrollbar) 主题色):
此处不得设 scrollbar-width —— Chromium 121+ 一旦设了标准 scrollbar-* 属性,
该元素 ::-webkit-scrollbar 定制即失效,文件列表会回退成原生样式滚动条(不随主题) */
}
.explorer-changes {
@@ -888,43 +890,8 @@ async function onRemoveModule() {
min-height: 200px;
}
/* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-sidebar ::-webkit-scrollbar-track {
background: transparent;
}
/* 文件列表(.explorer-tree)滚动条显式对齐全局,与右侧预览区一致 */
.explorer-tree ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-tree ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-tree ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-tree ::-webkit-scrollbar-track {
background: transparent;
}
/* 滚动条统一样式(styles/global.css 全局 ::-webkit-scrollbar:5px + var(--df-scrollbar))已覆盖,
此处不重复声明 —— 原局部副本硬编码白色系 rgba,浅色主题下 thumb 不可见 */
/* 窄窗口自适应 */
@media (max-width: 900px) {
+31 -3
View File
@@ -4,7 +4,7 @@
//!
//! L2 发送韧性:三级降级
//! L0 normal:后端 idle → 直接发送
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队)
//! L1 queued: 后端 busy → 纯文本先试补充注入(并入当前轮,u-claw 语义);失败/带载荷 → 入队等待(≤30s 正常排队)
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
//!
//! 耦合:
@@ -356,7 +356,7 @@ export function drainQueue(convId?: string | null) {
* 发送消息 — 三级降级(L0 直接发 / L1 入队 / L2 强制):
*
* L0 normal: 后端 idle → 直接 doSend()
* L1 queued: 后端 busy → 入队等 AiCompleted 续发
* L1 queued: 后端 busy → 纯文本先试 ai_inject_runtime(补充并入当前轮);失败回退入队等 AiCompleted 续发
* L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm;
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
*
@@ -387,9 +387,37 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
backendGenerating = false
}
// ── L1:后端 busy → 入队(消息已在本地显示,待后端空闲后自动发送)──
// ── L1:后端 busy → 纯文本先试补充注入,失败回退入队(消息已在本地显示,待后端空闲后自动发送)──
// 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。
if (backendGenerating) {
// 补充注入旁路(u-claw 语义,与飞书渠道 feishu_channel 同源同一后端实现):纯文本补充
// 经 ai_inject_runtime 直写运行中会话,loop 下一轮自然消化——补充并进当前回答,不开新轮不入队。
// 注入通道只收文本:带图(parts 含 image)/带技能/带 mention 增强的消息注入会丢载荷
// (同飞书「图被丢、引导词空跑」判定),照旧入队走完整发送路径。
// Ok(false)=会话不存在、Err=非活跃/Stopping 均为常态路径:静默回退下方入队,
// 行为与接入注入前逐字一致。
const pureText = !skill && !(spans && spans.length > 0) && !(parts && parts.some(p => p.type === 'image'))
if (pureText && targetConvId) {
try {
const injected = await invoke<boolean>('ai_inject_runtime', { conversationId: targetConvId, message: text.trim() })
if (injected) {
// 本地气泡即时可见(后端已落库同内容,重进会话从 DB 加载不重复):
// 当前视图才 push,后台会话续发不污染当前视图(与下方 L0 既有口径一致)
if (targetConvId === state.activeConversationId) {
state.messages.push({
id: `user-${nextMsgId()}` as MessageId,
role: 'user',
content: text.trim(),
timestamp: Date.now(),
})
}
void emit('ai-toast', { msg: t('ai.injectMerged'), type: 'success' })
return
}
} catch {
// 非活跃(生成刚收尾的竞态窗口)是常态:静默回退入队,不弹错不打扰
}
}
if (state.queue.length >= QUEUE_LIMIT) {
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
}
+2
View File
@@ -52,6 +52,8 @@ export default {
// UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent)
queueCleared: 'Cleared {n} queued message(s)',
queueWillContinue: 'Stopped. {n} queued message(s) will still send automatically.',
// Success toast when a runtime supplement is merged into the in-flight answer (u-claw semantics: no new round)
injectMerged: 'Merged into the current answer; takes effect next round.',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// 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.',
+1
View File
@@ -13,6 +13,7 @@ export default {
edit: 'Edit',
close: 'Close',
loading: 'Loading…',
retry: 'Retry',
unknownError: 'Unknown error',
// Collapse/expand (reusable generic verbs for description/log/future long content)
expand: 'Expand',
+7
View File
@@ -33,6 +33,13 @@ export default {
taskListTitle: '🔀 Tasks',
taskCount: '{n} tasks',
emptyTasks: 'No tasks yet',
filterEmptyTasks: 'No tasks match the current filter',
// Sync (PD5-9)
syncDone: 'Sync complete',
// PD5-4 tri-state: archived (restore from archive box)
archivedNotice: 'This project is archived and not in the project list',
restoreFromArchive: 'Restore from Archive',
restoreSuccess: 'Restored to the project list',
// Workflow log panel
workflowLogTitle: '📋 Workflow Log',
// A2[PD-P1-3]: event stream has no project_id dimension, cannot filter per project; label "global events" to avoid misreading as this project's logs
+2
View File
@@ -52,6 +52,8 @@ export default {
// UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈)
queueCleared: '已清 {n} 条排队消息',
queueWillContinue: '已停止,{n} 条排队消息仍将自动发送',
// 运行中补充注入成功(u-claw 语义:纯文本补充并进当前回答,不开新轮)的 toast 提示
injectMerged: '已并入当前回答,下一轮生效',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
+1
View File
@@ -13,6 +13,7 @@ export default {
edit: '编辑',
close: '关闭',
loading: '加载中…',
retry: '重试',
unknownError: '未知错误',
// 折叠/展开(可复用通用词,描述折叠/日志折叠/未来长内容)
expand: '展开',
+7
View File
@@ -33,6 +33,13 @@ export default {
taskListTitle: '🔀 任务列表',
taskCount: '{n} 个任务',
emptyTasks: '暂无任务',
filterEmptyTasks: '当前筛选无任务',
// 同步(PD5-9)
syncDone: '同步完成',
// PD5-4 三态:已归档(归档箱恢复)
archivedNotice: '项目已归档,不在项目列表中',
restoreFromArchive: '从归档箱恢复',
restoreSuccess: '已恢复到项目列表',
// 工作流日志面板
workflowLogTitle: '📋 工作流日志',
// A2[PD-P1-3]:事件流无 project_id 维度,无法按项目过滤,标注「全局事件」避免误读为本项目
+5 -2
View File
@@ -54,11 +54,14 @@ export function createProjectsStore() {
return record
}
async function deleteProject(id: string) {
await runWithCatch(state, t('projects.err.deleteFailed'), async () => {
async function deleteProject(id: string): Promise<boolean> {
// 返成功布尔(失败时 runWithCatch 已置 state.error),详情页删除据此阻断失败后的误跳转
const ok = await runWithCatch(state, t('projects.err.deleteFailed'), async () => {
await projectApi.delete(id) // 软删 → 回收站(可恢复)
state.projects = state.projects.filter(p => p.id !== id)
return true
})
return ok === true
}
async function loadDeletedProjects() {
+133 -25
View File
@@ -2,11 +2,22 @@
<div class="project-detail">
<!-- 加载态 -->
<div v-if="loading" class="loading-state">{{ $t('common.loading') }}</div>
<!-- 加载失败(列表拉取报错,单列给重试,不让失败伪装成「项目不存在」) -->
<div v-else-if="loadFailed" class="empty-state">
<p>{{ store.error }}</p>
<button class="btn btn-primary" @click="retryLoad">{{ $t('common.retry') }}</button>
</div>
<!-- 项目不存在 -->
<div v-else-if="!currentProject" class="empty-state">
<p>{{ $t('projectDetail.notFound') }}</p>
<router-link to="/projects" class="btn btn-primary">{{ $t('projectDetail.backToList') }}</router-link>
</div>
<!-- 已归档(归档项目不在主列表,直链可达;给归档箱恢复入口) -->
<div v-else-if="currentProject.archived" class="empty-state">
<p>{{ $t('projectDetail.archivedNotice') }}</p>
<button class="btn btn-primary" @click="restoreFromArchive">{{ $t('projectDetail.restoreFromArchive') }}</button>
<router-link to="/projects" class="btn btn-ghost">{{ $t('projectDetail.backToList') }}</router-link>
</div>
<!-- 项目详情 -->
<template v-else>
<!-- 页面头部 -->
@@ -17,7 +28,10 @@
<span v-if="statusLabel" class="stage-badge" :class="'stage-' + stageKey">{{ $t(statusLabel) }}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" @click="handleSync">{{ $t('projectDetail.sync') }}</button>
<button class="btn btn-ghost" :disabled="syncing" @click="handleSync">
<span v-if="syncing" class="spinner"></span>
{{ $t('projectDetail.sync') }}
</button>
<button class="btn btn-ghost" type="button" @click="handleImportDir">{{ $t('projectDetail.importDir') }}</button>
<button class="btn btn-danger" @click="handleDelete">{{ $t('projectDetail.delete') }}</button>
<button class="btn btn-primary" @click="showNewTaskModal = true">{{ $t('projectDetail.newTask') }}</button>
@@ -293,7 +307,7 @@
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: filteredProjectTasks.length }) }}</span>
<!-- 全选(勾选后触发底部批量操作栏) -->
<label class="select-all" :title="$t('tasks.batch.selectHint')">
<input
@@ -322,7 +336,7 @@
class="task-card"
v-for="task in filteredProjectTasks"
:key="task.id"
@click="router.push('/tasks/' + task.id)"
@click="openDrawer(task.id)"
>
<div class="task-card-checkbox">
<input
@@ -348,6 +362,8 @@
</div>
</div>
<div v-if="!tasksLoading && projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
<!-- PD5-12:筛选把列表清空后给提示,不留空白区 -->
<div v-else-if="!tasksLoading && filteredProjectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.filterEmptyTasks') }}</div>
<!-- 批量操作栏(选中任务后浮现) -->
<TaskBatchBar
v-if="selection.count > 0"
@@ -418,6 +434,15 @@
<!-- 确认弹层(删除/重定位确认,替代原生 window.confirm/alert) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
<!-- 任务快开抽屉(PD5-22):点任务卡就地滑出详情,替代整页跳 /tasks/:id(其返回链固定回
/tasks 会断项目上下文);与 Tasks.vue 同款接线,推进成功后重拉本项目任务列表 -->
<TaskDrawer
:task-id="drawerTaskId"
:visible="drawerVisible"
@close="closeDrawer()"
@advance-done="loadProjectTasks()"
/>
<!-- 新增/编辑工程弹窗(概览内 CRUD,对标 FileExplorer 工程 modal) -->
<div v-if="showModuleModal" class="modal-overlay" @click.self="showModuleModal = false">
<div class="modal-box">
@@ -463,7 +488,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, reactive, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
@@ -478,6 +503,7 @@ import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as ass
import { projectStatusLabel, taskStatusLabel, taskStatusClass, TASK_STATUS_TRANSITIONS, TASK_STATUS_ORDER } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import TaskDrawer from '@/components/task/TaskDrawer.vue'
import FileExplorer from '@/components/project/FileExplorer.vue'
import DependencyGraph from '@/components/project/DependencyGraph.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
@@ -485,7 +511,7 @@ import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
import type { ProjectId, TaskRecord, DfDataChangedPayload } from '@/api/types'
import type { ProjectId, ProjectRecord, TaskRecord, DfDataChangedPayload } from '@/api/types'
const route = useRoute()
const router = useRouter()
@@ -520,7 +546,9 @@ function setActiveTab(tab: DetailTab) {
// A8[PD-P3-16]:弃字符数近似(渲染后 Markdown 列表/标题行高不一,字符数与 px 无线性关系),
// 对齐 TaskDetail 用 descEl 实测 scrollHeight —— 首次渲染 + renderedDesc 变化后 nextTick 测量。
// descCollapsible 仅当真实渲染高度超过折叠阈值才为 true(避免短文本显无用的展开按钮)。
const DESC_COLLAPSE_PX = 96 // 折叠 max-height 6em(≈4 行 ×14px×1.6)
// 折叠阈值必须与折叠裁剪高(.description-wrap.is-collapsed 的 max-height 6em = 14px×6 = 84px)一致:
// 阈值高于裁剪高时,内容高落在 84~阈值 区间会「已被 CSS 裁掉尾部却无展开按钮」(4 行描述 89.6px 即命中)
const DESC_COLLAPSE_PX = 84
const descEl = ref<HTMLElement | null>(null)
const descExpanded = ref(false)
const descOverflow = ref(false)
@@ -551,10 +579,54 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
// 根治此前 stageMap 含不存在的 testing 状态、三套映射互相矛盾)
const projectId = computed(() => route.params.id as string)
// PD5-4:列表 find 不到时单查兜底(归档项目不在主列表/列表侧带 query 时直链仍可达),
// 同时消化 projectApi.get 死代码;「已归档/加载失败/不存在」三态由模板分支单列
const fallbackProject = ref<ProjectRecord | null>(null)
const currentProject = computed(() =>
store.projects.find(p => p.id === projectId.value)
store.projects.find(p => p.id === projectId.value) ?? fallbackProject.value
)
const loading = computed(() => store.projects.length === 0 && !store.error)
// 兜底单查进行中也算加载中,避免先闪「项目不存在」
const fallbackLoading = ref(false)
const loading = computed(() =>
(store.projects.length === 0 && !store.error) || fallbackLoading.value
)
// 列表拉取失败(store.error 置位且无项目可显)单列,给重试入口
const loadFailed = computed(() => !loading.value && !currentProject.value && !!store.error)
// 序号守卫(A4 同款):快速切项目时旧单查结果晚到不覆盖新项目
let projectReqSeq = 0
async function loadFallbackProject() {
const seq = ++projectReqSeq
const pid = projectId.value
if (!pid || store.projects.some(p => p.id === pid)) { fallbackProject.value = null; return }
fallbackLoading.value = true
try {
const record = await projectApi.get(pid)
if (seq !== projectReqSeq || pid !== projectId.value) return
fallbackProject.value = record
} catch {
if (seq !== projectReqSeq || pid !== projectId.value) return
fallbackProject.value = null
} finally {
if (seq === projectReqSeq) fallbackLoading.value = false
}
}
// 加载失败重试(清 error 后重拉列表 + 兜底单查,期间 loading 态接管视图)
async function retryLoad() {
store.clearError()
await store.loadProjects()
await loadFallbackProject()
}
// 归档箱恢复(取消归档回主列表;成功后 store.projects 已刷新,currentProject 自动命中列表)
async function restoreFromArchive() {
const p = currentProject.value
if (!p) return
await store.unarchiveProject(p.id)
if (store.error) { Message.error(store.error); return }
Message.success(t('projectDetail.restoreSuccess'))
}
// 状态文案/阶段进度统一走 ../constants/project
// 项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
@@ -674,6 +746,9 @@ const batch = reactive(useTaskBatchActions({
async function afterBatch() {
selection.prune()
}
// 幽灵勾选收敛:筛选切换/外部数据变更重拉后可见范围已变,选中集中被筛掉或已删除的 id 若残留,
// 批量栏计数含不可见任务、对其执行动作会落 failed —— 范围一变即收敛(afterBatch 只覆盖批量后时机)
watch(filteredProjectTasks, () => { selection.prune() })
const batchAdvanceTargets = computed(() => {
const ids = [...selection.selected]
if (ids.length === 0) return []
@@ -725,6 +800,13 @@ const assigneeSuggestions = computed(() => {
return [...set]
})
// 任务快开抽屉状态(与 Tasks.vue 同款):打开是同步置 ref,无失败路径;
// 抽屉内数据拉取失败由 TaskDrawer 自身错误态兜底,页面行为不受影响
const drawerTaskId = ref<string | null>(null)
const drawerVisible = computed(() => drawerTaskId.value !== null)
function openDrawer(id: string) { drawerTaskId.value = id }
function closeDrawer() { drawerTaskId.value = null }
// ── 工程列表(概览内 CRUD)──
// 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认)
// + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。
@@ -920,6 +1002,7 @@ const newTaskBranch = ref('')
const submitting = ref(false)
async function submitNewTask() {
if (submitting.value) return // 重入守卫:Enter 直调绕过按钮 disabled,IPC 期间二次 Enter 会重复建任务
if (!newTaskTitle.value.trim()) return
submitting.value = true
try {
@@ -929,7 +1012,11 @@ async function submitNewTask() {
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
if (!r) {
// store.createTask 失败只写 state.error 不弹提示(useStoreAction 统一吞错),此处补页面可见反馈
if (store.error) Message.error(store.error)
return // 保持弹窗不关,便于修正后重试
}
// 同步到本地 ref(store.createTask 已 push 全局 tasks,但本视图不再读 store.tasks)
projectTasks.value = [r, ...projectTasks.value]
showNewTaskModal.value = false
@@ -942,25 +1029,41 @@ async function submitNewTask() {
}
// ── 同步 ──
// PD5-9:busy 态防并发同步(串行 await 期间按钮可重复点)+ 完成轻提示
const syncing = ref(false)
async function handleSync() {
if (syncing.value) return
syncing.value = true
try {
await store.loadProjects()
await loadFallbackProject() // AI 工具可在别处归档本项目,列表外项目靠兜底单查刷新
await loadProjectTasks() // 本项目任务独立拉取,不依赖 store.loadTasks
// A9[PD-P3-18]:同步补齐工程列表 + 灵感回溯,避免「新增/扫描工程后点同步不刷新」
await loadModules()
await store.loadIdeas()
await checkPath()
Message.success(t('projectDetail.syncDone'))
} finally {
syncing.value = false
}
}
// ── 代码目录绑定 ──
const pathExists = ref<boolean | null>(null)
// 检查绑定目录是否存在(详情页「目录是否还在」)
// PD5-14:请求序号守卫(A4 同款)—— 快速切项目时旧检测结果晚到,不覆盖新项目目录状态
let pathReqSeq = 0
async function checkPath() {
const seq = ++pathReqSeq
const p = currentProject.value
if (!p?.path) { pathExists.value = null; return }
try {
pathExists.value = await projectApi.checkPathExists(p.path)
const exists = await projectApi.checkPathExists(p.path)
if (seq !== pathReqSeq) return
pathExists.value = exists
} catch {
if (seq !== pathReqSeq) return
pathExists.value = null
}
}
@@ -1026,8 +1129,13 @@ async function handleImportDir() {
async function handleDelete() {
const p = currentProject.value
if (!p) return
if (!await confirmDialog(t('projectDetail.confirmDelete', { name: p.name }))) return
await store.deleteProject(p.id)
if (!await confirmDialog(t('projectDetail.confirmDelete', { name: p.name }), t('common.delete'))) return
const ok = await store.deleteProject(p.id)
if (!ok) {
// 删除失败留在本页(store 已写 state.error,此处补可见提示),不能与成功同样跳走
if (store.error) Message.error(store.error)
return
}
router.push('/projects')
}
@@ -1045,13 +1153,10 @@ onMounted(async () => {
// 恢复持久化的 Tab 选择(按项目隔离)
activeTab.value = readStoredTab(projectId.value)
await store.loadProjects()
// 本项目任务独立拉取(不调 store.loadTasks,避免被 Tasks 视图分页/筛选污染)
await loadProjectTasks()
// 工程列表独立拉取(FileExplorer 文件 Tab 内 CRUD,概览只读展示)
await loadModules()
// 来源灵感回溯:项目有 idea_id 但 store.ideas 为空(本页未 load 过)时补拉,
// 否则 sourceIdea computed 永远找不到对应灵感记录
await store.loadIdeas()
// PD5-10:任务/工程/灵感/兜底单查互不依赖,并行拉取缩短首屏串行链;
// checkPath 依赖项目记录就位(列表或兜底),保持原序殿后。
// 任务独立拉取不读 store.tasks(避免被 Tasks 视图分页/筛选污染);灵感补拉供来源回溯
await Promise.all([loadProjectTasks(), loadModules(), store.loadIdeas(), loadFallbackProject()])
await store.startEventListener()
await checkPath()
// A8[PD-P3-16]:首次渲染后测描述真实高度(纯文本描述同步渲染,renderedDesc watch 可能不触发)
@@ -1073,12 +1178,16 @@ onMounted(async () => {
// 路由切换到不同项目(URL /projects/:id 变化)时,重新拉取本项目任务 + 恢复 Tab + 检测目录
watch(projectId, async (newId) => {
if (!newId) return
// 切项目清空上一项目的勾选集:参数级导航组件复用不清状态,残留选中在新项目列表查不到,批量动作全落 failed
selection.clear()
activeTab.value = readStoredTab(newId)
// A5[PD-P2-5]:折叠态跨项目残留 —— 切项目重置描述展开 + 工作流日志折叠
descExpanded.value = false
workflowLogCollapsed.value = true
await loadProjectTasks()
await loadModules()
// PD5-4:切项目后重定兜底单查(上一项目的兜底记录不能带进新项目)
await loadFallbackProject()
})
// A3[PD-P2-1]:概览工程列表与 FileExplorer 双向同步 —— 文件 tab 内增删工程后,切回概览自动重拉
@@ -1098,11 +1207,10 @@ onBeforeUnmount(() => {
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
})
onUnmounted(() => {
// 用 store.stopEventListener 统一停全局监听并复位句柄(直接调 unlisten() 停监听但
// 不复位 _eventUnlisten,后续 startEventListener 幂等会误返回已死句柄,共享化后必查)
store.stopEventListener()
})
// 不在此停工作流事件监听:该监听是 workflow store 的全局单例(_eventUnlisten 共享,
// 本页与 TaskDetail 幂等共用),App.vue 的全局审批弹窗/浮窗全靠它常驻收事件置 pendingApproval。
// 卸载时 stop 会误杀全局监听——用户进过一次详情页再离开,任意页面审批弹窗即全失聪,
// 故对齐 App.vue data-changed「启动后常驻」模式,监听随应用生命周期存活,页面卸载不停。
</script>
<style scoped>
@@ -1325,7 +1433,7 @@ onUnmounted(() => {
/* P1-g+ 问题10③:描述折叠(对齐 TaskDetail 长内容)— 折叠时 max-height 截断 + 渐变遮罩 */
.description-wrap { position: relative; }
.description-wrap.is-collapsed .description {
max-height: 6em; /* 约 4 行(line-height 1.5) */
max-height: 6em; /* 14px×6=84px,与脚本 DESC_COLLAPSE_PX 同源,改一处必同步另一处 */
overflow: hidden;
}
.desc-toggle { margin-top: 6px; }