mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +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>
|
<script lang="ts" setup>
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tag?: string | Component
|
tag?: string | Component
|
||||||
@@ -14,6 +16,17 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { mdAndDown } = useDisplay()
|
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 refNav = ref()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -54,7 +67,20 @@ function handleNavScroll(evt: Event) {
|
|||||||
<ThemeLogoMark />
|
<ThemeLogoMark />
|
||||||
|
|
||||||
<h1 class="leading-normal text-xl">
|
<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>
|
</h1>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</slot>
|
</slot>
|
||||||
@@ -92,7 +118,10 @@ function handleNavScroll(evt: Event) {
|
|||||||
inline-size: variables.$layout-vertical-nav-width;
|
inline-size: variables.$layout-vertical-nav-width;
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
inset-inline-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;
|
visibility: hidden;
|
||||||
will-change: transform, inline-size;
|
will-change: transform, inline-size;
|
||||||
|
|
||||||
@@ -113,6 +142,20 @@ function handleNavScroll(evt: Event) {
|
|||||||
margin-inline-end: auto;
|
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 {
|
.nav-items {
|
||||||
block-size: 100%;
|
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(
|
watch(
|
||||||
() => pluginRuntimeStore.reconciliation,
|
() => pluginRuntimeStore.reconciliation,
|
||||||
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(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
emptyComponent: { template: '<div><slot /></div>' },
|
emptyComponent: { template: '<div><slot /></div>' },
|
||||||
ensureSidebarNav: vi.fn(),
|
ensureSidebarNav: vi.fn(),
|
||||||
|
loadUserSettings: vi.fn(),
|
||||||
navLink: {
|
navLink: {
|
||||||
name: 'VerticalNavLink',
|
name: 'VerticalNavLink',
|
||||||
props: ['item'],
|
props: ['item'],
|
||||||
@@ -76,7 +77,10 @@ vi.mock('@/stores', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
|
useGlobalSettingsStore: () => ({
|
||||||
|
get: vi.fn(() => false),
|
||||||
|
loadUserSettings: mocks.loadUserSettings,
|
||||||
|
}),
|
||||||
usePluginRuntimeStore: () => mocks.runtimeStore,
|
usePluginRuntimeStore: () => mocks.runtimeStore,
|
||||||
usePluginSidebarNavStore: () => mocks.sidebarStore,
|
usePluginSidebarNavStore: () => mocks.sidebarStore,
|
||||||
useUserStore: () => mocks.userStore,
|
useUserStore: () => mocks.userStore,
|
||||||
@@ -125,6 +129,8 @@ describe('DefaultLayout', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.ensureSidebarNav.mockReset()
|
mocks.ensureSidebarNav.mockReset()
|
||||||
mocks.ensureSidebarNav.mockResolvedValue(undefined)
|
mocks.ensureSidebarNav.mockResolvedValue(undefined)
|
||||||
|
mocks.loadUserSettings.mockReset()
|
||||||
|
mocks.loadUserSettings.mockResolvedValue(undefined)
|
||||||
mocks.startPluginRuntime.mockReset()
|
mocks.startPluginRuntime.mockReset()
|
||||||
mocks.stopPluginRuntime.mockReset()
|
mocks.stopPluginRuntime.mockReset()
|
||||||
mocks.runtimeStore!.reconciliation = 0
|
mocks.runtimeStore!.reconciliation = 0
|
||||||
@@ -264,11 +270,14 @@ describe('DefaultLayout', () => {
|
|||||||
mocks.runtimeStore!.reconciliation = 1
|
mocks.runtimeStore!.reconciliation = 1
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
expect(mocks.loadUserSettings).toHaveBeenCalled()
|
||||||
mocks.ensureSidebarNav.mockClear()
|
mocks.ensureSidebarNav.mockClear()
|
||||||
|
mocks.loadUserSettings.mockClear()
|
||||||
|
|
||||||
mocks.runtimeStore!.reconciliation = 2
|
mocks.runtimeStore!.reconciliation = 2
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
expect(mocks.loadUserSettings).toHaveBeenCalled()
|
||||||
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
expect(mocks.stopPluginRuntime).toHaveBeenCalledTimes(1)
|
expect(mocks.stopPluginRuntime).toHaveBeenCalledTimes(1)
|
||||||
|
|||||||
@@ -277,6 +277,9 @@ export default {
|
|||||||
continueBrowsing: 'Continue browsing',
|
continueBrowsing: 'Continue browsing',
|
||||||
online: 'Application Online',
|
online: 'Application Online',
|
||||||
onlineMessage: 'Network connection restored',
|
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: {
|
pwa: {
|
||||||
installApp: 'Install MoviePilot App',
|
installApp: 'Install MoviePilot App',
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ export default {
|
|||||||
continueBrowsing: '继续浏览',
|
continueBrowsing: '继续浏览',
|
||||||
online: '应用在线',
|
online: '应用在线',
|
||||||
onlineMessage: '网络连接已恢复',
|
onlineMessage: '网络连接已恢复',
|
||||||
|
freeThreadedExperimentalHint: '实验性 free-threaded 运行时',
|
||||||
|
freeThreadedGilFallbackWarning: 'free-threaded 运行时已退化为 GIL 模式,请检查后端日志中的原生扩展兼容告警',
|
||||||
},
|
},
|
||||||
pwa: {
|
pwa: {
|
||||||
installApp: '安装 MoviePilot 应用',
|
installApp: '安装 MoviePilot 应用',
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ export default {
|
|||||||
continueBrowsing: '繼續瀏覽',
|
continueBrowsing: '繼續瀏覽',
|
||||||
online: '應用在線',
|
online: '應用在線',
|
||||||
onlineMessage: '網絡連接已恢復',
|
onlineMessage: '網絡連接已恢復',
|
||||||
|
freeThreadedExperimentalHint: '實驗性 free-threaded 運行環境',
|
||||||
|
freeThreadedGilFallbackWarning: 'free-threaded 運行環境已退化為 GIL 模式,請檢查後端日誌中的原生擴展兼容警告',
|
||||||
},
|
},
|
||||||
pwa: {
|
pwa: {
|
||||||
installApp: '安裝 MoviePilot 應用',
|
installApp: '安裝 MoviePilot 應用',
|
||||||
|
|||||||
@@ -551,7 +551,6 @@ const rustAccelHint = computed(() =>
|
|||||||
? t('setting.system.rustAccelHint')
|
? t('setting.system.rustAccelHint')
|
||||||
: t('setting.system.rustAccelUnavailableHint'),
|
: t('setting.system.rustAccelUnavailableHint'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const thinkingLevelItems = computed(() => [
|
const thinkingLevelItems = computed(() => [
|
||||||
{ title: t('setting.system.llmThinkingLevelOff'), value: 'off' },
|
{ title: t('setting.system.llmThinkingLevelOff'), value: 'off' },
|
||||||
{ title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },
|
{ title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },
|
||||||
@@ -2782,7 +2781,8 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
v-model="SystemSettings.Advanced.RUST_ACCEL"
|
v-model="SystemSettings.Advanced.RUST_ACCEL"
|
||||||
:label="t('setting.system.rustAccel')"
|
:label="t('setting.system.rustAccel')"
|
||||||
:hint="rustAccelHint"
|
:hint="rustAccelHint"
|
||||||
:disabled="!rustAccelAvailable || rustAccelRequired"
|
:disabled="!rustAccelAvailable"
|
||||||
|
:readonly="rustAccelRequired"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|||||||
@@ -1342,7 +1342,9 @@ describe('AccountSettingSystem', () => {
|
|||||||
|
|
||||||
const dialog = await openAdvancedTab('实验室')
|
const dialog = await openAdvancedTab('实验室')
|
||||||
const rust = dialog.getByLabelText('Rust 加速')
|
const rust = dialog.getByLabelText('Rust 加速')
|
||||||
expect(rust).toBeDisabled()
|
expect(rust).toHaveAttribute('aria-disabled', 'false')
|
||||||
|
expect(rust).toBeChecked()
|
||||||
|
await fireEvent.click(rust)
|
||||||
expect(rust).toBeChecked()
|
expect(rust).toBeChecked()
|
||||||
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user