mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +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 historyLoadingMore = ref(false)
|
||||||
const historyPage = ref(1)
|
const historyPage = ref(1)
|
||||||
const historyHasMore = ref(true)
|
const historyHasMore = ref(true)
|
||||||
|
const fullscreen = ref(false)
|
||||||
const slashCommands = ref<AgentSlashCommand[]>([])
|
const slashCommands = ref<AgentSlashCommand[]>([])
|
||||||
const slashCommandsLoading = ref(false)
|
const slashCommandsLoading = ref(false)
|
||||||
const slashCommandsLoaded = ref(false)
|
const slashCommandsLoaded = ref(false)
|
||||||
@@ -293,8 +294,10 @@ const recordingTimeText = computed(() => {
|
|||||||
|
|
||||||
return `${minutes}:${String(remainSeconds).padStart(2, '0')}`
|
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 hasMessages = computed(() => messages.value.length > 0)
|
||||||
const hasHistorySessions = computed(() => historySessions.value.length > 0)
|
const hasHistorySessions = computed(() => historySessions.value.length > 0)
|
||||||
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
||||||
@@ -2180,6 +2183,11 @@ function closeDrawer() {
|
|||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在桌面侧栏与全屏对话模式之间切换。
|
||||||
|
function toggleFullscreen() {
|
||||||
|
fullscreen.value = !fullscreen.value
|
||||||
|
}
|
||||||
|
|
||||||
// 同步面板打开状态到全局 DOM,供悬浮入口避让面板宽度。
|
// 同步面板打开状态到全局 DOM,供悬浮入口避让面板宽度。
|
||||||
function syncAgentAssistantOpenState(isOpen: boolean) {
|
function syncAgentAssistantOpenState(isOpen: boolean) {
|
||||||
if (typeof document === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
@@ -2238,10 +2246,12 @@ function handleInputKeydown(event: KeyboardEvent) {
|
|||||||
sendMessage()
|
sendMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开始输入法组合输入,避免回车确认候选词时误发送消息。
|
||||||
function handleCompositionStart() {
|
function handleCompositionStart() {
|
||||||
isComposing.value = true
|
isComposing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 结束输入法组合输入,恢复回车发送能力。
|
||||||
function handleCompositionEnd() {
|
function handleCompositionEnd() {
|
||||||
isComposing.value = false
|
isComposing.value = false
|
||||||
}
|
}
|
||||||
@@ -2288,7 +2298,11 @@ onScopeDispose(() => {
|
|||||||
<aside
|
<aside
|
||||||
v-show="isOpen"
|
v-show="isOpen"
|
||||||
class="agent-assistant-panel"
|
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"
|
:style="drawerStyle"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
:aria-label="t('agentAssistant.title')"
|
:aria-label="t('agentAssistant.title')"
|
||||||
@@ -2404,6 +2418,16 @@ onScopeDispose(() => {
|
|||||||
>
|
>
|
||||||
<VIcon icon="mdi-message-plus-outline" />
|
<VIcon icon="mdi-message-plus-outline" />
|
||||||
</IconBtn>
|
</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">
|
<IconBtn :title="t('common.close')" :aria-label="t('common.close')" @click="closeDrawer">
|
||||||
<VIcon icon="mdi-close" />
|
<VIcon icon="mdi-close" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
@@ -2713,6 +2737,27 @@ onScopeDispose(() => {
|
|||||||
overscroll-behavior: contain;
|
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) {
|
@supports (block-size: 100lvh) {
|
||||||
.agent-assistant-panel {
|
.agent-assistant-panel {
|
||||||
block-size: 100lvh !important;
|
block-size: 100lvh !important;
|
||||||
@@ -3048,6 +3093,11 @@ onScopeDispose(() => {
|
|||||||
min-block-size: 100%;
|
min-block-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-assistant-panel.is-fullscreen .agent-assistant-messages__content {
|
||||||
|
inline-size: min(100%, 72rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
|
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
|
||||||
.agent-assistant-messages--has-content {
|
.agent-assistant-messages--has-content {
|
||||||
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
|
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
|
||||||
|
|
||||||
|
const displayState = vi.hoisted(() => ({ mdAndDown: { value: true } }))
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', () => ({
|
||||||
useI18n: () => ({ t: (key: string) => key }),
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vuetify', () => ({
|
vi.mock('vuetify', () => ({
|
||||||
useDisplay: () => ({ mdAndDown: { value: true } }),
|
useDisplay: () => displayState,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores', () => ({
|
vi.mock('@/stores', () => ({
|
||||||
@@ -47,9 +49,77 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
displayState.mdAndDown.value = true
|
||||||
vi.useRealTimers()
|
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 () => {
|
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 startedAt = Date.now() - 5000
|
||||||
const localSessionId = 'web-local-session'
|
const localSessionId = 'web-local-session'
|
||||||
|
|||||||
@@ -847,6 +847,8 @@ export default {
|
|||||||
ready: 'Ready',
|
ready: 'Ready',
|
||||||
thinking: 'Thinking',
|
thinking: 'Thinking',
|
||||||
newChat: 'New Chat',
|
newChat: 'New Chat',
|
||||||
|
enterFullscreen: 'Enter fullscreen',
|
||||||
|
exitFullscreen: 'Exit fullscreen',
|
||||||
history: 'Chat History',
|
history: 'Chat History',
|
||||||
historyLoading: 'Loading chat history...',
|
historyLoading: 'Loading chat history...',
|
||||||
historyLoadFailed: 'Failed to load chat history',
|
historyLoadFailed: 'Failed to load chat history',
|
||||||
|
|||||||
@@ -836,6 +836,8 @@ export default {
|
|||||||
ready: '随时待命',
|
ready: '随时待命',
|
||||||
thinking: '思考中',
|
thinking: '思考中',
|
||||||
newChat: '新会话',
|
newChat: '新会话',
|
||||||
|
enterFullscreen: '全屏显示',
|
||||||
|
exitFullscreen: '退出全屏',
|
||||||
history: '历史会话',
|
history: '历史会话',
|
||||||
historyLoading: '正在加载历史会话...',
|
historyLoading: '正在加载历史会话...',
|
||||||
historyLoadFailed: '历史会话加载失败',
|
historyLoadFailed: '历史会话加载失败',
|
||||||
|
|||||||
@@ -836,6 +836,8 @@ export default {
|
|||||||
ready: '隨時待命',
|
ready: '隨時待命',
|
||||||
thinking: '思考中',
|
thinking: '思考中',
|
||||||
newChat: '新會話',
|
newChat: '新會話',
|
||||||
|
enterFullscreen: '全屏顯示',
|
||||||
|
exitFullscreen: '退出全屏',
|
||||||
history: '歷史會話',
|
history: '歷史會話',
|
||||||
historyLoading: '正在載入歷史會話...',
|
historyLoading: '正在載入歷史會話...',
|
||||||
historyLoadFailed: '歷史會話載入失敗',
|
historyLoadFailed: '歷史會話載入失敗',
|
||||||
|
|||||||
Reference in New Issue
Block a user