优化: 会话入口与对话交互打磨

- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转
- 提及片段编辑差分三向判定,不再错位
- 长回复折叠完成瞬间锚定消息顶,视口不跳
- 空编辑/技能无匹配/回填覆盖草稿均有提示确认
- 队列编辑态切会话重置,刷新快捷键不再误触
- git 变更面板去重缓存,项目卡骨架与溢出重测
This commit is contained in:
lxy
2026-10-03 17:57:43 +08:00
parent 890ceef0ec
commit f67e65633b
8 changed files with 433 additions and 19 deletions
+56 -2
View File
@@ -25,7 +25,8 @@
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
rows="1"
@keydown="handleKeydown"
@input="autoResize"
@beforeinput="onBeforeInputText"
@input="onInputText"
@paste="onPaste"
@dragover="onDragOver"
@drop="onDrop"
@@ -401,6 +402,38 @@ const mentionStart = ref(-1)
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
const pendingMentionSpans = ref<MentionSpan[]>([])
// ── UX-P3: mention span 编辑平移 ──
// span.start 是 selectMention 时刻的绝对偏移,用户在 chip 文本之前增删字符后全部 span 错位
// (MessageList 渲染守卫对不上文本即降级纯文本,chip 全灭)。借 beforeinput(仅用户编辑触发,
// 程序赋值不派发事件)快照旧文本,input 时做公共前后缀差分求出编辑区间,按区间相对各 span
// 的位置处理:编辑点在 span 前→整体平移;在 span 后→不动;落在 span 文本内→丢弃该 span
// (文本已被改写区间失真,宁丢一枚 chip 也不让后端 resolve 到错位区间)。
let mentionTextBeforeEdit = ''
function onBeforeInputText(): void {
mentionTextBeforeEdit = inputEl.value?.value ?? inputText.value
}
function onInputText(): void {
autoResize()
shiftMentionSpans(mentionTextBeforeEdit, inputEl.value?.value ?? inputText.value)
}
function shiftMentionSpans(oldText: string, newText: string): void {
if (pendingMentionSpans.value.length === 0 || oldText === newText) return
// 公共前后缀夹出旧文本中被编辑的区间(粘贴/剪切的多字符增删同为一段区间,该差分通用)
const minLen = Math.min(oldText.length, newText.length)
let p = 0
while (p < minLen && oldText[p] === newText[p]) p++
let s = 0
while (s < minLen - p && oldText[oldText.length - 1 - s] === newText[newText.length - 1 - s]) s++
const editStart = p
const editEnd = oldText.length - s
const delta = newText.length - oldText.length
pendingMentionSpans.value = pendingMentionSpans.value.flatMap((sp) => {
if (editEnd <= sp.start) return [{ ...sp, start: sp.start + delta }]
if (editStart >= sp.start + sp.length) return [sp]
return []
})
}
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
// 在输入区下方显式 badge + 可展开的上下文参考面板。
@@ -685,6 +718,14 @@ function handleKeydown(e: KeyboardEvent) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
if (e.isComposing || e.keyCode === 229) return
// UX-P3: AI 输入框聚焦时 Ctrl/Cmd+R 会触发 WebView 刷新(浏览器直开/开发态整页刷新丢会话态),
// 此处 preventDefault 掐掉刷新默认行为。只拦输入框聚焦这一场景(本 handler 仅 textarea 触发,
// 全局其他区域不拦);不 stopPropagation,冒泡到 AiChat onGlobalKeydown 的 Ctrl+R 分支,
// 重新生成语义照常生效。
if ((e.ctrlKey || e.metaKey) && (e.key === 'r' || e.key === 'R')) {
e.preventDefault()
return
}
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
// 编辑态 Escape 取消编辑(最高优先级)
@@ -732,6 +773,14 @@ function handleKeydown(e: KeyboardEvent) {
return
}
}
// UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通
// 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。
if (skillOpen.value && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
e.stopPropagation()
emit('error', { msg: t('aiChat.skillNoMatchQuery', { q: skillQuery.value.trim() }), type: 'warning' })
return
}
// @ 实体引用浮层导航(仅有匹配项时拦截方向/回车;与技能联想互斥)
if (mentionOpen.value && mentionItems.value.length) {
if (e.key === 'ArrowDown') {
@@ -775,7 +824,12 @@ async function handleSend() {
const text = inputText.value.trim()
// UX-09 编辑态:走 editMessage(不 push 新 user,编辑末条 user 后重生成)
if (props.editingMsgId) {
if (!text) return
// UX-P3 空编辑静默:清空文本后 Enter 原来静默 return,用户无反馈卡在编辑态。
// 仍阻止空提交(editMessage 空文本语义不明),但补轻提示告知原因。
if (!text) {
emit('error', { msg: t('aiChat.editEmptyBlocked'), type: 'warning' })
return
}
inputText.value = ''
if (inputEl.value) inputEl.value.style.height = 'auto'
try {
+232 -3
View File
@@ -23,6 +23,7 @@ import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
import { aiApi } from '../../api'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
@@ -273,9 +274,32 @@ watch(() => store.state.streaming, (s) => {
saveSelection()
clearStreamingState()
nextTick(() => restoreSelection())
anchorCollapseScroll()
}
})
// ── UX-P3: 长回复完成瞬间折叠的滚动锚 ──
// 末条 AI 回复超阈值(LONG_MSG_THRESHOLD)时,完成瞬间 shouldCollapseLong 从 false 翻 true
// (流式中不折叠的约束解除),气泡收缩到 400px;跟随意图下的 scrollToBottom 把视口钉在收缩后
// 的底部,内容整体上跳,刚流完的答案开头/结尾都看不全。改锚消息顶:本回调在流式结束当拍注册
// nextTick(此时折叠 class 尚未渲染),渲染拍后把该消息顶对齐视口顶,视口落点确定(答案开头,
// 回底按钮照常出现),不再随内容收缩被浏览器 clamp 到任意位置。
// 仅跟随意图成立时干预(用户上滑读历史不拉扯);手动展开过的消息不会折叠,无收跳不处理。
// 注册时序:currentText watch(先注册)的 scrollToBottom nextTick 先入队,本 nextTick 后入队
// 后执行,锚定是滚动链的最后一手,不被钉底覆盖。
const COLLAPSE_ANCHOR_PAD_PX = 8
function anchorCollapseScroll(): void {
if (!isFollowingBottom.value) return
const last = store.state.messages[store.state.messages.length - 1]
if (!last || !shouldCollapseLong(last)) return
nextTick(() => {
const c = messagesContainer.value
const el = c?.querySelector(`[data-msg-id="${last.id}"]`)
if (!c || !el) return
c.scrollTop += el.getBoundingClientRect().top - c.getBoundingClientRect().top - COLLAPSE_ANCHOR_PAD_PX
})
}
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
@@ -1083,6 +1107,68 @@ function agentFinSummary(summary: string): string {
return s.length > 160 ? s.slice(0, 160) + '…' : s
}
// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ──
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
// 不进 aiShared/stores——子会话轨迹纯只读,与主会话状态机(消息流/生成态)完全无关。
interface AgentPeekMsg {
role: 'user' | 'assistant' | 'tool' | 'system'
text: string
toolNames: string[]
}
const agentPeekVisible = ref(false)
const agentPeekLoading = ref(false)
const agentPeekMsgs = ref<AgentPeekMsg[]>([])
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
// 完整数据仍在库,不做全文渲染(不要求富文本)。
const AGENT_PEEK_TEXT_MAX = 4000
/**
* 完成卡点击:peek 子会话轨迹并开浮层。
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
* 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。
*/
async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
if (agentPeekVisible.value || agentPeekLoading.value) return
agentPeekLoading.value = true
try {
const res = await aiApi.peekConversation(card.agentId)
const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || [])
agentPeekMsgs.value = raw.map((m) => {
const text = String(m.content || '')
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
? m.role
: 'assistant'
return {
role,
text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text,
toolNames: Array.isArray(m.tool_calls)
? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean)
: [],
}
})
agentPeekVisible.value = true
} catch (e) {
console.error('[AI] 拉取子代理轨迹失败:', e)
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
} finally {
agentPeekLoading.value = false
}
}
/** 关浮层即弃:清空局部消息态,不缓存轨迹 */
function closeAgentTrace(): void {
agentPeekVisible.value = false
agentPeekMsgs.value = []
}
/** role → 本地化角色名(简版渲染,无头像气泡) */
function agentPeekRoleText(role: AgentPeekMsg['role']): string {
if (role === 'user') return t('ai.agentTrace.roleUser')
if (role === 'tool') return t('ai.agentTrace.roleTool')
if (role === 'system') return t('ai.agentTrace.roleSystem')
return t('ai.agentTrace.roleAssistant')
}
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
@@ -1230,9 +1316,16 @@ defineExpose({
</div>
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
轻量细条范式,非消息数据不进气泡 -->
轻量细条范式,非消息数据不进气泡。
S3:整卡可点,点击 peek 子会话轨迹开只读浮层(数据关即弃,见 openAgentTrace) -->
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div class="ai-agent-fin" :class="'ai-agent-fin--' + agentFinTone(item.card.status)">
<div
class="ai-agent-fin"
:class="'ai-agent-fin--' + agentFinTone(item.card.status)"
role="button"
:title="$t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
@@ -1485,6 +1578,35 @@ defineExpose({
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button>
</Transition>
<!-- S3 子代理轨迹浮层(只读,关即弃):与回底部按钮同为模板根级兄弟,position:fixed
不受 .ai-messages 滚动容器 overflow 裁剪;backdrop 点击关闭,数据不缓存 -->
<div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace">
<div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
<div class="ai-agent-peek-head">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="ai-agent-peek-body">
<div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div>
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染) -->
<div
v-for="(m, i) in agentPeekMsgs"
:key="i"
class="ai-agent-peek-msg"
:class="'ai-agent-peek-msg--' + m.role"
>
<div class="ai-agent-peek-role">
<span>{{ agentPeekRoleText(m.role) }}</span>
<span v-for="name in m.toolNames" :key="name" class="ai-agent-peek-tool">{{ name }}</span>
</div>
<div v-if="m.text" class="ai-agent-peek-text">{{ m.text }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@@ -2268,7 +2390,8 @@ defineExpose({
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看。
S3:整卡可点开轨迹浮层,cursor pointer + hover 提示可交互 */
.ai-agent-fin {
display: flex;
align-items: center;
@@ -2280,6 +2403,10 @@ defineExpose({
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
cursor: pointer;
}
.ai-agent-fin:hover {
background: color-mix(in srgb, var(--df-text-secondary) 12%, transparent);
}
.ai-agent-fin-dot {
width: 5px;
@@ -2323,4 +2450,106 @@ defineExpose({
font-family: var(--df-font-mono);
font-size: 10.5px;
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
.ai-agent-peek-mask {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
}
.ai-agent-peek {
width: min(680px, 86vw);
max-height: 76vh;
display: flex;
flex-direction: column;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border-strong);
border-radius: var(--df-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.ai-agent-peek-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.ai-agent-peek-title {
font-size: 12.5px;
font-weight: 600;
color: var(--df-text);
}
.ai-agent-peek-close {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
padding: 4px;
border-radius: var(--df-radius-sm);
display: flex;
}
.ai-agent-peek-close:hover {
color: var(--df-text);
background: color-mix(in srgb, var(--df-text-secondary) 10%, transparent);
}
.ai-agent-peek-body {
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-agent-peek-empty {
color: var(--df-text-dim);
font-size: 12px;
text-align: center;
padding: 24px 0;
}
.ai-agent-peek-msg {
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 8px 10px;
}
/* role 左侧色条:user 中性/accent、assistant 默认、tool 结果弱化、system 弱化斜体 */
.ai-agent-peek-msg--user { border-left: 2px solid var(--df-accent); }
.ai-agent-peek-msg--assistant { border-left: 2px solid var(--df-success); }
.ai-agent-peek-msg--tool,
.ai-agent-peek-msg--system { border-left: 2px solid var(--df-border-strong); }
.ai-agent-peek-role {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
font-weight: 600;
color: var(--df-text-secondary);
margin-bottom: 4px;
}
.ai-agent-peek-tool {
font-family: var(--df-font-mono);
font-size: 10px;
font-weight: 500;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-text {
font-size: 12px;
line-height: 1.55;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-word;
}
.ai-agent-peek-msg--tool .ai-agent-peek-text,
.ai-agent-peek-msg--system .ai-agent-peek-text {
color: var(--df-text-secondary);
font-size: 11.5px;
}
</style>