feat(chat): 优化块展开/折叠滚动锚定,避免 stick-to-bottom 弹簧追底顿挫
This commit is contained in:
parent
55b7b7deea
commit
edb557bdd3
@ -108,6 +108,7 @@
|
|||||||
:render-markdown="renderMarkdown"
|
:render-markdown="renderMarkdown"
|
||||||
:register-thinking-ref="registerThinkingRef"
|
:register-thinking-ref="registerThinkingRef"
|
||||||
:handle-thinking-scroll="props.handleThinkingScroll"
|
:handle-thinking-scroll="props.handleThinkingScroll"
|
||||||
|
@group-toggle="handleMinimalGroupToggle"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="stackedBlocksEnabled">
|
<template v-else-if="stackedBlocksEnabled">
|
||||||
@ -154,6 +155,7 @@
|
|||||||
group.action.blockId || `${index}-thinking-${group.actionIndex}`
|
group.action.blockId || `${index}-thinking-${group.actionIndex}`
|
||||||
)
|
)
|
||||||
}"
|
}"
|
||||||
|
:data-block-id="group.action.blockId || `${index}-thinking-${group.actionIndex}`"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="collapsible-header"
|
class="collapsible-header"
|
||||||
@ -345,6 +347,7 @@
|
|||||||
:streaming-message="streamingMessage"
|
:streaming-message="streamingMessage"
|
||||||
:register-collapse-content="registerCollapseContent"
|
:register-collapse-content="registerCollapseContent"
|
||||||
:collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`"
|
:collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`"
|
||||||
|
:block-id="group.action.blockId || `${index}-tool-${group.actionIndex}`"
|
||||||
@toggle="
|
@toggle="
|
||||||
toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)
|
toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)
|
||||||
"
|
"
|
||||||
@ -378,6 +381,7 @@
|
|||||||
action.blockId || `${index}-thinking-${actionIndex}`
|
action.blockId || `${index}-thinking-${actionIndex}`
|
||||||
)
|
)
|
||||||
}"
|
}"
|
||||||
|
:data-block-id="action.blockId || `${index}-thinking-${actionIndex}`"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="collapsible-header"
|
class="collapsible-header"
|
||||||
@ -551,6 +555,7 @@
|
|||||||
:streaming-message="streamingMessage"
|
:streaming-message="streamingMessage"
|
||||||
:register-collapse-content="registerCollapseContent"
|
:register-collapse-content="registerCollapseContent"
|
||||||
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
|
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
|
||||||
|
:block-id="action.blockId || `${index}-tool-${actionIndex}`"
|
||||||
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
|
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -572,6 +577,7 @@
|
|||||||
<div
|
<div
|
||||||
class="collapsible-block system-block"
|
class="collapsible-block system-block"
|
||||||
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
|
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
|
||||||
|
:data-block-id="`system-${index}`"
|
||||||
>
|
>
|
||||||
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
|
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
|
||||||
<div class="arrow"></div>
|
<div class="arrow"></div>
|
||||||
@ -603,6 +609,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useStickToBottom } from 'vue-stick-to-bottom';
|
import { useStickToBottom } from 'vue-stick-to-bottom';
|
||||||
|
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
||||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||||
import StackedBlocks from './StackedBlocks.vue';
|
import StackedBlocks from './StackedBlocks.vue';
|
||||||
import MinimalBlocks from './MinimalBlocks.vue';
|
import MinimalBlocks from './MinimalBlocks.vue';
|
||||||
@ -797,6 +804,10 @@ const {
|
|||||||
initial: 'instant'
|
initial: 'instant'
|
||||||
});
|
});
|
||||||
const rootEl = scrollRef;
|
const rootEl = scrollRef;
|
||||||
|
const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpansionAnchor(
|
||||||
|
scrollRef,
|
||||||
|
{ stopScroll }
|
||||||
|
);
|
||||||
const thinkingRefs = new Map<string, HTMLElement | null>();
|
const thinkingRefs = new Map<string, HTMLElement | null>();
|
||||||
let scrollbarResizeObserver: ResizeObserver | null = null;
|
let scrollbarResizeObserver: ResizeObserver | null = null;
|
||||||
let bounceTraceCount = 0;
|
let bounceTraceCount = 0;
|
||||||
@ -1059,6 +1070,78 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function escapeBlockSelector(value: string) {
|
||||||
|
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||||
|
return CSS.escape(value);
|
||||||
|
}
|
||||||
|
return value.replace(/["\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementLooksStreaming(el: HTMLElement) {
|
||||||
|
return (
|
||||||
|
el.classList.contains('processing') ||
|
||||||
|
el.classList.contains('running') ||
|
||||||
|
!!el.querySelector('.processing, .running, .thinking-animation')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevExpandedBlocks = ref(new Set<string>());
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.expandedBlocks,
|
||||||
|
(newSet, oldSet) => {
|
||||||
|
const prev = oldSet ?? prevExpandedBlocks.value;
|
||||||
|
const all = new Set([...Array.from(newSet), ...Array.from(prev)]);
|
||||||
|
const changed = new Set<string>();
|
||||||
|
for (const id of all) {
|
||||||
|
if (newSet.has(id) !== prev.has(id)) {
|
||||||
|
changed.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevExpandedBlocks.value = new Set(newSet);
|
||||||
|
|
||||||
|
if (changed.size === 0) return;
|
||||||
|
const container = scrollRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
for (const id of changed) {
|
||||||
|
const el = container.querySelector(
|
||||||
|
`[data-block-id="${escapeBlockSelector(id)}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (el) {
|
||||||
|
const isExpanding = newSet.has(id);
|
||||||
|
const streaming = elementLooksStreaming(el);
|
||||||
|
anchorBlockElement(el, id, {
|
||||||
|
direction: 'auto',
|
||||||
|
duration: streaming ? 5000 : 260,
|
||||||
|
extendOnGrowth: streaming,
|
||||||
|
phase: isExpanding ? 'expand' : 'collapse'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleMinimalGroupToggle(payload: { groupId: string; expanded: boolean }) {
|
||||||
|
const { groupId, expanded } = payload;
|
||||||
|
const container = scrollRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
const el = container.querySelector(
|
||||||
|
`[data-group-id="${escapeBlockSelector(groupId)}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (el) {
|
||||||
|
const streaming = elementLooksStreaming(el);
|
||||||
|
anchorBlockElement(el, groupId, {
|
||||||
|
direction: 'auto',
|
||||||
|
duration: streaming ? 5000 : 300,
|
||||||
|
extendOnGrowth: streaming,
|
||||||
|
phase: expanded ? 'expand' : 'collapse'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const registerCollapseContent = (key: string, el: Element | null) => {
|
const registerCollapseContent = (key: string, el: Element | null) => {
|
||||||
if (!(el instanceof HTMLElement)) {
|
if (!(el instanceof HTMLElement)) {
|
||||||
return;
|
return;
|
||||||
@ -1530,6 +1613,7 @@ watch(scrollRef, () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
detachBounceListener();
|
detachBounceListener();
|
||||||
|
stopBlockExpansionAnchors();
|
||||||
if (scrollbarResizeObserver) {
|
if (scrollbarResizeObserver) {
|
||||||
scrollbarResizeObserver.disconnect();
|
scrollbarResizeObserver.disconnect();
|
||||||
scrollbarResizeObserver = null;
|
scrollbarResizeObserver = null;
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
<div
|
<div
|
||||||
class="summary-line-text"
|
class="summary-line-text"
|
||||||
:class="{ running: isSummaryRunning(group.actions, group.id) }"
|
:class="{ running: isSummaryRunning(group.actions, group.id) }"
|
||||||
|
:data-group-id="group.id"
|
||||||
@click="toggleExpand(group.id)"
|
@click="toggleExpand(group.id)"
|
||||||
>
|
>
|
||||||
<div class="summary-content-wrapper">
|
<div class="summary-content-wrapper">
|
||||||
@ -200,6 +201,10 @@ const props = defineProps<{
|
|||||||
handleThinkingScroll?: (blockId: string, event: Event) => void;
|
handleThinkingScroll?: (blockId: string, event: Event) => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'group-toggle', payload: { groupId: string; expanded: boolean }): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const personalizationStore = usePersonalizationStore();
|
const personalizationStore = usePersonalizationStore();
|
||||||
const expandedGroups = ref(new Set<string>());
|
const expandedGroups = ref(new Set<string>());
|
||||||
const thinkingRefs = new Map<string, HTMLElement>();
|
const thinkingRefs = new Map<string, HTMLElement>();
|
||||||
@ -672,6 +677,7 @@ const getSummarySteps = (actions: Action[]) => {
|
|||||||
const toggleExpand = (groupId: string) => {
|
const toggleExpand = (groupId: string) => {
|
||||||
if (expandedGroups.value.has(groupId)) {
|
if (expandedGroups.value.has(groupId)) {
|
||||||
expandedGroups.value.delete(groupId);
|
expandedGroups.value.delete(groupId);
|
||||||
|
emit('group-toggle', { groupId, expanded: false });
|
||||||
|
|
||||||
// 折叠后,取消注册该组中的思考内容 ref
|
// 折叠后,取消注册该组中的思考内容 ref
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@ -689,6 +695,7 @@ const toggleExpand = (groupId: string) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
expandedGroups.value.add(groupId);
|
expandedGroups.value.add(groupId);
|
||||||
|
emit('group-toggle', { groupId, expanded: true });
|
||||||
|
|
||||||
// 展开后,注册该组中的思考内容 ref
|
// 展开后,注册该组中的思考内容 ref
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|||||||
@ -41,6 +41,7 @@
|
|||||||
v-if="action.type === 'thinking'"
|
v-if="action.type === 'thinking'"
|
||||||
class="collapsible-block thinking-block stacked-block"
|
class="collapsible-block thinking-block stacked-block"
|
||||||
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
|
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
|
||||||
|
:data-block-id="blockKey(action, idx)"
|
||||||
>
|
>
|
||||||
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||||||
<div class="arrow"></div>
|
<div class="arrow"></div>
|
||||||
@ -71,6 +72,7 @@
|
|||||||
processing: isToolProcessing(action),
|
processing: isToolProcessing(action),
|
||||||
completed: isToolCompleted(action)
|
completed: isToolCompleted(action)
|
||||||
}"
|
}"
|
||||||
|
:data-block-id="blockKey(action, idx)"
|
||||||
>
|
>
|
||||||
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||||||
<div class="arrow"></div>
|
<div class="arrow"></div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
processing: action.tool.status === 'preparing' || action.tool.status === 'running',
|
processing: action.tool.status === 'preparing' || action.tool.status === 'running',
|
||||||
completed: action.tool.status === 'completed'
|
completed: action.tool.status === 'completed'
|
||||||
}"
|
}"
|
||||||
|
:data-block-id="blockId || collapseKey || action.tool.id || action.id || 'tool'"
|
||||||
>
|
>
|
||||||
<div class="collapsible-header" @click="$emit('toggle')">
|
<div class="collapsible-header" @click="$emit('toggle')">
|
||||||
<div class="arrow"></div>
|
<div class="arrow"></div>
|
||||||
@ -51,6 +52,7 @@ defineOptions({ name: 'ToolAction' });
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
action: any;
|
action: any;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
|
blockId?: string;
|
||||||
iconStyle: (key: string) => Record<string, string>;
|
iconStyle: (key: string) => Record<string, string>;
|
||||||
getToolAnimationClass: (tool: any) => Record<string, unknown>;
|
getToolAnimationClass: (tool: any) => Record<string, unknown>;
|
||||||
getToolIcon: (tool: any) => string;
|
getToolIcon: (tool: any) => string;
|
||||||
|
|||||||
183
static/src/composables/useBlockExpansionAnchor.ts
Normal file
183
static/src/composables/useBlockExpansionAnchor.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { type Ref } from 'vue';
|
||||||
|
|
||||||
|
export type BlockExpandDirection = 'auto' | 'up' | 'down';
|
||||||
|
|
||||||
|
interface AnchorAnimation {
|
||||||
|
element: HTMLElement;
|
||||||
|
startTime: number;
|
||||||
|
duration: number;
|
||||||
|
mode: 'up' | 'down';
|
||||||
|
initialTop: number;
|
||||||
|
initialBottom: number;
|
||||||
|
extendOnGrowth: boolean;
|
||||||
|
phase: 'expand' | 'collapse';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBlockExpansionAnchorOptions {
|
||||||
|
stopScroll: () => void;
|
||||||
|
duration?: number;
|
||||||
|
defaultDirection?: BlockExpandDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 块展开/折叠时,以块的某条边为锚点保持视口位置稳定。
|
||||||
|
*
|
||||||
|
* 传统 stick-to-bottom 在内容高度变化时会用弹簧动画追底,和 CSS 展开动画不同步,
|
||||||
|
* 产生顿挫。该 composable 在 CSS 过渡期间用 rAF 持续覆盖 scrollTop。
|
||||||
|
*
|
||||||
|
* 方向策略:
|
||||||
|
* - 展开时根据块顶边在视口的位置决定:上半向下展开(顶边不动),下半向上展开(底边不动)。
|
||||||
|
* - 该方向在本次展开-收起周期内保持一致,确保收起是展开的倒放。
|
||||||
|
* - 收起动画结束后清除方向记录,下次展开再根据新位置重新判断。
|
||||||
|
*/
|
||||||
|
export function useBlockExpansionAnchor(
|
||||||
|
scrollRef: Ref<HTMLElement | null>,
|
||||||
|
options: UseBlockExpansionAnchorOptions
|
||||||
|
) {
|
||||||
|
const { stopScroll, duration = 300, defaultDirection = 'auto' } = options;
|
||||||
|
const animations = new Map<string, AnchorAnimation>();
|
||||||
|
const preferredModes = new Map<string, 'up' | 'down'>();
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
function resolveMode(rect: DOMRect, containerRect: DOMRect, direction: BlockExpandDirection) {
|
||||||
|
if (direction !== 'auto') return direction;
|
||||||
|
const top = rect.top - containerRect.top;
|
||||||
|
const midY = containerRect.height / 2;
|
||||||
|
return top > midY ? 'up' : 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const container = scrollRef.value;
|
||||||
|
if (!container) {
|
||||||
|
rafId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const now = performance.now();
|
||||||
|
let hasActive = false;
|
||||||
|
|
||||||
|
for (const [id, anim] of animations) {
|
||||||
|
const elapsed = now - anim.startTime;
|
||||||
|
const stillInDom =
|
||||||
|
document.body.contains(container) && container.contains(anim.element);
|
||||||
|
if (!stillInDom) {
|
||||||
|
animations.delete(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = anim.element.getBoundingClientRect();
|
||||||
|
const currentTop = rect.top - containerRect.top;
|
||||||
|
const currentBottom = rect.bottom - containerRect.top;
|
||||||
|
|
||||||
|
let delta = 0;
|
||||||
|
if (anim.mode === 'up') {
|
||||||
|
delta = currentBottom - anim.initialBottom;
|
||||||
|
} else {
|
||||||
|
delta = -(currentTop - anim.initialTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillGrowing = Math.abs(delta) > 1;
|
||||||
|
const shouldExtend = anim.extendOnGrowth && stillGrowing && elapsed > anim.duration * 0.6;
|
||||||
|
if (shouldExtend) {
|
||||||
|
anim.startTime = now - anim.duration * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed >= anim.duration && !shouldExtend) {
|
||||||
|
// 收起动画结束后清除方向记录,下次展开按新位置重新判断
|
||||||
|
if (anim.phase === 'collapse') {
|
||||||
|
preferredModes.delete(id);
|
||||||
|
}
|
||||||
|
animations.delete(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasActive = true;
|
||||||
|
|
||||||
|
if (Math.abs(delta) > 0.5) {
|
||||||
|
container.scrollTop += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasActive) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorBlockElement(
|
||||||
|
element: HTMLElement,
|
||||||
|
id: string,
|
||||||
|
opts?: {
|
||||||
|
duration?: number;
|
||||||
|
direction?: BlockExpandDirection;
|
||||||
|
extendOnGrowth?: boolean;
|
||||||
|
phase?: 'expand' | 'collapse';
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const container = scrollRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const initialTop = rect.top - containerRect.top;
|
||||||
|
const initialBottom = rect.bottom - containerRect.top;
|
||||||
|
|
||||||
|
// 只处理视口附近可见的块
|
||||||
|
if (initialBottom < -50 || initialTop > containerRect.height + 50) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = opts?.phase ?? 'expand';
|
||||||
|
const explicitDirection = opts?.direction ?? defaultDirection;
|
||||||
|
let mode: 'up' | 'down';
|
||||||
|
|
||||||
|
if (explicitDirection !== 'auto') {
|
||||||
|
mode = explicitDirection;
|
||||||
|
} else if (phase === 'collapse' && preferredModes.has(id)) {
|
||||||
|
// 收起时沿用展开时记录的方向,保证倒放一致
|
||||||
|
mode = preferredModes.get(id)!;
|
||||||
|
} else if (phase === 'expand' && preferredModes.has(id)) {
|
||||||
|
// 同一次周期内再次展开(不应常见),沿用已有方向
|
||||||
|
mode = preferredModes.get(id)!;
|
||||||
|
} else {
|
||||||
|
mode = resolveMode(rect, containerRect, 'auto');
|
||||||
|
preferredModes.set(id, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendOnGrowth = opts?.extendOnGrowth ?? false;
|
||||||
|
|
||||||
|
stopScroll();
|
||||||
|
|
||||||
|
const existing = animations.get(id);
|
||||||
|
animations.set(id, {
|
||||||
|
element,
|
||||||
|
startTime: performance.now(),
|
||||||
|
duration: opts?.duration ?? duration,
|
||||||
|
mode,
|
||||||
|
initialTop,
|
||||||
|
initialBottom,
|
||||||
|
extendOnGrowth: extendOnGrowth || existing?.extendOnGrowth || false,
|
||||||
|
phase
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rafId === null) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAll() {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
animations.clear();
|
||||||
|
preferredModes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
anchorBlockElement,
|
||||||
|
stopAll
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user