feat: 展示 V3t 运行时状态并锁定 Rust 加速 (#710)

* feat(settings): warn when V3t enables GIL

* feat(runtime): expose V3t status in the interface
This commit is contained in:
InfinityPacer
2026-08-24 17:27:32 +08:00
committed by GitHub
parent 7c02bf7c5c
commit c55e97b74e
9 changed files with 156 additions and 7 deletions
+45 -2
View File
@@ -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)
+3
View File
@@ -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',
+2
View File
@@ -270,6 +270,8 @@ export default {
continueBrowsing: '继续浏览',
online: '应用在线',
onlineMessage: '网络连接已恢复',
freeThreadedExperimentalHint: '实验性 free-threaded 运行时',
freeThreadedGilFallbackWarning: 'free-threaded 运行时已退化为 GIL 模式,请检查后端日志中的原生扩展兼容告警',
},
pwa: {
installApp: '安装 MoviePilot 应用',
+2
View File
@@ -270,6 +270,8 @@ export default {
continueBrowsing: '繼續瀏覽',
online: '應用在線',
onlineMessage: '網絡連接已恢復',
freeThreadedExperimentalHint: '實驗性 free-threaded 運行環境',
freeThreadedGilFallbackWarning: 'free-threaded 運行環境已退化為 GIL 模式,請檢查後端日誌中的原生擴展兼容警告',
},
pwa: {
installApp: '安裝 MoviePilot 應用',
+2 -2
View File
@@ -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: '保存' }))