mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-29 20:17:36 +08:00
feat: 展示 V3t 运行时状态并锁定 Rust 加速 (#710)
* feat(settings): warn when V3t enables GIL * feat(runtime): expose V3t status in the interface
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
tag?: string | Component
|
||||
@@ -14,6 +16,17 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const { mdAndDown } = useDisplay()
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const pythonFreeThreaded = computed(() => globalSettingsStore.get('PYTHON_FREE_THREADED') === true)
|
||||
const runtimeVersion = computed(() => (pythonFreeThreaded.value ? 'v3t' : 'v3'))
|
||||
const runtimeGilFallback = computed(
|
||||
() => pythonFreeThreaded.value && globalSettingsStore.get('PYTHON_GIL_ENABLED') === true,
|
||||
)
|
||||
const runtimeStatusIcon = computed(() => (runtimeGilFallback.value ? 'mdi-alert-circle-outline' : 'mdi-flask-outline'))
|
||||
const runtimeStatusHint = computed(() =>
|
||||
t(runtimeGilFallback.value ? 'app.freeThreadedGilFallbackWarning' : 'app.freeThreadedExperimentalHint'),
|
||||
)
|
||||
const refNav = ref()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -54,7 +67,20 @@ function handleNavScroll(evt: Event) {
|
||||
<ThemeLogoMark />
|
||||
|
||||
<h1 class="leading-normal text-xl">
|
||||
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v3</span>
|
||||
<span class="moviepilot-wordmark">MOVIEPILOT</span>
|
||||
<span
|
||||
class="runtime-version text-sm text-gray-500 d-inline-flex align-center"
|
||||
:class="{
|
||||
'runtime-version--free-threaded': pythonFreeThreaded,
|
||||
'runtime-version--degraded': runtimeGilFallback,
|
||||
}"
|
||||
>
|
||||
{{ runtimeVersion }}
|
||||
<VIcon v-if="pythonFreeThreaded" :icon="runtimeStatusIcon" size="13" :aria-label="runtimeStatusHint" />
|
||||
<VTooltip v-if="pythonFreeThreaded" activator="parent" location="bottom">
|
||||
{{ runtimeStatusHint }}
|
||||
</VTooltip>
|
||||
</span>
|
||||
</h1>
|
||||
</RouterLink>
|
||||
</slot>
|
||||
@@ -92,7 +118,10 @@ function handleNavScroll(evt: Event) {
|
||||
inline-size: variables.$layout-vertical-nav-width;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
|
||||
transition:
|
||||
transform 0.25s ease-in-out,
|
||||
inline-size 0.25s ease-in-out,
|
||||
box-shadow 0.25s ease-in-out;
|
||||
visibility: hidden;
|
||||
will-change: transform, inline-size;
|
||||
|
||||
@@ -113,6 +142,20 @@ function handleNavScroll(evt: Event) {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.runtime-version {
|
||||
margin-inline-start: 0.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.runtime-version--free-threaded {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.runtime-version--degraded {
|
||||
color: rgb(var(--v-theme-warning)) !important;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
block-size: 100%;
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import VerticalNav from '@/@layouts/components/VerticalNav.vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
freeThreaded: false,
|
||||
gilEnabled: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useGlobalSettingsStore: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'PYTHON_FREE_THREADED') return mocks.freeThreaded
|
||||
if (key === 'PYTHON_GIL_ENABLED') return mocks.gilEnabled
|
||||
return undefined
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-router')>()),
|
||||
useRoute: () => ({ path: '/' }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vuetify')>()),
|
||||
useDisplay: () => ({ mdAndDown: { value: false } }),
|
||||
}))
|
||||
|
||||
function renderNavigation() {
|
||||
return shallowMount(VerticalNav, {
|
||||
props: {
|
||||
isOverlayNavActive: false,
|
||||
toggleIsOverlayNavActive: vi.fn(),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
PerfectScrollbar: { template: '<ul><slot /></ul>' },
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
ThemeLogoMark: true,
|
||||
VIcon: { props: ['ariaLabel'], template: '<span>{{ ariaLabel }}</span>' },
|
||||
VTooltip: { template: '<span><slot /></span>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('VerticalNav runtime version', () => {
|
||||
beforeEach(() => {
|
||||
mocks.freeThreaded = false
|
||||
mocks.gilEnabled = true
|
||||
})
|
||||
|
||||
it('shows v3 for the standard runtime', () => {
|
||||
const navigation = renderNavigation()
|
||||
|
||||
expect(navigation.text()).toContain('MOVIEPILOT')
|
||||
expect(navigation.get('.runtime-version').text()).toBe('v3')
|
||||
})
|
||||
|
||||
it('shows v3t for the free-threaded runtime', () => {
|
||||
mocks.freeThreaded = true
|
||||
mocks.gilEnabled = false
|
||||
|
||||
const navigation = renderNavigation()
|
||||
|
||||
expect(navigation.get('.runtime-version--free-threaded').text()).toContain('v3t')
|
||||
expect(navigation.get('.runtime-version--free-threaded').classes()).not.toContain('runtime-version--degraded')
|
||||
expect(navigation.text()).toContain('app.freeThreadedExperimentalHint')
|
||||
})
|
||||
|
||||
it('marks v3t when the runtime has enabled the GIL', () => {
|
||||
mocks.freeThreaded = true
|
||||
|
||||
const navigation = renderNavigation()
|
||||
|
||||
expect(navigation.get('.runtime-version--degraded').text()).toContain('v3t')
|
||||
expect(navigation.text()).toContain('app.freeThreadedGilFallbackWarning')
|
||||
})
|
||||
})
|
||||
@@ -484,7 +484,10 @@ watch([() => pluginSidebarNavStore.items, userPermissions], () => {
|
||||
watch(
|
||||
() => pluginRuntimeStore.reconciliation,
|
||||
reconciliation => {
|
||||
if (reconciliation > 0) void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
if (reconciliation <= 0) return
|
||||
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
void globalSettingsStore.loadUserSettings()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface UserStoreMock {
|
||||
const mocks = vi.hoisted(() => ({
|
||||
emptyComponent: { template: '<div><slot /></div>' },
|
||||
ensureSidebarNav: vi.fn(),
|
||||
loadUserSettings: vi.fn(),
|
||||
navLink: {
|
||||
name: 'VerticalNavLink',
|
||||
props: ['item'],
|
||||
@@ -76,7 +77,10 @@ vi.mock('@/stores', async () => {
|
||||
})
|
||||
|
||||
return {
|
||||
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
|
||||
useGlobalSettingsStore: () => ({
|
||||
get: vi.fn(() => false),
|
||||
loadUserSettings: mocks.loadUserSettings,
|
||||
}),
|
||||
usePluginRuntimeStore: () => mocks.runtimeStore,
|
||||
usePluginSidebarNavStore: () => mocks.sidebarStore,
|
||||
useUserStore: () => mocks.userStore,
|
||||
@@ -125,6 +129,8 @@ describe('DefaultLayout', () => {
|
||||
beforeEach(() => {
|
||||
mocks.ensureSidebarNav.mockReset()
|
||||
mocks.ensureSidebarNav.mockResolvedValue(undefined)
|
||||
mocks.loadUserSettings.mockReset()
|
||||
mocks.loadUserSettings.mockResolvedValue(undefined)
|
||||
mocks.startPluginRuntime.mockReset()
|
||||
mocks.stopPluginRuntime.mockReset()
|
||||
mocks.runtimeStore!.reconciliation = 0
|
||||
@@ -264,11 +270,14 @@ describe('DefaultLayout', () => {
|
||||
mocks.runtimeStore!.reconciliation = 1
|
||||
await nextTick()
|
||||
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(mocks.loadUserSettings).toHaveBeenCalled()
|
||||
mocks.ensureSidebarNav.mockClear()
|
||||
mocks.loadUserSettings.mockClear()
|
||||
|
||||
mocks.runtimeStore!.reconciliation = 2
|
||||
await nextTick()
|
||||
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(mocks.loadUserSettings).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
expect(mocks.stopPluginRuntime).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -277,6 +277,9 @@ export default {
|
||||
continueBrowsing: 'Continue browsing',
|
||||
online: 'Application Online',
|
||||
onlineMessage: 'Network connection restored',
|
||||
freeThreadedExperimentalHint: 'Experimental free-threaded runtime',
|
||||
freeThreadedGilFallbackWarning:
|
||||
'The free-threaded runtime has fallen back to GIL mode. Check backend logs for native extension compatibility warnings.',
|
||||
},
|
||||
pwa: {
|
||||
installApp: 'Install MoviePilot App',
|
||||
|
||||
@@ -270,6 +270,8 @@ export default {
|
||||
continueBrowsing: '继续浏览',
|
||||
online: '应用在线',
|
||||
onlineMessage: '网络连接已恢复',
|
||||
freeThreadedExperimentalHint: '实验性 free-threaded 运行时',
|
||||
freeThreadedGilFallbackWarning: 'free-threaded 运行时已退化为 GIL 模式,请检查后端日志中的原生扩展兼容告警',
|
||||
},
|
||||
pwa: {
|
||||
installApp: '安装 MoviePilot 应用',
|
||||
|
||||
@@ -270,6 +270,8 @@ export default {
|
||||
continueBrowsing: '繼續瀏覽',
|
||||
online: '應用在線',
|
||||
onlineMessage: '網絡連接已恢復',
|
||||
freeThreadedExperimentalHint: '實驗性 free-threaded 運行環境',
|
||||
freeThreadedGilFallbackWarning: 'free-threaded 運行環境已退化為 GIL 模式,請檢查後端日誌中的原生擴展兼容警告',
|
||||
},
|
||||
pwa: {
|
||||
installApp: '安裝 MoviePilot 應用',
|
||||
|
||||
@@ -551,7 +551,6 @@ const rustAccelHint = computed(() =>
|
||||
? t('setting.system.rustAccelHint')
|
||||
: t('setting.system.rustAccelUnavailableHint'),
|
||||
)
|
||||
|
||||
const thinkingLevelItems = computed(() => [
|
||||
{ title: t('setting.system.llmThinkingLevelOff'), value: 'off' },
|
||||
{ title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },
|
||||
@@ -2782,7 +2781,8 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
v-model="SystemSettings.Advanced.RUST_ACCEL"
|
||||
:label="t('setting.system.rustAccel')"
|
||||
:hint="rustAccelHint"
|
||||
:disabled="!rustAccelAvailable || rustAccelRequired"
|
||||
:disabled="!rustAccelAvailable"
|
||||
:readonly="rustAccelRequired"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
@@ -1342,7 +1342,9 @@ describe('AccountSettingSystem', () => {
|
||||
|
||||
const dialog = await openAdvancedTab('实验室')
|
||||
const rust = dialog.getByLabelText('Rust 加速')
|
||||
expect(rust).toBeDisabled()
|
||||
expect(rust).toHaveAttribute('aria-disabled', 'false')
|
||||
expect(rust).toBeChecked()
|
||||
await fireEvent.click(rust)
|
||||
expect(rust).toBeChecked()
|
||||
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user