优化: 会话入口与对话交互打磨
- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转 - 提及片段编辑差分三向判定,不再错位 - 长回复折叠完成瞬间锚定消息顶,视口不跳 - 空编辑/技能无匹配/回填覆盖草稿均有提示确认 - 队列编辑态切会话重置,刷新快捷键不再误触 - git 变更面板去重缓存,项目卡骨架与溢出重测
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user