新增: 待发箱抽屉与对话体验前端批

- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
This commit is contained in:
lxy
2026-10-11 07:38:00 +08:00
parent 31510d3555
commit 3818a63522
22 changed files with 1151 additions and 19 deletions
+91
View File
@@ -155,6 +155,32 @@ impl OutboxRepo {
.map_err(storage_err)? .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 条。 /// 取到点的待派发项(status='pending' 且 due_at 为空或已到),按 created_at 升序,至多 limit 条。
/// 命中 idx_ai_outbox_due。 /// 命中 idx_ai_outbox_due。
pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> { pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> {
@@ -291,6 +317,27 @@ impl OutboxRepo {
.map_err(storage_err)? .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> { pub async fn cancel(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone(); let conn = self.conn.clone();
@@ -525,4 +572,48 @@ mod tests {
assert_eq!(n, 1); assert_eq!(n, 1);
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_CANCELED); 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 终态不可重入队");
}
} }
+25 -3
View File
@@ -136,10 +136,32 @@ async function handleCancelApproval() {
showApprovalDialog.value = false 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) => { watch(() => route.path, (path) => {
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) { // 分离窗口不参与主面板最大化联动:分离窗是独立 JS context,state 与主窗无关;
aiStore.state.maximized = false // 且其 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 域:若离开前是全宽则恢复(面板已关闭则不恢复,避免 <main> 被 v-show 隐藏致其他页不可交互)
if (restoreMaximizedOnReturn.value) {
restoreMaximizedOnReturn.value = false
if (aiStore.state.panelOpen) aiStore.setMaximized(true)
}
return
}
// 离开 AI 域:记忆全宽态后退出最大化(<main> 恢复可交互)
if (aiStore.state.maximized) {
restoreMaximizedOnReturn.value = true
aiStore.setMaximized(false)
} }
}) })
+13
View File
@@ -463,6 +463,12 @@ export type AiChatEvent = ({
* undefined/false=真实值。前端 MessageList 据此标注『估算』区分 estimated vs real。 * undefined/false=真实值。前端 MessageList 据此标注『估算』区分 estimated vs real。
*/ */
is_estimated?: boolean is_estimated?: boolean
/**
* 看板 953: 最终回复是否因 provider max_tokens 上限被截断(finish_reason=length)。
* true=正常收敛但内容被腰斩,MessageList 末条 assistant 气泡渲染截断提示条 + 继续输出按钮;
* undefined/false=完整(旧后端事件无此字段,按 false 兜底,向后兼容)。
*/
truncated_by_max_tokens?: boolean
/** /**
* incomplete 场景细分(达上限自动收尾 vs 网络中断,前端据此区分提示文案): * incomplete 场景细分(达上限自动收尾 vs 网络中断,前端据此区分提示文案):
* "max_rounds"=达最大轮次后端自动收尾;其他/缺省=网络中断等异常。 * "max_rounds"=达最大轮次后端自动收尾;其他/缺省=网络中断等异常。
@@ -647,6 +653,13 @@ export interface AiMessage {
prompt_cache_miss_tokens?: number prompt_cache_miss_tokens?: number
/** 思考 token(deepseek-reasoner/o1 reasoning_tokens)。老消息 undefined。 */ /** 思考 token(deepseek-reasoner/o1 reasoning_tokens)。老消息 undefined。 */
reasoning_tokens?: number reasoning_tokens?: number
/**
* 看板 953: 该 assistant 回复因 provider max_tokens 上限被截断(live 态 AiCompleted 事件透传标记)。
* 末条 assistant 带此标记时 MessageList 渲染截断提示条 + 继续输出按钮;用户再发消息/
* 继续输出后新气泡顶掉末条位置,标记自然失效,无需清除逻辑。仅 live 内存标记不入库
* (reload 后历史消息不带,截断提示属当轮即时性信息,可接受口径)。
*/
truncatedByMaxTokens?: boolean
timestamp: number timestamp: number
} }
+29 -4
View File
@@ -81,7 +81,7 @@
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span> <span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
<div class="ai-queue-actions"> <div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button> <button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="store.clearQueue(store.state.activeConversationId)">{{ $t('aiChat.clearQueue') }}</button> <button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
</div> </div>
</div> </div>
<div class="ai-queue-list"> <div class="ai-queue-list">
@@ -101,7 +101,7 @@
</template> </template>
<template v-else> <template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span> <span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text">{{ item.text }}</span> <span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) --> <!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span> <span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) --> <!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
@@ -178,7 +178,7 @@ import { useToast } from '../composables/useToast'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace' import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
import { useAiContext } from '../composables/ai/useAiContext' import { useAiContext } from '../composables/ai/useAiContext'
import { emitReattachToMain, registerMainReattachListener } from '@/composables/ai/useAiWindow' import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow'
import type { AiMessage } from '../api/types' import type { AiMessage } from '../api/types'
const props = defineProps<{ const props = defineProps<{
@@ -206,7 +206,10 @@ const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass',
/** 跳 Settings 配置 provider:嵌入模式 router.push;分离窗口无 Settings 路由,降级 toast 提示 */ /** 跳 Settings 配置 provider:嵌入模式 router.push;分离窗口无 Settings 路由,降级 toast 提示 */
function goToSettings() { function goToSettings() {
if (props.detached) { if (props.detached) {
showToast(t('aiChat.goToSettingsHint'), 'warning') // P1(2026-10-10):分离窗跨窗直达设置 —— 广播 SETTINGS_EVENT,主窗口 listen 后显示主窗并导航 /settings。
// 原实现仅弹 toast 提示用户「去主窗口设置」,用户在分离窗工作流中改配置须手动找窗口,链路断裂。
void emitOpenSettings()
showToast(t('aiChat.goToSettingsOpened'), 'info')
return return
} }
router.push('/settings') router.push('/settings')
@@ -535,6 +538,16 @@ async function handleForceSend() {
await store.tryForceSend(confirmDialog, store.state.activeConversationId) await store.tryForceSend(confirmDialog, store.state.activeConversationId)
} }
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
* 一键误触会静默丢掉全部排队消息且不可恢复,复用本组件 ConfirmDialog 先例
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
async function handleClearQueue() {
const convId = store.state.activeConversationId
if (!convId) return
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
if (confirmed) store.clearQueue(convId)
}
// ── 队列消息编辑(只编 text)+ 立即发送(插队)── // ── 队列消息编辑(只编 text)+ 立即发送(插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */ /** 当前正在编辑的队列项 index;null=非编辑态 */
@@ -692,6 +705,18 @@ onMounted(async () => {
// V11b: 主窗口注册跨窗收回分离窗监听(label 判定在内部,分离窗调用为 no-op); // V11b: 主窗口注册跨窗收回分离窗监听(label 判定在内部,分离窗调用为 no-op);
// reattachPanel 由主窗 store 执行(关全部 ai-detached-* 窗 + 恢复主面板)。 // reattachPanel 由主窗 store 执行(关全部 ai-detached-* 窗 + 恢复主面板)。
registerMainReattachListener(() => store.reattachPanel()) registerMainReattachListener(() => store.reattachPanel())
// P1(2026-10-10):主窗口承接分离窗「去设置」广播(label 判定在内部,分离窗调用为 no-op)
registerMainOpenSettingsListener(async () => {
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const mainWin = await WebviewWindow.getByLabel('main')
if (mainWin) {
try { await mainWin.setFocus() } catch { /* setFocus 失败不阻塞导航 */ }
try { await mainWin.show() } catch { /* show 未授权,忽略 */ }
}
} catch { /* 忽略 */ }
router.push('/settings')
})
// P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播, // P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播,
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费) // 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => { _unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
+16 -2
View File
@@ -363,8 +363,22 @@ const statusTitle = computed(() => {
return '' return ''
}) })
/** 头部摘要 hover 回看完整路径(摘要统一缩略尾段,全路径走 title;路径类工具才有值) */ /** 头部摘要 hover 回看关键参数全值(看板 945,按工具类型取参):
const summaryTitle = computed(() => argString(props.tc.args, 'path')) * run_command→command(命令行是唯一关键参数);其余 path 优先(覆盖 read_file/grep/
* search_files 等路径类),path 缺省回退首个非空字符串标量参数(hover 总有内容可看)。
* 摘要统一缩略尾段,全值走 title。 */
const summaryTitle = computed(() => {
const args = props.tc.args
if (props.tc.name === 'run_command') return argString(args, 'command')
const byPath = argString(args, 'path')
if (byPath) return byPath
if (args && typeof args === 'object' && !Array.isArray(args)) {
for (const v of Object.values(args as Record<string, unknown>)) {
if (typeof v === 'string' && v) return v
}
}
return ''
})
/** /**
* 风险等级(high/medium/low):审批挂起时卡片左侧着色条 + 风险徽章数据源。 * 风险等级(high/medium/low):审批挂起时卡片左侧着色条 + 风险徽章数据源。
+16
View File
@@ -66,6 +66,17 @@
> >
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
</button> </button>
<!-- 定时/待发箱入口(设计 2026-10-10 §3.4):icon-only ⏰ 开 OutboxDrawer
(组件内 Teleport 到 body,不占输入区布局;抽屉组件标签在模板尾部) -->
<button
type="button"
class="ai-img-pick-btn"
:title="$t('aiChat.outboxEntry')"
:aria-label="$t('aiChat.outboxEntry')"
@click="outboxOpen = true"
>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态, <!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 --> idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
<button <button
@@ -119,6 +130,8 @@
@dismiss="dismissEnrichment" @dismiss="dismissEnrichment"
@update:enrichment-expanded="enrichmentExpanded = $event" @update:enrichment-expanded="enrichmentExpanded = $event"
/> />
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div> <div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
</div> </div>
</template> </template>
@@ -148,6 +161,7 @@ import SkillMention from './SkillMention.vue'
import ImageInput from './ImageInput.vue' import ImageInput from './ImageInput.vue'
import MentionPopover from './MentionPopover.vue' import MentionPopover from './MentionPopover.vue'
import EnrichmentPanel from './EnrichmentPanel.vue' import EnrichmentPanel from './EnrichmentPanel.vue'
import OutboxDrawer from './OutboxDrawer.vue'
import { taskApi } from '../../api/task' import { taskApi } from '../../api/task'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types' import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
import type { MentionItem } from './MentionItem' import type { MentionItem } from './MentionItem'
@@ -173,6 +187,8 @@ const { t } = useI18n()
const inputText = ref('') const inputText = ref('')
const inputEl = ref<HTMLTextAreaElement>() const inputEl = ref<HTMLTextAreaElement>()
// 定时/待发箱抽屉开合(⏰ 按钮开,抽屉 close 事件关;列表数据在 useAiOutbox 单例缓存)
const outboxOpen = ref(false)
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽/选择) ── // ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽/选择) ──
// 待发送图片列表(url 或 base64 data URI + media_type),handleSend 时构造 ContentPart[]。 // 待发送图片列表(url 或 base64 data URI + media_type),handleSend 时构造 ContentPart[]。
+48
View File
@@ -815,6 +815,23 @@ async function onInstallObscura(): Promise<void> {
} }
} }
/**
* 看板 953:截断气泡「继续输出」按钮。回复被 provider max_tokens 上限腰斩时,注入续写指令
* 走既有发送通道(复用 store.sendMessage,对齐 onInstallObscura 模式),模型从上条回复的
* 中断处衔接输出,不再整段重发。streaming 中按钮 disabled(模板绑定);失败 toast 对齐
* handleRetry 错误处理模式。
*/
async function onContinueAfterTruncation(): Promise<void> {
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: 消息分段(归档段折叠) ── // ── F-15 阶段2: 消息分段(归档段折叠) ──
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义): // ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状) // null | undefined | 'active' → 正常消息(现状)
@@ -1835,6 +1852,20 @@ defineExpose({
<span class="ai-cursor"></span> <span class="ai-cursor"></span>
</div> </div>
<!-- 看板 953:max_tokens 截断提示条(仅末条 assistant 带标记时,气泡尾部弱提示)。
点击继续输出=注入续写指令复用 sendMessage,衔接勿重复;streaming 中禁用防叠发。 -->
<div v-if="isLastAi(item.msg) && item.msg.truncatedByMaxTokens" class="ai-msg-truncated">
<span class="ai-msg-truncated-text">{{ $t('ai.truncatedByMaxTokens') }}</span>
<button
class="ai-msg-act"
:disabled="store.state.streaming"
@click.stop="onContinueAfterTruncation"
>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<span>{{ $t('ai.continueOutput') }}</span>
</button>
</div>
<!-- UX-03 / 错误气泡操作入口(重试 / 去设置) --> <!-- UX-03 / 错误气泡操作入口(重试 / 去设置) -->
<div v-if="item.msg.isError" class="ai-msg-actions ai-msg-actions--error"> <div v-if="item.msg.isError" class="ai-msg-actions ai-msg-actions--error">
<span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span> <span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
@@ -2458,6 +2489,23 @@ defineExpose({
color: var(--df-accent); 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 -- */ /* -- Streaming Cursor -- */
.ai-cursor { .ai-cursor {
display: inline-block; display: inline-block;
+405
View File
@@ -0,0 +1,405 @@
<template>
<!-- 定时/待发列表抽屉(设计 2026-10-10 §3.4)。Teleport 到 body:入口按钮在 ChatInput
输入区,抽屉不能被 .ai-input-wrap 的 flex/overflow 布局裁剪,固定定位挂 body 最稳。
数据走 useAiOutbox(后端命令未接线时静默降级为空列表,抽屉可用不崩)。 -->
<Teleport to="body">
<div v-if="open" class="outbox-overlay" @click.self="emit('close')">
<div class="outbox-panel" role="dialog" aria-modal="true" :aria-label="t('aiChat.outboxTitle')">
<div class="outbox-header">
<h3 class="outbox-title">{{ t('aiChat.outboxTitle') }}</h3>
<button type="button" class="outbox-close" :aria-label="t('common.close')" @click="emit('close')">✕</button>
</div>
<div class="outbox-list">
<div v-if="loading && items.length === 0" class="outbox-empty">{{ t('common.loading') }}</div>
<div v-else-if="visibleItems.length === 0" class="outbox-empty">{{ t('aiChat.outboxEmpty') }}</div>
<div v-for="item in visibleItems" :key="item.id" class="outbox-row">
<div class="outbox-row-main">
<span class="outbox-time">{{ timeLabel(item) }}</span>
<span class="outbox-badge" :class="'outbox-badge--' + item.status">{{ statusLabel(item.status) }}</span>
<span class="outbox-text" :title="item.text">{{ summarize(item.text) }}</span>
</div>
<!-- 行操作仅活跃项(pending/sending/failed)展示;终态行只读留档 -->
<div v-if="isActive(item)" class="outbox-row-actions">
<template v-if="editingId === item.id">
<input
v-model="editDue"
type="datetime-local"
class="outbox-dt"
:aria-label="t('aiChat.outboxTimeLabel')"
/>
<button type="button" class="outbox-act outbox-act--primary" :disabled="acting" @click="confirmReschedule(item)">{{ t('common.save') }}</button>
<button type="button" class="outbox-act" @click="editingId = null">{{ t('common.cancel') }}</button>
</template>
<template v-else>
<button type="button" class="outbox-act" :disabled="acting" @click="startReschedule(item)">{{ t('aiChat.outboxReschedule') }}</button>
<button type="button" class="outbox-act" :disabled="acting" @click="onSendNow(item)">{{ t('aiChat.outboxSendNow') }}</button>
<button type="button" class="outbox-act outbox-act--danger" :disabled="acting" @click="onCancel(item)">{{ t('aiChat.outboxCancel') }}</button>
</template>
</div>
</div>
</div>
<div class="outbox-new">
<div class="outbox-new-title">{{ t('aiChat.outboxNewTitle') }}</div>
<textarea
v-model="newText"
class="outbox-new-text"
rows="2"
:placeholder="t('aiChat.outboxNewPlaceholder')"
></textarea>
<div class="outbox-new-row">
<input
v-model="newDue"
type="datetime-local"
class="outbox-dt"
:aria-label="t('aiChat.outboxTimeLabel')"
/>
<button
type="button"
class="outbox-act outbox-act--primary"
:disabled="acting || !canCreate"
@click="onCreate"
>{{ t('aiChat.outboxCreate') }}</button>
</div>
<div v-if="notice" class="outbox-notice">{{ notice }}</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
import { useAiStore } from '../../stores/ai'
const props = defineProps<{
/** 抽屉开合(ChatInput ⏰ 按钮控制;开时拉取列表,关时仅隐藏不销毁缓存) */
open: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const { t } = useI18n()
const store = useAiStore()
const { items, loading, refresh, enqueue, cancel, reschedule, sendNow } = useAiOutbox()
// 进入抽屉拉取(操作后由 composable 自动失效重拉,此处只管开合时机)
watch(() => props.open, (v) => {
if (v) void refresh()
})
// ── 列表展示 ──
// 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前)
const STATUS_ORDER: Record<string, number> = { sending: 0, pending: 1, failed: 2, sent: 3, canceled: 4 }
const visibleItems = computed<AiOutboxItem[]>(() => {
const arr = [...items.value]
arr.sort((a, b) => {
const d = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9)
if (d !== 0) return d
return (a.due_at ?? 0) - (b.due_at ?? 0)
})
return arr
})
/** 活跃项(有行操作):pending/sending/failed;终态只读 */
function isActive(item: AiOutboxItem): boolean {
return item.status === 'pending' || item.status === 'sending' || item.status === 'failed'
}
const STATUS_KEYS: Record<string, string> = {
pending: 'aiChat.outboxStatusPending',
sending: 'aiChat.outboxStatusSending',
sent: 'aiChat.outboxStatusSent',
failed: 'aiChat.outboxStatusFailed',
canceled: 'aiChat.outboxStatusCanceled',
}
function statusLabel(s: string): string {
return t(STATUS_KEYS[s] ?? 'aiChat.outboxStatusPending')
}
/** 时间列:队列项(无 due_at)显「尽快」;定时项本地时间 */
function timeLabel(item: AiOutboxItem): string {
if (item.due_at == null) return t('aiChat.outboxQueueAsap')
return new Date(item.due_at).toLocaleString()
}
/** 文本摘要(首行 + 截断,完整内容 title 悬浮) */
function summarize(text: string): string {
const line = text.split('\n')[0] ?? ''
return line.length > 48 ? line.slice(0, 48) + '…' : line
}
// ── 行操作 ──
const acting = ref(false)
const notice = ref('')
// 改期:行内 datetime 输入,预填当前 due(无 due 用现在+30min)
const editingId = ref<string | null>(null)
const editDue = ref('')
function toLocalInput(ms: number): string {
const d = new Date(ms)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function startReschedule(item: AiOutboxItem): void {
editingId.value = item.id
editDue.value = toLocalInput(item.due_at ?? Date.now() + 30 * 60 * 1000)
notice.value = ''
}
async function confirmReschedule(item: AiOutboxItem): Promise<void> {
const due = editDue.value ? new Date(editDue.value).getTime() : NaN
if (Number.isNaN(due)) return
acting.value = true
const ok = await reschedule(item.id, due)
acting.value = false
if (ok) {
editingId.value = null
} else {
notice.value = t('aiChat.outboxActionFail')
}
}
async function onSendNow(item: AiOutboxItem): Promise<void> {
acting.value = true
const ok = await sendNow(item.id)
acting.value = false
if (!ok) notice.value = t('aiChat.outboxActionFail')
}
async function onCancel(item: AiOutboxItem): Promise<void> {
acting.value = true
const ok = await cancel(item.id)
acting.value = false
if (!ok) notice.value = t('aiChat.outboxActionFail')
}
// ── 新建定时消息(目标会话=当前活跃会话;图片/技能入口待后续,纯文本先行) ──
const newText = ref('')
const newDue = ref('')
const canCreate = computed(() => newText.value.trim().length > 0 && newDue.value !== '')
async function onCreate(): Promise<void> {
const conv = store.state.activeConversationId
if (!conv) {
notice.value = t('aiChat.outboxNeedConv')
return
}
const due = newDue.value ? new Date(newDue.value).getTime() : NaN
if (Number.isNaN(due) || due <= Date.now()) {
notice.value = t('aiChat.outboxDuePast')
return
}
acting.value = true
const id = await enqueue(newText.value.trim(), due, { conversationId: conv })
acting.value = false
if (!id) {
notice.value = t('aiChat.outboxActionFail')
return
}
// 成功:清表单与提示(列表由 composable 自动刷新)
notice.value = ''
newText.value = ''
newDue.value = ''
}
</script>
<style scoped>
.outbox-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.35);
display: flex;
justify-content: flex-end;
}
.outbox-panel {
width: min(420px, 92vw);
height: 100%;
background: var(--df-bg-card);
border-left: 0.5px solid var(--df-border);
display: flex;
flex-direction: column;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.15);
}
.outbox-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 0.5px solid var(--df-border);
}
.outbox-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--df-text);
}
.outbox-close {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 14px;
padding: 4px 6px;
border-radius: var(--df-radius);
}
.outbox-close:hover {
background: var(--df-bg-raised);
color: var(--df-text);
}
.outbox-list {
flex: 1;
overflow-y: auto;
padding: 8px 10px;
}
.outbox-empty {
padding: 32px 0;
text-align: center;
font-size: 12px;
color: var(--df-text-dim);
}
.outbox-row {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 6px;
border-bottom: 0.5px solid var(--df-border);
}
.outbox-row-main {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.outbox-time {
flex-shrink: 0;
font-size: 11px;
color: var(--df-text-dim);
font-variant-numeric: tabular-nums;
}
.outbox-badge {
flex-shrink: 0;
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
border: 0.5px solid var(--df-border);
color: var(--df-text-dim);
}
.outbox-badge--pending {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
}
.outbox-badge--sending {
color: var(--df-warning);
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
}
.outbox-badge--failed {
color: #e5484d;
border-color: color-mix(in srgb, #e5484d 40%, transparent);
}
.outbox-text {
font-size: 12px;
color: var(--df-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outbox-row-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.outbox-act {
border: 0.5px solid var(--df-border);
background: transparent;
color: var(--df-text-dim);
font-size: 11px;
padding: 3px 8px;
border-radius: var(--df-radius);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.outbox-act:hover:not(:disabled) {
background: var(--df-bg-raised);
color: var(--df-text);
}
.outbox-act:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.outbox-act--primary {
background: var(--df-accent);
border-color: var(--df-accent);
color: #fff;
}
.outbox-act--primary:hover:not(:disabled) {
background: var(--df-accent-hover);
color: #fff;
}
.outbox-act--danger:hover:not(:disabled) {
color: #e5484d;
border-color: color-mix(in srgb, #e5484d 40%, transparent);
}
.outbox-dt {
border: 0.5px solid var(--df-border);
background: var(--df-bg-raised);
color: var(--df-text);
font-size: 11px;
padding: 3px 6px;
border-radius: var(--df-radius);
color-scheme: inherit;
}
.outbox-new {
border-top: 0.5px solid var(--df-border);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.outbox-new-title {
font-size: 12px;
font-weight: 600;
color: var(--df-text);
}
.outbox-new-text {
width: 100%;
box-sizing: border-box;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
background: var(--df-bg-raised);
color: var(--df-text);
font-size: 12px;
font-family: var(--df-font-sans);
padding: 6px 8px;
resize: vertical;
}
.outbox-new-text::placeholder {
color: var(--df-text-dim);
}
.outbox-new-row {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.outbox-notice {
font-size: 11px;
color: var(--df-warning);
}
</style>
+12 -1
View File
@@ -2,7 +2,10 @@
<!-- 无人值守安全停靠卡(设计 §四):后端非正常终局(六路,stop_kind 词表见 i18n)emit AiTaskParked(已保存现场并复位 <!-- 无人值守安全停靠卡(设计 §四):后端非正常终局(六路,stop_kind 词表见 i18n)emit AiTaskParked(已保存现场并复位
generating,非死等),useAiEvents/useAiLifecycleEvents 登记 pendingTaskParked(per-conv Map) generating,非死等),useAiEvents/useAiLifecycleEvents 登记 pendingTaskParked(per-conv Map)
后本卡渲染。复用 HelpRequiredCard 模式:per-conv 守卫(挂起会话===当前展示会话)+ 行为后摘卡。 后本卡渲染。复用 HelpRequiredCard 模式:per-conv 守卫(挂起会话===当前展示会话)+ 行为后摘卡。
卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/ 卡面话术按 U5 异常态公式组织:状态(title+stop_kind 标签)+真实原因(reason)+已保留什么
(preserved 常驻行,前端兜底后端 reason 措辞不齐)+出路(继续/停止归档两枚;cancelled 只显
停止归档,第二条出路由 reason 文案"重发消息可继续"承担)。
操作两枚:继续(ai_continue_loop 重新授权续跑)/
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。 停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 --> cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 -->
<div v-if="parkedActive" class="ai-task-parked"> <div v-if="parkedActive" class="ai-task-parked">
@@ -11,6 +14,8 @@
<span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span> <span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span>
</div> </div>
<span class="ai-task-parked-reason">{{ parkedActive.reason }}</span> <span class="ai-task-parked-reason">{{ parkedActive.reason }}</span>
<!-- U5 公式「已保留什么」:常驻一行而非依赖后端 reason 措辞,保证任意 stop_kind 公式完整 -->
<span class="ai-task-parked-preserved">{{ t('ai.taskParked.preserved') }}</span>
<div class="ai-task-parked-actions"> <div class="ai-task-parked-actions">
<!-- cancelled(审批挂起中被用户主动取消)不显「继续」:用户刚点过停止,首位按钮再诱导续跑与意图相逆 --> <!-- cancelled(审批挂起中被用户主动取消)不显「继续」:用户刚点过停止,首位按钮再诱导续跑与意图相逆 -->
<button v-if="parkedActive.stopKind !== 'cancelled'" class="ai-task-parked-btn" :disabled="busy" @click="handleContinue">{{ t('aiChat.continueLoop') }}</button> <button v-if="parkedActive.stopKind !== 'cancelled'" class="ai-task-parked-btn" :disabled="busy" @click="handleContinue">{{ t('aiChat.continueLoop') }}</button>
@@ -136,6 +141,12 @@ async function handleStopArchive(): Promise<void> {
word-break: break-word; word-break: break-word;
white-space: pre-wrap; 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 { .ai-task-parked-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+125 -2
View File
@@ -156,13 +156,22 @@
<div class="ai-bottom-tools-right"> <div class="ai-bottom-tools-right">
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示, <!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。 静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。 max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token(防假进度规则2:无分母禁画百分比)。
U2 三层(设计 §三):活性=脉冲点(静默降速,永不伪装停止);位置=轮次/token 数字;
内容=阶段标签(最近工具映射)+已完成步数,静默档附加判断材料(不自动判死,决策留给用户)。
A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0) A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾; 隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾;
快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 --> 快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 -->
<div v-if="taskProgress && !agenticProgressVisible" class="ai-task-progress"> <div
v-if="taskProgress && !agenticProgressVisible"
class="ai-task-progress"
:class="silenceTier !== 'normal' ? `ai-task-progress--${silenceTier}` : ''"
:title="taskProgressHint"
>
<span class="ai-task-progress-dot"></span> <span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span> <span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span v-if="stageText" class="ai-task-progress-stage">{{ stageText }}</span>
<span v-if="silenceSuffix" class="ai-task-progress-silence">{{ silenceSuffix }}</span>
<span <span
v-if="taskProgress.maxRounds > 0" v-if="taskProgress.maxRounds > 0"
class="ai-task-progress-bar" class="ai-task-progress-bar"
@@ -589,6 +598,106 @@ const taskProgressPct = computed(() => {
return Math.min(100, Math.round((p.round / p.maxRounds) * 100)) 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<string, string> = {
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<typeof setInterval> | 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) // 🎯 对话目标面板(对话透明化 L1:Goal visibility)
const goals = ref<GoalEntry[]>([]) const goals = ref<GoalEntry[]>([])
const goalsExpanded = ref(false) const goalsExpanded = ref(false)
@@ -1077,6 +1186,20 @@ onBeforeUnmount(() => {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.35; } 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 后归零默认样式) ═══ */ /* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */
.ai-provider-bar { .ai-provider-bar {
display: inline-flex; display: inline-flex;
+8 -1
View File
@@ -17,7 +17,7 @@
<!-- ═══ AI Chat Panel ═══ --> <!-- ═══ AI Chat Panel ═══ -->
<transition name="ai-slide"> <transition name="ai-slide">
<div v-if="aiStore.state.panelOpen && !aiStore.state.detached" class="df-ai-panel" :style="panelStyle"> <div v-if="aiStore.state.panelOpen && !aiStore.state.detached" class="df-ai-panel" :class="{ 'ai-panel--resizing': resizing }" :style="panelStyle">
<div v-show="!aiStore.state.maximized" class="ai-resize-handle" :class="{ active: resizing }" @mousedown="startResize"></div> <div v-show="!aiStore.state.maximized" class="ai-resize-handle" :class="{ active: resizing }" @mousedown="startResize"></div>
<slot name="ai-panel" /> <slot name="ai-panel" />
</div> </div>
@@ -165,6 +165,13 @@ function startResize(e: MouseEvent) {
position: relative; position: relative;
height: 100vh; height: 100vh;
overflow: hidden; 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 — */ /* — AI Resize Handle — */
+16
View File
@@ -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
}
@@ -202,6 +202,19 @@ export async function handleLifecycleEvent(event: AiChatEvent): Promise<boolean>
timestamp: Date.now(), 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 异步刷新 // 直接从事件更新 pinned_goals,不等 loadConversations 异步刷新
if (event.pinned_goals && state.activeConversationId) { if (event.pinned_goals && state.activeConversationId) {
const conv = state.conversations.find(c => c.id === state.activeConversationId) const conv = state.conversations.find(c => c.id === state.activeConversationId)
+131
View File
@@ -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<AiOutboxItem[]>([])
const loading = ref(false)
/** invoke 静默降级封装:命令未接线/后端错误时 warn 并返回 fallback,调用方 UI 不崩 */
async function safeInvoke<T>(cmd: string, args: Record<string, unknown>, fallback: T, label: string): Promise<T> {
try {
return await invoke<T>(cmd, args)
} catch (e) {
console.warn(`[AI outbox] ${label}失败(命令未接线或后端错误):`, e)
return fallback
}
}
/** 拉取列表写入缓存。convId 缺省=跨会话全量(抽屉口径);失败降级空列表 */
async function refresh(convId?: string): Promise<void> {
loading.value = true
try {
const list = await invoke<AiOutboxItem[]>('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<string | null> {
const id = await safeInvoke<string | null>(
'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<boolean> {
const ok = await safeInvoke<boolean>('ai_outbox_cancel', { id }, false, '取消')
if (ok) void refresh()
return ok
}
/** 改期(dueAt=epoch ms;后端允许过去时刻=立即派发语义)。成功 true 并刷新 */
async function reschedule(id: string, dueAt: number): Promise<boolean> {
const ok = await safeInvoke<boolean>('ai_outbox_reschedule', { id, dueAt }, false, '改期')
if (ok) void refresh()
return ok
}
/** 编辑待发文本。终态项后端拒绝(返 false) */
async function updateText(id: string, text: string): Promise<boolean> {
const ok = await safeInvoke<boolean>('ai_outbox_update_text', { id, text }, false, '编辑')
if (ok) void refresh()
return ok
}
/** 立即派发一次(后端与调度器同一条派发链;不受 df-outbox-enabled 开关约束)。成功 true 并刷新 */
async function sendNow(id: string): Promise<boolean> {
const ok = await safeInvoke<boolean>('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 }
}
+12 -2
View File
@@ -94,6 +94,16 @@ export function setSidebarWidth(px: number) {
state.sidebarWidth = clampSidebarWidth(px) 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 交互) */ /** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */
function togglePanel() { function togglePanel() {
state.panelOpen = !state.panelOpen state.panelOpen = !state.panelOpen
@@ -104,8 +114,7 @@ function togglePanel() {
/** 最大化/还原侧栏宽度 */ /** 最大化/还原侧栏宽度 */
function toggleMaximize() { function toggleMaximize() {
state.maximized = !state.maximized setMaximized(!state.maximized)
persistUiState()
} }
/** /**
@@ -225,6 +234,7 @@ export function useAiPanel() {
clearChat, clearChat,
// 侧栏宽度拖拽 // 侧栏宽度拖拽
setSidebarWidth, setSidebarWidth,
setMaximized,
persistUiState, persistUiState,
} }
} }
+36 -3
View File
@@ -10,9 +10,12 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' 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 { 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' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -23,6 +26,32 @@ function isUnifiedApproval(): boolean {
return appSettings.get<boolean>('df-ai-unified-approval', true) return appSettings.get<boolean>('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=已处理。 */ /** streaming 域:流式文本累积/新轮/心跳/重试。返回 true=已处理。 */
export function handleStreamingEvent(event: AiChatEvent): boolean { export function handleStreamingEvent(event: AiChatEvent): boolean {
switch (event.type) { switch (event.type) {
@@ -72,9 +101,13 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
return true return true
} }
case 'AiHeartbeat': case 'AiHeartbeat': {
// 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透 // 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透
// 看板 949 自愈兜底:后端在生成而前端流式态丢失(force_send 竞态误杀)时恢复,
// 自愈延迟≤一拍心跳(后端 30s)
selfHealStreamingOnHeartbeat(event.conversation_id)
return true return true
}
case 'AiStreamRetry': { case 'AiStreamRetry': {
// 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡), // 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡),
+38
View File
@@ -98,6 +98,44 @@ export function registerMainReattachListener(handler: () => Promise<void>): 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<void> {
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>): 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<Record<string, never>>(SETTINGS_EVENT, async () => {
await handler()
})
} catch (e) {
console.error('[AI] 主窗口设置监听注册失败:', e)
_settingsListenerRegistered = false
}
})()
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口, /** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。 * 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */ * handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
+26 -1
View File
@@ -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.', 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) // 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.', 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)', queueFull: 'Send queue is full (max {limit} items)',
forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)', forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)',
forceSendBtn: 'Force Send', forceSendBtn: 'Force Send',
@@ -139,16 +145,35 @@ export default {
midstream: 'Stream interrupted', midstream: 'Stream interrupted',
}, },
unknownKind: 'Parked', 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', stopArchive: 'Stop & Archive',
stoppedToast: 'Task stopped, will not resume', stoppedToast: 'Task stopped, will not resume',
archiveFailed: 'Stop failed: {msg}', archiveFailed: 'Stop failed: {msg}',
}, },
// Unattended task progress (AiTaskProgress, design §4.3): TopBar lightweight progress, // 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: { taskProgress: {
running: 'Running · round {round}/{max} · {tokens} tokens', running: 'Running · round {round}/{max} · {tokens} tokens',
unlimited: 'Running · round {round} · {tokens} tokens', unlimited: 'Running · round {round} · {tokens} tokens',
barHint: 'Round {round}/{max}', 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, // Unattended boundary config card (P1 simplified: single authorization tier,
// only the two budget fields remain user-tunable) // only the two budget fields remain user-tunable)
+24
View File
@@ -113,6 +113,7 @@ export default {
examplePrompt4: 'Generate a project idea', examplePrompt4: 'Generate a project idea',
goToSettings: 'Open settings', goToSettings: 'Open settings',
goToSettingsHint: 'The detached window cannot open settings directly. Configure AI providers on the main window’s Settings page.', 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.', goToTaskHint: 'The detached window cannot open tasks directly. View task details in the main window.',
// ── Provider switch feedback ── // ── Provider switch feedback ──
@@ -125,6 +126,8 @@ export default {
// ── Pending queue ── // ── Pending queue ──
queueTitle: 'Queued {n}/10', queueTitle: 'Queued {n}/10',
clearQueue: 'Clear', 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 // queue item edit + send now
queueEdit: 'Edit', queueEdit: 'Edit',
queueSave: 'Save', queueSave: 'Save',
@@ -339,5 +342,26 @@ export default {
editEmptyBlocked: 'Edit content is empty, submission cancelled', editEmptyBlocked: 'Edit content is empty, submission cancelled',
skillNoMatchQuery: 'No skill matches "{q}", send blocked', skillNoMatchQuery: 'No skill matches "{q}", send blocked',
backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?', 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',
}, },
} }
+23
View File
@@ -37,6 +37,12 @@ export default {
responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。', responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。',
// 达最大轮次后端自动收尾(非网络中断,复用 incomplete 气泡但语义独立) // 达最大轮次后端自动收尾(非网络中断,复用 incomplete 气泡但语义独立)
responseIncompleteMaxRounds: '已达最大轮次,AI 已自动收尾本轮结果;可继续对话让 AI 补充完善', responseIncompleteMaxRounds: '已达最大轮次,AI 已自动收尾本轮结果;可继续对话让 AI 补充完善',
// 看板 953: 主对话回复正常收敛但被 provider max_tokens 上限截断(finish_reason=length)的气泡尾部弱提示条
truncatedByMaxTokens: '⚠ 输出因长度上限截断,内容可能不完整',
// 截断提示条的继续输出按钮
continueOutput: '继续输出',
// 「继续输出」注入的续写指令(作为新 user 消息发送,要求衔接勿重复)
continueOutputPrompt: '从上一条回复的中断处继续输出,直接衔接,勿重复已输出的内容。',
queueFull: '待发送队列已满(最多 {limit} 条)', queueFull: '待发送队列已满(最多 {limit} 条)',
forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)', forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)',
forceSendBtn: '强制发送', forceSendBtn: '强制发送',
@@ -136,15 +142,32 @@ export default {
midstream: '响应流中断', midstream: '响应流中断',
}, },
unknownKind: '任务停靠', unknownKind: '任务停靠',
// U5 异常态话术公式第 3 要素「已保留什么」:前端常驻兜底——后端各停靠路径 reason 措辞不齐
// (fatal 非超时类/external_change 未带保留信息),卡面统一保证公式对全部 stop_kind 成立
preserved: '对话上下文与已完成进度已完整保留,随时可继续',
stopArchive: '停止归档', stopArchive: '停止归档',
stoppedToast: '任务已停止,不再续跑', stoppedToast: '任务已停止,不再续跑',
archiveFailed: '停止失败:{msg}', archiveFailed: '停止失败:{msg}',
}, },
// 无人值守任务进度(AiTaskProgress,设计 §4.3):TopBar 轻量进度条,静默期可见「在干活」 // 无人值守任务进度(AiTaskProgress,设计 §4.3):TopBar 轻量进度条,静默期可见「在干活」
// U2 三层补充:stage=内容层阶段标签(§3.3 工具→语义映射,未命中工具直显工具名不走此表);
// longRun/silent=静默期三档(§3.4),只给判断材料不自动判死
taskProgress: { taskProgress: {
running: '进行中 · 第 {round}/{max} 轮 · {tokens} tokens', running: '进行中 · 第 {round}/{max} 轮 · {tokens} tokens',
unlimited: '进行中 · 第 {round} 轮 · {tokens} tokens', unlimited: '进行中 · 第 {round} 轮 · {tokens} tokens',
barHint: '第 {round}/{max} 轮', barHint: '第 {round}/{max} 轮',
steps: '已完成 {n} 步',
stage: {
read: '阅读检索中',
edit: '编辑写入中',
run: '执行验证中',
agent: '子代理执行中',
knowledge: '知识与任务操作中',
},
longRun: '耗时较久',
longRunHint: '当前步骤耗时较久(大工具/子代理运行中)',
silentShort: '{n} 分钟无活动',
silentWarn: '距上次活动 {n} 分钟(最后动作:{tool})。仍在运行,可查看轨迹;疑似卡死可停止。',
}, },
// 无人值守边界配置卡(P1 总装简化:授权面收敛单一档,可调项只剩预算两项) // 无人值守边界配置卡(P1 总装简化:授权面收敛单一档,可调项只剩预算两项)
taskBoundary: { taskBoundary: {
+24
View File
@@ -113,6 +113,7 @@ export default {
examplePrompt4: '生成一个项目灵感', examplePrompt4: '生成一个项目灵感',
goToSettings: '去设置', goToSettings: '去设置',
goToSettingsHint: '分离窗口无法直接打开设置,请在主窗口的设置页配置 AI 提供商', goToSettingsHint: '分离窗口无法直接打开设置,请在主窗口的设置页配置 AI 提供商',
goToSettingsOpened: '已在主窗口打开设置页',
goToTaskHint: '分离窗口无法直接打开任务,请在主窗口查看任务详情', goToTaskHint: '分离窗口无法直接打开任务,请在主窗口查看任务详情',
// ── Provider 切换反馈── // ── Provider 切换反馈──
@@ -125,6 +126,8 @@ export default {
// ── 待发送队列 ── // ── 待发送队列 ──
queueTitle: '待发送 {n}/10', queueTitle: '待发送 {n}/10',
clearQueue: '清空', clearQueue: '清空',
// UI-4(看板 943):清空队列二次确认(一键全清不可恢复)
clearQueueConfirm: '确定清空全部待发送消息?清空后不可恢复',
// 队列项编辑 + 立即发送 // 队列项编辑 + 立即发送
queueEdit: '编辑', queueEdit: '编辑',
queueSave: '保存', queueSave: '保存',
@@ -340,5 +343,26 @@ export default {
editEmptyBlocked: '编辑内容为空,已取消提交', editEmptyBlocked: '编辑内容为空,已取消提交',
skillNoMatchQuery: '未找到技能「{q}」,已阻止发送', skillNoMatchQuery: '未找到技能「{q}」,已阻止发送',
backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?', 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: '无目标会话,请先创建会话',
}, },
} }
+20
View File
@@ -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)
})