新增: UX队列三件套(打断保留续发+队列编辑+立即发送)

This commit is contained in:
2026-06-16 23:59:10 +08:00
parent 8dad15ecc1
commit 313459b8e4
4 changed files with 117 additions and 4 deletions

View File

@@ -515,9 +515,29 @@
</div> </div>
<div class="ai-queue-list"> <div class="ai-queue-list">
<div v-for="(item, idx) in store.state.queue" :key="idx" class="ai-queue-item"> <div v-for="(item, idx) in store.state.queue" :key="idx" class="ai-queue-item">
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span> <!-- UX-260616-06 决策只编 text:编辑态切 inline input,回车/失焦写回,ESC 取消 -->
<span class="ai-queue-text">{{ item.text }}</span> <template v-if="editingQueueIdx === idx">
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')">×</button> <span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<input
v-model="editingQueueText"
class="ai-queue-edit-input"
type="text"
@keydown.enter.prevent="saveEditQueued"
@keydown.esc.prevent="cancelEditQueued"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text">{{ item.text }}</span>
<!-- UX-260616-06:编辑按钮 -->
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- UX-260616-07 决策 a:立即发送(打断当前+发本条) -->
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')">×</button>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -1541,6 +1561,40 @@ async function handleForceSend() {
await store.tryForceSend(confirmDialog) await store.tryForceSend(confirmDialog)
} }
// ── UX-260616-06 队列消息编辑(决策只编 text)+ UX-260616-07 立即发送(决策 a 插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */
const editingQueueIdx = ref<number | null>(null)
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
const editingQueueText = ref('')
/** 进入编辑态:记录 index + 快照当前 text */
function startEditQueued(idx: number) {
const item = store.state.queue[idx]
if (!item) return
editingQueueIdx.value = idx
editingQueueText.value = item.text
}
/** 取消编辑:还原非编辑态,不写回 */
function cancelEditQueued() {
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
store.editQueued(editingQueueIdx.value, editingQueueText.value)
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** UX-260616-07 立即发送:委托 store.sendQueuedNow(splice+stop+sendMessage) */
async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
// 用户是否已在底部附近(上滑查看历史时不强制拉回) // 用户是否已在底部附近(上滑查看历史时不强制拉回)
function isNearBottom(): boolean { function isNearBottom(): boolean {
const el = messagesContainer.value const el = messagesContainer.value
@@ -2919,6 +2973,19 @@ body.ai-sidebar-resizing * {
} }
.ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; } .ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
.ai-queue-x:hover { color: var(--df-danger); } .ai-queue-x:hover { color: var(--df-danger); }
/* UX-260616-06 队列项编辑/立即发送按钮 + 编辑态 input */
.ai-queue-btn {
background: none; border: none; color: var(--df-text-dim); cursor: pointer;
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
}
.ai-queue-btn:hover { color: var(--df-accent); }
.ai-queue-edit-input {
flex: 1; min-width: 0; font-size: 12px; padding: 1px 4px;
background: var(--df-bg-card); color: var(--df-text);
border: 0.5px solid var(--df-accent);
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
/* ── 技能 chip / 联想浮层 ── */ /* ── 技能 chip / 联想浮层 ── */
.ai-skill-chip { .ai-skill-chip {

View File

@@ -359,13 +359,47 @@ function clearQueue() {
state.queue = [] state.queue = []
} }
/**
* UX-260616-06: 编辑队列项的 text(决策只编 text)。
*
* 设计:
* - 只改 text,skill 编辑态不暴露(skill 来自发送时技能联想,编辑态改 skill 复杂化交互无收益)。
* - 边界:index 越界 / newText 空白 → 直接忽略(no-op),不抛错(UI 不会触发,防御)。
* - queue item 类型 { text, skill?, enqueuedAt } 不改。
*/
function editQueued(index: number, newText: string) {
if (index < 0 || index >= state.queue.length) return
const trimmed = newText.trim()
if (!trimmed) return
state.queue[index].text = trimmed
}
/**
* UX-260616-07: 队列项立即发送(决策 a:插队=打断当前+发本条,复用 UX-05 stop 链路)。
*
* 流程(顺序关键):
* 1. 先 splice 出该项(记下 {text, skill})——必须在 stopChat 之前,
* 否则 stopChat→AiCompleted→drainQueue 续发时该项还在队列致重复发。
* 2. 调 stopChat() 打断当前生成(stopChat UX-05 后已不清队列,故 splice 步骤前置必要)。
* 3. 直接 sendMessage(splicedItem.text, skill) 立即发这条(不等 drainQueue 轮到)。
* 当前未完成回复已由 stop 截断保留(agentic.rs:185 save_conversation),无需额外处理。
*/
async function sendQueuedNow(index: number) {
if (index < 0 || index >= state.queue.length) return
const spliced = state.queue.splice(index, 1)[0]
await stopChat()
await sendMessage(spliced.text, spliced.skill)
}
/** 停止当前生成:本地先复位 streaming再发停止信号。 /** 停止当前生成:本地先复位 streaming再发停止信号。
* 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。 * 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。
*/ */
async function stopChat() { async function stopChat() {
state.streaming = false // 本地先复位,不依赖后端 AiCompleted审批态看门狗已 clear竞态丢失则卡死 state.streaming = false // 本地先复位,不依赖后端 AiCompleted审批态看门狗已 clear竞态丢失则卡死
clearStreamWatchdog() // 清残留看门狗 clearStreamWatchdog() // 清残留看门狗
state.queue = [] // B-32:用户主动停止,清队列防生成中入队的消息被静默丢弃 // UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic.rs:190 emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic.rs:185 save_conversation)保留为截断回复。
await aiApi.stopChat() await aiApi.stopChat()
} }
@@ -379,6 +413,8 @@ export function useAiSend() {
drainQueue, drainQueue,
cancelQueued, cancelQueued,
clearQueue, clearQueue,
editQueued, // UX-260616-06
sendQueuedNow, // UX-260616-07
stopChat, stopChat,
isQueueTimedOut, isQueueTimedOut,
tryForceSend, tryForceSend,

View File

@@ -64,6 +64,11 @@ export default {
// ── Pending queue ── // ── Pending queue ──
queueTitle: 'Queued {n}/10', queueTitle: 'Queued {n}/10',
clearQueue: 'Clear', clearQueue: 'Clear',
// UX-260616-06/07: queue item edit + send now
queueEdit: 'Edit',
queueSave: 'Save',
queueCancel: 'Cancel',
queueSendNow: 'Send now',
// ── Skill ── // ── Skill ──
clearSkill: 'Clear skill', clearSkill: 'Clear skill',

View File

@@ -64,6 +64,11 @@ export default {
// ── 待发送队列 ── // ── 待发送队列 ──
queueTitle: '待发送 {n}/10', queueTitle: '待发送 {n}/10',
clearQueue: '清空', clearQueue: '清空',
// UX-260616-06/07:队列项编辑 + 立即发送
queueEdit: '编辑',
queueSave: '保存',
queueCancel: '取消',
queueSendNow: '立即发送',
// ── 技能 ── // ── 技能 ──
clearSkill: '取消技能', clearSkill: '取消技能',