Compare commits

..

18 Commits

Author SHA1 Message Date
jxxghp
7f0f12ac41 fix: adjust wave action duration and refine animation keyframes 2026-06-24 12:01:24 +08:00
jxxghp
6789d63ca1 Add happy-jump animation to agent assistant FAB 2026-06-24 08:45:44 +08:00
jxxghp
3202251f55 Add disassemble animation to assistant FAB 2026-06-24 08:33:59 +08:00
jxxghp
8e99ad9cf9 Fix agent assistant arm animation 2026-06-24 07:39:29 +08:00
jxxghp
83dde400e7 fix: refine agent wave animation 2026-06-24 07:28:06 +08:00
jxxghp
1b57f925ee Add random FAB actions and global pointer tracking 2026-06-24 07:14:52 +08:00
InfinityPacer
16428066b9 ci: review PRs with PR-Agent (#500) 2026-06-24 06:10:12 +08:00
InfinityPacer
e211a80cf4 ci: add PR-Agent review workflow (#499) 2026-06-23 23:21:55 +08:00
jxxghp
ea0b5b62d9 Remove obsolete frontend code 2026-06-23 20:16:39 +08:00
jxxghp
62dc2c4f66 feat(agent-assistant): enhance FAB interactive bounds calculation for improved drag functionality 2026-06-23 19:58:13 +08:00
jxxghp
b2a2c7080e feat(agent-assistant): add active prop to AgentAssistantEntry for visibility control 2026-06-23 17:56:53 +08:00
jxxghp
05c2e7855a feat: add agent assistant notification bubble functionality
- Introduced a new utility for managing agent assistant notifications.
- Created functions to emit and listen for notification bubbles using custom events.
- Defined types for notification payloads to ensure type safety.
2026-06-23 17:37:48 +08:00
jxxghp
8d9c622dc5 Improve agent assistant message scrolling 2026-06-23 16:24:23 +08:00
jxxghp
bf0b17c314 style(agent-assistant): update FAB styles for transparency and layout adjustments 2026-06-23 16:13:00 +08:00
jxxghp
37f31f6554 style(agent-assistant): update FAB and mini-bot styles with new color variables 2026-06-23 15:57:11 +08:00
jxxghp
3de409fb07 style(agent-assistant): update FAB styles for transparency and bubble background 2026-06-23 15:52:07 +08:00
jxxghp
7e9c0fd206 feat(notification): support scoped message clearing 2026-06-23 13:43:33 +08:00
jxxghp
fb4f5658a8 更新 package.json 2026-06-23 11:46:32 +08:00
47 changed files with 3270 additions and 286 deletions

123
.github/workflows/pr-agent.yml vendored Normal file
View File

@@ -0,0 +1,123 @@
name: PR Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets因此 workflow 只运行固定 digest 的 PR-Agent 容器。
types:
- opened
- reopened
- ready_for_review
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# issue_comment 同时覆盖普通 issue因此 job 里还会再判断是否属于 PR。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments手动命令和总结评论需要该权限。
issues: write
jobs:
pr-agent:
name: PR-Agent review and describe
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
if: >-
github.event.sender.type != 'Bot' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
)
)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Run PR-Agent
id: pragent
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
env:
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
GITHUB_TOKEN: ${{ github.token }}
# 仓库设置中添加的 SecretSettings -> Secrets and variables -> Actions。
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
# 模型、输出语言和大 diff 处理策略。
config.model: "gpt-5.5"
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: "zh-CN"
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# pull_request_target 事件默认自动执行 /review 和 /describe/improve 保持手动触发。
github_action_config.auto_review: "true"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "false"
# 允许触发自动工具的 PR 动作。包含 synchronize便于新 commit 推送后刷新结果。
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
# 保留 action outputs便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.enable_pr_diagram: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
pr_reviewer.extra_instructions: |
请用中文输出。
优先指出 P0/P1 风险,避免纠结纯格式问题。
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
pr_reviewer.num_max_findings: "5"
pr_reviewer.persistent_comment: "true"
pr_reviewer.publish_output_no_suggestions: "true"
pr_reviewer.require_tests_review: "true"
pr_reviewer.require_security_review: "true"
pr_reviewer.require_estimate_effort_to_review: "true"
pr_reviewer.require_can_be_split_review: "true"
pr_reviewer.require_todo_scan: "false"
pr_reviewer.enable_review_labels_effort: "false"
pr_reviewer.enable_review_labels_security: "true"
# /improve 和 /ask 的手动命令策略。
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "7"
pr_code_suggestions.commitable_code_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
# pr_reviewer.num_max_findings: "3"

1
.gitignore vendored
View File

