新增: 待发箱抽屉与对话体验前端批
- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
This commit is contained in:
+25
-3
@@ -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 域:若离开前是全宽则恢复(面板已关闭则不恢复,避免 <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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
|
||||
<div class="ai-queue-actions">
|
||||
<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 class="ai-queue-list">
|
||||
@@ -101,7 +101,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<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 每秒推进,入发送随队列清空即停) -->
|
||||
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
|
||||
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
|
||||
@@ -178,7 +178,7 @@ import { useToast } from '../composables/useToast'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -206,7 +206,10 @@ const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass',
|
||||
/** 跳 Settings 配置 provider:嵌入模式 router.push;分离窗口无 Settings 路由,降级 toast 提示 */
|
||||
function goToSettings() {
|
||||
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
|
||||
}
|
||||
router.push('/settings')
|
||||
@@ -535,6 +538,16 @@ async function handleForceSend() {
|
||||
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)+ 立即发送(插队)──
|
||||
|
||||
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
||||
@@ -692,6 +705,18 @@ onMounted(async () => {
|
||||
// V11b: 主窗口注册跨窗收回分离窗监听(label 判定在内部,分离窗调用为 no-op);
|
||||
// reattachPanel 由主窗 store 执行(关全部 ai-detached-* 窗 + 恢复主面板)。
|
||||
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 未送达经事件总线广播,
|
||||
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
|
||||
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
|
||||
|
||||
@@ -363,8 +363,22 @@ const statusTitle = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 头部摘要 hover 回看完整路径(摘要统一缩略尾段,全路径走 title;路径类工具才有值) */
|
||||
const summaryTitle = computed(() => argString(props.tc.args, 'path'))
|
||||
/** 头部摘要 hover 回看关键参数全值(看板 945,按工具类型取参):
|
||||
* 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):审批挂起时卡片左侧着色条 + 风险徽章数据源。
|
||||
|
||||
@@ -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>
|
||||
</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 三态,
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
@@ -119,6 +130,8 @@
|
||||
@dismiss="dismissEnrichment"
|
||||
@update:enrichment-expanded="enrichmentExpanded = $event"
|
||||
/>
|
||||
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
|
||||
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
|
||||
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<HTMLTextAreaElement>()
|
||||
// 定时/待发箱抽屉开合(⏰ 按钮开,抽屉 close 事件关;列表数据在 useAiOutbox 单例缓存)
|
||||
const outboxOpen = ref(false)
|
||||
|
||||
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽/选择) ──
|
||||
// 待发送图片列表(url 或 base64 data URI + media_type),handleSend 时构造 ContentPart[]。
|
||||
|
||||
@@ -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: 消息分段(归档段折叠) ──
|
||||
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
|
||||
// null | undefined | 'active' → 正常消息(现状)
|
||||
@@ -1835,6 +1852,20 @@ defineExpose({
|
||||
<span class="ai-cursor"></span>
|
||||
</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 / 错误气泡操作入口(重试 / 去设置) -->
|
||||
<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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -2,7 +2,10 @@
|
||||
<!-- 无人值守安全停靠卡(设计 §四):后端非正常终局(六路,stop_kind 词表见 i18n)emit AiTaskParked(已保存现场并复位
|
||||
generating,非死等),useAiEvents/useAiLifecycleEvents 登记 pendingTaskParked(per-conv Map)
|
||||
后本卡渲染。复用 HelpRequiredCard 模式:per-conv 守卫(挂起会话===当前展示会话)+ 行为后摘卡。
|
||||
卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/
|
||||
卡面话术按 U5 异常态公式组织:状态(title+stop_kind 标签)+真实原因(reason)+已保留什么
|
||||
(preserved 常驻行,前端兜底后端 reason 措辞不齐)+出路(继续/停止归档两枚;cancelled 只显
|
||||
停止归档,第二条出路由 reason 文案"重发消息可继续"承担)。
|
||||
操作两枚:继续(ai_continue_loop 重新授权续跑)/
|
||||
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
|
||||
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 -->
|
||||
<div v-if="parkedActive" class="ai-task-parked">
|
||||
@@ -11,6 +14,8 @@
|
||||
<span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span>
|
||||
</div>
|
||||
<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">
|
||||
<!-- cancelled(审批挂起中被用户主动取消)不显「继续」:用户刚点过停止,首位按钮再诱导续跑与意图相逆 -->
|
||||
<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;
|
||||
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;
|
||||
|
||||
@@ -156,13 +156,22 @@
|
||||
<div class="ai-bottom-tools-right">
|
||||
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
|
||||
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
|
||||
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。
|
||||
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token(防假进度规则2:无分母禁画百分比)。
|
||||
U2 三层(设计 §三):活性=脉冲点(静默降速,永不伪装停止);位置=轮次/token 数字;
|
||||
内容=阶段标签(最近工具映射)+已完成步数,静默档附加判断材料(不自动判死,决策留给用户)。
|
||||
A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
|
||||
隐藏本快照徽章——两处同屏且轮数口径不同(实时 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-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
|
||||
v-if="taskProgress.maxRounds > 0"
|
||||
class="ai-task-progress-bar"
|
||||
@@ -589,6 +598,106 @@ const taskProgressPct = computed(() => {
|
||||
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)
|
||||
const goals = ref<GoalEntry[]>([])
|
||||
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;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<!-- ═══ AI Chat Panel ═══ -->
|
||||
<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>
|
||||
<slot name="ai-panel" />
|
||||
</div>
|
||||
@@ -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 — */
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
// 看板 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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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=已处理。 */
|
||||
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 渲染为琥珀细条而非错误红卡),
|
||||
|
||||
@@ -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 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
|
||||
|
||||
+26
-1
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: '无目标会话,请先创建会话',
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user