Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb662f092 | ||
|
|
a644e93604 | ||
|
|
795935bf6a |
@@ -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,
|
||||
|
||||
@@ -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(), "应被压缩");
|
||||
|
||||
@@ -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`**。以下为可执行登记,开工以现场源码为准。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,17 +126,25 @@ 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
|
||||
@@ -130,8 +152,7 @@ impl MediaStore {
|
||||
/// 平铺不分会话目录:会话归属经 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);
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,8 @@ export default {
|
||||
// UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈)
|
||||
queueCleared: '已清 {n} 条排队消息',
|
||||
queueWillContinue: '已停止,{n} 条排队消息仍将自动发送',
|
||||
// 运行中补充注入成功(u-claw 语义:纯文本补充并进当前回答,不开新轮)的 toast 提示
|
||||
injectMerged: '已并入当前回答,下一轮生效',
|
||||
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
|
||||
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
|
||||
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
edit: '编辑',
|
||||
close: '关闭',
|
||||
loading: '加载中…',
|
||||
retry: '重试',
|
||||
unknownError: '未知错误',
|
||||
// 折叠/展开(可复用通用词,描述折叠/日志折叠/未来长内容)
|
||||
expand: '展开',
|
||||
|
||||
@@ -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 维度,无法按项目过滤,标注「全局事件」避免误读为本项目
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user