@@ -37,3 +37,4 @@ src/@iconify/*.js
public/plugin_icon/**
docs-lock/
.trae/
output/

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.13",
"version": "2.13.14",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -9,7 +9,7 @@ import { checkAndEmitUnreadMessages } from '@/utils/badge'
import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
import { completeLaunchLoading } from '@/composables/useLaunchLoading'

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import type { PerfectScrollbarExpose } from 'vue3-perfect-scrollbar'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useUserStore } from '@/stores'
@@ -119,6 +118,21 @@ const display = useDisplay()
const authStore = useAuthStore()
const userStore = useUserStore()
const props = withDefaults(
defineProps<{
modelValue?: boolean
}>(),
{
modelValue: false,
},
)
const emit = defineEmits<{
'assistant-preview': [value: string]
'thinking-change': [value: boolean]
'update:modelValue': [value: boolean]
}>()
const STORAGE_KEY = 'moviepilot-agent-assistant-state'
const HISTORY_STORAGE_KEY = 'moviepilot-agent-assistant-history'
const HISTORY_PAGE_SIZE = 30
@@ -126,8 +140,9 @@ const MAX_LOCAL_HISTORY_SESSIONS = 120
const MAX_PERSISTED_MESSAGES = 30
const HISTORY_TITLE_LENGTH = 36
const HISTORY_ITEM_HEIGHT = 76
const MESSAGE_SCROLL_FOLLOW_THRESHOLD = 96
const STREAM_STATE_PERSIST_DELAY = 1000
const drawer = ref(false)
const inputText = ref('')
const messages = ref<AgentChatMessage[]>([])
const historySessions = ref<AgentSessionHistoryItem[]>([])
@@ -135,7 +150,7 @@ const sessionId = ref('')
const sending = ref(false)
const streamError = ref('')
const historyMenuOpen = ref(false)
const messageListRef = ref<PerfectScrollbarExpose | null>(null)
const messageListRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingAttachments = ref<AgentPendingAttachment[]>([])
@@ -151,6 +166,9 @@ let mediaRecorder: MediaRecorder | null = null
let mediaRecorderStream: MediaStream | null = null
let recordingTimer: number | null = null
let recordingChunks: BlobPart[] = []
let messageScrollFrame: number | null = null
let pendingMessageScrollToBottom = false
let streamPersistTimer: number | null = null
const md = new MarkdownIt({
html: true,
@@ -183,6 +201,10 @@ const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem')
const hasMessages = computed(() => messages.value.length > 0)
const hasHistorySessions = computed(() => historySessions.value.length > 0)
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
const isOpen = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const drawerStyle = computed(() => ({
'--agent-assistant-panel-width': drawerWidth.value,
}))
@@ -543,21 +565,46 @@ function resolveApiUrl(path: string) {
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
}
// PerfectScrollbar 使
// 使 JS
function getMessageScrollerElement() {
return messageListRef.value?.ps?.element || null
return messageListRef.value
}
function scrollToBottom() {
nextTick(() => {
requestAnimationFrame(() => {
const scroller = getMessageScrollerElement()
if (!scroller) return
function isMessageScrollerNearBottom() {
const scroller = getMessageScrollerElement()
if (!scroller) return true
messageListRef.value?.ps?.update()
scroller.scrollTop = scroller.scrollHeight
messageListRef.value?.ps?.update()
})
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
}
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
const { toBottom = false } = options
pendingMessageScrollToBottom ||= toBottom
if (messageScrollFrame !== null) return
messageScrollFrame = window.requestAnimationFrame(() => {
messageScrollFrame = null
const scroller = getMessageScrollerElement()
if (!scroller) return
if (pendingMessageScrollToBottom) scroller.scrollTop = scroller.scrollHeight
pendingMessageScrollToBottom = false
})
}
function scrollToBottom(options: { smooth?: boolean } = {}) {
const { smooth = false } = options
nextTick(() => {
const scroller = getMessageScrollerElement()
if (!scroller) return
if (smooth) {
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
scheduleMessageScrollerUpdate()
return
}
scheduleMessageScrollerUpdate({ toBottom: true })
})
}
@@ -568,13 +615,34 @@ function scrollToTop() {
const scroller = getMessageScrollerElement()
if (!scroller) return
messageListRef.value?.ps?.update()
scroller.scrollTop = 0
messageListRef.value?.ps?.update()
})
})
}
function clearStreamPersistTimer() {
if (streamPersistTimer === null) return
window.clearTimeout(streamPersistTimer)
streamPersistTimer = null
}
function clearMessageScrollFrame() {
if (messageScrollFrame === null) return
window.cancelAnimationFrame(messageScrollFrame)
messageScrollFrame = null
pendingMessageScrollToBottom = false
}
function scheduleStreamPersist() {
clearStreamPersistTimer()
streamPersistTimer = window.setTimeout(() => {
persistState()
streamPersistTimer = null
}, STREAM_STATE_PERSIST_DELAY)
}
function syncInputHeight() {
nextTick(() => {
const input = inputRef.value
@@ -626,9 +694,12 @@ function markToolsDone(message: AgentChatMessage) {
}
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
const shouldFollowBottom = isMessageScrollerNearBottom()
switch (event.type) {
case 'delta':
assistantMessage.content += event.content || ''
emit('assistant-preview', assistantMessage.content)
break
case 'tool':
markToolsDone(assistantMessage)
@@ -661,6 +732,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
assistantMessage.status = 'error'
// AI
assistantMessage.content ||= event.message || t('agentAssistant.error')
emit('assistant-preview', assistantMessage.content)
markToolsDone(assistantMessage)
break
case 'start':
@@ -670,9 +742,10 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
break
}
refreshMessageList()
persistState()
scrollToBottom()
scheduleStreamPersist()
nextTick(() => {
scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
})
}
function parseSseBlock(block: string) {
@@ -873,6 +946,7 @@ async function streamAgentMessage(
const assistantMessage = addMessage('assistant', '', 'streaming')
abortController = new AbortController()
let shouldFollowBottomAfterStream = true
try {
const response = await fetch(resolveApiUrl('message/agent/stream'), {
@@ -898,6 +972,7 @@ async function streamAgentMessage(
}
await readAgentStream(response, assistantMessage)
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
if (assistantMessage.status === 'streaming') {
assistantMessage.status = 'done'
markToolsDone(assistantMessage)
@@ -917,6 +992,7 @@ async function streamAgentMessage(
refreshMessageList()
} finally {
abortController = null
clearStreamPersistTimer()
persistState()
try {
await saveCurrentSessionToServer()
@@ -924,7 +1000,7 @@ async function streamAgentMessage(
} catch (error) {
//
}
scrollToBottom()
if (shouldFollowBottomAfterStream) scrollToBottom()
}
}
@@ -1191,13 +1267,8 @@ function formatHistoryTime(timestamp: number) {
}).format(new Date(timestamp))
}
function openDrawer() {
drawer.value = true
scrollToBottom()
}
function closeDrawer() {
drawer.value = false
isOpen.value = false
}
function syncAgentAssistantOpenState(isOpen: boolean) {
@@ -1225,7 +1296,7 @@ function clearAgentAssistantOpenState() {
}
function handleGlobalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && drawer.value) closeDrawer()
if (event.key === 'Escape' && isOpen.value) closeDrawer()
}
function handleInputKeydown(event: KeyboardEvent) {
@@ -1234,11 +1305,17 @@ function handleInputKeydown(event: KeyboardEvent) {
sendMessage()
}
watch(drawer, syncAgentAssistantOpenState, { immediate: true })
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
watch(drawerWidth, () => {
if (drawer.value) syncAgentAssistantOpenState(true)
if (isOpen.value) syncAgentAssistantOpenState(true)
})
watch(isOpen, open => {
if (open) scrollToBottom()
})
watch(sending, value => emit('thinking-change', value), { immediate: true })
onMounted(() => {
restoreHistorySessions()
restoreState()
@@ -1250,6 +1327,8 @@ onMounted(() => {
onScopeDispose(clearAgentAssistantOpenState)
onScopeDispose(clearPendingAttachments)
onScopeDispose(cancelVoiceRecording)
onScopeDispose(clearMessageScrollFrame)
onScopeDispose(clearStreamPersistTimer)
onScopeDispose(() => {
if (typeof window === 'undefined') return
@@ -1258,19 +1337,8 @@ onScopeDispose(() => {
</script>
<template>
<button
v-if="!drawer"
class="agent-assistant-fab"
type="button"
:aria-label="t('agentAssistant.title')"
:title="t('agentAssistant.title')"
@click="openDrawer"
>
<VIcon class="agent-assistant-fab__icon" icon="lucide:bot" size="21" />
</button>
<aside
v-show="drawer"
v-show="isOpen"
class="agent-assistant-panel"
:style="drawerStyle"
role="dialog"
@@ -1280,7 +1348,16 @@ onScopeDispose(() => {
<header class="agent-assistant-header">
<div class="agent-assistant-title">
<div class="agent-assistant-title__mark">
<VIcon icon="lucide:bot" size="22" />
<span class="agent-assistant-mini-bot" aria-hidden="true">
<span class="agent-assistant-mini-bot__antenna" />
<span class="agent-assistant-mini-bot__head">
<span class="agent-assistant-mini-bot__face">
<span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--left" />
<span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--right" />
</span>
</span>
<span class="agent-assistant-mini-bot__body" />
</span>
</div>
<div>
<div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
@@ -1383,153 +1460,157 @@ onScopeDispose(() => {
</div>
</header>
<PerfectScrollbar
<main
ref="messageListRef"
tag="main"
class="agent-assistant-messages"
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
:options="{ wheelPropagation: false }"
>
<div v-if="!hasMessages" class="agent-assistant-empty">
<div class="agent-assistant-empty__mark">
<VIcon icon="lucide:sparkles" size="28" />
</div>
<div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
<div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
</div>
<div
v-for="message in messages"
:key="message.id"
class="agent-assistant-message"
:class="`agent-assistant-message--${message.role}`"
>
<div class="agent-assistant-message__meta">
<VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
</div>
<div v-if="message.tools.length" class="agent-assistant-tools">
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
<VIcon
:icon="
tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
/>
<span>{{ tool.message }}</span>
<div class="agent-assistant-messages__content">
<div v-if="!hasMessages" class="agent-assistant-empty">
<div class="agent-assistant-empty__mark">
<VIcon icon="lucide:sparkles" size="28" />
</div>
<div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
<div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
</div>
<div
v-if="message.content"
class="agent-assistant-message__bubble markdown-body"
v-html="renderMarkdown(message.content)"
/>
v-for="message in messages"
:key="message.id"
class="agent-assistant-message"
:class="`agent-assistant-message--${message.role}`"
>
<div class="agent-assistant-message__meta">
<VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
</div>
<div v-if="message.choices.length" class="agent-assistant-choices">
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
<div class="agent-assistant-choice__prompt">{{ choice.prompt }}</div>
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
<VIcon icon="mdi-check-circle-outline" size="16" />
<span>{{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }}</span>
</div>
<div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
<VIcon icon="mdi-alert-circle-outline" size="16" />
<span>{{ t('agentAssistant.choiceExpired') }}</span>
</div>
<div class="agent-assistant-choice__buttons">
<VBtn
v-for="button in choice.buttons"
:key="button.callback_data"
size="small"
rounded="lg"
variant="tonal"
color="primary"
:disabled="sending || choice.status !== 'pending'"
@click="handleChoiceClick(choice, button)"
>
{{ button.label }}
</VBtn>
<div v-if="message.tools.length" class="agent-assistant-tools">
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
<VIcon
:icon="
tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
/>
<span>{{ tool.message }}</span>
</div>
</div>
</div>
<div v-if="message.attachments.length" class="agent-assistant-attachments">
<div
v-for="attachment in message.attachments"
:key="`${message.id}-${attachment.url}`"
class="agent-assistant-attachment"
:class="`agent-assistant-attachment--${attachment.kind}`"
>
<img
v-if="attachment.kind === 'image'"
class="agent-assistant-attachment__image"
:src="resolveAttachmentUrl(attachment.url)"
:alt="getAttachmentName(attachment)"
loading="lazy"
/>
v-if="message.content"
class="agent-assistant-message__bubble markdown-body"
v-html="renderMarkdown(message.content)"
/>
<template v-else-if="attachment.kind === 'audio'">
<div class="agent-assistant-attachment__meta">
<VIcon :icon="getAttachmentIcon(attachment)" size="18" />
<span>{{ getAttachmentName(attachment) }}</span>
<div v-if="message.choices.length" class="agent-assistant-choices">
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
<div class="agent-assistant-choice__prompt">{{ choice.prompt }}</div>
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
<VIcon icon="mdi-check-circle-outline" size="16" />
<span>{{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }}</span>
</div>
<audio class="agent-assistant-attachment__audio" controls :src="resolveAttachmentUrl(attachment.url)" />
<VBtn
class="agent-assistant-surface-btn"
:href="getAttachmentDownloadUrl(attachment)"
:download="getAttachmentName(attachment)"
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-download"
>
{{ t('agentAssistant.download') }}
</VBtn>
</template>
<div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
<VIcon icon="mdi-alert-circle-outline" size="16" />
<span>{{ t('agentAssistant.choiceExpired') }}</span>
</div>
<div class="agent-assistant-choice__buttons">
<VBtn
v-for="button in choice.buttons"
:key="button.callback_data"
size="small"
rounded="lg"
variant="tonal"
color="primary"
:disabled="sending || choice.status !== 'pending'"
@click="handleChoiceClick(choice, button)"
>
{{ button.label }}
</VBtn>
</div>
</div>
</div>
<template v-else>
<div class="agent-assistant-attachment__file">
<VIcon :icon="getAttachmentIcon(attachment)" size="22" />
<div class="agent-assistant-attachment__file-text">
<div v-if="message.attachments.length" class="agent-assistant-attachments">
<div
v-for="attachment in message.attachments"
:key="`${message.id}-${attachment.url}`"
class="agent-assistant-attachment"
:class="`agent-assistant-attachment--${attachment.kind}`"
>
<img
v-if="attachment.kind === 'image'"
class="agent-assistant-attachment__image"
:src="resolveAttachmentUrl(attachment.url)"
:alt="getAttachmentName(attachment)"
loading="lazy"
/>
<template v-else-if="attachment.kind === 'audio'">
<div class="agent-assistant-attachment__meta">
<VIcon :icon="getAttachmentIcon(attachment)" size="18" />
<span>{{ getAttachmentName(attachment) }}</span>
<small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
</div>
<audio
class="agent-assistant-attachment__audio"
controls
:src="resolveAttachmentUrl(attachment.url)"
/>
<VBtn
class="agent-assistant-surface-btn"
:href="getAttachmentDownloadUrl(attachment)"
:download="getAttachmentName(attachment)"
icon
variant="text"
size="small"
variant="tonal"
color="primary"
:aria-label="t('agentAssistant.download')"
prepend-icon="mdi-download"
>
<VIcon icon="mdi-download" />
{{ t('agentAssistant.download') }}
</VBtn>
</div>
</template>
</template>
<template v-else>
<div class="agent-assistant-attachment__file">
<VIcon :icon="getAttachmentIcon(attachment)" size="22" />
<div class="agent-assistant-attachment__file-text">
<span>{{ getAttachmentName(attachment) }}</span>
<small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
</div>
<VBtn
class="agent-assistant-surface-btn"
:href="getAttachmentDownloadUrl(attachment)"
:download="getAttachmentName(attachment)"
icon
variant="text"
color="primary"
:aria-label="t('agentAssistant.download')"
>
<VIcon icon="mdi-download" />
</VBtn>
</div>
</template>
</div>
</div>
<div
v-if="
!message.content &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
"
class="agent-assistant-typing"
>
<span />
<span />
<span />
</div>
</div>
<div
v-if="
!message.content &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
"
class="agent-assistant-typing"
>
<span />
<span />
<span />
</div>
</div>
</PerfectScrollbar>
</main>
<footer class="agent-assistant-composer">
<VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
@@ -1635,45 +1716,6 @@ onScopeDispose(() => {
/* stylelint-disable selector-pseudo-class-no-unknown */
/* stylelint-disable no-descending-specificity */
.agent-assistant-fab {
position: fixed;
z-index: 1000;
display: grid;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 999px 0 0 999px;
backdrop-filter: blur(10px);
background: rgba(var(--v-theme-surface), 0.86);
block-size: 2.5rem;
border-inline-end: 0;
box-shadow: var(--app-surface-shadow);
color: rgb(var(--v-theme-primary));
inline-size: 2.8rem;
/* 入口避开屏幕正中,放到视觉上更轻的下三分之一位置。 */
inset-block-start: clamp(8rem, 66.666vh, calc(100vh - 8rem));
inset-inline-end: 0;
place-items: center;
transform: translate(1rem, -50%);
transition:
inset-inline-end 0.2s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
}
.agent-assistant-fab:hover {
box-shadow: var(--app-surface-hover-shadow);
transform: translate(0, -50%);
}
.agent-assistant-fab__icon {
transform: rotate(-90deg);
transition: transform 0.18s ease;
}
.agent-assistant-fab:hover .agent-assistant-fab__icon {
transform: rotate(0);
}
.agent-assistant-panel {
position: fixed;
z-index: 2101;
@@ -1703,7 +1745,7 @@ onScopeDispose(() => {
position: relative;
display: grid;
block-size: 100%;
grid-template-rows: auto 1fr;
grid-template-rows: auto minmax(0, 1fr);
min-block-size: 0;
--agent-assistant-assistant-bg: rgba(var(--v-theme-surface), 0.92);
@@ -1734,12 +1776,123 @@ onScopeDispose(() => {
align-items: center;
justify-content: center;
border-radius: 12px;
--agent-assistant-mini-robot-outline: #5b00c5;
--agent-assistant-mini-robot-outline-soft: #7432df;
--agent-assistant-mini-robot-shell-start: #d3bbff;
--agent-assistant-mini-robot-shell-mid: #a576ff;
--agent-assistant-mini-robot-shell-end: #8d51f9;
--agent-assistant-mini-robot-face-start: #24124e;
--agent-assistant-mini-robot-face-end: #100525;
--agent-assistant-mini-robot-eye: #f1dcff;
background: rgba(var(--v-theme-primary), 0.12);
block-size: 2.5rem;
color: rgb(var(--v-theme-primary));
inline-size: 2.5rem;
}
.agent-assistant-mini-bot,
.agent-assistant-mini-bot span {
box-sizing: border-box;
}
.agent-assistant-mini-bot {
position: relative;
display: block;
block-size: 1.85rem;
inline-size: 1.85rem;
}
.agent-assistant-mini-bot__antenna {
position: absolute;
display: block;
border-radius: 999px;
background: var(--agent-assistant-mini-robot-outline);
block-size: 0.42rem;
inline-size: 0.12rem;
inset-block-start: 0;
inset-inline-start: 1.18rem;
transform: rotate(20deg);
transform-origin: bottom center;
}
.agent-assistant-mini-bot__antenna::after {
position: absolute;
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
border-radius: 999px;
background: var(--agent-assistant-mini-robot-shell-start);
block-size: 0.28rem;
content: '';
inline-size: 0.28rem;
inset-block-start: -0.24rem;
inset-inline-start: -0.09rem;
}
.agent-assistant-mini-bot__head {
position: absolute;
display: block;
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
border-radius: 8px;
background: linear-gradient(
145deg,
var(--agent-assistant-mini-robot-shell-start) 0%,
var(--agent-assistant-mini-robot-shell-end) 100%
);
block-size: 1.04rem;
box-shadow:
inset 0 -0.12rem 0 rgba(54, 0, 126, 0.22),
inset 0.08rem 0.08rem 0 rgba(255, 255, 255, 0.22);
inline-size: 1.45rem;
inset-block-start: 0.42rem;
inset-inline-start: 0.2rem;
}
.agent-assistant-mini-bot__face {
position: absolute;
display: block;
border: 1.5px solid var(--agent-assistant-mini-robot-outline-soft);
border-radius: 6px;
background: linear-gradient(180deg, var(--agent-assistant-mini-robot-face-start) 0%, var(--agent-assistant-mini-robot-face-end) 100%);
block-size: 0.62rem;
inline-size: 1rem;
inset-block-start: 0.18rem;
inset-inline-start: 0.16rem;
}
.agent-assistant-mini-bot__eye {
position: absolute;
display: block;
animation: agent-fab-blink 4.8s ease-in-out infinite;
border-radius: 0 0 999px 999px;
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
block-size: 0.24rem;
inline-size: 0.22rem;
inset-block-start: 0.16rem;
}
.agent-assistant-mini-bot__eye--left {
inset-inline-start: 0.22rem;
}
.agent-assistant-mini-bot__eye--right {
inset-inline-end: 0.22rem;
}
.agent-assistant-mini-bot__body {
position: absolute;
display: block;
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
border-radius: 0.4rem;
background: linear-gradient(
145deg,
var(--agent-assistant-mini-robot-shell-mid) 0%,
var(--agent-assistant-mini-robot-shell-end) 82%
);
block-size: 0.54rem;
inline-size: 0.98rem;
inset-block-start: 1.3rem;
inset-inline-start: 0.44rem;
}
.agent-assistant-status {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.78rem;
@@ -1876,25 +2029,28 @@ onScopeDispose(() => {
}
.agent-assistant-messages {
display: flex;
box-sizing: border-box;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding-block: 1rem;
padding-inline: 1rem;
scroll-behavior: auto;
scrollbar-gutter: stable;
scrollbar-width: thin;
}
:deep(.ps__rail-x),
:deep(.ps__rail-y) {
display: none !important;
}
.agent-assistant-messages__content {
display: flex;
flex-direction: column;
min-block-size: 100%;
}
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
.agent-assistant-messages--has-content {
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 6rem);
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
}
.agent-assistant-empty {
@@ -2443,6 +2599,22 @@ onScopeDispose(() => {
}
}
@keyframes agent-fab-blink {
0%,
4%,
8%,
100% {
opacity: 1;
scale: 1 1;
}
6% {
opacity: 0.45;
scale: 1 0.15;
}
}
@media (width <= 960px) {
.agent-assistant-panel {
inline-size: 100vw !important;
@@ -2469,4 +2641,14 @@ onScopeDispose(() => {
inset-inline: 0.85rem;
}
}
@media (prefers-reduced-motion: reduce) {
.agent-assistant-mini-bot__eye,
.agent-assistant-typing span {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
function openPanel() {
panelOpen.value = true
entryRef.value?.clearBubbles()
}
function handleAssistantPreview(value: string) {
if (panelOpen.value) return
entryRef.value?.showAssistantReplyPreview(value)
}
</script>
<template>
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
</template>

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
import { PropType } from 'vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'

View File

@@ -6,8 +6,8 @@ import useDragAndDrop from '@core/utils/workflow'
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toastification'
import api from '@/api'
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
import DropzoneBackground from '@/components/workflow/DropzoneBackground.vue'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import { useI18n } from 'vue-i18n'

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import FileNavigator from './filebrowser/FileNavigator.vue'
import FileList from './FileList.vue'
import FileToolbar from './FileToolbar.vue'
import FileNavigator from './FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios'

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import DefaultLayout from './components/DefaultLayout.vue'
import DefaultLayout from './default/components/DefaultLayout.vue'
const route = useRoute()

View File

@@ -2,15 +2,15 @@
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
import Footer from '@/layouts/components/Footer.vue'
import UserNofification from '@/layouts/components/UserNotification.vue'
import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import QuickAccess from '@/layouts/components/QuickAccess.vue'
import HeaderTab from '@/layouts/components/HeaderTab.vue'
import AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
import Footer from './Footer.vue'
import UserNofification from './UserNotification.vue'
import SearchBar from './SearchBar.vue'
import ShortcutBar from './ShortcutBar.vue'
import UserProfile from './UserProfile.vue'
import QuickAccess from './QuickAccess.vue'
import HeaderTab from './HeaderTab.vue'
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
import ThemeCustomizer from '@/components/theme/ThemeCustomizer.vue'
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
@@ -28,7 +28,7 @@ import {
} from '@/utils/permission'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { usePWA } from '@/composables/usePWA'
import OfflinePage from '@/layouts/components/OfflinePage.vue'
import OfflinePage from './OfflinePage.vue'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import {
readThemeCustomizerSettings,

View File

@@ -2,6 +2,7 @@
import type { SystemNotification } from '@/api/types'
import api from '@/api'
import { clearUnreadMessages } from '@/utils/badge'
import { emitAgentAssistantNotificationBubble } from '@/utils/agentAssistantBubble'
import { formatDateDifference } from '@core/utils/formatters'
import { useBackground } from '@/composables/useBackground'
import { useToast } from 'vue-toastification'
@@ -11,6 +12,8 @@ import { useConfirm } from '@/composables/useConfirm'
type NotificationDisplayItem =
| { kind: 'section'; key: string; title: string; count: number }
| { kind: 'notification'; key: string; notification: SystemNotification }
type NotificationClearScope = 'all' | 'system' | 'media'
type NotificationClearBefore = Record<NotificationClearScope, number>
const { t } = useI18n()
const { useDelayedSSE } = useBackground()
@@ -20,6 +23,7 @@ const createConfirm = useConfirm()
const PAGE_SIZE = 20
// VVirtualScroll itemRef
const NOTIFICATION_ITEM_HEIGHT = 136
const MAX_FILTERED_PAGES_PER_LOAD = 5
const CLEAR_NOTIFICATION_ENDPOINTS = ['message/notification', 'message/notification/clear']
const NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY = 'moviepilot-notification-clear-before'
@@ -36,20 +40,83 @@ const expandedNotificationKeys = ref(new Set<string>())
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
const notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.value))
const notificationClearCounts = computed(() => getNotificationClearCounts())
const notificationClearOptions = computed(() => [
{
scope: 'system' as const,
title: t('notification.clearSystemMessages'),
icon: 'mdi-alert-circle-outline',
color: 'error',
count: notificationClearCounts.value.system,
},
{
scope: 'media' as const,
title: t('notification.clearMediaMessages'),
icon: 'mdi-image-outline',
color: 'primary',
count: notificationClearCounts.value.media,
},
{
scope: 'all' as const,
title: t('notification.clearAllMessages'),
icon: 'mdi-trash-can-outline',
color: 'secondary',
count: notificationClearCounts.value.all,
},
])
/** 生成默认清理时间,分别记录全部、系统消息和媒体消息的清理范围。 */
function createDefaultNotificationClearBefore(): NotificationClearBefore {
return {
all: 0,
system: 0,
media: 0,
}
}
/** 规范化清理时间,兼容旧版本只存一个数字的本地存储格式。 */
function normalizeNotificationClearBefore(value: unknown): NotificationClearBefore {
const clearBefore = createDefaultNotificationClearBefore()
if (typeof value === 'number' && Number.isFinite(value)) {
clearBefore.all = value
return clearBefore
}
if (!value || typeof value !== 'object') return clearBefore
const scopes: NotificationClearScope[] = ['all', 'system', 'media']
scopes.forEach(scope => {
const scopeValue = Number((value as Partial<NotificationClearBefore>)[scope] || 0)
clearBefore[scope] = Number.isFinite(scopeValue) ? scopeValue : 0
})
return clearBefore
}
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
function readNotificationClearBefore() {
if (typeof localStorage === 'undefined') return 0
if (typeof localStorage === 'undefined') return createDefaultNotificationClearBefore()
return Number(localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY) || 0)
const storedValue = localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY)
if (!storedValue) return createDefaultNotificationClearBefore()
try {
return normalizeNotificationClearBefore(JSON.parse(storedValue))
} catch {
return normalizeNotificationClearBefore(Number(storedValue))
}
}
/** 写入通知清理时间戳,使清理结果在刷新后仍然生效。 */
function writeNotificationClearBefore(value: number) {
notificationClearBefore.value = value
/** 写入指定范围的通知清理时间戳,使清理结果在刷新后仍然生效。 */
function writeNotificationClearBefore(scope: NotificationClearScope, value: number) {
notificationClearBefore.value = {
...notificationClearBefore.value,
[scope]: value,
}
if (typeof localStorage === 'undefined') return
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, String(value))
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, JSON.stringify(notificationClearBefore.value))
}
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
@@ -120,7 +187,8 @@ function parseNotificationTime(value: string) {
/** 判断历史通知是否早于本地清理时间,需要从列表中过滤。 */
function isClearedHistoryNotification(item: SystemNotification) {
const clearBefore = notificationClearBefore.value
const scope = getNotificationClearScope(item)
const clearBefore = Math.max(notificationClearBefore.value.all, notificationClearBefore.value[scope])
if (!clearBefore) return false
const notificationTime = parseNotificationTime(getNotificationTime(item))
@@ -198,7 +266,74 @@ function resetNotifications() {
hasNewMessage.value = false
}
/** 通过后端接口清理通知历史,兼容新旧后端可能暴露的清理路径。 */
/** 重新根据当前列表生成去重 key避免分类清理后遗留已移除消息的去重状态。 */
function rebuildNotificationKeys() {
notificationKeys.clear()
notificationList.value.forEach(item => {
getNotificationKeys(item).forEach(key => notificationKeys.add(key))
})
}
/** 清理已移除通知的展开状态,避免虚拟列表复用时保留无效 key。 */
function rebuildExpandedNotificationKeys() {
const currentKeys = new Set(notificationList.value.map(getNotificationExpansionKey))
expandedNotificationKeys.value = new Set(
[...expandedNotificationKeys.value].filter(key => currentKeys.has(key)),
)
}
/** 列表内容变化后同步未读红点和应用角标状态。 */
function syncUnreadStateAfterListChange() {
hasNewMessage.value = hasUnreadNotifications.value
if (!hasUnreadNotifications.value) void clearUnreadMessages()
}
/** 统计当前已加载通知中各清理范围的数量,用于菜单展示和禁用空操作。 */
function getNotificationClearCounts() {
const counts: Record<NotificationClearScope, number> = {
all: notificationList.value.length,
system: 0,
media: 0,
}
notificationList.value.forEach(item => {
counts[getNotificationClearScope(item)] += 1
})
return counts
}
/** 移除指定范围的通知,并让分页从第一页重新校验,方便继续加载剩余分类历史。 */
function removeNotificationsByScope(scope: NotificationClearScope) {
if (scope === 'all') {
resetNotifications()
hasMore.value = false
return
}
notificationList.value = notificationList.value.filter(item => getNotificationClearScope(item) !== scope)
page.value = 1
hasMore.value = true
rebuildNotificationKeys()
rebuildExpandedNotificationKeys()
syncUnreadStateAfterListChange()
}
/** 获取不同清理范围的确认文案。 */
function getClearConfirmText(scope: NotificationClearScope) {
if (scope === 'system') return t('notification.clearSystemConfirm')
if (scope === 'media') return t('notification.clearMediaConfirm')
return t('notification.clearAllConfirm')
}
/** 获取不同清理范围的成功文案。 */
function getClearSuccessText(scope: NotificationClearScope) {
if (scope === 'system') return t('notification.clearSystemSuccess')
if (scope === 'media') return t('notification.clearMediaSuccess')
return t('notification.clearAllSuccess')
}
/** 通过后端接口清理全部通知历史,兼容新旧后端可能暴露的清理路径。 */
async function deleteNotificationHistory() {
let lastError: unknown = null
@@ -214,8 +349,10 @@ async function deleteNotificationHistory() {
throw lastError
}
/** 尝试调用后端清理接口,不支持时回退为本地清理。 */
async function tryDeleteNotificationHistory() {
/** 尝试调用后端清理接口;分类清理使用本地时间戳过滤以兼容当前全量接口语义。 */
async function tryDeleteNotificationHistory(scope: NotificationClearScope) {
if (scope !== 'all') return true
try {
const result: { [key: string]: any } = await deleteNotificationHistory()
return result?.success !== false
@@ -226,31 +363,32 @@ async function tryDeleteNotificationHistory() {
}
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
async function clearNotifications() {
if (clearing.value || notificationList.value.length === 0) return
async function clearNotifications(scope: NotificationClearScope) {
if (clearing.value || notificationClearCounts.value[scope] === 0) return
const confirmed = await createConfirm({
type: 'warn',
title: t('notification.clear'),
content: t('notification.clearConfirm'),
content: getClearConfirmText(scope),
confirmText: t('notification.clear'),
})
if (!confirmed) return
clearing.value = true
try {
const cleared = await tryDeleteNotificationHistory()
const cleared = await tryDeleteNotificationHistory(scope)
if (!cleared) {
$toast.error(t('notification.clearFailed'))
return
}
writeNotificationClearBefore(Date.now())
resetNotifications()
await clearUnreadMessages()
appsMenu.value = false
hasMore.value = false
$toast.success(t('notification.clearSuccess'))
writeNotificationClearBefore(scope, Date.now())
removeNotificationsByScope(scope)
if (scope === 'all') {
await clearUnreadMessages()
appsMenu.value = false
}
$toast.success(getClearSuccessText(scope))
} catch (error: any) {
$toast.error(error?.response?.data?.message || error?.message || t('notification.clearFailed'))
} finally {
@@ -272,23 +410,30 @@ async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'er
try {
loading.value = true
const items = (await api.get('message/notification', {
params: {
page: page.value,
count: PAGE_SIZE,
},
})) as SystemNotification[]
let accepted = false
let loadedPages = 0
if (items.length === 0) {
hasMore.value = false
done('empty')
return
//
while (hasMore.value && !accepted && loadedPages < MAX_FILTERED_PAGES_PER_LOAD) {
const items = (await api.get('message/notification', {
params: {
page: page.value,
count: PAGE_SIZE,
},
})) as SystemNotification[]
if (items.length === 0) {
hasMore.value = false
break
}
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
accepted = mergeNotifications(visibleItems, { read: true })
page.value += 1
loadedPages += 1
hasMore.value = items.length >= PAGE_SIZE
}
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
mergeNotifications(visibleItems, { read: true })
page.value += 1
hasMore.value = visibleItems.length === items.length && items.length >= PAGE_SIZE
done(hasMore.value ? 'ok' : 'empty')
} catch (error) {
console.error('加载通知失败:', error)
@@ -304,8 +449,11 @@ function handleMessage(event: MessageEvent) {
try {
const notification = JSON.parse(event.data) as SystemNotification
if (isClearedHistoryNotification(notification)) return
if (mergeNotifications([notification], { prepend: true, read: false })) {
hasNewMessage.value = true
emitAgentAssistantNotificationBubble(notification)
}
} catch (error) {
console.error('解析通知失败:', error)
@@ -347,6 +495,11 @@ function isMediaNotification(item: SystemNotification) {
return Boolean(item.image)
}
/** 获取通知清理范围,目前通知中心展示上以是否包含图片区分媒体和系统消息。 */
function getNotificationClearScope(item: SystemNotification): Exclude<NotificationClearScope, 'all'> {
return isMediaNotification(item) ? 'media' : 'system'
}
/** 按系统类消息和媒体消息生成带分组标题的虚拟列表数据。 */
function buildNotificationDisplayList(items: SystemNotification[]) {
const systemItems = items.filter(item => !isMediaNotification(item))
@@ -443,14 +596,34 @@ useDelayedSSE(
<div class="notification-actions">
<VTooltip :text="t('notification.clear')">
<template #activator="{ props }">
<IconBtn
v-bind="props"
:disabled="notificationList.length === 0 || clearing"
@click.stop="clearNotifications"
>
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
</IconBtn>
<VMenu location="bottom end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn
v-bind="{ ...props, ...menuProps }"
:disabled="notificationList.length === 0 || clearing"
@click.stop
>
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
</IconBtn>
</template>
<VList density="compact" min-width="180">
<VListItem
v-for="option in notificationClearOptions"
:key="option.scope"
:disabled="option.count === 0 || clearing"
@click="clearNotifications(option.scope)"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" size="20" />
</template>
<VListItemTitle>{{ option.title }}</VListItemTitle>
<template #append>
<span class="notification-clear-count">{{ option.count }}</span>
</template>
</VListItem>
</VList>
</VMenu>
</template>
</VTooltip>
<VTooltip :text="t('notification.markRead')">
@@ -575,6 +748,13 @@ useDelayedSSE(
gap: 4px;
}
.notification-clear-count {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.75rem;
line-height: 1;
margin-inline-start: 16px;
}
.notification-list-container {
overflow: hidden;
max-block-size: min(560px, 62vh);

View File

@@ -461,7 +461,16 @@ export default {
markRead: 'Mark as Read',
clear: 'Clear Notifications',
clearConfirm: 'Clear all notification history from Notification Center?',
clearAllMessages: 'Clear All Messages',
clearSystemMessages: 'Clear System Messages',
clearMediaMessages: 'Clear Media Messages',
clearAllConfirm: 'Clear all notification history from Notification Center?',
clearSystemConfirm: 'Clear system message history from Notification Center?',
clearMediaConfirm: 'Clear media message history from Notification Center?',
clearSuccess: 'Notifications cleared',
clearAllSuccess: 'All notifications cleared',
clearSystemSuccess: 'System messages cleared',
clearMediaSuccess: 'Media messages cleared',
clearFailed: 'Failed to clear notifications',
empty: 'No Notifications',
systemMessages: 'System Messages',

View File

@@ -459,7 +459,16 @@ export default {
markRead: '设为已读',
clear: '清理通知',
clearConfirm: '是否确认清理通知中心内的全部历史消息?',
clearAllMessages: '清理全部消息',
clearSystemMessages: '清理系统消息',
clearMediaMessages: '清理媒体消息',
clearAllConfirm: '是否确认清理通知中心内的全部历史消息?',
clearSystemConfirm: '是否确认清理通知中心内的系统类历史消息?',
clearMediaConfirm: '是否确认清理通知中心内的媒体历史消息?',
clearSuccess: '通知已清理',
clearAllSuccess: '全部通知已清理',
clearSystemSuccess: '系统消息已清理',
clearMediaSuccess: '媒体消息已清理',
clearFailed: '通知清理失败',
empty: '暂无通知',
systemMessages: '系统类消息',

View File

@@ -459,7 +459,16 @@ export default {
markRead: '設為已讀',
clear: '清理通知',
clearConfirm: '是否確認清理通知中心內的全部歷史消息?',
clearAllMessages: '清理全部消息',
clearSystemMessages: '清理系統消息',
clearMediaMessages: '清理媒體消息',
clearAllConfirm: '是否確認清理通知中心內的全部歷史消息?',
clearSystemConfirm: '是否確認清理通知中心內的系統類歷史消息?',
clearMediaConfirm: '是否確認清理通知中心內的媒體歷史消息?',
clearSuccess: '通知已清理',
clearAllSuccess: '全部通知已清理',
clearSystemSuccess: '系統消息已清理',
clearMediaSuccess: '媒體消息已清理',
clearFailed: '通知清理失敗',
empty: '暫無通知',
systemMessages: '系統類消息',

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
// 国际化

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import { DownloaderConf } from '@/api/types'
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import type { LocationQuery } from 'vue-router'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import api from '@/api'
import type { Context, SubtitleInfo } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'

View File

@@ -157,6 +157,10 @@ html[data-theme="transparent"] {
}
.agent-assistant-fab {
background-color: transparent;
}
.agent-assistant-fab__bubble {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
}

View File

@@ -0,0 +1,56 @@
import type { SystemNotification } from '@/api/types'
const AGENT_ASSISTANT_BUBBLE_EVENT = 'agentAssistantBubble'
export interface AgentAssistantNotificationBubblePayload {
id: string
title?: string
text?: string
type?: string
mtype?: string
source?: string
date?: string
reg_time?: string
}
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantNotificationBubblePayload> {}
function createNotificationBubbleId(notification: SystemNotification) {
if (notification.id) return `notification-${notification.id}`
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
// 通知中心和智能助手入口没有父子关系,通过全局事件传递实时通知气泡数据。
export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent<AgentAssistantNotificationBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, {
detail: {
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(
callback: (payload: AgentAssistantNotificationBubblePayload) => void,
) {
if (typeof window === 'undefined') return () => {}
const handler = (event: Event) => {
callback((event as AgentAssistantBubbleEvent).detail)
}
window.addEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
return () => window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
}

View File

@@ -3,7 +3,7 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()

View File

@@ -4,7 +4,7 @@ import PersonCardSlideView from './PersonCardSlideView.vue'
import MediaCardSlideView from './MediaCardSlideView.vue'
import api from '@/api'
import type { MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { formatSeason } from '@/@core/utils/formatters'
import { formatSeasonLabel } from '@/@core/utils/season'

View File

@@ -3,7 +3,7 @@ import api from '@/api'
import type { Person } from '@/api/types'
import PersonCard from '@/components/cards/PersonCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()

View File

@@ -3,7 +3,7 @@ import MediaCardListView from './MediaCardListView.vue'
import api from '@/api'
import personIcon from '@images/misc/person.png'
import type { Person } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'

View File

@@ -2,7 +2,7 @@
import { useToast } from 'vue-toastification'
import api from '@/api'
import type { Plugin } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useDisplay } from 'vuetify'
import { isNullOrEmptyObject } from '@/@core/utils'
import { getPluginTabs } from '@/router/i18n-menu'

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useUserStore } from '@/stores'

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
import { FileItem, StorageConf, TransferDirectoryConf } from '@/api/types'
import FileBrowser from '@/components/FileBrowser.vue'
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
const endpoints = {
list: {

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import type { Site, SiteUserData } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'

View File

@@ -2,7 +2,7 @@
import draggable from 'vuedraggable'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useUserStore } from '@/stores'

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
import type { SubscribeShare } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
import type { User } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import UserCard from '@/components/cards/UserCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDynamicButton } from '@/composables/useDynamicButton'

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import { Workflow } from '@/api/types'
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
import type { WorkflowShare } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'