新增: 待发箱抽屉与对话体验前端批
- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
This commit is contained in:
@@ -155,6 +155,32 @@ impl OutboxRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 全量列表(跨会话,「定时列表」抽屉数据源)。`include_done=false` 时排除终态,
|
||||
/// 按 created_at 升序(与 list_by_conv 同序语义)。
|
||||
pub async fn list_all(&self, include_done: bool) -> Result<Vec<AiOutboxRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = if include_done {
|
||||
"SELECT * FROM ai_outbox ORDER BY created_at ASC".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"SELECT * FROM ai_outbox \
|
||||
WHERE status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
|
||||
)
|
||||
};
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt.query_map([], outbox_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 取到点的待派发项(status='pending' 且 due_at 为空或已到),按 created_at 升序,至多 limit 条。
|
||||
/// 命中 idx_ai_outbox_due。
|
||||
pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> {
|
||||
@@ -291,6 +317,27 @@ impl OutboxRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 手动重试入口:仅 failed → pending(attempts 归零=手动重试给全新预算,清 last_error)。
|
||||
/// 其余状态不动返回 false——调度器自动重试走 revert_pending(瞬时失败保序),两者互补。
|
||||
pub async fn requeue(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET status = 'pending', attempts = 0, last_error = NULL, \
|
||||
updated_at = ?1 WHERE id = ?2 AND status = 'failed'",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 取消单项。
|
||||
pub async fn cancel(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
@@ -525,4 +572,48 @@ mod tests {
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_CANCELED);
|
||||
}
|
||||
|
||||
/// list_all:跨会话全量返回;include_done=false 排除终态,created_at 升序。
|
||||
#[tokio::test]
|
||||
async fn list_all_covers_convs_and_filters_done() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||
repo.insert(rec("o2", "conv-2")).await.unwrap();
|
||||
let mut done = rec("o3", "conv-1");
|
||||
done.status = STATUS_SENT.to_string();
|
||||
repo.insert(done).await.unwrap();
|
||||
|
||||
let all = repo.list_all(true).await.unwrap();
|
||||
assert_eq!(all.len(), 3, "include_done=true 全量(含跨会话)");
|
||||
|
||||
let active = repo.list_all(false).await.unwrap();
|
||||
let ids: Vec<&str> = active.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["o1", "o2"], "排除终态后仅两条 pending,升序");
|
||||
}
|
||||
|
||||
/// requeue:仅 failed → pending 且 attempts 归零清错;pending/sent 不动返 false。
|
||||
#[tokio::test]
|
||||
async fn requeue_only_resets_failed() {
|
||||
let (_db, repo) = setup().await;
|
||||
// o1:pending → claim(占取)→ revert(attempts=1)→ mark_failed(failed,attempts 保留 1)
|
||||
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||
repo.claim("o1").await.unwrap();
|
||||
let attempts = repo.revert_pending("o1", Some("boom")).await.unwrap();
|
||||
assert_eq!(attempts, 1);
|
||||
repo.mark_failed("o1", "boom").await.unwrap();
|
||||
|
||||
assert!(repo.requeue("o1").await.unwrap(), "failed 应可重入队");
|
||||
let got = repo.get_by_id("o1").await.unwrap().unwrap();
|
||||
assert_eq!(got.status, STATUS_PENDING);
|
||||
assert_eq!(got.attempts, 0, "手动重试给全新预算");
|
||||
assert_eq!(got.last_error, None, "重入队清上次错误");
|
||||
|
||||
// pending 项:requeue 不动(返回 false)
|
||||
repo.insert(rec("o2", "conv-1")).await.unwrap();
|
||||
assert!(!repo.requeue("o2").await.unwrap(), "pending 非 failed,requeue 不动");
|
||||
// sent 终态:requeue 不动
|
||||
repo.insert(rec("o3", "conv-1")).await.unwrap();
|
||||
repo.mark_sent("o3").await.unwrap();
|
||||
assert!(!repo.requeue("o3").await.unwrap(), "sent 终态不可重入队");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user