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

- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转
- 提及片段编辑差分三向判定,不再错位
- 长回复折叠完成瞬间锚定消息顶,视口不跳
- 空编辑/技能无匹配/回填覆盖草稿均有提示确认
- 队列编辑态切会话重置,刷新快捷键不再误触
- 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
+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>