mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +08:00
feat: support fullscreen AI assistant
This commit is contained in:
@@ -228,6 +228,7 @@ const historyLoading = ref(false)
|
||||
const historyLoadingMore = ref(false)
|
||||
const historyPage = ref(1)
|
||||
const historyHasMore = ref(true)
|
||||
const fullscreen = ref(false)
|
||||
const slashCommands = ref<AgentSlashCommand[]>([])
|
||||
const slashCommandsLoading = ref(false)
|
||||
const slashCommandsLoaded = ref(false)
|
||||
@@ -293,8 +294,10 @@ const recordingTimeText = computed(() => {
|
||||
|
||||
return `${minutes}:${String(remainSeconds).padStart(2, '0')}`
|
||||
})
|
||||
// 窄屏下直接全屏,避免聊天内容被压成半屏窄栏。
|
||||
const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem'))
|
||||
// 窄屏下直接全屏,桌面端则允许用户按需扩展对话区域。
|
||||
const drawerWidth = computed(() => (display.mdAndDown.value || fullscreen.value ? '100vw' : '30rem'))
|
||||
// 仅桌面宽屏展示全屏开关,窄屏已默认占满视口。
|
||||
const canToggleFullscreen = computed(() => !display.mdAndDown.value)
|
||||
const hasMessages = computed(() => messages.value.length > 0)
|
||||
const hasHistorySessions = computed(() => historySessions.value.length > 0)
|
||||
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
||||
@@ -2180,6 +2183,11 @@ function closeDrawer() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// 在桌面侧栏与全屏对话模式之间切换。
|
||||
function toggleFullscreen() {
|
||||
fullscreen.value = !fullscreen.value
|
||||
}
|
||||
|
||||
// 同步面板打开状态到全局 DOM,供悬浮入口避让面板宽度。
|
||||
function syncAgentAssistantOpenState(isOpen: boolean) {
|
||||
if (typeof document === 'undefined') return
|
||||
@@ -2238,10 +2246,12 @@ function handleInputKeydown(event: KeyboardEvent) {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
// 开始输入法组合输入,避免回车确认候选词时误发送消息。
|
||||
function handleCompositionStart() {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
// 结束输入法组合输入,恢复回车发送能力。
|
||||
function handleCompositionEnd() {
|
||||
isComposing.value = false
|
||||
}
|
||||
@@ -2288,7 +2298,11 @@ onScopeDispose(() => {
|
||||
<aside
|
||||
v-show="isOpen"
|
||||
class="agent-assistant-panel"
|
||||
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
|
||||
:class="{
|
||||
'is-fullscreen': fullscreen,
|
||||
'is-motion-paused': !props.motionActive,
|
||||
'is-open': isOpen,
|
||||
}"
|
||||
:style="drawerStyle"
|
||||
role="dialog"
|
||||
:aria-label="t('agentAssistant.title')"
|
||||
@@ -2404,6 +2418,16 @@ onScopeDispose(() => {
|
||||
>
|
||||
<VIcon icon="mdi-message-plus-outline" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
v-if="canToggleFullscreen"
|
||||
class="agent-assistant-fullscreen-toggle"
|
||||
:title="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
|
||||
:aria-label="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
|
||||
:aria-pressed="fullscreen"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<VIcon :icon="fullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" />
|
||||
</IconBtn>
|
||||
<IconBtn :title="t('common.close')" :aria-label="t('common.close')" @click="closeDrawer">
|
||||
<VIcon icon="mdi-close" />
|
||||
</IconBtn>
|
||||
@@ -2713,6 +2737,27 @@ onScopeDispose(() => {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.agent-assistant-panel.is-fullscreen {
|
||||
border-inline-start: 0;
|
||||
box-shadow: none;
|
||||
|
||||
.agent-assistant-message__bubble {
|
||||
max-inline-size: min(100%, 64rem);
|
||||
}
|
||||
|
||||
.agent-assistant-segments,
|
||||
.agent-assistant-choices,
|
||||
.agent-assistant-attachments {
|
||||
inline-size: min(100%, 64rem);
|
||||
}
|
||||
|
||||
.agent-assistant-composer {
|
||||
inline-size: min(calc(100% - 2rem), 72rem);
|
||||
inset-inline: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (block-size: 100lvh) {
|
||||
.agent-assistant-panel {
|
||||
block-size: 100lvh !important;
|
||||
@@ -3048,6 +3093,11 @@ onScopeDispose(() => {
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
.agent-assistant-panel.is-fullscreen .agent-assistant-messages__content {
|
||||
inline-size: min(100%, 72rem);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
|
||||
.agent-assistant-messages--has-content {
|
||||
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
|
||||
|
||||
@@ -2,12 +2,14 @@ import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
|
||||
|
||||
const displayState = vi.hoisted(() => ({ mdAndDown: { value: true } }))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ mdAndDown: { value: true } }),
|
||||
useDisplay: () => displayState,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
@@ -47,9 +49,77 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
displayState.mdAndDown.value = true
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('toggles the desktop assistant between the side panel and fullscreen modes', async () => {
|
||||
displayState.mdAndDown.value = false
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => createAgentResponse([])),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const panel = wrapper.get('.agent-assistant-panel')
|
||||
const fullscreenToggle = wrapper.get('.agent-assistant-fullscreen-toggle')
|
||||
expect(panel.classes()).not.toContain('is-fullscreen')
|
||||
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
|
||||
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.enterFullscreen')
|
||||
expect(fullscreenToggle.attributes('aria-pressed')).toBe('false')
|
||||
|
||||
await fullscreenToggle.trigger('click')
|
||||
|
||||
expect(panel.classes()).toContain('is-fullscreen')
|
||||
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
|
||||
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.exitFullscreen')
|
||||
expect(fullscreenToggle.attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await fullscreenToggle.trigger('click')
|
||||
|
||||
expect(panel.classes()).not.toContain('is-fullscreen')
|
||||
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the narrow assistant fullscreen without a redundant toggle', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => createAgentResponse([])),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.agent-assistant-panel').attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
|
||||
expect(wrapper.find('.agent-assistant-fullscreen-toggle').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('restores a legacy empty message to loading and waits for the final reply after a PWA reload', async () => {
|
||||
const startedAt = Date.now() - 5000
|
||||
const localSessionId = 'web-local-session'
|
||||
|
||||
@@ -847,6 +847,8 @@ export default {
|
||||
ready: 'Ready',
|
||||
thinking: 'Thinking',
|
||||
newChat: 'New Chat',
|
||||
enterFullscreen: 'Enter fullscreen',
|
||||
exitFullscreen: 'Exit fullscreen',
|
||||
history: 'Chat History',
|
||||
historyLoading: 'Loading chat history...',
|
||||
historyLoadFailed: 'Failed to load chat history',
|
||||
|
||||
@@ -836,6 +836,8 @@ export default {
|
||||
ready: '随时待命',
|
||||
thinking: '思考中',
|
||||
newChat: '新会话',
|
||||
enterFullscreen: '全屏显示',
|
||||
exitFullscreen: '退出全屏',
|
||||
history: '历史会话',
|
||||
historyLoading: '正在加载历史会话...',
|
||||
historyLoadFailed: '历史会话加载失败',
|
||||
|
||||
@@ -836,6 +836,8 @@ export default {
|
||||
ready: '隨時待命',
|
||||
thinking: '思考中',
|
||||
newChat: '新會話',
|
||||
enterFullscreen: '全屏顯示',
|
||||
exitFullscreen: '退出全屏',
|
||||
history: '歷史會話',
|
||||
historyLoading: '正在載入歷史會話...',
|
||||
historyLoadFailed: '歷史會話載入失敗',
|
||||
|
||||
Reference in New Issue
Block a user