feat(agent-assistant): add active prop to AgentAssistantEntry for visibility control

This commit is contained in:
jxxghp
2026-06-23 17:56:53 +08:00
parent 05c2e7855a
commit b2a2c7080e
2 changed files with 225 additions and 60 deletions
+224 -59
View File
@@ -27,9 +27,11 @@ interface AgentAssistantEntryBubbleInput {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
active?: boolean
thinking?: boolean thinking?: boolean
}>(), }>(),
{ {
active: true,
thinking: false, thinking: false,
}, },
) )
@@ -40,24 +42,50 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const FAB_DOCK_STORAGE_KEY = 'moviepilot-agent-assistant-entry-docked'
const FAB_IDLE_DOCK_DELAY = 4200 const FAB_IDLE_DOCK_DELAY = 4200
const FAB_DOCK_REVEAL_DISTANCE = 18 const FAB_RIGHT_EDGE_DOCK_DISTANCE = 88
const FAB_NOTIFICATION_BUBBLE_DURATION = 7000 const FAB_NOTIFICATION_BUBBLE_DURATION = 7000
const FAB_MAX_BUBBLES = 4 const FAB_MAX_BUBBLES = 4
const FAB_DEFAULT_RIGHT_OFFSET = 18
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
// 入口位置只保存在当前页面生命周期内,刷新后回到默认位置。
interface FabPosition {
x: number
y: number
}
interface FabDragState {
pointerId: number
startClientX: number
startClientY: number
startX: number
startY: number
moved: boolean
}
const fabDocked = ref(false) const fabDocked = ref(false)
const fabPosition = ref<FabPosition | null>(null)
const fabPointerStyle = ref({ const fabPointerStyle = ref({
'--agent-assistant-pointer-x': '0px', '--agent-assistant-pointer-x': '0px',
'--agent-assistant-pointer-y': '0px', '--agent-assistant-pointer-y': '0px',
'--agent-assistant-robot-tilt': '0deg', '--agent-assistant-robot-tilt': '0deg',
}) })
const fabPositionStyle = computed(() => {
const position = fabPosition.value || getDefaultFabPosition()
return {
...fabPointerStyle.value,
'--agent-assistant-fab-x': `${position.x}px`,
'--agent-assistant-fab-y': `${position.y}px`,
}
})
const fabPressed = ref(false) const fabPressed = ref(false)
const fabBubbles = ref<AgentAssistantEntryBubble[]>([]) const fabBubbles = ref<AgentAssistantEntryBubble[]>([])
const fabDragging = ref(false) const fabDragging = ref(false)
let fabIdleTimer: number | null = null let fabIdleTimer: number | null = null
let fabDragStart: { pointerId: number; x: number; y: number } | null = null let fabDragState: FabDragState | null = null
let fabSuppressNextClick = false let fabSuppressNextClick = false
let stopNotificationBubbleListener: (() => void) | null = null let stopNotificationBubbleListener: (() => void) | null = null
@@ -70,6 +98,94 @@ function createBubbleId(prefix = 'bubble') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}` return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
} }
function getViewportSize() {
return {
height: window.innerHeight || document.documentElement.clientHeight || 0,
width: window.innerWidth || document.documentElement.clientWidth || 0,
}
}
function getOpenFabSize() {
const viewport = getViewportSize()
const isMobile = viewport.width <= 600
return {
height: isMobile ? 106 : 115,
width: isMobile ? Math.min(198, Math.max(0, viewport.width - 16)) : 211,
}
}
function getDockedFabX() {
return Math.max(0, getViewportSize().width - 42)
}
function getFabSize() {
const root = document.querySelector('.agent-assistant-fab') as HTMLElement | null
const rect = root?.getBoundingClientRect()
return {
height: rect?.height || 115,
width: rect?.width || 211,
}
}
function getDefaultFabPosition() {
if (typeof window === 'undefined') return { x: 0, y: 0 }
const viewport = getViewportSize()
const size = getOpenFabSize()
// 默认落点放在视窗约 2/3 高度,避开页面底部 FAB 和移动端底部导航。
return clampFabPosition({
x: viewport.width - size.width - FAB_DEFAULT_RIGHT_OFFSET,
y: Math.round(viewport.height * FAB_DEFAULT_VERTICAL_RATIO - size.height / 2),
})
}
function clampFabPosition(position: FabPosition) {
if (typeof window === 'undefined') return position
const viewport = getViewportSize()
const size = fabDocked.value ? getFabSize() : getOpenFabSize()
const maxX = Math.max(0, viewport.width - size.width)
const maxY = Math.max(0, viewport.height - size.height)
return {
x: Math.min(maxX, Math.max(0, position.x)),
y: Math.min(maxY, Math.max(0, position.y)),
}
}
function getCurrentFabPosition() {
return fabPosition.value || getDefaultFabPosition()
}
function isFabNearRightEdge(position = getCurrentFabPosition()) {
const viewport = getViewportSize()
const size = getFabSize()
return viewport.width - (position.x + size.width) <= FAB_RIGHT_EDGE_DOCK_DISTANCE
}
function updateFabPosition(position: FabPosition) {
fabPosition.value = clampFabPosition(position)
}
function resetFabPosition() {
fabPosition.value = getDefaultFabPosition()
if (isFabNearRightEdge()) scheduleFabAutoDock()
}
function handleWindowResize() {
updateFabPosition(getCurrentFabPosition())
if (fabDocked.value && isFabNearRightEdge()) {
fabPosition.value = {
...getCurrentFabPosition(),
x: getDockedFabX(),
}
}
}
function stripMarkdownPreview(value: string) { function stripMarkdownPreview(value: string) {
return value return value
.replace(/```[\s\S]*?```/g, ' ') .replace(/```[\s\S]*?```/g, ' ')
@@ -115,10 +231,10 @@ function clearFabIdleTimer() {
function scheduleFabAutoDock() { function scheduleFabAutoDock() {
clearFabIdleTimer() clearFabIdleTimer()
if (fabDocked.value || hasKeepOpenFabBubbles.value) return if (fabDocked.value || hasKeepOpenFabBubbles.value || !isFabNearRightEdge()) return
fabIdleTimer = window.setTimeout(() => { fabIdleTimer = window.setTimeout(() => {
setFabDocked(true, { persist: false }) setFabDocked(true)
fabIdleTimer = null fabIdleTimer = null
}, FAB_IDLE_DOCK_DELAY) }, FAB_IDLE_DOCK_DELAY)
} }
@@ -154,7 +270,7 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
} }
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) { function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
if (!bubble.text) return if (!props.active || !bubble.text) return
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id) const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
fabBubbles.value = [bubble, ...existingBubbles].slice(0, FAB_MAX_BUBBLES) fabBubbles.value = [bubble, ...existingBubbles].slice(0, FAB_MAX_BUBBLES)
@@ -166,7 +282,7 @@ function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose
}) })
if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration) if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
setFabDocked(false, { persist: false }) setFabDocked(false)
} }
function showBubble(input: AgentAssistantEntryBubbleInput) { function showBubble(input: AgentAssistantEntryBubbleInput) {
@@ -231,62 +347,84 @@ function resetFabBubbles() {
fabBubbles.value = [] fabBubbles.value = []
} }
function setFabDocked(docked: boolean, options: { persist?: boolean } = {}) { function setFabDocked(docked: boolean) {
const { persist = true } = options const currentPosition = getCurrentFabPosition()
fabDocked.value = docked fabDocked.value = docked
resetFabPointer() resetFabPointer()
if (docked) clearFabIdleTimer()
else scheduleFabAutoDock()
if (!persist) return
try { if (docked) {
localStorage.setItem(FAB_DOCK_STORAGE_KEY, JSON.stringify(fabDocked.value)) clearFabIdleTimer()
} catch (error) { fabPosition.value = {
// 停靠状态只是入口偏好,写入失败时保持当前内存状态即可。 ...currentPosition,
x: getDockedFabX(),
}
return
} }
}
function restoreFabDockState() { updateFabPosition({
try { ...currentPosition,
fabDocked.value = JSON.parse(localStorage.getItem(FAB_DOCK_STORAGE_KEY) || 'true') !== false x: Math.min(currentPosition.x, Math.max(0, getViewportSize().width - getOpenFabSize().width - FAB_DEFAULT_RIGHT_OFFSET)),
} catch (error) { })
fabDocked.value = true scheduleFabAutoDock()
}
} }
function handleFabTriggerPointerDown(event: PointerEvent) { function handleFabTriggerPointerDown(event: PointerEvent) {
fabPressed.value = true fabPressed.value = true
pauseFabAutoDock() pauseFabAutoDock()
if (!fabDocked.value) return const currentPosition = getCurrentFabPosition()
fabDragState = {
fabDragStart = {
pointerId: event.pointerId, pointerId: event.pointerId,
x: event.clientX, startClientX: event.clientX,
y: event.clientY, startClientY: event.clientY,
startX: currentPosition.x,
startY: currentPosition.y,
moved: false,
} }
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId) ;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
} }
function handleFabTriggerPointerMove(event: PointerEvent) { function handleFabTriggerPointerMove(event: PointerEvent) {
updateFabPointer(event) updateFabPointer(event)
if (!fabDocked.value || !fabDragStart) return if (!fabDragState || fabDragState.pointerId !== event.pointerId) return
const movedX = fabDragStart.x - event.clientX const deltaX = event.clientX - fabDragState.startClientX
const movedY = Math.abs(fabDragStart.y - event.clientY) const deltaY = event.clientY - fabDragState.startClientY
if (movedX > FAB_DOCK_REVEAL_DISTANCE && movedY < 48) { const movedDistance = Math.hypot(deltaX, deltaY)
fabDragging.value = true
fabSuppressNextClick = true if (movedDistance < 4 && !fabDragState.moved) return
setFabDocked(false, { persist: false })
} fabDragState.moved = true
fabDragging.value = true
fabSuppressNextClick = true
fabDocked.value = false
updateFabPosition({
x: fabDragState.startX + deltaX,
y: fabDragState.startY + deltaY,
})
} }
function handleFabTriggerPointerUp(event: PointerEvent) { function handleFabTriggerPointerUp(event: PointerEvent) {
fabPressed.value = false fabPressed.value = false
const wasDragging = fabDragging.value
fabDragging.value = false fabDragging.value = false
fabDragStart = null fabDragState = null
;(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId) ;(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId)
if (!fabDocked.value) scheduleFabAutoDock()
if (!wasDragging) {
scheduleFabAutoDock()
return
}
if (isFabNearRightEdge()) {
scheduleFabAutoDock()
} else {
clearFabIdleTimer()
fabDocked.value = false
}
} }
function handleFabTriggerClick() { function handleFabTriggerClick() {
@@ -296,18 +434,17 @@ function handleFabTriggerClick() {
} }
if (fabDocked.value) { if (fabDocked.value) {
setFabDocked(false, { persist: false }) setFabDocked(false)
return return
} }
clearBubbles() clearBubbles()
setFabDocked(true, { persist: false })
emit('open') emit('open')
} }
function handleFabPointerLeave() { function handleFabPointerLeave() {
resetFabPointer() resetFabPointer()
if (!fabDocked.value) scheduleFabAutoDock() if (!fabDocked.value && isFabNearRightEdge()) scheduleFabAutoDock()
} }
function handleFabPointerEnter() { function handleFabPointerEnter() {
@@ -315,15 +452,30 @@ function handleFabPointerEnter() {
} }
onMounted(() => { onMounted(() => {
restoreFabDockState() nextTick(resetFabPosition)
window.addEventListener('resize', handleWindowResize)
stopNotificationBubbleListener = onAgentAssistantNotificationBubble(showNotificationBubble) stopNotificationBubbleListener = onAgentAssistantNotificationBubble(showNotificationBubble)
}) })
watch(
() => props.active,
active => {
if (active) {
if (isFabNearRightEdge()) scheduleFabAutoDock()
return
}
clearBubbles()
clearFabIdleTimer()
},
)
onScopeDispose(clearFabIdleTimer) onScopeDispose(clearFabIdleTimer)
onScopeDispose(resetFabBubbles) onScopeDispose(resetFabBubbles)
onScopeDispose(() => { onScopeDispose(() => {
stopNotificationBubbleListener?.() stopNotificationBubbleListener?.()
stopNotificationBubbleListener = null stopNotificationBubbleListener = null
window.removeEventListener('resize', handleWindowResize)
}) })
defineExpose({ defineExpose({
@@ -338,14 +490,16 @@ defineExpose({
<template> <template>
<div <div
v-show="props.active"
class="agent-assistant-fab" class="agent-assistant-fab"
:class="{ :class="{
'is-docked': fabDocked, 'is-docked': fabDocked,
'is-dragging': fabDragging,
'is-pressed': fabPressed, 'is-pressed': fabPressed,
'is-thinking': props.thinking, 'is-thinking': props.thinking,
'is-bubble-visible': hasFabBubbles, 'is-bubble-visible': hasFabBubbles,
}" }"
:style="fabPointerStyle" :style="fabPositionStyle"
@pointermove="updateFabPointer" @pointermove="updateFabPointer"
@pointerenter="handleFabPointerEnter" @pointerenter="handleFabPointerEnter"
@pointerleave="handleFabPointerLeave" @pointerleave="handleFabPointerLeave"
@@ -408,7 +562,9 @@ defineExpose({
.agent-assistant-fab { .agent-assistant-fab {
position: fixed; position: fixed;
z-index: 1000;
/* 保持高于菜单浮层,但低于 agent 会话面板(2101)。 */
z-index: 2100;
--agent-assistant-robot-outline: #5b00c5; --agent-assistant-robot-outline: #5b00c5;
--agent-assistant-robot-outline-soft: #7432df; --agent-assistant-robot-outline-soft: #7432df;
@@ -421,24 +577,27 @@ defineExpose({
--agent-assistant-robot-play: #fff; --agent-assistant-robot-play: #fff;
--agent-assistant-robot-shadow: rgba(54, 0, 126, 28%); --agent-assistant-robot-shadow: rgba(54, 0, 126, 28%);
--agent-assistant-robot-shadow-strong: rgba(54, 0, 126, 34%); --agent-assistant-robot-shadow-strong: rgba(54, 0, 126, 34%);
--agent-assistant-fab-x: calc(100vw - 14.3rem);
--agent-assistant-fab-y: calc(100vh - 13.2rem);
block-size: 7.2rem; block-size: 7.2rem;
inline-size: 13.2rem; inline-size: 13.2rem;
inset-block-start: 0;
/* 入口停在右下侧,但保留一点悬浮感,避免挡住底部导航和常用操作。 */ inset-inline-start: 0;
inset-block-start: clamp(12rem, 66vh, calc(100vh - 8.2rem));
inset-inline-end: max(1.1rem, env(safe-area-inset-right));
pointer-events: none; pointer-events: none;
transform: translateY(-50%); transform: translate3d(var(--agent-assistant-fab-x), var(--agent-assistant-fab-y), 0);
transition: transition:
inline-size 0.24s ease, inline-size 0.24s ease,
inset-inline-end 0.24s ease,
transform 0.24s ease; transform 0.24s ease;
user-select: none;
} }
.agent-assistant-fab.is-docked { .agent-assistant-fab.is-docked {
inline-size: 3.85rem; inline-size: 3.85rem;
inset-inline-end: max(-1.55rem, calc(env(safe-area-inset-right) - 1.55rem)); }
.agent-assistant-fab.is-dragging {
transition: none;
} }
.agent-assistant-fab__trigger { .agent-assistant-fab__trigger {
@@ -447,14 +606,19 @@ defineExpose({
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
block-size: 100%; block-size: 5.1rem;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
inline-size: 100%; inline-size: 5.4rem;
inset: 0; inset-block: auto 0;
inset-inline: auto 0;
pointer-events: auto; pointer-events: auto;
text-align: start; text-align: start;
touch-action: manipulation; touch-action: none;
}
.agent-assistant-fab.is-dragging .agent-assistant-fab__trigger {
cursor: grabbing;
} }
.agent-assistant-fab__trigger::after { .agent-assistant-fab__trigger::after {
@@ -940,14 +1104,10 @@ defineExpose({
.agent-assistant-fab { .agent-assistant-fab {
block-size: 6.65rem; block-size: 6.65rem;
inline-size: min(12.4rem, calc(100vw - 1rem)); inline-size: min(12.4rem, calc(100vw - 1rem));
inset-block: auto calc(env(safe-area-inset-bottom, 0px) + 5.6rem);
inset-inline-end: 0.7rem;
transform: none;
} }
.agent-assistant-fab.is-docked { .agent-assistant-fab.is-docked {
inline-size: 3.45rem; inline-size: 3.45rem;
inset-inline-end: -1.28rem;
} }
.agent-assistant-fab__bubbles { .agent-assistant-fab__bubbles {
@@ -970,6 +1130,11 @@ defineExpose({
font-size: 0.68rem; font-size: 0.68rem;
} }
.agent-assistant-fab__trigger {
block-size: 4.8rem;
inline-size: 5rem;
}
.agent-assistant-fab__bot { .agent-assistant-fab__bot {
inset-inline-end: 1.02rem; inset-inline-end: 1.02rem;
transform: scale(0.82) translate(var(--agent-assistant-pointer-x), var(--agent-assistant-pointer-y)) transform: scale(0.82) translate(var(--agent-assistant-pointer-x), var(--agent-assistant-pointer-y))
+1 -1
View File
@@ -21,6 +21,6 @@ function handleAssistantPreview(value: string) {
</script> </script>
<template> <template>
<AgentAssistantEntry ref="entryRef" :thinking="thinking" @open="openPanel" /> <AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" /> <AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
</template> </template>