feat: add explicit managed model download sources
Managed speech and OCR downloads previously used catalog-specific source URLs without a global selection. Platform Features now lets users choose ModelScope by default or Hugging Face, while Main validates and freezes that source for each download. Verified coverage remains explicit: downloads never mix artifacts or silently switch sources, and installed models plus ZIP imports stay source-independent. Release note: 可在“设置 → 平台功能 → 通用设置”中选择 ModelScope 或 Hugging Face 作为后续语音输入与 OCR 模型下载源;缺少完整已验证文件或下载失败时不会静默换源。
This commit is contained in:
@@ -1084,6 +1084,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1176,6 +1177,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1183,6 +1185,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1276,6 +1279,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1283,6 +1287,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7206,6 +7211,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7213,6 +7219,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7253,6 +7260,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7260,6 +7268,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7292,6 +7301,7 @@ describe('App', () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -7315,6 +7325,9 @@ describe('App', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '平台功能' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
|
||||
@@ -31,8 +31,6 @@ const modelEntry = {
|
||||
quality: 'basic' as const,
|
||||
speed: 'fast' as const,
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice: '使用前请阅读模型许可证。',
|
||||
@@ -42,29 +40,32 @@ const modelEntry = {
|
||||
{
|
||||
name: 'detection.onnx',
|
||||
role: 'detection' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/detection.onnx',
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'recognition.onnx',
|
||||
role: 'recognition' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/recognition.onnx',
|
||||
size: 2_000,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
size: 2_000,
|
||||
sha256: 'b'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'dictionary.yml',
|
||||
role: 'dictionary' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/dictionary.yml',
|
||||
size: 500,
|
||||
sha256: 'c'.repeat(64)
|
||||
}
|
||||
size: 500,
|
||||
sha256: 'c'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: true,
|
||||
totalBytes: 3_500
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 3_500
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -101,6 +102,7 @@ const snapshot: DocumentParsingSnapshot = {
|
||||
},
|
||||
ocrModels: {
|
||||
rootDirectory: 'C:\\Users\\test\\models\\document-ocr',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [modelEntry, secondModelEntry, thirdModelEntry],
|
||||
installed: [
|
||||
{
|
||||
@@ -111,8 +113,8 @@ const snapshot: DocumentParsingSnapshot = {
|
||||
files: secondModelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
}
|
||||
],
|
||||
@@ -137,7 +139,7 @@ const test = vi.fn(async () => ({
|
||||
warnings: []
|
||||
}))
|
||||
const installOcrModel =
|
||||
vi.fn<() => Promise<DocumentParsingSnapshot>>(async () => ({
|
||||
vi.fn(async (): Promise<DocumentParsingSnapshot> => ({
|
||||
...snapshot,
|
||||
status: {
|
||||
...snapshot.status,
|
||||
@@ -159,8 +161,8 @@ const installOcrModel =
|
||||
files: modelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
}
|
||||
]
|
||||
@@ -305,7 +307,10 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(installOcrModel).toHaveBeenCalledWith('pp-ocrv6-tiny')
|
||||
expect(installOcrModel).toHaveBeenCalledWith(
|
||||
'pp-ocrv6-tiny',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -336,8 +341,8 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
files: thirdModelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
} satisfies InstalledDocumentOcrModel
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
DocumentParsingDiagnostic,
|
||||
DocumentOcrModelCatalogEntry,
|
||||
DocumentOcrModelCatalogViewEntry,
|
||||
DocumentOcrModelOperation,
|
||||
DocumentParsingSettings,
|
||||
DocumentParsingSnapshot,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
|
||||
type DocumentParsingSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string): string {
|
||||
@@ -54,9 +55,9 @@ function formatBytes(bytes: number): string {
|
||||
: `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
function catalogSize(entry: DocumentOcrModelCatalogEntry): number {
|
||||
function catalogSize(entry: DocumentOcrModelCatalogViewEntry): number {
|
||||
return entry.files.reduce(
|
||||
(total, file) => total + file.download.size,
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
}
|
||||
@@ -207,7 +208,8 @@ function DiagnosticDialog({
|
||||
}
|
||||
|
||||
export function DocumentParsingSettingsSection({
|
||||
onNotify
|
||||
onNotify,
|
||||
onOpenModelDownloadSourceSettings
|
||||
}: DocumentParsingSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
|
||||
@@ -439,6 +441,13 @@ export function DocumentParsingSettingsSection({
|
||||
const modelProgress = modelOperation
|
||||
? progressPercent(modelOperation)
|
||||
: undefined
|
||||
const modelDownloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.ocrModels.selectedDownloadSource
|
||||
)
|
||||
const modelSourceAvailable =
|
||||
modelDownloadAvailability?.available === true
|
||||
const pendingModelSelection =
|
||||
draft.localOcrModelId !== snapshot.settings.localOcrModelId
|
||||
const settingsDirty =
|
||||
@@ -701,7 +710,17 @@ export function DocumentParsingSettingsSection({
|
||||
{entryDisplayName} ·{' '}
|
||||
{installed
|
||||
? t('documentParsing.ocr.installedOption')
|
||||
: t('documentParsing.ocr.downloadableOption')}
|
||||
: entry.downloadAvailability.some(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.ocrModels
|
||||
.selectedDownloadSource &&
|
||||
availability.available
|
||||
)
|
||||
? t('documentParsing.ocr.downloadableOption')
|
||||
: t(
|
||||
'documentParsing.ocr.sourceUnavailableOption'
|
||||
)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -720,6 +739,13 @@ export function DocumentParsingSettingsSection({
|
||||
<code>{snapshot.ocrModels.rootDirectory}</code>
|
||||
{t('documentParsing.ocr.storageSuffix')}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('documentParsing.ocr.downloadSource', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
|
||||
{model ? (
|
||||
<article className="document-ocr-model">
|
||||
@@ -735,7 +761,11 @@ export function DocumentParsingSettingsSection({
|
||||
</div>
|
||||
<p>{modelDescription}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">ModelScope</span>
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.languages
|
||||
.map((language) =>
|
||||
@@ -774,9 +804,15 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.openRepository',
|
||||
{ name: modelDisplayName }
|
||||
{
|
||||
name: modelDisplayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
className="secondary-button document-ocr-model__repository"
|
||||
disabled={!modelSourceAvailable}
|
||||
onClick={() =>
|
||||
void window.goodbuddy.documentParsing
|
||||
?.openOcrModelRepository(model.id)
|
||||
@@ -784,7 +820,11 @@ export function DocumentParsingSettingsSection({
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={13} />
|
||||
{t('documentParsing.ocr.openRepository')}
|
||||
{t('documentParsing.ocr.openRepository', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -806,7 +846,12 @@ export function DocumentParsingSettingsSection({
|
||||
? 'documentParsing.ocr.operations.installing'
|
||||
: modelOperation.kind === 'import'
|
||||
? 'documentParsing.ocr.operations.importing'
|
||||
: 'documentParsing.ocr.operations.downloading'
|
||||
: 'documentParsing.ocr.operations.downloading',
|
||||
{
|
||||
source: t(
|
||||
`modelDownloadSources.${modelOperation.downloadSource}`
|
||||
)
|
||||
}
|
||||
)
|
||||
: t('documentParsing.ocr.installed')}
|
||||
</span>
|
||||
@@ -879,41 +924,61 @@ export function DocumentParsingSettingsSection({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadModel',
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
async () => {
|
||||
const installed =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.installOcrModel(model.id)
|
||||
if (!pendingModelSelection) {
|
||||
return installed
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: modelDisplayName }
|
||||
{modelSourceAvailable && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadModel',
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
async () => {
|
||||
const installed =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.installOcrModel(
|
||||
model.id,
|
||||
snapshot.ocrModels
|
||||
.selectedDownloadSource
|
||||
)
|
||||
if (!pendingModelSelection) {
|
||||
return installed
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: modelDisplayName }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{pendingModelSelection
|
||||
? t('documentParsing.ocr.downloadAndSelect')
|
||||
: t('documentParsing.ocr.download')}
|
||||
</button>
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{pendingModelSelection
|
||||
? t('documentParsing.ocr.downloadAndSelect')
|
||||
: t('documentParsing.ocr.download')}
|
||||
</button>
|
||||
)}
|
||||
{!modelSourceAvailable &&
|
||||
onOpenModelDownloadSourceSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={
|
||||
onOpenModelDownloadSourceSettings
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t(
|
||||
'documentParsing.ocr.openDownloadSourceSettings'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.importModelZip',
|
||||
@@ -951,6 +1016,16 @@ export function DocumentParsingSettingsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!installedModel && !modelSourceAvailable && (
|
||||
<p className="settings-warning">
|
||||
{t('documentParsing.ocr.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{modelOperation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -169,6 +169,7 @@ const onAnalysisEvent = vi.fn<
|
||||
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -180,6 +181,7 @@ beforeEach(() => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -680,6 +682,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -711,6 +714,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'narrative'
|
||||
@@ -825,6 +829,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -2,10 +2,15 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
MagicNoteCommentMode
|
||||
MagicNoteCommentMode,
|
||||
ModelDownloadSource
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import {
|
||||
PageTabs,
|
||||
SegmentedControl
|
||||
} from './WorkspacePrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
@@ -13,14 +18,21 @@ import {
|
||||
|
||||
type PlatformFeaturesSettingsSectionProps = {
|
||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
}
|
||||
|
||||
type PlatformFeaturesTab = 'general' | 'magic-notes'
|
||||
|
||||
export function PlatformFeaturesSettingsSection({
|
||||
onMagicNotesEnabledChange
|
||||
onMagicNotesEnabledChange,
|
||||
onNotify
|
||||
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PlatformFeaturesTab>('general')
|
||||
const [settings, setSettings] = useState<ApplicationSettings>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [sourceError, setSourceError] = useState<string>()
|
||||
const [error, setError] = useState<string | undefined>(() =>
|
||||
window.goodbuddy.updates
|
||||
? undefined
|
||||
@@ -52,6 +64,45 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const changeModelDownloadSource = async (
|
||||
modelDownloadSource: ModelDownloadSource
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (
|
||||
!updates ||
|
||||
!settings ||
|
||||
settings.modelDownloadSource === modelDownloadSource
|
||||
) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSourceError(undefined)
|
||||
try {
|
||||
const nextSettings = await updates.updateSettings({
|
||||
modelDownloadSource
|
||||
})
|
||||
setSettings(nextSettings)
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t(
|
||||
'platformFeatures.modelDownloadSource.notification',
|
||||
{
|
||||
source: t(
|
||||
`modelDownloadSources.${nextSettings.modelDownloadSource}`
|
||||
)
|
||||
}
|
||||
),
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
} catch {
|
||||
setSourceError(
|
||||
t('platformFeatures.errors.saveModelDownloadSourceFailed')
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeMagicNotes = async (enabled: boolean): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
@@ -120,84 +171,220 @@ export function PlatformFeaturesSettingsSection({
|
||||
headingId="platform-features-heading"
|
||||
/>
|
||||
<SettingsWarningList warnings={settings?.warnings} />
|
||||
<section
|
||||
aria-label={t('platformFeatures.label')}
|
||||
className="settings-section"
|
||||
>
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('platformFeatures.magicNotes.title')}</strong>
|
||||
<small>{t('platformFeatures.magicNotes.description')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings?.magicNotesEnabled ?? false}
|
||||
disabled={!settings || saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
<div className="platform-features-tabs">
|
||||
<PageTabs
|
||||
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
|
||||
idPrefix="platform-features"
|
||||
onChange={setActiveSection}
|
||||
tabs={[
|
||||
{
|
||||
id: 'general',
|
||||
label: t('platformFeatures.tabs.general')
|
||||
},
|
||||
{
|
||||
id: 'magic-notes',
|
||||
label: t('platformFeatures.tabs.magicNotes')
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||
</label>
|
||||
<div className="platform-feature-option">
|
||||
<span>{t('platformFeatures.magicNotes.commentMode')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentModeAria')}
|
||||
disabled={!settings || saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t('platformFeatures.magicNotes.modes.immediate')
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t('platformFeatures.magicNotes.modes.afterSaveAuto')
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual'
|
||||
)
|
||||
]}
|
||||
value={activeSection}
|
||||
variant="segmented"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-general"
|
||||
className="settings-section"
|
||||
hidden={activeSection !== 'general'}
|
||||
id="platform-features-panel-general"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>
|
||||
{t('platformFeatures.modelDownloadSource.cardTitle')}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'platformFeatures.modelDownloadSource.cardDescription'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="model-download-source">
|
||||
<legend>
|
||||
{t('platformFeatures.modelDownloadSource.title')}
|
||||
</legend>
|
||||
<p>
|
||||
{t('platformFeatures.modelDownloadSource.description')}
|
||||
</p>
|
||||
{(
|
||||
['modelscope', 'hugging-face'] as const
|
||||
).map((source) => (
|
||||
<label
|
||||
className={
|
||||
source === settings.modelDownloadSource
|
||||
? 'model-download-source__option model-download-source__option--selected'
|
||||
: 'model-download-source__option'
|
||||
}
|
||||
key={source}
|
||||
>
|
||||
<input
|
||||
checked={source === settings.modelDownloadSource}
|
||||
disabled={saving}
|
||||
name="model-download-source"
|
||||
onChange={() =>
|
||||
void changeModelDownloadSource(source)
|
||||
}
|
||||
type="radio"
|
||||
value={source}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t(`modelDownloadSources.${source}`)}</strong>
|
||||
<small>
|
||||
{t(
|
||||
`platformFeatures.modelDownloadSource.options.${source}`
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{sourceError && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{sourceError}
|
||||
</p>
|
||||
)}
|
||||
<p className="model-download-source__current">
|
||||
{t('platformFeatures.modelDownloadSource.current', {
|
||||
source: t(
|
||||
`modelDownloadSources.${settings.modelDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('platformFeatures.modelDownloadSource.activeDownloadNote')}
|
||||
</p>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-magic-notes"
|
||||
className="settings-section"
|
||||
hidden={activeSection !== 'magic-notes'}
|
||||
id="platform-features-panel-magic-notes"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('platformFeatures.magicNotes.title')}</strong>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.description')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings.magicNotesEnabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
}
|
||||
]}
|
||||
value={settings?.magicNoteCommentMode ?? 'immediate'}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentModeHelp')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>{t('platformFeatures.magicNotes.commentFormat')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentFormatAria')}
|
||||
disabled={!settings || saving}
|
||||
onChange={(value) => void changeCommentFormat(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t('platformFeatures.magicNotes.formats.combined')
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t('platformFeatures.magicNotes.formats.narrative')
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t('platformFeatures.magicNotes.formats.structured')
|
||||
}
|
||||
]}
|
||||
value={settings?.magicNoteCommentFormat ?? 'combined'}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentFormatHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||
</label>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentMode')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentModeAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.immediate'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveAuto'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentMode}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentModeHelp')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentFormat')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentFormatAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentFormat(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.combined'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.narrative'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.structured'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentFormat}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentFormatHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -396,6 +396,7 @@ const diagnoseEmbedding = vi.fn(
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -423,14 +424,25 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://example.com/sensevoice',
|
||||
license: {
|
||||
name: 'Model License',
|
||||
notice: 'Review the model license before use.',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: []
|
||||
files: [],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'paraformer-bilingual-zh-en-int8',
|
||||
@@ -442,20 +454,32 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://example.com/paraformer',
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Review the model license before use.',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: []
|
||||
files: [],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
const createSpeechModelSnapshot = (
|
||||
selectedModelId: string | null = 'sensevoice-small-int8'
|
||||
): SpeechModelSnapshot => ({
|
||||
rootDirectory: 'C:\\Users\\test\\models\\speech',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: speechCatalog,
|
||||
installed: speechCatalog.map((model) => ({
|
||||
id: model.id,
|
||||
@@ -562,6 +586,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
applicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -1033,6 +1058,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
@@ -1066,6 +1094,111 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the global model download source from General settings', async () => {
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
onNotify={onNotify}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
expect(
|
||||
await screen.findByRole('tab', { name: '通用设置' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
const modelScope = screen.getByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
const huggingFace = screen.getByRole('radio', {
|
||||
name: /Hugging Face/u
|
||||
})
|
||||
expect(modelScope).toBeChecked()
|
||||
expect(huggingFace).not.toBeChecked()
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: '显示魔法笔记入口' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(huggingFace)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
modelDownloadSource: 'hugging-face'
|
||||
})
|
||||
)
|
||||
expect(huggingFace).toBeChecked()
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: '模型下载源已切换为 Hugging Face。',
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '魔法笔记' }))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '显示魔法笔记入口' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not guess a model download source when settings fail to load', async () => {
|
||||
getApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('read failed')
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
|
||||
expect(
|
||||
await screen.findByText('读取平台功能设置失败')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: /ModelScope/u })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('当前选择:ModelScope')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the confirmed model download source when saving fails', async () => {
|
||||
updateApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('save failed')
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
const modelScope = await screen.findByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
const huggingFace = screen.getByRole('radio', {
|
||||
name: /Hugging Face/u
|
||||
})
|
||||
fireEvent.click(huggingFace)
|
||||
|
||||
expect(
|
||||
await screen.findByText('保存模型下载源失败,请重试')
|
||||
).toBeInTheDocument()
|
||||
expect(modelScope).toBeChecked()
|
||||
expect(huggingFace).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
@@ -1095,6 +1228,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
|
||||
@@ -1610,6 +1610,7 @@ export function SettingsPanel({
|
||||
{activeTab === 'platform-features' && (
|
||||
<PlatformFeaturesSettingsSection
|
||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||
onNotify={onNotify}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'runtime' && (
|
||||
@@ -2924,6 +2925,9 @@ export function SettingsPanel({
|
||||
{modelType === 'speech' && (
|
||||
<SpeechModelSettingsSection
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
setActiveTab('platform-features')
|
||||
}
|
||||
onSelectedModelIdChange={(modelId, changed) => {
|
||||
setSpeechModelDraftId(modelId)
|
||||
setSpeechModelSelectionDirty(changed)
|
||||
@@ -3119,7 +3123,12 @@ export function SettingsPanel({
|
||||
)}
|
||||
|
||||
{activeTab === 'document-parsing' && (
|
||||
<DocumentParsingSettingsSection onNotify={onNotify} />
|
||||
<DocumentParsingSettingsSection
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
setActiveTab('platform-features')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
|
||||
@@ -21,7 +21,6 @@ const entry = {
|
||||
quality: 'high' as const,
|
||||
speed: 'fast' as const,
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://huggingface.co/example/model',
|
||||
license: {
|
||||
name: '模型仓库自定义许可',
|
||||
notice: '使用前请阅读许可。',
|
||||
@@ -32,26 +31,33 @@ const entry = {
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/model/resolve/revision/model.int8.onnx',
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/model/resolve/revision/tokens.txt',
|
||||
size: 100,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
size: 100,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const snapshot: SpeechModelSnapshot = {
|
||||
rootDirectory: 'C:\\Users\\test\\models\\speech',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [entry],
|
||||
installed: [],
|
||||
operations: [],
|
||||
@@ -105,7 +111,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(screen.queryByText('Model details')).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Open the SenseVoiceSmall INT8 model repository'
|
||||
name: 'Open the ModelScope repository for SenseVoiceSmall INT8'
|
||||
})
|
||||
)
|
||||
expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
@@ -159,7 +165,10 @@ describe('SpeechModelSettingsSection', () => {
|
||||
}))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(install).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
'sensevoice-small-int8',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
@@ -176,7 +185,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
family: 'whisper' as const,
|
||||
files: [
|
||||
{
|
||||
...entry.files[0],
|
||||
...entry.files[0]!,
|
||||
name: 'tiny-encoder.int8.onnx',
|
||||
role: 'encoder' as const
|
||||
}
|
||||
@@ -212,10 +221,81 @@ describe('SpeechModelSettingsSection', () => {
|
||||
}))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual')
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
'whisper-tiny-multilingual',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an unavailable source explicit and offers General settings', async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
const onOpenModelDownloadSourceSettings = vi.fn()
|
||||
const unavailableSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
catalog: [
|
||||
{
|
||||
...entry,
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: false,
|
||||
unavailableReason:
|
||||
'当前下载源暂不提供此模型的完整已验证文件'
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
speechModels: {
|
||||
getSnapshot: vi.fn(async () => unavailableSnapshot),
|
||||
install: vi.fn(),
|
||||
cancel: vi.fn(async () => true),
|
||||
remove: vi.fn(),
|
||||
select: vi.fn(),
|
||||
importArchive: vi.fn(),
|
||||
exportArchive: vi.fn(),
|
||||
openRepository: vi.fn(),
|
||||
openModelsDirectory: vi.fn()
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(
|
||||
<SpeechModelSettingsSection
|
||||
onOpenModelDownloadSourceSettings={
|
||||
onOpenModelDownloadSourceSettings
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText('当前来源不可下载')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: '下载 SenseVoiceSmall INT8'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '打开 SenseVoiceSmall INT8 的 ModelScope 模型仓库'
|
||||
})
|
||||
).toBeDisabled()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '前往通用设置' })
|
||||
)
|
||||
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('imports and exports verified speech model ZIP archives', async () => {
|
||||
const installedSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
@@ -306,7 +386,8 @@ describe('SpeechModelSettingsSection', () => {
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.int8.onnx',
|
||||
completedBytes: 550,
|
||||
totalBytes: 1_100
|
||||
totalBytes: 1_100,
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -332,6 +413,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(await screen.findByRole('progressbar', {
|
||||
name: 'SenseVoiceSmall INT8下载进度'
|
||||
})).toHaveValue(50)
|
||||
expect(screen.getByText('正在从 ModelScope 下载')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '取消 SenseVoiceSmall INT8 操作'
|
||||
}))
|
||||
@@ -350,7 +432,8 @@ describe('SpeechModelSettingsSection', () => {
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.int8.onnx',
|
||||
completedBytes: 550,
|
||||
totalBytes: 1_100
|
||||
totalBytes: 1_100,
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TFunction } from 'i18next'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
SpeechModelCatalogEntry,
|
||||
SpeechModelCatalogViewEntry,
|
||||
SpeechModelOperation,
|
||||
SpeechModelSnapshot
|
||||
} from '../../shared/speech-model-contracts'
|
||||
@@ -27,6 +27,7 @@ type SpeechModelSettingsSectionProps = {
|
||||
changed: boolean
|
||||
) => void
|
||||
onSelectionInvalidated?: (modelId: string | null) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -36,14 +37,8 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function catalogSize(entry: SpeechModelCatalogEntry): number | undefined {
|
||||
const downloads = entry.files.map((file) => file.download)
|
||||
return downloads.every(Boolean)
|
||||
? downloads.reduce(
|
||||
(total, download) => total + (download?.size ?? 0),
|
||||
0
|
||||
)
|
||||
: undefined
|
||||
function catalogSize(entry: SpeechModelCatalogViewEntry): number {
|
||||
return entry.files.reduce((total, file) => total + file.size, 0)
|
||||
}
|
||||
|
||||
function progressPercent(operation: SpeechModelOperation): number | undefined {
|
||||
@@ -65,11 +60,19 @@ function operationLabel(
|
||||
if (operation.phase === 'preparing') {
|
||||
return operation.kind === 'import'
|
||||
? t('speech.operations.preparingImport')
|
||||
: t('speech.operations.preparingDownload')
|
||||
: t('speech.operations.preparingDownloadFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
return operation.kind === 'import'
|
||||
? t('speech.operations.importing')
|
||||
: t('speech.operations.downloading')
|
||||
: t('speech.operations.downloadingFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function SpeechModelSettingsSection({
|
||||
@@ -77,7 +80,8 @@ export function SpeechModelSettingsSection({
|
||||
persistedSelectedModelId,
|
||||
selectedModelId,
|
||||
onSelectedModelIdChange,
|
||||
onSelectionInvalidated
|
||||
onSelectionInvalidated,
|
||||
onOpenModelDownloadSourceSettings
|
||||
}: SpeechModelSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [snapshot, setSnapshot] = useState<SpeechModelSnapshot>()
|
||||
@@ -306,6 +310,11 @@ export function SpeechModelSettingsSection({
|
||||
? progressPercent(operation)
|
||||
: undefined
|
||||
const size = model ? catalogSize(model) : undefined
|
||||
const downloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source === snapshot.selectedDownloadSource
|
||||
)
|
||||
const sourceAvailable = downloadAvailability?.available === true
|
||||
const selected = model?.id === effectiveSelectedModelId
|
||||
const inUse = model?.id === effectivePersistedModelId
|
||||
const pendingSelection =
|
||||
@@ -322,7 +331,9 @@ export function SpeechModelSettingsSection({
|
||||
? t('speech.status.installed')
|
||||
: model?.manualOnly
|
||||
? t('speech.status.manualImport')
|
||||
: t('speech.status.availableToDownload')
|
||||
: sourceAvailable
|
||||
? t('speech.status.availableToDownload')
|
||||
: t('speech.status.sourceUnavailable')
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -354,6 +365,13 @@ export function SpeechModelSettingsSection({
|
||||
<code>{snapshot.rootDirectory}</code>
|
||||
{t('speech.storageSuffix')}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('speech.downloadSource', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
{error && <p className="settings-warning" role="alert">{error}</p>}
|
||||
|
||||
<label className="field document-ocr-model-selector">
|
||||
@@ -383,7 +401,14 @@ export function SpeechModelSettingsSection({
|
||||
{optionName} ·{' '}
|
||||
{installedById.has(entry.id)
|
||||
? t('speech.status.installed')
|
||||
: t('speech.status.availableToDownload')}
|
||||
: entry.downloadAvailability.some(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.selectedDownloadSource &&
|
||||
availability.available
|
||||
)
|
||||
? t('speech.status.availableToDownload')
|
||||
: t('speech.status.sourceUnavailable')}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -411,9 +436,15 @@ export function SpeechModelSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
className="icon-button speech-model-card__repository"
|
||||
disabled={!sourceAvailable}
|
||||
onClick={() =>
|
||||
void window.goodbuddy.speechModels?.openRepository(
|
||||
model.id
|
||||
@@ -421,7 +452,12 @@ export function SpeechModelSettingsSection({
|
||||
}
|
||||
title={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
@@ -430,6 +466,11 @@ export function SpeechModelSettingsSection({
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{t('speech.family.' + model.family)}
|
||||
</span>
|
||||
@@ -540,7 +581,7 @@ export function SpeechModelSettingsSection({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!model.manualOnly && (
|
||||
{!model.manualOnly && sourceAvailable && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.downloadModel',
|
||||
@@ -553,7 +594,8 @@ export function SpeechModelSettingsSection({
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!.install(
|
||||
model.id
|
||||
model.id,
|
||||
snapshot.selectedDownloadSource
|
||||
),
|
||||
t('speech.notifications.installed', {
|
||||
name: displayName
|
||||
@@ -567,6 +609,19 @@ export function SpeechModelSettingsSection({
|
||||
{t('speech.actions.download')}
|
||||
</button>
|
||||
)}
|
||||
{!model.manualOnly &&
|
||||
!sourceAvailable &&
|
||||
onOpenModelDownloadSourceSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onOpenModelDownloadSourceSettings}
|
||||
type="button"
|
||||
>
|
||||
{t(
|
||||
'speech.actions.openDownloadSourceSettings'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.importModelZip',
|
||||
@@ -596,6 +651,16 @@ export function SpeechModelSettingsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!installed && !sourceAvailable && !model.manualOnly && (
|
||||
<p className="settings-warning">
|
||||
{t('speech.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{operation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('UpdateSettingsSection', () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { settings as chineseSettings } from '../zh-CN/settings'
|
||||
|
||||
export const settings = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
center: {
|
||||
eyebrow: 'Settings',
|
||||
title: 'Settings',
|
||||
@@ -472,7 +476,7 @@ export const settings = {
|
||||
ocrReady:
|
||||
'The model is installed, SHA-256 verified, and available offline',
|
||||
ocrUnavailable:
|
||||
'The model is not installed or failed verification. Download it from ModelScope.',
|
||||
'The model is not installed or failed verification. Get it from the current model download source.',
|
||||
partialNotice:
|
||||
'Basic document parsing is available. Legacy Office conversion is not implemented yet; scenario modes can use local OCR for scanned PDFs.'
|
||||
},
|
||||
@@ -524,11 +528,13 @@ export const settings = {
|
||||
'This model selection is not active yet. Save settings to switch.',
|
||||
installedOption: 'Installed',
|
||||
downloadableOption: 'Available to download',
|
||||
sourceUnavailableOption: 'Unavailable from current source',
|
||||
unavailableOption: 'Unavailable in this version',
|
||||
openModelsDirectory: 'Open model folder',
|
||||
storagePrefix: 'Models are installed on demand in',
|
||||
storageSuffix:
|
||||
' and can be exported as ZIP archives for offline devices.',
|
||||
downloadSource: 'Current model download source: {{source}}',
|
||||
recommended: 'Recommended',
|
||||
quality: {
|
||||
label: 'Quality: {{value}}',
|
||||
@@ -576,18 +582,21 @@ export const settings = {
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
cancel: 'Cancel',
|
||||
openRepository: 'Open ModelScope',
|
||||
openRepository: 'Open {{source}}',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
catalogUnavailable:
|
||||
'No OCR model catalog is available in this version.',
|
||||
selectedModelUnavailable:
|
||||
'The saved OCR model is unavailable in this version. Select and install another model above.',
|
||||
installBeforeSelecting:
|
||||
'Download this model first. It will become the current model after installation.',
|
||||
'Download or import this model first. It will become the current model after installation.',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
privacyNotice:
|
||||
'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
|
||||
operations: {
|
||||
preparing: 'Preparing model files',
|
||||
downloading: 'Downloading from ModelScope',
|
||||
downloading: 'Downloading from {{source}}',
|
||||
importing: 'Importing model ZIP',
|
||||
installing: 'Verifying and installing'
|
||||
},
|
||||
@@ -598,7 +607,7 @@ export const settings = {
|
||||
deleteModel: 'Delete {{name}}',
|
||||
cancelOperation: 'Cancel {{name}} operation',
|
||||
downloadProgress: '{{name}} download progress',
|
||||
openRepository: 'Open the ModelScope page for {{name}}'
|
||||
openRepository: 'Open the {{source}} page for {{name}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
|
||||
@@ -4,6 +4,10 @@ import type {
|
||||
} from '../zh-CN/settingsSections'
|
||||
|
||||
export const settingsSections = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
speech: {
|
||||
title: 'Speech models',
|
||||
description:
|
||||
@@ -12,6 +16,7 @@ export const settingsSections = {
|
||||
storagePrefix: 'Models are stored in',
|
||||
storageSuffix:
|
||||
'. Automatic downloads pin the source revision and verify SHA-256 hashes. Export a ZIP on an online device and import it directly on an offline device.',
|
||||
downloadSource: 'Current model download source: {{source}}',
|
||||
modelSelector: 'Current speech model',
|
||||
modelSelectorDescription:
|
||||
'Choose an installed model, then select Save settings to switch speech recognition models.',
|
||||
@@ -45,9 +50,9 @@ export const settingsSections = {
|
||||
operations: {
|
||||
installing: 'Verifying and installing',
|
||||
preparingImport: 'Preparing import',
|
||||
preparingDownload: 'Preparing download',
|
||||
preparingDownloadFrom: 'Preparing to download from {{source}}',
|
||||
importing: 'Importing',
|
||||
downloading: 'Downloading',
|
||||
downloadingFrom: 'Downloading from {{source}}',
|
||||
processingFile: 'Processing {{file}}'
|
||||
},
|
||||
status: {
|
||||
@@ -56,6 +61,7 @@ export const settingsSections = {
|
||||
installed: 'Installed',
|
||||
manualImport: 'Manual import',
|
||||
availableToDownload: 'Available to download',
|
||||
sourceUnavailable: 'Unavailable from current source',
|
||||
unknownSize: 'Unknown size'
|
||||
},
|
||||
tags: {
|
||||
@@ -66,6 +72,7 @@ export const settingsSections = {
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
download: 'Download',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
importZip: 'Import ZIP',
|
||||
exportZip: 'Export ZIP'
|
||||
},
|
||||
@@ -76,7 +83,7 @@ export const settingsSections = {
|
||||
importModelZip: 'Import {{name}} from a ZIP archive',
|
||||
exportModelZip: 'Export {{name}} as a ZIP archive',
|
||||
downloadProgress: '{{name}} download progress',
|
||||
openRepository: 'Open the {{name}} model repository'
|
||||
openRepository: 'Open the {{source}} repository for {{name}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
@@ -84,6 +91,8 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} exported as ZIP',
|
||||
removed: 'Speech model deleted'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
languages: {
|
||||
中文: 'Chinese',
|
||||
粤语: 'Cantonese',
|
||||
@@ -211,6 +220,7 @@ export const settingsSections = {
|
||||
'No roles yet. Create a role to configure its system prompt.'
|
||||
},
|
||||
platformFeatures: {
|
||||
loading: 'Loading platform feature settings…',
|
||||
errors: {
|
||||
serviceUnavailable:
|
||||
'Application settings are not available in this version',
|
||||
@@ -219,9 +229,34 @@ export const settingsSections = {
|
||||
saveCommentModeFailed:
|
||||
'Could not save the AI comment mode. Try again.',
|
||||
saveCommentFormatFailed:
|
||||
'Could not save the AI comment format. Try again.'
|
||||
'Could not save the AI comment format. Try again.',
|
||||
saveModelDownloadSourceFailed:
|
||||
'Could not save the model download source. Try again.'
|
||||
},
|
||||
label: 'Platform feature options',
|
||||
tabs: {
|
||||
ariaLabel: 'Platform feature settings',
|
||||
general: 'General',
|
||||
magicNotes: 'Magic Notes'
|
||||
},
|
||||
modelDownloadSource: {
|
||||
cardTitle: 'Local models',
|
||||
cardDescription:
|
||||
'Manage how GoodBuddy-managed local models are downloaded',
|
||||
title: 'Model download source',
|
||||
description:
|
||||
'Choose the platform for future GoodBuddy-managed local model downloads. Installed models, ZIP imports, Ollama models, and app updates are not affected.',
|
||||
options: {
|
||||
modelscope:
|
||||
'Default. Use when your network prioritizes access to ModelScope.',
|
||||
'hugging-face':
|
||||
'Use when your network can access Hugging Face reliably.'
|
||||
},
|
||||
current: 'Current selection: {{source}}',
|
||||
activeDownloadNote:
|
||||
'Downloads already in progress keep the source they started with. New downloads use the current selection.',
|
||||
notification: 'Model download source changed to {{source}}.'
|
||||
},
|
||||
magicNotes: {
|
||||
title: 'Magic Notes',
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const settings = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
center: {
|
||||
eyebrow: '设置',
|
||||
title: '设置中心',
|
||||
@@ -431,7 +435,7 @@ export const settings = {
|
||||
localOcr: '本地 OCR',
|
||||
localOcrModel: '当前 OCR:{{name}}',
|
||||
ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用',
|
||||
ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载',
|
||||
ocrUnavailable: '模型尚未安装或校验失败,请从当前模型下载源获取',
|
||||
partialNotice:
|
||||
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 可按场景模式使用本地 OCR。'
|
||||
},
|
||||
@@ -477,10 +481,12 @@ export const settings = {
|
||||
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
|
||||
installedOption: '已安装',
|
||||
downloadableOption: '可下载',
|
||||
sourceUnavailableOption: '当前来源不可下载',
|
||||
unavailableOption: '当前版本不可用',
|
||||
openModelsDirectory: '打开模型目录',
|
||||
storagePrefix: '模型按需安装到',
|
||||
storageSuffix: '。可导出 ZIP,并在内网设备直接导入。',
|
||||
downloadSource: '当前模型下载源:{{source}}',
|
||||
recommended: '推荐',
|
||||
quality: {
|
||||
label: '质量:{{value}}',
|
||||
@@ -528,16 +534,20 @@ export const settings = {
|
||||
delete: '删除',
|
||||
confirmDelete: '确认删除',
|
||||
cancel: '取消',
|
||||
openRepository: '打开 ModelScope',
|
||||
openRepository: '打开 {{source}}',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
|
||||
selectedModelUnavailable:
|
||||
'已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
|
||||
installBeforeSelecting: '请先下载该模型;下载完成后会自动设为当前模型。',
|
||||
installBeforeSelecting:
|
||||
'请先下载或导入该模型;安装完成后会自动设为当前模型。',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
privacyNotice:
|
||||
'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
|
||||
operations: {
|
||||
preparing: '正在准备模型文件',
|
||||
downloading: '正在从 ModelScope 下载',
|
||||
downloading: '正在从 {{source}} 下载',
|
||||
importing: '正在导入模型 ZIP',
|
||||
installing: '正在校验并安装'
|
||||
},
|
||||
@@ -548,7 +558,7 @@ export const settings = {
|
||||
deleteModel: '删除 {{name}}',
|
||||
cancelOperation: '取消 {{name}} 操作',
|
||||
downloadProgress: '{{name}} 下载进度',
|
||||
openRepository: '打开 {{name}} 的 ModelScope 页面'
|
||||
openRepository: '打开 {{name}} 的 {{source}} 页面'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const settingsSections = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
speech: {
|
||||
title: '语音模型',
|
||||
description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移',
|
||||
@@ -6,6 +10,7 @@ export const settingsSections = {
|
||||
storagePrefix: '模型保存在',
|
||||
storageSuffix:
|
||||
'。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。',
|
||||
downloadSource: '当前模型下载源:{{source}}',
|
||||
modelSelector: '当前语音模型',
|
||||
modelSelectorDescription:
|
||||
'选择已安装模型后,点击“保存设置”切换语音识别模型。',
|
||||
@@ -37,9 +42,9 @@ export const settingsSections = {
|
||||
operations: {
|
||||
installing: '正在校验并安装',
|
||||
preparingImport: '正在准备导入',
|
||||
preparingDownload: '正在准备下载',
|
||||
preparingDownloadFrom: '正在准备从 {{source}} 下载',
|
||||
importing: '正在导入',
|
||||
downloading: '正在下载',
|
||||
downloadingFrom: '正在从 {{source}} 下载',
|
||||
processingFile: '正在处理 {{file}}'
|
||||
},
|
||||
status: {
|
||||
@@ -48,6 +53,7 @@ export const settingsSections = {
|
||||
installed: '已安装',
|
||||
manualImport: '手动导入',
|
||||
availableToDownload: '可下载',
|
||||
sourceUnavailable: '当前来源不可下载',
|
||||
unknownSize: '大小未知'
|
||||
},
|
||||
tags: {
|
||||
@@ -58,6 +64,7 @@ export const settingsSections = {
|
||||
delete: '删除',
|
||||
confirmDelete: '确认删除',
|
||||
download: '下载',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
importZip: '导入 ZIP',
|
||||
exportZip: '导出 ZIP'
|
||||
},
|
||||
@@ -68,7 +75,7 @@ export const settingsSections = {
|
||||
importModelZip: '从 ZIP 导入 {{name}}',
|
||||
exportModelZip: '将 {{name}} 导出为 ZIP',
|
||||
downloadProgress: '{{name}}下载进度',
|
||||
openRepository: '打开 {{name}} 模型仓库'
|
||||
openRepository: '打开 {{name}} 的 {{source}} 模型仓库'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
@@ -76,6 +83,8 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} 已导出为 ZIP',
|
||||
removed: '语音模型已删除'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
languages: {
|
||||
中文: '中文',
|
||||
粤语: '粤语',
|
||||
@@ -197,14 +206,36 @@ export const settingsSections = {
|
||||
empty: '还没有角色。新建角色后,可以为它配置系统提示词。'
|
||||
},
|
||||
platformFeatures: {
|
||||
loading: '正在读取平台功能设置…',
|
||||
errors: {
|
||||
serviceUnavailable: '当前版本未提供应用设置服务',
|
||||
readFailed: '读取平台功能设置失败',
|
||||
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
|
||||
saveCommentModeFailed: '保存 AI 评论方式失败,请重试',
|
||||
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试'
|
||||
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试',
|
||||
saveModelDownloadSourceFailed: '保存模型下载源失败,请重试'
|
||||
},
|
||||
label: '平台功能选项',
|
||||
tabs: {
|
||||
ariaLabel: '平台功能设置',
|
||||
general: '通用设置',
|
||||
magicNotes: '魔法笔记'
|
||||
},
|
||||
modelDownloadSource: {
|
||||
cardTitle: '本地模型',
|
||||
cardDescription: '管理 GoodBuddy 托管本地模型的获取方式',
|
||||
title: '模型下载源',
|
||||
description:
|
||||
'选择 GoodBuddy 托管本地模型后续下载使用的平台。已安装模型、ZIP 导入、Ollama 模型和应用更新不受影响。',
|
||||
options: {
|
||||
modelscope: '默认,适合优先访问 ModelScope 的网络环境。',
|
||||
'hugging-face': '适合可以稳定访问 Hugging Face 的网络环境。'
|
||||
},
|
||||
current: '当前选择:{{source}}',
|
||||
activeDownloadNote:
|
||||
'正在进行的模型下载会继续使用启动时的来源;新的下载使用当前选择。',
|
||||
notification: '模型下载源已切换为 {{source}}。'
|
||||
},
|
||||
magicNotes: {
|
||||
title: '魔法笔记',
|
||||
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
|
||||
|
||||
@@ -5189,6 +5189,83 @@ button > svg {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.platform-features-tabs {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.platform-features-tabs + .settings-section,
|
||||
.platform-features-tabs ~ .settings-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.platform-features-tabs ~ .settings-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.model-download-source {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.model-download-source legend {
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.model-download-source > p {
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.model-download-source__option {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-download-source__option--selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-selected);
|
||||
}
|
||||
|
||||
.model-download-source__option:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.model-download-source__option input {
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.model-download-source__option > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-download-source__option small {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.model-download-source__current {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
Reference in New Issue
Block a user