From 3818a63522190d169a0a2712eb8314e7da4f7396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 11 Oct 2026 07:38:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=BE=85=E5=8F=91?= =?UTF-8?q?=E7=AE=B1=E6=8A=BD=E5=B1=89=E4=B8=8E=E5=AF=B9=E8=AF=9D=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E5=89=8D=E7=AB=AF=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装 - U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死 - 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底) - 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传 --- crates/df-storage/src/crud/outbox_repo.rs | 91 +++++ src/App.vue | 28 +- src/api/types.ts | 13 + src/components/AiChat.vue | 33 +- src/components/ToolCard.vue | 18 +- src/components/ai/ChatInput.vue | 16 + src/components/ai/MessageList.vue | 48 +++ src/components/ai/OutboxDrawer.vue | 405 +++++++++++++++++++++ src/components/ai/TaskParkedCard.vue | 13 +- src/components/ai/TopBar.vue | 127 ++++++- src/components/layout/AppLayout.vue | 9 +- src/composables/ai/streamSelfHeal.ts | 16 + src/composables/ai/useAiLifecycleEvents.ts | 13 + src/composables/ai/useAiOutbox.ts | 131 +++++++ src/composables/ai/useAiPanel.ts | 14 +- src/composables/ai/useAiStreamingEvents.ts | 39 +- src/composables/ai/useAiWindow.ts | 38 ++ src/i18n/en/ai.ts | 27 +- src/i18n/en/aiChat.ts | 24 ++ src/i18n/zh-CN/ai.ts | 23 ++ src/i18n/zh-CN/aiChat.ts | 24 ++ tests/streamSelfHeal.test.mjs | 20 + 22 files changed, 1151 insertions(+), 19 deletions(-) create mode 100644 src/components/ai/OutboxDrawer.vue create mode 100644 src/composables/ai/streamSelfHeal.ts create mode 100644 src/composables/ai/useAiOutbox.ts create mode 100644 tests/streamSelfHeal.test.mjs diff --git a/crates/df-storage/src/crud/outbox_repo.rs b/crates/df-storage/src/crud/outbox_repo.rs index c3c0907..f5e7b5f 100644 --- a/crates/df-storage/src/crud/outbox_repo.rs +++ b/crates/df-storage/src/crud/outbox_repo.rs @@ -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> { + 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> { @@ -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 { + 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 { 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 终态不可重入队"); + } } diff --git a/src/App.vue b/src/App.vue index 0cd49d8..ef86723 100644 --- a/src/App.vue +++ b/src/App.vue @@ -136,10 +136,32 @@ async function handleCancelApproval() { showApprovalDialog.value = false } -// 点击菜单自动退出最大化 → 切为分栏模式 +// ── 路由 ↔ 最大化态联动(P0 修复 + P1 返回记忆)── +// P0:统一走 setMaximized(落盘),不再直写 state 绕过 persistUiState +// (原内存/磁盘分歧:退出最大化未落盘,重启后读到旧值 true 闪回) +// P1:离开 AI 域时记忆全宽态,回到 AI 域自动恢复 —— 解决「最大化态点设置/任务被迫退出, +// 回来无法自动恢复全宽」的心流断裂(用户不必手动再点最大化) +const AI_DOMAIN_PATHS = new Set(['/', '/ai-home', '/ai-detached']) +const restoreMaximizedOnReturn = ref(false) + watch(() => route.path, (path) => { - if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) { - aiStore.state.maximized = false + // 分离窗口不参与主面板最大化联动:分离窗是独立 JS context,state 与主窗无关; + // 且其 path(/ai-detached、/file-explorer-detached、/approval-popup)不在 AI 域内, + // 若不守卫会因分离窗默认 maximized=true 误触发 setMaximized(false) 并落盘, + // 覆盖主窗口的 df-ai-ui 布局 + if (isDetached.value) return + if (AI_DOMAIN_PATHS.has(path)) { + // 回到 AI 域:若离开前是全宽则恢复(面板已关闭则不恢复,避免
被 v-show 隐藏致其他页不可交互) + if (restoreMaximizedOnReturn.value) { + restoreMaximizedOnReturn.value = false + if (aiStore.state.panelOpen) aiStore.setMaximized(true) + } + return + } + // 离开 AI 域:记忆全宽态后退出最大化(
恢复可交互) + if (aiStore.state.maximized) { + restoreMaximizedOnReturn.value = true + aiStore.setMaximized(false) } }) diff --git a/src/api/types.ts b/src/api/types.ts index 4bef5e4..080859d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -463,6 +463,12 @@ export type AiChatEvent = ({ * undefined/false=真实值。前端 MessageList 据此标注『估算』区分 estimated vs real。 */ is_estimated?: boolean + /** + * 看板 953: 最终回复是否因 provider max_tokens 上限被截断(finish_reason=length)。 + * true=正常收敛但内容被腰斩,MessageList 末条 assistant 气泡渲染截断提示条 + 继续输出按钮; + * undefined/false=完整(旧后端事件无此字段,按 false 兜底,向后兼容)。 + */ + truncated_by_max_tokens?: boolean /** * incomplete 场景细分(达上限自动收尾 vs 网络中断,前端据此区分提示文案): * "max_rounds"=达最大轮次后端自动收尾;其他/缺省=网络中断等异常。 @@ -647,6 +653,13 @@ export interface AiMessage { prompt_cache_miss_tokens?: number /** 思考 token(deepseek-reasoner/o1 reasoning_tokens)。老消息 undefined。 */ reasoning_tokens?: number + /** + * 看板 953: 该 assistant 回复因 provider max_tokens 上限被截断(live 态 AiCompleted 事件透传标记)。 + * 末条 assistant 带此标记时 MessageList 渲染截断提示条 + 继续输出按钮;用户再发消息/ + * 继续输出后新气泡顶掉末条位置,标记自然失效,无需清除逻辑。仅 live 内存标记不入库 + * (reload 后历史消息不带,截断提示属当轮即时性信息,可接受口径)。 + */ + truncatedByMaxTokens?: boolean timestamp: number } diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 088db9d..5f064dd 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -81,7 +81,7 @@ {{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}
- +
@@ -101,7 +101,7 @@ @@ -148,6 +161,7 @@ import SkillMention from './SkillMention.vue' import ImageInput from './ImageInput.vue' import MentionPopover from './MentionPopover.vue' import EnrichmentPanel from './EnrichmentPanel.vue' +import OutboxDrawer from './OutboxDrawer.vue' import { taskApi } from '../../api/task' import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types' import type { MentionItem } from './MentionItem' @@ -173,6 +187,8 @@ const { t } = useI18n() const inputText = ref('') const inputEl = ref() +// 定时/待发箱抽屉开合(⏰ 按钮开,抽屉 close 事件关;列表数据在 useAiOutbox 单例缓存) +const outboxOpen = ref(false) // ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽/选择) ── // 待发送图片列表(url 或 base64 data URI + media_type),handleSend 时构造 ContentPart[]。 diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index bc95555..d7a76dc 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -815,6 +815,23 @@ async function onInstallObscura(): Promise { } } +/** + * 看板 953:截断气泡「继续输出」按钮。回复被 provider max_tokens 上限腰斩时,注入续写指令 + * 走既有发送通道(复用 store.sendMessage,对齐 onInstallObscura 模式),模型从上条回复的 + * 中断处衔接输出,不再整段重发。streaming 中按钮 disabled(模板绑定);失败 toast 对齐 + * handleRetry 错误处理模式。 + */ +async function onContinueAfterTruncation(): Promise { + try { + await store.sendMessage(t('ai.continueOutputPrompt')) + await nextTick() + scrollToBottom() + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + emit('toast', { msg: t('aiChat.sendFailed', { msg }), type: 'error' }) + } +} + // ── F-15 阶段2: 消息分段(归档段折叠) ── // ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义): // null | undefined | 'active' → 正常消息(现状) @@ -1835,6 +1852,20 @@ defineExpose({
+ +
+ {{ $t('ai.truncatedByMaxTokens') }} + +
+
{{ $t('ai.notRetryable') }} @@ -2458,6 +2489,23 @@ defineExpose({ color: var(--df-accent); } +/* 看板 953:max_tokens 截断弱提示条(末条 assistant 气泡尾部,warning 弱化不着色抢焦) */ +.ai-msg-truncated { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + padding: 3px 8px; + border-radius: var(--df-radius-sm); + background: color-mix(in srgb, var(--df-warning, #ffc107) 10%, transparent); + width: fit-content; +} +.ai-msg-truncated-text { + font-size: 10.5px; + line-height: 1.4; + color: var(--df-text-dim); +} + /* -- Streaming Cursor -- */ .ai-cursor { display: inline-block; diff --git a/src/components/ai/OutboxDrawer.vue b/src/components/ai/OutboxDrawer.vue new file mode 100644 index 0000000..c896da0 --- /dev/null +++ b/src/components/ai/OutboxDrawer.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/src/components/ai/TaskParkedCard.vue b/src/components/ai/TaskParkedCard.vue index 1675362..b4e6ed5 100644 --- a/src/components/ai/TaskParkedCard.vue +++ b/src/components/ai/TaskParkedCard.vue @@ -2,7 +2,10 @@
@@ -11,6 +14,8 @@ {{ t('ai.taskParked.title') }}
{{ parkedActive.reason }} + + {{ t('ai.taskParked.preserved') }}
@@ -136,6 +141,12 @@ async function handleStopArchive(): Promise { word-break: break-word; white-space: pre-wrap; } +/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */ +.ai-task-parked-preserved { + font-size: 10.5px; + color: var(--df-text-dim); + opacity: 0.8; +} .ai-task-parked-actions { display: flex; align-items: center; diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index 90bef9a..0072618 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -156,13 +156,22 @@
-
+
{{ taskProgressText }} + {{ stageText }} + {{ silenceSuffix }} { return Math.min(100, Math.round((p.round / p.maxRounds) * 100)) }) +// ── U2 进度语义三层补齐(设计 §三):内容层(最近动作)+静默期三档(§3.4) ── +// 规则1 兑现:下方定时器只算「距上次信号多久」,永不推进任何计数(轮次/步数只由事件写)。 +// 规则4 兑现:活性=脉冲节奏(静默降速),位置=数字,分开渲染;永不自动判死(重入退避最长 +// 15min 是合法等待),警示档只给判断材料,「疑似卡死可停止」的决策留给用户。 + +// 内容层数据源:工具卡挂在 assistant 消息上(useAiToolEvents),从消息流纯派生——零新事件 +// 监听零协议改动。steps=当前消息流工具调用总数(压缩仅标记不清消息,计数不回退;清空上下文 +// 重置属用户显式重开,语义自洽);last 优先取 running 卡(当前动作),无 running 取最后一张。 +const toolActivity = computed(() => { + let steps = 0 + let lastName: string | null = null + let runningName: string | null = null + for (const m of store.state.messages) { + const tcs = m.toolCalls + if (!tcs?.length) continue + steps += tcs.length + for (const tc of tcs) { + lastName = tc.name + if (tc.status === 'running') runningName = tc.name + } + } + return { steps, last: runningName ?? lastName } +}) + +// 阶段标签映射(设计 §3.3 同源词表桌面份):工具名→语义标签;未命中直显工具名 +const STAGE_KEYS: Record = { + read_file: 'read', read_symbol: 'read', search_files: 'read', list_directory: 'read', + write_file: 'edit', patch_file: 'edit', + run_command: 'run', + spawn_agent: 'agent', +} +const STAGE_PREFIXES: [string, string][] = [['knowledge_', 'knowledge'], ['idea_', 'knowledge'], ['task_', 'knowledge']] +function stageKeyOf(name: string): string | null { + if (STAGE_KEYS[name]) return STAGE_KEYS[name] + for (const [p, k] of STAGE_PREFIXES) if (name.startsWith(p)) return k + return null +} +const stageText = computed(() => { + const act = toolActivity.value + if (!act.last) return '' + const key = stageKeyOf(act.last) + const parts = [key ? t(`ai.taskProgress.stage.${key}`) : act.last] + if (act.steps > 0) parts.push(t('ai.taskProgress.steps', { n: act.steps })) + return parts.join(' · ') +}) + +// 静默期计时:基准=最近一次真实信号(AiTaskProgress.updatedAt 或消息流指纹变化时刻)。 +// 指纹 watch 只在信号真实变化时刷新(规则5:同值重刷不产生新活性);文本增量/工具卡增减/ +// 轮次推进都会被捕获。 +const lastActivityAt = ref(Date.now()) +watch(() => { + const msgs = store.state.messages + const last = msgs[msgs.length - 1] + return [msgs.length, last?.id, last?.content?.length, last?.toolCalls?.length, store.state.agentRound] +}, () => { lastActivityAt.value = Date.now() }) +// 切会话用已知时间锚点重置(快照 updatedAt/末条消息时间),防「刚切回就看不到本就持续的静默」 +watch(() => store.state.activeConversationId, () => { + const msgs = store.state.messages + const last = msgs[msgs.length - 1] + lastActivityAt.value = Math.max(taskProgress.value?.updatedAt ?? 0, last?.timestamp ?? 0) +}) +// 15s 心跳只为刷新「距上次信号」展示(§3.4 档位阈值 2/10min,15s 粒度足够;规则1:定时器 +// 不碰任何计数) +const nowTick = ref(Date.now()) +let silenceTimer: ReturnType | null = null +onMounted(() => { + silenceTimer = setInterval(() => { nowTick.value = Date.now() }, 15_000) +}) +onBeforeUnmount(() => { + if (silenceTimer) { clearInterval(silenceTimer); silenceTimer = null } +}) +const silenceSecs = computed(() => { + const p = taskProgress.value + if (!p) return 0 + return Math.max(0, Math.floor((nowTick.value - Math.max(p.updatedAt, lastActivityAt.value)) / 1000)) +}) +// §3.4 三档:<2min 正常 / 2-10min 长执行(脉冲降速) / >10min 警示(给判断材料不自动判死) +const silenceTier = computed<'normal' | 'long' | 'warn'>(() => { + const s = silenceSecs.value + if (s >= 600) return 'warn' + if (s >= 120) return 'long' + return 'normal' +}) +const silenceSuffix = computed(() => { + if (!taskProgress.value) return '' + if (silenceTier.value === 'warn') return t('ai.taskProgress.silentShort', { n: Math.floor(silenceSecs.value / 60) }) + if (silenceTier.value === 'long') return t('ai.taskProgress.longRun') + return '' +}) +// 悬浮提示:警示档给完整判断材料(距上次活动 n 分钟+最后动作+出路),长执行档给成因提示, +// 正常档不弹(位置信息正文已有,避免无意义提示) +const taskProgressHint = computed(() => { + if (!taskProgress.value) return '' + if (silenceTier.value === 'warn') { + return t('ai.taskProgress.silentWarn', { n: Math.floor(silenceSecs.value / 60), tool: toolActivity.value.last ?? '-' }) + } + if (silenceTier.value === 'long') return t('ai.taskProgress.longRunHint') + return '' +}) + // 🎯 对话目标面板(对话透明化 L1:Goal visibility) const goals = ref([]) const goalsExpanded = ref(false) @@ -1077,6 +1186,20 @@ onBeforeUnmount(() => { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } +/* U2 静默期档位(设计 §3.4):长执行/警示=脉冲降速——活性层表达「慢」而非伪装「停」, + 不自动判死;内容层/静默档文字用弱化色,视觉从属位置层数字 */ +.ai-task-progress--long .ai-task-progress-dot, +.ai-task-progress--warn .ai-task-progress-dot { + animation-duration: 3.2s; +} +.ai-task-progress-stage { + font-size: 11px; + color: var(--df-text-dim); +} +.ai-task-progress-silence { + font-size: 11px; + color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary)); +} /* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */ .ai-provider-bar { display: inline-flex; diff --git a/src/components/layout/AppLayout.vue b/src/components/layout/AppLayout.vue index 019c655..cbf060e 100644 --- a/src/components/layout/AppLayout.vue +++ b/src/components/layout/AppLayout.vue @@ -17,7 +17,7 @@ -
+
@@ -165,6 +165,13 @@ function startResize(e: MouseEvent) { position: relative; height: 100vh; overflow: hidden; + /* P2 修复:最大化↔分栏切换原为瞬时跳变,加宽度/弹性过渡消除视觉闪跳。 + 仅过渡 flex-grow/width/max-width,不动 opacity(避免与 ai-slide 叠加抖动) */ + transition: max-width 0.25s var(--df-ease), width 0.25s var(--df-ease); +} +/* 拖拽调整宽度期间禁用过渡:否则每像素跟随都被 0.25s 缓动拖尾,手感发飘 */ +.df-ai-panel.ai-panel--resizing { + transition: none; } /* — AI Resize Handle — */ diff --git a/src/composables/ai/streamSelfHeal.ts b/src/composables/ai/streamSelfHeal.ts new file mode 100644 index 0000000..06e7361 --- /dev/null +++ b/src/composables/ai/streamSelfHeal.ts @@ -0,0 +1,16 @@ +//! 心跳自愈判定纯函数(看板 949 前端兜底) +//! +//! 场景:force_send 竞态窗口下,旧任务迟到的 AiCompleted 可能把前端流式态误收敛为 +//! false(迟到仲裁查后端 generating 时新 loop 尚未占用,误判真收尾);后端新 loop 仍在 +//! 生成,delta 照流但发送按钮失态。心跳(后端 30s 一拍)到达时用本判定决定是否自愈。 +//! +//! 互补不打架:仅「后端在生成 且 前端流式态丢失」才自愈;迟到仲裁只拦「后端 true 且 +//! 前端 true」,两机制判定域不相交,不会同时动作。 +//! +//! 独立零依赖模块:node --test 单测直接 import(useAiStreamingEvents.ts 带 @/ 别名与 +//! Tauri 依赖,单测环境无法加载),useAiStreamingEvents.ts 消费本判定。 + +/** 自愈判定:后端生成中(backendGenerating=true)且前端流式态丢失(frontendStreaming=false)→ true */ +export function shouldSelfHealStreaming(backendGenerating: boolean, frontendStreaming: boolean): boolean { + return backendGenerating && !frontendStreaming +} diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts index ac7f7f3..aa0f42a 100644 --- a/src/composables/ai/useAiLifecycleEvents.ts +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -202,6 +202,19 @@ export async function handleLifecycleEvent(event: AiChatEvent): Promise timestamp: Date.now(), }) } + // 看板 953:max_tokens 截断标记(主对话气泡截断提示条的数据源)。后端正常收敛出口且 + // 最终回复被 provider 上限腰斩时为 true;旧事件无字段(undefined)不标,向后兼容。 + // 只标末条 assistant(倒序找,跳过错误气泡):用户再发消息/继续输出后新气泡顶掉末条 + // 位置,提示条随 isLastAi 判定自然消失,无需清除逻辑。 + if (event.truncated_by_max_tokens) { + for (let i = state.messages.length - 1; i >= 0; i--) { + const m = state.messages[i] + if (m.role === 'assistant' && !m.isError) { + m.truncatedByMaxTokens = true + break + } + } + } // 直接从事件更新 pinned_goals,不等 loadConversations 异步刷新 if (event.pinned_goals && state.activeConversationId) { const conv = state.conversations.find(c => c.id === state.activeConversationId) diff --git a/src/composables/ai/useAiOutbox.ts b/src/composables/ai/useAiOutbox.ts new file mode 100644 index 0000000..e658961 --- /dev/null +++ b/src/composables/ai/useAiOutbox.ts @@ -0,0 +1,131 @@ +//! 待发箱(outbox)视图 — 定时消息/待发队列的 IPC 封装 + 本地缓存 +//! +//! 对应后端 `src-tauri/src/commands/ai/outbox.rs` 的 ai_outbox_* 命令族。 +//! ⚠️ 后端命令已实现但未注册进 generate_handler(接线清单见交付说明):接线前所有 +//! invoke 均失败,本模块**静默降级**(console.warn + 空列表/false 返回),保证抽屉 +//! 可用不崩;接线后行为自动完整,前端零改动。 +//! +//! 加载/失效时机:进入抽屉 refresh()(OutboxDrawer watch open)+ 每次操作成功后 +//! 自动失效重拉 + 监听后端 ai-outbox-changed 事件增量刷新(他窗口操作也能同步)。 +//! +//! 设计见 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.4。 + +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' + +/** 与后端 AiOutboxItem(Tauri DTO)同形,snake_case 原样透传 */ +export interface AiOutboxItem { + id: string + conversation_id: string + kind: 'queue' | 'scheduled' + text: string + skill: string | null + model_override: string | null + /** epoch ms;null=尽快队列项 */ + due_at: number | null + status: 'pending' | 'sending' | 'sent' | 'failed' | 'canceled' + attempts: number + last_error: string | null + created_at: number + updated_at: number + sent_at: number | null +} + +// 模块级单例缓存:抽屉与入口按钮(角标计数等未来消费方)共享同一份视图, +// 操作后统一失效重拉,避免多实例缓存漂移。主/分离窗口各自 JS context 天然隔离。 +const items = ref([]) +const loading = ref(false) + +/** invoke 静默降级封装:命令未接线/后端错误时 warn 并返回 fallback,调用方 UI 不崩 */ +async function safeInvoke(cmd: string, args: Record, fallback: T, label: string): Promise { + try { + return await invoke(cmd, args) + } catch (e) { + console.warn(`[AI outbox] ${label}失败(命令未接线或后端错误):`, e) + return fallback + } +} + +/** 拉取列表写入缓存。convId 缺省=跨会话全量(抽屉口径);失败降级空列表 */ +async function refresh(convId?: string): Promise { + loading.value = true + try { + const list = await invoke('ai_outbox_list', { convId: convId ?? null }) + items.value = Array.isArray(list) ? list : [] + } catch (e) { + console.warn('[AI outbox] 列表拉取失败(命令未接线或后端错误):', e) + items.value = [] + } finally { + loading.value = false + } +} + +/** + * 新建定时/待发项。dueAt=epoch ms(定时项,须晚于当前时刻);缺省=尽快队列项。 + * conversationId 必传(后端不猜 active);返回新项 id,失败 null(错误已 warn)。 + */ +async function enqueue( + text: string, + dueAt?: number, + opts?: { skill?: string; modelOverride?: string; conversationId?: string }, +): Promise { + const id = await safeInvoke( + 'ai_outbox_enqueue', + { + text, + dueAt: dueAt ?? null, + skill: opts?.skill ?? null, + modelOverride: opts?.modelOverride ?? null, + conversationId: opts?.conversationId ?? null, + }, + null, + '入队', + ) + if (id) void refresh() + return id +} + +/** 取消待发项。成功 true 并刷新;未接线/后端拒绝(终态等)false */ +async function cancel(id: string): Promise { + const ok = await safeInvoke('ai_outbox_cancel', { id }, false, '取消') + if (ok) void refresh() + return ok +} + +/** 改期(dueAt=epoch ms;后端允许过去时刻=立即派发语义)。成功 true 并刷新 */ +async function reschedule(id: string, dueAt: number): Promise { + const ok = await safeInvoke('ai_outbox_reschedule', { id, dueAt }, false, '改期') + if (ok) void refresh() + return ok +} + +/** 编辑待发文本。终态项后端拒绝(返 false) */ +async function updateText(id: string, text: string): Promise { + const ok = await safeInvoke('ai_outbox_update_text', { id, text }, false, '编辑') + if (ok) void refresh() + return ok +} + +/** 立即派发一次(后端与调度器同一条派发链;不受 df-outbox-enabled 开关约束)。成功 true 并刷新 */ +async function sendNow(id: string): Promise { + const ok = await safeInvoke('ai_outbox_send_now', { id }, false, '立即发送') + if (ok) void refresh() + return ok +} + +// 后端变更事件驱动刷新:任何入队/派发/失败/改期后各窗口视图同步。 +// 幂等注册(模块级一次);后端未接线时不会有该事件,注册本身零开销。 +let changeListenerReady = false +function ensureChangeListener(): void { + if (changeListenerReady) return + changeListenerReady = true + listen('ai-outbox-changed', () => { void refresh() }).catch(() => { + changeListenerReady = false // 注册失败(非 Tauri 环境等)允许下次重试 + }) +} + +export function useAiOutbox() { + ensureChangeListener() + return { items, loading, refresh, enqueue, cancel, reschedule, updateText, sendNow } +} diff --git a/src/composables/ai/useAiPanel.ts b/src/composables/ai/useAiPanel.ts index aa74f6e..0ef9cb0 100644 --- a/src/composables/ai/useAiPanel.ts +++ b/src/composables/ai/useAiPanel.ts @@ -94,6 +94,16 @@ export function setSidebarWidth(px: number) { state.sidebarWidth = clampSidebarWidth(px) } +/** 设置最大化态 — 单一写入入口(所有 maximized 赋值收敛于此,杜绝绕过 persistUiState 的分歧)。 + * 修复 P0:App.vue 路由 watch 直接置 state.maximized=false 未走 persistUiState, + * 导致内存态与磁盘态(df-ai-ui)不一致,重启后旧值闪回。本函数保证任何调用方改最大化 + * 态都会落盘。 + * 参数 persist:默认 true(立即落盘)。高频/连续赋值场景可调 false,由调用方收口落盘。 */ +export function setMaximized(v: boolean, persist = true) { + state.maximized = v + if (persist) persistUiState() +} + /** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */ function togglePanel() { state.panelOpen = !state.panelOpen @@ -104,8 +114,7 @@ function togglePanel() { /** 最大化/还原侧栏宽度 */ function toggleMaximize() { - state.maximized = !state.maximized - persistUiState() + setMaximized(!state.maximized) } /** @@ -225,6 +234,7 @@ export function useAiPanel() { clearChat, // 侧栏宽度拖拽 setSidebarWidth, + setMaximized, persistUiState, } } diff --git a/src/composables/ai/useAiStreamingEvents.ts b/src/composables/ai/useAiStreamingEvents.ts index 52a1a12..742e66d 100644 --- a/src/composables/ai/useAiStreamingEvents.ts +++ b/src/composables/ai/useAiStreamingEvents.ts @@ -10,9 +10,12 @@ import { emit } from '@tauri-apps/api/event' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' -import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound } from './aiShared' +import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound, getConvStreamState } from './aiShared' import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState' -import { clearStreamWatchdog } from './useAiStream' +import { clearStreamWatchdog, resetStreamWatchdog } from './useAiStream' +import { setStreaming } from './streamingGuard' +import { aiApi } from '@/api/ai' +import { shouldSelfHealStreaming } from './streamSelfHeal' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' const appSettings = useAppSettingsStore() @@ -23,6 +26,32 @@ function isUnifiedApproval(): boolean { return appSettings.get('df-ai-unified-approval', true) } +/** + * 心跳自愈(火后不理,看板 949 兜底):前端流式态丢失而后端仍在生成时恢复 streaming + 重启看门狗。 + * + * 链路:force_send 竞态让旧任务迟到 AiCompleted 误杀前端流式态(仲裁查后端时新 loop 尚未 + * 占用 generating),后端内容照流但按钮失态;心跳 30s 一拍,到达即先零开销短路(前端仍 true + * 不查态),疑似丢失才 IPC 查后端真值,「后端 true 且前端 false」才动作 —— 与迟到仲裁 + * (后端 true 且前端 true 才拦截)判定域互补,两机制不同时动作。 + * IPC 返回后重读前端流式态再决策(等待窗口内可能有新事件改写,防陈旧快照反向覆盖); + * 查态失败静默放弃,等下一拍心跳再试。 + */ +function selfHealStreamingOnHeartbeat(convId?: string | null): void { + const target = convId || state.activeConversationId + // 仅当前视图会话:state.streaming 是当前视图流式态,后台会话事件在 useAiEvents 路由层已过滤 + if (!target || target !== state.activeConversationId) return + // 疑似丢失才发起 IPC(前端仍 streaming=true 是常态,零开销短路) + if (!shouldSelfHealStreaming(true, getConvStreamState(target)?.streaming ?? false)) return + void aiApi.isGenerating(target) + .then(backendGenerating => { + if (!shouldSelfHealStreaming(backendGenerating, getConvStreamState(target)?.streaming ?? false)) return + setStreaming(true, { convId: target, reason: 'AiHeartbeat 自愈' }) + resetStreamWatchdog(target) + console.warn(`[AI-Streaming] 心跳自愈:后端生成中而前端流式态丢失,已恢复(conv=${target.slice(0, 8)})`) + }) + .catch(() => { /* 查态失败:本拍不动作,等下一拍心跳 */ }) +} + /** streaming 域:流式文本累积/新轮/心跳/重试。返回 true=已处理。 */ export function handleStreamingEvent(event: AiChatEvent): boolean { switch (event.type) { @@ -72,9 +101,13 @@ export function handleStreamingEvent(event: AiChatEvent): boolean { return true } - case 'AiHeartbeat': + case 'AiHeartbeat': { // 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透 + // 看板 949 自愈兜底:后端在生成而前端流式态丢失(force_send 竞态误杀)时恢复, + // 自愈延迟≤一拍心跳(后端 30s) + selfHealStreamingOnHeartbeat(event.conversation_id) return true + } case 'AiStreamRetry': { // 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡), diff --git a/src/composables/ai/useAiWindow.ts b/src/composables/ai/useAiWindow.ts index 90eb464..99b2b16 100644 --- a/src/composables/ai/useAiWindow.ts +++ b/src/composables/ai/useAiWindow.ts @@ -98,6 +98,44 @@ export function registerMainReattachListener(handler: () => Promise): void })() } +// ── P1(2026-10-10)分离窗口「去设置」跨窗直达:分离窗无 Settings 路由,原实现仅 toast +// 提示用户「去主窗口设置」,链路断裂(分离窗长时间工作场景下改配置须手动找窗口)。 +// 现走事件广播(对齐 REATTACH_EVENT 模式):分离窗 emit → 主窗口 listen → 显示/聚焦 +// 主窗 + router.push('/settings')。 +const SETTINGS_EVENT = 'ai-open-settings' + +/** 发起方(分离窗)侧:广播「打开设置」事件,主窗口消费 */ +export async function emitOpenSettings(): Promise { + try { + const { emit } = await import('@tauri-apps/api/event') + await emit(SETTINGS_EVENT, {}) + } catch (e) { + console.error('[AI] 跨窗打开设置事件发送失败:', e) + } +} + +/** 承接方(主窗口)侧:注册「打开设置」监听。handler 由 AiChat 注入(显示主窗 + 导航) */ +let _settingsListenerRegistered = false +export function registerMainOpenSettingsListener(handler: () => Promise): void { + if (_settingsListenerRegistered) return + _settingsListenerRegistered = true + void (async () => { + try { + const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([ + import('@tauri-apps/api/event'), + import('@tauri-apps/api/webviewWindow'), + ]) + if (getCurrentWebviewWindow().label !== 'main') return + await listen>(SETTINGS_EVENT, async () => { + await handler() + }) + } catch (e) { + console.error('[AI] 主窗口设置监听注册失败:', e) + _settingsListenerRegistered = false + } + })() +} + /** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口, * 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。 * handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */ diff --git a/src/i18n/en/ai.ts b/src/i18n/en/ai.ts index 45f83bd..5821609 100644 --- a/src/i18n/en/ai.ts +++ b/src/i18n/en/ai.ts @@ -37,6 +37,12 @@ export default { responseIncomplete: '⚠ The response is incomplete due to a network interruption. The above is the partial content received. You can resend to get a full reply.', // Backend auto-finalized after reaching the max rounds (not a network interruption; reuses the incomplete bubble with distinct semantics) responseIncompleteMaxRounds: 'Maximum rounds reached. The AI has finalized this round with the results so far; continue the conversation to have it refine and complete the answer.', + // Kanban 953: weak hint bar at the bubble tail when a reply converged normally but was cut off by the provider max_tokens limit (finish_reason=length) + truncatedByMaxTokens: '⚠ Output truncated by the length limit; content may be incomplete', + // Continue button on the truncation hint bar + continueOutput: 'Continue', + // Continuation instruction injected by the Continue button (sent as a new user message; resume seamlessly, no repetition) + continueOutputPrompt: 'Continue your previous reply from where it stopped. Resume seamlessly; do not repeat content already output.', queueFull: 'Send queue is full (max {limit} items)', forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)', forceSendBtn: 'Force Send', @@ -139,16 +145,35 @@ export default { midstream: 'Stream interrupted', }, unknownKind: 'Parked', + // U5 copy formula element 3 "what is preserved": always-on frontend fallback — + // backend park reasons vary in wording (non-timeout fatal / external_change omit it), + // one line here keeps the formula intact for every stop_kind + preserved: 'Conversation context and completed progress are fully preserved, ready to resume', stopArchive: 'Stop & Archive', stoppedToast: 'Task stopped, will not resume', archiveFailed: 'Stop failed: {msg}', }, // Unattended task progress (AiTaskProgress, design §4.3): TopBar lightweight progress, - // visible as "still working" during silent periods + // visible as "still working" during silent periods. + // U2 additions: stage = content-layer phase labels (§3.3 tool-to-semantic mapping, + // unmatched tools show the raw name instead); longRun/silent = §3.4 silence tiers, + // judgement material only, never an automatic stuck verdict. taskProgress: { running: 'Running · round {round}/{max} · {tokens} tokens', unlimited: 'Running · round {round} · {tokens} tokens', barHint: 'Round {round}/{max}', + steps: '{n} steps done', + stage: { + read: 'Reading & searching', + edit: 'Editing & writing', + run: 'Running & verifying', + agent: 'Sub-agent working', + knowledge: 'Knowledge & task ops', + }, + longRun: 'Slow step', + longRunHint: 'Current step is taking a while (large tool or sub-agent running)', + silentShort: '{n} min no activity', + silentWarn: 'No activity for {n} min (last action: {tool}). Still running — check the trace, or stop if it looks stuck.', }, // Unattended boundary config card (P1 simplified: single authorization tier, // only the two budget fields remain user-tunable) diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index 4807a69..dbc6b69 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -113,6 +113,7 @@ export default { examplePrompt4: 'Generate a project idea', goToSettings: 'Open settings', goToSettingsHint: 'The detached window cannot open settings directly. Configure AI providers on the main window’s Settings page.', + goToSettingsOpened: 'Settings opened in the main window', goToTaskHint: 'The detached window cannot open tasks directly. View task details in the main window.', // ── Provider switch feedback ── @@ -125,6 +126,8 @@ export default { // ── Pending queue ── queueTitle: 'Queued {n}/10', clearQueue: 'Clear', + // UI-4 (board 943): clear-queue confirm (one click wipes all queued items, unrecoverable) + clearQueueConfirm: 'Clear all queued messages? This cannot be undone', // queue item edit + send now queueEdit: 'Edit', queueSave: 'Save', @@ -339,5 +342,26 @@ export default { editEmptyBlocked: 'Edit content is empty, submission cancelled', skillNoMatchQuery: 'No skill matches "{q}", send blocked', backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?', + + // ── Outbox / scheduled messages (OutboxDrawer + ChatInput clock entry, design 2026-10-10 §3.4) ── + outboxEntry: 'Scheduled messages', + outboxTitle: 'Scheduled & Outbox', + outboxEmpty: 'No scheduled or queued messages', + outboxNewTitle: 'New scheduled message', + outboxNewPlaceholder: 'Content to send on schedule', + outboxTimeLabel: 'Send time', + outboxCreate: 'Schedule', + outboxReschedule: 'Reschedule', + outboxSendNow: 'Send now', + outboxCancel: 'Cancel', + outboxQueueAsap: 'ASAP', + outboxStatusPending: 'Pending', + outboxStatusSending: 'Sending', + outboxStatusSent: 'Sent', + outboxStatusFailed: 'Failed', + outboxStatusCanceled: 'Canceled', + outboxActionFail: 'Action failed, please retry', + outboxDuePast: 'Scheduled time must be in the future', + outboxNeedConv: 'No target conversation; create one first', }, } diff --git a/src/i18n/zh-CN/ai.ts b/src/i18n/zh-CN/ai.ts index f86adb5..5174615 100644 --- a/src/i18n/zh-CN/ai.ts +++ b/src/i18n/zh-CN/ai.ts @@ -37,6 +37,12 @@ export default { responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。', // 达最大轮次后端自动收尾(非网络中断,复用 incomplete 气泡但语义独立) responseIncompleteMaxRounds: '已达最大轮次,AI 已自动收尾本轮结果;可继续对话让 AI 补充完善', + // 看板 953: 主对话回复正常收敛但被 provider max_tokens 上限截断(finish_reason=length)的气泡尾部弱提示条 + truncatedByMaxTokens: '⚠ 输出因长度上限截断,内容可能不完整', + // 截断提示条的继续输出按钮 + continueOutput: '继续输出', + // 「继续输出」注入的续写指令(作为新 user 消息发送,要求衔接勿重复) + continueOutputPrompt: '从上一条回复的中断处继续输出,直接衔接,勿重复已输出的内容。', queueFull: '待发送队列已满(最多 {limit} 条)', forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)', forceSendBtn: '强制发送', @@ -136,15 +142,32 @@ export default { midstream: '响应流中断', }, unknownKind: '任务停靠', + // U5 异常态话术公式第 3 要素「已保留什么」:前端常驻兜底——后端各停靠路径 reason 措辞不齐 + // (fatal 非超时类/external_change 未带保留信息),卡面统一保证公式对全部 stop_kind 成立 + preserved: '对话上下文与已完成进度已完整保留,随时可继续', stopArchive: '停止归档', stoppedToast: '任务已停止,不再续跑', archiveFailed: '停止失败:{msg}', }, // 无人值守任务进度(AiTaskProgress,设计 §4.3):TopBar 轻量进度条,静默期可见「在干活」 + // U2 三层补充:stage=内容层阶段标签(§3.3 工具→语义映射,未命中工具直显工具名不走此表); + // longRun/silent=静默期三档(§3.4),只给判断材料不自动判死 taskProgress: { running: '进行中 · 第 {round}/{max} 轮 · {tokens} tokens', unlimited: '进行中 · 第 {round} 轮 · {tokens} tokens', barHint: '第 {round}/{max} 轮', + steps: '已完成 {n} 步', + stage: { + read: '阅读检索中', + edit: '编辑写入中', + run: '执行验证中', + agent: '子代理执行中', + knowledge: '知识与任务操作中', + }, + longRun: '耗时较久', + longRunHint: '当前步骤耗时较久(大工具/子代理运行中)', + silentShort: '{n} 分钟无活动', + silentWarn: '距上次活动 {n} 分钟(最后动作:{tool})。仍在运行,可查看轨迹;疑似卡死可停止。', }, // 无人值守边界配置卡(P1 总装简化:授权面收敛单一档,可调项只剩预算两项) taskBoundary: { diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index ec90288..785baa8 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -113,6 +113,7 @@ export default { examplePrompt4: '生成一个项目灵感', goToSettings: '去设置', goToSettingsHint: '分离窗口无法直接打开设置,请在主窗口的设置页配置 AI 提供商', + goToSettingsOpened: '已在主窗口打开设置页', goToTaskHint: '分离窗口无法直接打开任务,请在主窗口查看任务详情', // ── Provider 切换反馈── @@ -125,6 +126,8 @@ export default { // ── 待发送队列 ── queueTitle: '待发送 {n}/10', clearQueue: '清空', + // UI-4(看板 943):清空队列二次确认(一键全清不可恢复) + clearQueueConfirm: '确定清空全部待发送消息?清空后不可恢复', // 队列项编辑 + 立即发送 queueEdit: '编辑', queueSave: '保存', @@ -340,5 +343,26 @@ export default { editEmptyBlocked: '编辑内容为空,已取消提交', skillNoMatchQuery: '未找到技能「{q}」,已阻止发送', backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?', + + // ── 待发箱/定时消息(OutboxDrawer + ChatInput ⏰ 入口,设计 2026-10-10 §3.4)── + outboxEntry: '定时消息', + outboxTitle: '定时与待发', + outboxEmpty: '暂无定时或待发消息', + outboxNewTitle: '新建定时消息', + outboxNewPlaceholder: '输入要定时发送的内容', + outboxTimeLabel: '发送时间', + outboxCreate: '定时发送', + outboxReschedule: '改期', + outboxSendNow: '立即发送', + outboxCancel: '取消', + outboxQueueAsap: '尽快', + outboxStatusPending: '待发', + outboxStatusSending: '发送中', + outboxStatusSent: '已发送', + outboxStatusFailed: '失败', + outboxStatusCanceled: '已取消', + outboxActionFail: '操作失败,请重试', + outboxDuePast: '定时时间必须晚于当前时刻', + outboxNeedConv: '无目标会话,请先创建会话', }, } diff --git a/tests/streamSelfHeal.test.mjs b/tests/streamSelfHeal.test.mjs new file mode 100644 index 0000000..b76a80f --- /dev/null +++ b/tests/streamSelfHeal.test.mjs @@ -0,0 +1,20 @@ +// 心跳自愈判定纯函数单测(看板 949)—— 仓库无 vitest,走 node 内置测试跑(零第三方依赖): +// node --test tests/streamSelfHeal.test.mjs +// 契约:仅「后端生成中 且 前端流式态丢失」自愈 —— 与迟到收尾仲裁(后端 true 且前端 true +// 才拦截)判定域互补,两机制不同时动作。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { shouldSelfHealStreaming } from '../src/composables/ai/streamSelfHeal.ts' + +test('自愈:后端生成中而前端流式态丢失(force_send 竞态误杀场景)', () => { + assert.equal(shouldSelfHealStreaming(true, false), true) +}) + +test('不自愈:后端生成中且前端仍在流式(迟到仲裁管辖域)', () => { + assert.equal(shouldSelfHealStreaming(true, true), false) +}) + +test('不自愈:后端已终止(真收尾,收敛语义正确)', () => { + assert.equal(shouldSelfHealStreaming(false, true), false) + assert.equal(shouldSelfHealStreaming(false, false), false) +})