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:
mesalogo
2026-08-19 13:43:16 +08:00
parent c41d7e8b96
commit 43e1d162dc
44 changed files with 6083 additions and 839 deletions
+13
View File
@@ -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>
</>
)
+140 -4
View File
@@ -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: '显示魔法笔记入口'
+10 -1
View File
@@ -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'
}
]
}
+83 -18
View File
@@ -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 分析内容',
+77
View File
@@ -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;