新增: 待发箱抽屉与对话体验前端批
- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
This commit is contained in:
@@ -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 — */
|
||||
|
||||
Reference in New Issue
Block a user