Route toast notifications to agent assistant bubbles

This commit is contained in:
jxxghp
2026-06-24 12:55:01 +08:00
parent 7f0f12ac41
commit 2b426a47c6
3 changed files with 257 additions and 40 deletions
+129 -20
View File
@@ -1,15 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
onAgentAssistantNotificationBubble, onAgentAssistantBubble,
setAgentAssistantBubbleEntryActive,
type AgentAssistantBubbleKind,
type AgentAssistantBubblePayload,
type AgentAssistantBubbleVariant,
type AgentAssistantNotificationBubblePayload, type AgentAssistantNotificationBubblePayload,
} from '@/utils/agentAssistantBubble' } from '@/utils/agentAssistantBubble'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
type AgentAssistantEntryBubbleKind = 'assistant' | 'custom' | 'notification'
interface AgentAssistantEntryBubble { interface AgentAssistantEntryBubble {
id: string id: string
kind: AgentAssistantEntryBubbleKind kind: AgentAssistantBubbleKind
variant: AgentAssistantBubbleVariant
title?: string title?: string
text: string text: string
keepOpen?: boolean keepOpen?: boolean
@@ -17,7 +20,8 @@ interface AgentAssistantEntryBubble {
interface AgentAssistantEntryBubbleInput { interface AgentAssistantEntryBubbleInput {
id?: string id?: string
kind?: AgentAssistantEntryBubbleKind kind?: AgentAssistantBubbleKind
variant?: AgentAssistantBubbleVariant
title?: string title?: string
text: string text: string
autoClose?: boolean autoClose?: boolean
@@ -45,6 +49,7 @@ const { t } = useI18n()
const FAB_IDLE_DOCK_DELAY = 4200 const FAB_IDLE_DOCK_DELAY = 4200
const FAB_RIGHT_EDGE_DOCK_DISTANCE = 88 const FAB_RIGHT_EDGE_DOCK_DISTANCE = 88
const FAB_NOTIFICATION_BUBBLE_DURATION = 7000 const FAB_NOTIFICATION_BUBBLE_DURATION = 7000
const FAB_TOAST_BUBBLE_DURATION = 4500
const FAB_MAX_BUBBLES = 4 const FAB_MAX_BUBBLES = 4
const FAB_DEFAULT_RIGHT_OFFSET = 18 const FAB_DEFAULT_RIGHT_OFFSET = 18
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3 const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
@@ -128,7 +133,7 @@ let fabPendingPointerPoint: FabPointerPoint | null = null
let fabLastRandomAction: FabRandomAction | null = null let fabLastRandomAction: FabRandomAction | null = null
let fabRandomActionTimer: number | null = null let fabRandomActionTimer: number | null = null
let fabRandomActionEndTimer: number | null = null let fabRandomActionEndTimer: number | null = null
let stopNotificationBubbleListener: (() => void) | null = null let stopBubbleListener: (() => void) | null = null
const fabBubbleTimers = new Map<string, number>() const fabBubbleTimers = new Map<string, number>()
@@ -483,6 +488,36 @@ function buildNotificationBubbleText(payload: AgentAssistantNotificationBubblePa
return stripMarkdownPreview(payload.text || payload.title || payload.source || payload.mtype || '') return stripMarkdownPreview(payload.text || payload.title || payload.source || payload.mtype || '')
} }
function getBubbleVariant(payload: AgentAssistantBubblePayload): AgentAssistantBubbleVariant {
return payload.variant || 'default'
}
function getBubbleIcon(variant: AgentAssistantBubbleVariant) {
const icons: Record<AgentAssistantBubbleVariant, string> = {
default: 'mdi-bell-outline',
error: 'mdi-alert-circle-outline',
info: 'mdi-information-outline',
success: 'mdi-check-circle-outline',
warning: 'mdi-alert-outline',
}
return icons[variant]
}
function getToastBubbleTitle(payload: AgentAssistantBubblePayload) {
if (payload.title) return payload.title
const titles: Record<AgentAssistantBubbleVariant, string> = {
default: t('common.notice'),
error: t('common.error'),
info: t('common.notice'),
success: t('common.success'),
warning: t('common.notice'),
}
return titles[getBubbleVariant(payload)]
}
function clearFabBubbleTimer(id: string) { function clearFabBubbleTimer(id: string) {
const timer = fabBubbleTimers.get(id) const timer = fabBubbleTimers.get(id)
if (!timer) return if (!timer) return
@@ -525,6 +560,7 @@ function showBubble(input: AgentAssistantEntryBubbleInput) {
{ {
id: input.id || createBubbleId(input.kind || 'custom'), id: input.id || createBubbleId(input.kind || 'custom'),
kind: input.kind || 'custom', kind: input.kind || 'custom',
variant: input.variant || 'default',
title: input.title, title: input.title,
text, text,
keepOpen: input.keepOpen, keepOpen: input.keepOpen,
@@ -551,6 +587,7 @@ function showNotificationBubble(payload: AgentAssistantNotificationBubblePayload
showBubble({ showBubble({
id: payload.id, id: payload.id,
kind: 'notification', kind: 'notification',
variant: getBubbleVariant(payload),
title: buildNotificationBubbleTitle(payload), title: buildNotificationBubbleTitle(payload),
text, text,
autoClose: true, autoClose: true,
@@ -558,6 +595,31 @@ function showNotificationBubble(payload: AgentAssistantNotificationBubblePayload
}) })
} }
function showToastBubble(payload: AgentAssistantBubblePayload) {
const text = stripMarkdownPreview(payload.text || payload.title || '')
if (!text) return
showBubble({
id: payload.id,
kind: 'toast',
variant: getBubbleVariant(payload),
title: getToastBubbleTitle(payload),
text,
autoClose: true,
duration: payload.duration || FAB_TOAST_BUBBLE_DURATION,
keepOpen: payload.keepOpen,
})
}
function showAgentAssistantBubble(payload: AgentAssistantBubblePayload) {
if ((payload.kind || 'notification') === 'toast') {
showToastBubble(payload)
return
}
showNotificationBubble(payload as AgentAssistantNotificationBubblePayload)
}
function closeBubble(id?: string) { function closeBubble(id?: string) {
if (id) { if (id) {
clearFabBubbleTimer(id) clearFabBubbleTimer(id)
@@ -684,16 +746,19 @@ function handleFabPointerEnter() {
onMounted(() => { onMounted(() => {
nextTick(resetFabPosition) nextTick(resetFabPosition)
setAgentAssistantBubbleEntryActive(props.active)
window.addEventListener('resize', handleWindowResize) window.addEventListener('resize', handleWindowResize)
window.addEventListener('pointermove', handleGlobalFabPointer, { passive: true }) window.addEventListener('pointermove', handleGlobalFabPointer, { passive: true })
window.addEventListener('pointerdown', handleGlobalFabPointer, { passive: true }) window.addEventListener('pointerdown', handleGlobalFabPointer, { passive: true })
stopNotificationBubbleListener = onAgentAssistantNotificationBubble(showNotificationBubble) stopBubbleListener = onAgentAssistantBubble(showAgentAssistantBubble)
scheduleFabRandomAction() scheduleFabRandomAction()
}) })
watch( watch(
() => props.active, () => props.active,
active => { active => {
setAgentAssistantBubbleEntryActive(active)
if (active) { if (active) {
if (isFabNearRightEdge()) scheduleFabAutoDock() if (isFabNearRightEdge()) scheduleFabAutoDock()
return return
@@ -712,8 +777,9 @@ onScopeDispose(clearFabIdleTimer)
onScopeDispose(clearFabRandomAction) onScopeDispose(clearFabRandomAction)
onScopeDispose(resetFabBubbles) onScopeDispose(resetFabBubbles)
onScopeDispose(() => { onScopeDispose(() => {
stopNotificationBubbleListener?.() setAgentAssistantBubbleEntryActive(false)
stopNotificationBubbleListener = null stopBubbleListener?.()
stopBubbleListener = null
window.removeEventListener('resize', handleWindowResize) window.removeEventListener('resize', handleWindowResize)
teardownFabPointerTracking() teardownFabPointerTracking()
}) })
@@ -725,6 +791,7 @@ defineExpose({
showAssistantReplyPreview, showAssistantReplyPreview,
showBubble, showBubble,
showNotificationBubble, showNotificationBubble,
showToastBubble,
}) })
</script> </script>
@@ -750,10 +817,16 @@ defineExpose({
v-for="bubble in fabBubbles" v-for="bubble in fabBubbles"
:key="bubble.id" :key="bubble.id"
class="agent-assistant-fab__bubble" class="agent-assistant-fab__bubble"
:class="`agent-assistant-fab__bubble--${bubble.kind}`" :class="[
`agent-assistant-fab__bubble--${bubble.kind}`,
`agent-assistant-fab__bubble--${bubble.variant}`,
]"
role="status" role="status"
> >
<strong v-if="bubble.title">{{ bubble.title }}</strong> <strong v-if="bubble.title" class="agent-assistant-fab__bubble-title">
<VIcon class="agent-assistant-fab__bubble-icon" :icon="getBubbleIcon(bubble.variant)" size="18" />
<span>{{ bubble.title }}</span>
</strong>
<span>{{ bubble.text }}</span> <span>{{ bubble.text }}</span>
<button <button
class="agent-assistant-fab__bubble-close" class="agent-assistant-fab__bubble-close"
@@ -886,12 +959,12 @@ defineExpose({
.agent-assistant-fab__bubbles { .agent-assistant-fab__bubbles {
position: absolute; position: absolute;
display: grid; display: grid;
overflow: visible; overflow-y: auto;
gap: 0.45rem; gap: 0.45rem;
inline-size: 13.2rem; inline-size: clamp(15.5rem, 22vw, 19rem);
inset-block-end: 4.45rem; inset-block-end: 4.45rem;
inset-inline-end: 2.75rem; inset-inline-end: 2.75rem;
max-block-size: min(22rem, calc(100vh - 8rem)); max-block-size: min(34rem, calc(100vh - 8rem));
max-inline-size: calc(100vw - 6.4rem); max-inline-size: calc(100vw - 6.4rem);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@@ -905,6 +978,8 @@ defineExpose({
.agent-assistant-fab__bubble { .agent-assistant-fab__bubble {
position: relative; position: relative;
display: grid; display: grid;
--agent-assistant-bubble-accent: var(--v-theme-primary);
--agent-assistant-bubble-accent-rgb: var(--v-theme-primary);
border: 1px solid rgba(var(--v-theme-on-surface), 0.1); border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 18px; border-radius: 18px;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -921,25 +996,59 @@ defineExpose({
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94); linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
} }
.agent-assistant-fab__bubble strong { .agent-assistant-fab__bubble--success {
--agent-assistant-bubble-accent-rgb: var(--v-theme-success);
}
.agent-assistant-fab__bubble--error {
--agent-assistant-bubble-accent-rgb: var(--v-theme-error);
}
.agent-assistant-fab__bubble--warning {
--agent-assistant-bubble-accent-rgb: 245, 158, 11;
}
.agent-assistant-fab__bubble--info {
--agent-assistant-bubble-accent-rgb: 14, 165, 233;
}
.agent-assistant-fab__bubble--toast {
border-color: rgba(var(--agent-assistant-bubble-accent-rgb), 0.3);
background:
linear-gradient(135deg, rgba(var(--agent-assistant-bubble-accent-rgb), 0.12), transparent 54%),
rgba(var(--v-theme-surface), 0.95);
}
.agent-assistant-fab__bubble-title {
overflow: hidden; overflow: hidden;
color: rgba(var(--v-theme-primary), 0.92); display: inline-grid;
font-size: 0.72rem; align-items: center;
grid-template-columns: auto minmax(0, 1fr);
color: rgba(var(--agent-assistant-bubble-accent-rgb), 0.92);
column-gap: 0.32rem;
font-size: 0.92rem;
font-weight: 700; font-weight: 700;
line-height: 1.25; line-height: 1.25;
margin-block-end: 0.22rem; margin-block-end: 0.22rem;
}
.agent-assistant-fab__bubble-title span {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.agent-assistant-fab__bubble span { .agent-assistant-fab__bubble-icon {
color: rgba(var(--agent-assistant-bubble-accent-rgb), 0.92) !important;
}
.agent-assistant-fab__bubble > span {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), 0.9); color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 0.78rem; font-size: 0.84rem;
font-weight: 600; font-weight: 600;
-webkit-line-clamp: 4; -webkit-line-clamp: 8;
line-height: 1.42; line-height: 1.42;
text-align: start; text-align: start;
white-space: normal; white-space: normal;
+64 -1
View File
@@ -14,9 +14,14 @@ import App from '@/App.vue'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar' import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
// 4. 其他插件和功能模块 // 4. 其他插件和功能模块
import Toast from 'vue-toastification' import Toast, { TYPE, type PluginOptions } from 'vue-toastification'
import ConfirmDialog from '@/composables/useConfirm' import ConfirmDialog from '@/composables/useConfirm'
import { configureApexChartsTheme } from '@/utils/apexCharts' import { configureApexChartsTheme } from '@/utils/apexCharts'
import {
canUseAgentAssistantBubble,
emitAgentAssistantToastBubble,
type AgentAssistantBubbleVariant,
} from '@/utils/agentAssistantBubble'
// 5. 注册自定义组件 // 5. 注册自定义组件
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
@@ -29,6 +34,8 @@ import '@/styles/main.scss'
// 7. 状态恢复插件 // 7. 状态恢复插件
import stateRestorePlugin from '@/plugins/stateRestore' import stateRestorePlugin from '@/plugins/stateRestore'
type ToastFilterPayload = Parameters<NonNullable<PluginOptions['filterBeforeCreate']>>[0]
function runWhenBrowserIdle(callback: () => void, timeout = 1500) { function runWhenBrowserIdle(callback: () => void, timeout = 1500) {
const requestIdle = globalThis.requestIdleCallback const requestIdle = globalThis.requestIdleCallback
if (requestIdle) { if (requestIdle) {
@@ -53,6 +60,61 @@ function loadRemoteComponentsAfterLogin() {
}) })
} }
function shouldUseAgentAssistantToastBubble() {
const settings = pinia.state.value.globalSettings
if (!settings?.initialized) return false
return (
settings.data?.AI_AGENT_ENABLE === true &&
settings.data?.AI_AGENT_HIDE_ENTRY !== true &&
canUseAgentAssistantBubble()
)
}
function getAgentAssistantToastVariant(type?: ToastFilterPayload['type']): AgentAssistantBubbleVariant {
const variants: Record<string, AgentAssistantBubbleVariant> = {
[TYPE.DEFAULT]: 'default',
[TYPE.ERROR]: 'error',
[TYPE.INFO]: 'info',
[TYPE.SUCCESS]: 'success',
[TYPE.WARNING]: 'warning',
}
return variants[type || TYPE.DEFAULT] || 'default'
}
function getToastBubbleDuration(type?: ToastFilterPayload['type'], timeout?: ToastFilterPayload['timeout']) {
if (typeof timeout === 'number') return timeout
if (timeout === false) return undefined
return type === TYPE.ERROR || type === TYPE.WARNING ? 7000 : 4500
}
function getToastTextContent(content: ToastFilterPayload['content']) {
if (typeof content === 'string') return content
// 组件型 toast 可能包含操作按钮或复杂布局,无法可靠转成气泡文本时继续使用原生 toast。
return ''
}
function routeToastToAgentAssistantBubble(toast: ToastFilterPayload) {
const text = getToastTextContent(toast.content)
if (!text || !shouldUseAgentAssistantToastBubble()) return toast
const variant = getAgentAssistantToastVariant(toast.type)
emitAgentAssistantToastBubble({
id: `toast-${String(toast.id)}`,
kind: 'toast',
variant,
text,
duration: getToastBubbleDuration(toast.type, toast.timeout),
keepOpen: toast.timeout === false,
})
return false
}
let remoteComponentsInitialized = false let remoteComponentsInitialized = false
const AsyncAceEditor = defineAsyncComponent(async () => { const AsyncAceEditor = defineAsyncComponent(async () => {
@@ -111,6 +173,7 @@ app
.use(Toast, { .use(Toast, {
position: 'bottom-right', position: 'bottom-right',
hideProgressBar: true, hideProgressBar: true,
filterBeforeCreate: routeToastToAgentAssistantBubble,
}) })
.use(ConfirmDialog) .use(ConfirmDialog)
.use(i18n) .use(i18n)
+64 -19
View File
@@ -1,11 +1,20 @@
import type { SystemNotification } from '@/api/types' import type { SystemNotification } from '@/api/types'
const AGENT_ASSISTANT_BUBBLE_EVENT = 'agentAssistantBubble' const AGENT_ASSISTANT_BUBBLE_EVENT = 'agentAssistantBubble'
let agentAssistantBubbleListenerCount = 0
let agentAssistantBubbleEntryActive = false
export interface AgentAssistantNotificationBubblePayload { export type AgentAssistantBubbleKind = 'assistant' | 'custom' | 'notification' | 'toast'
export type AgentAssistantBubbleVariant = 'default' | 'info' | 'success' | 'warning' | 'error'
export interface AgentAssistantBubblePayload {
id: string id: string
kind?: AgentAssistantBubbleKind
variant?: AgentAssistantBubbleVariant
title?: string title?: string
text?: string text?: string
duration?: number
keepOpen?: boolean
type?: string type?: string
mtype?: string mtype?: string
source?: string source?: string
@@ -13,7 +22,16 @@ export interface AgentAssistantNotificationBubblePayload {
reg_time?: string reg_time?: string
} }
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantNotificationBubblePayload> {} export interface AgentAssistantNotificationBubblePayload extends AgentAssistantBubblePayload {
kind?: 'notification'
}
export interface AgentAssistantToastBubblePayload extends AgentAssistantBubblePayload {
kind: 'toast'
variant: AgentAssistantBubbleVariant
}
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantBubblePayload> {}
function createNotificationBubbleId(notification: SystemNotification) { function createNotificationBubbleId(notification: SystemNotification) {
if (notification.id) return `notification-${notification.id}` if (notification.id) return `notification-${notification.id}`
@@ -21,29 +39,44 @@ function createNotificationBubbleId(notification: SystemNotification) {
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}` return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
} }
// 通知中心和智能助手入口没有父子关系,通过全局事件传递实时通知气泡数据。 function emitAgentAssistantBubble(payload: AgentAssistantBubblePayload) {
export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
window.dispatchEvent( window.dispatchEvent(
new CustomEvent<AgentAssistantNotificationBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, { new CustomEvent<AgentAssistantBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, {
detail: { detail: payload,
id: createNotificationBubbleId(notification),
title: notification.title,
text: notification.text,
type: notification.type,
mtype: notification.mtype,
source: notification.source,
date: notification.date,
reg_time: notification.reg_time,
},
}), }),
) )
} }
export function onAgentAssistantNotificationBubble( // 通知中心、toast 和智能助手入口没有父子关系,通过全局事件传递实时气泡数据。
callback: (payload: AgentAssistantNotificationBubblePayload) => void, export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
) { emitAgentAssistantBubble({
id: createNotificationBubbleId(notification),
kind: 'notification',
title: notification.title,
text: notification.text,
type: notification.type,
mtype: notification.mtype,
source: notification.source,
date: notification.date,
reg_time: notification.reg_time,
})
}
export function emitAgentAssistantToastBubble(payload: AgentAssistantToastBubblePayload) {
emitAgentAssistantBubble(payload)
}
export function setAgentAssistantBubbleEntryActive(active: boolean) {
agentAssistantBubbleEntryActive = active
}
export function canUseAgentAssistantBubble() {
return agentAssistantBubbleEntryActive && agentAssistantBubbleListenerCount > 0
}
export function onAgentAssistantBubble(callback: (payload: AgentAssistantBubblePayload) => void) {
if (typeof window === 'undefined') return () => {} if (typeof window === 'undefined') return () => {}
const handler = (event: Event) => { const handler = (event: Event) => {
@@ -51,6 +84,18 @@ export function onAgentAssistantNotificationBubble(
} }
window.addEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler) window.addEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
agentAssistantBubbleListenerCount += 1
return () => window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler) return () => {
window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
agentAssistantBubbleListenerCount = Math.max(0, agentAssistantBubbleListenerCount - 1)
}
}
export function onAgentAssistantNotificationBubble(
callback: (payload: AgentAssistantNotificationBubblePayload) => void,
) {
return onAgentAssistantBubble(payload => {
if ((payload.kind || 'notification') === 'notification') callback(payload as AgentAssistantNotificationBubblePayload)
})
} }