@waterwx/dsh-novel-forge 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +35487 -745
- package/lib/client.js.map +1 -1
- package/lib/index.js +111 -12
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +1 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +6 -4
- package/lib/types/client/panel/RoleVisualPanel.d.ts +5 -3
- package/lib/types/client/panel/SceneLibrary.d.ts +1 -1
- package/lib/types/client/panel/StoryboardTab.d.ts +1 -1
- package/lib/types/engine.d.ts +8 -1
- package/lib/types/index.d.ts +6 -4
- package/lib/types/protocol.d.ts +47 -3
- package/package.json +99 -98
- package/src/client/api.ts +4 -0
- package/src/client/panel/MangaWorkspace.tsx +7 -3
- package/src/client/panel/NovelPanel.tsx +389 -231
- package/src/client/panel/RoleVisualPanel.tsx +23 -6
- package/src/client/panel/RunPanel.tsx +1 -1
- package/src/client/panel/SceneLibrary.tsx +1 -1
- package/src/client/panel/ShelfView.tsx +28 -35
- package/src/client/panel/StoryboardTab.tsx +1 -1
- package/src/client/panel/panel.module.css +407 -23
- package/src/engine.ts +48 -8
- package/src/index.ts +37 -6
- package/src/protocol.ts +50 -3
- package/src/routes.ts +26 -1
|
@@ -9,6 +9,7 @@ import type { ReactElement } from 'react'
|
|
|
9
9
|
import type { NovelApi } from '../api.ts'
|
|
10
10
|
import type { PanelController } from './controller.ts'
|
|
11
11
|
import { tt } from './helpers.ts'
|
|
12
|
+
import { BarChart3, Book, BookMarked, BookOpen, Brain, Clapperboard, Factory, FileText, Folder, GitBranch, Library, MessageSquare, Palette, PenLine, PlugZap, RotateCcw, ScrollText, Search, Settings, Sparkles, Wrench } from 'lucide-react'
|
|
12
13
|
import { AssistantTab } from './AssistantTab.tsx'
|
|
13
14
|
import { AssetsTab } from './AssetsTab.tsx'
|
|
14
15
|
import { ShelfView } from './ShelfView.tsx'
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
BookshelfSnapshot,
|
|
27
28
|
ChapterPlan,
|
|
28
29
|
Foreshadow,
|
|
30
|
+
ImageModelConfig,
|
|
29
31
|
JobFrame,
|
|
30
32
|
NovelConfig,
|
|
31
33
|
Plotline,
|
|
@@ -67,46 +69,55 @@ interface ProgressLine {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/** The navigation groups (AI-Novel-Writing-Assistant style grouping). */
|
|
70
|
-
const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon:
|
|
72
|
+
const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon: ReactElement }> }> = [
|
|
71
73
|
{
|
|
72
74
|
id: 'create',
|
|
73
75
|
label: '创作',
|
|
74
76
|
items: [
|
|
75
|
-
{ id: 'workflow', label: tt('tab.workflow'), icon:
|
|
76
|
-
{ id: 'overview', label: tt('tab.overview'), icon:
|
|
77
|
-
{ id: 'blurb', label: '简介 / 封面', icon:
|
|
78
|
-
{ id: 'plan', label: tt('tab.plan'), icon:
|
|
79
|
-
{ id: 'plotlines', label: '长线管理', icon:
|
|
80
|
-
{ id: 'book', label: '本书设定', icon:
|
|
77
|
+
{ id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={16} /> },
|
|
78
|
+
{ id: 'overview', label: tt('tab.overview'), icon: <FileText size={16} /> },
|
|
79
|
+
{ id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={16} /> },
|
|
80
|
+
{ id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={16} /> },
|
|
81
|
+
{ id: 'plotlines', label: '长线管理', icon: <ScrollText size={16} /> },
|
|
82
|
+
{ id: 'book', label: '本书设定', icon: <Library size={16} /> },
|
|
81
83
|
],
|
|
82
84
|
},
|
|
83
85
|
{
|
|
84
86
|
id: 'tools',
|
|
85
87
|
label: '工具',
|
|
86
88
|
items: [
|
|
87
|
-
{ id: 'assistant', label: tt('tab.assistant'), icon:
|
|
88
|
-
{ id: 'progress', label: '
|
|
89
|
-
{ id: 'breakdown', label: '拆书分析', icon:
|
|
90
|
-
{ id: 'manhua', label: '漫剧工作台', icon:
|
|
91
|
-
{ id: 'run', label: '生产单', icon:
|
|
89
|
+
{ id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={16} /> },
|
|
90
|
+
{ id: 'progress', label: 'AI进度', icon: <BarChart3 size={16} /> },
|
|
91
|
+
{ id: 'breakdown', label: '拆书分析', icon: <Search size={16} /> },
|
|
92
|
+
{ id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={16} /> },
|
|
93
|
+
{ id: 'run', label: '生产单', icon: <Factory size={16} /> },
|
|
92
94
|
],
|
|
93
95
|
},
|
|
94
96
|
{
|
|
95
97
|
id: 'assets',
|
|
96
98
|
label: '资产',
|
|
97
99
|
items: [
|
|
98
|
-
{ id: 'assets', label: '创作资产', icon:
|
|
100
|
+
{ id: 'assets', label: '创作资产', icon: <Wrench size={16} /> },
|
|
99
101
|
],
|
|
100
102
|
},
|
|
101
103
|
]
|
|
102
104
|
|
|
103
105
|
/** Settings tab — pinned to the bottom of the nav rail. */
|
|
104
|
-
const SETTINGS_TAB: { id: NovelTab; label: string; icon:
|
|
106
|
+
const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={16} /> }
|
|
105
107
|
|
|
106
108
|
/** Common DeepSeek model presets shown in settings; users can also type any model id. */
|
|
107
109
|
const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
|
|
108
110
|
const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
|
|
109
111
|
|
|
112
|
+
/** 设置页内子导航分组。 */
|
|
113
|
+
const SETTINGS_SECTIONS: ReadonlyArray<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: ReactElement }> = [
|
|
114
|
+
{ id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
|
|
115
|
+
{ id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
|
|
116
|
+
{ id: 'image', label: '生图模型', icon: <Palette size={16} /> },
|
|
117
|
+
{ id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
|
|
118
|
+
{ id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
|
|
119
|
+
]
|
|
120
|
+
|
|
110
121
|
/** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
|
|
111
122
|
declare const __NOVEL_FORGE_VERSION__: string | undefined
|
|
112
123
|
const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
|
|
@@ -382,6 +393,50 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
382
393
|
setPanelTheme(next)
|
|
383
394
|
try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
|
|
384
395
|
}
|
|
396
|
+
/** 显示模式(跟随系统 / 强制浅色 / 强制深色),localStorage 记忆,只作用于小说工坊面板。 */
|
|
397
|
+
const [themeMode, setThemeMode] = useState<'system' | 'light' | 'dark'>(() => {
|
|
398
|
+
try {
|
|
399
|
+
const v = window.localStorage.getItem('dsh-novel-forge.theme.mode')
|
|
400
|
+
return v === 'light' || v === 'dark' ? v : 'system'
|
|
401
|
+
} catch { return 'system' }
|
|
402
|
+
})
|
|
403
|
+
const changeThemeMode = (next: 'system' | 'light' | 'dark'): void => {
|
|
404
|
+
setThemeMode(next)
|
|
405
|
+
try { window.localStorage.setItem('dsh-novel-forge.theme.mode', next) } catch { /* ignore */ }
|
|
406
|
+
}
|
|
407
|
+
/** 界面密度(舒适 / 紧凑 / 宽松),localStorage 记忆。 */
|
|
408
|
+
const [themeDensity, setThemeDensity] = useState<'comfort' | 'compact' | 'spacious'>(() => {
|
|
409
|
+
try {
|
|
410
|
+
const v = window.localStorage.getItem('dsh-novel-forge.theme.density')
|
|
411
|
+
return v === 'compact' || v === 'spacious' ? v : 'comfort'
|
|
412
|
+
} catch { return 'comfort' }
|
|
413
|
+
})
|
|
414
|
+
const changeThemeDensity = (next: 'comfort' | 'compact' | 'spacious'): void => {
|
|
415
|
+
setThemeDensity(next)
|
|
416
|
+
try { window.localStorage.setItem('dsh-novel-forge.theme.density', next) } catch { /* ignore */ }
|
|
417
|
+
}
|
|
418
|
+
/** 恢复默认主题:清掉主题/模式/密度记忆。 */
|
|
419
|
+
const resetTheme = (): void => {
|
|
420
|
+
try {
|
|
421
|
+
window.localStorage.removeItem('dsh-novel-forge.theme')
|
|
422
|
+
window.localStorage.removeItem('dsh-novel-forge.theme.mode')
|
|
423
|
+
window.localStorage.removeItem('dsh-novel-forge.theme.density')
|
|
424
|
+
} catch { /* ignore */ }
|
|
425
|
+
setPanelTheme('liquid')
|
|
426
|
+
setThemeMode('system')
|
|
427
|
+
setThemeDensity('comfort')
|
|
428
|
+
}
|
|
429
|
+
/** 设置页内子导航:当前分组(localStorage 记忆)。 */
|
|
430
|
+
const [settingsTab, setSettingsTab] = useState<'model' | 'writing' | 'image' | 'files' | 'appearance'>(() => {
|
|
431
|
+
try {
|
|
432
|
+
const v = window.localStorage.getItem('dsh-novel-forge.settings.tab')
|
|
433
|
+
return v === 'writing' || v === 'image' || v === 'files' || v === 'appearance' ? v : 'model'
|
|
434
|
+
} catch { return 'model' }
|
|
435
|
+
})
|
|
436
|
+
const changeSettingsTab = (next: 'model' | 'writing' | 'image' | 'files' | 'appearance'): void => {
|
|
437
|
+
setSettingsTab(next)
|
|
438
|
+
try { window.localStorage.setItem('dsh-novel-forge.settings.tab', next) } catch { /* ignore */ }
|
|
439
|
+
}
|
|
385
440
|
/** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
|
|
386
441
|
const [draftNo, setDraftNo] = useState<number | null>(null)
|
|
387
442
|
/** 大纲页「更新大纲」编辑区是否展开。 */
|
|
@@ -509,9 +564,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
509
564
|
}, [])
|
|
510
565
|
/** AI 助手悬浮窗:是否打开。 */
|
|
511
566
|
const [assistantOpen, setAssistantOpen] = useState(false)
|
|
512
|
-
/**
|
|
567
|
+
/** AI进度悬浮窗:是否打开。 */
|
|
513
568
|
const [progressOpen, setProgressOpen] = useState(false)
|
|
514
|
-
/**
|
|
569
|
+
/** AI进度悬浮窗位置(localStorage 记忆)。 */
|
|
515
570
|
const [progressPos, setProgressPos] = useState(() => {
|
|
516
571
|
try {
|
|
517
572
|
const raw = window.localStorage.getItem('dsh-novel-forge.progress.float')
|
|
@@ -522,7 +577,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
522
577
|
} catch { /* ignore */ }
|
|
523
578
|
return { x: 60, y: 120 }
|
|
524
579
|
})
|
|
525
|
-
/**
|
|
580
|
+
/** AI进度悬浮窗尺寸(localStorage 记忆)。 */
|
|
526
581
|
const [progressSize, setProgressSize] = useState(() => {
|
|
527
582
|
try {
|
|
528
583
|
const raw = window.localStorage.getItem('dsh-novel-forge.progress.size')
|
|
@@ -555,7 +610,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
555
610
|
} catch { /* ignore */ }
|
|
556
611
|
return { w: 420, h: 460 }
|
|
557
612
|
})
|
|
558
|
-
/** 拖拽/缩放状态(target 区分 AI 助手 /
|
|
613
|
+
/** 拖拽/缩放状态(target 区分 AI 助手 / AI进度两个悬浮窗)。 */
|
|
559
614
|
const dragState = useRef<{ type: 'move' | 'resize'; target: 'assistant' | 'progress'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
|
|
560
615
|
|
|
561
616
|
/** 悬浮窗位置/尺寸持久化。 */
|
|
@@ -1798,7 +1853,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1798
1853
|
lastDeltaChars.current = 0
|
|
1799
1854
|
liveChars.current = 0
|
|
1800
1855
|
currentJobNo.current = frame.no
|
|
1801
|
-
//
|
|
1856
|
+
// 任务开始:不自动弹出进度悬浮窗(导航「AI进度」显示呼吸绿点提示,
|
|
1802
1857
|
// 想看进度时手动点导航打开;liveBar 数据照常累积)。
|
|
1803
1858
|
setProject(prev => prev === null ? prev : {
|
|
1804
1859
|
...prev,
|
|
@@ -2010,6 +2065,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2010
2065
|
imageApiKey: configDraft.imageApiKey,
|
|
2011
2066
|
imageApiModel: configDraft.imageApiModel,
|
|
2012
2067
|
imageApiEnabled: configDraft.imageApiEnabled ?? false,
|
|
2068
|
+
imageModels: configDraft.imageModels ?? [],
|
|
2013
2069
|
})
|
|
2014
2070
|
setConfig(result.config)
|
|
2015
2071
|
setConfigDraft(result.config)
|
|
@@ -2023,6 +2079,42 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2023
2079
|
}
|
|
2024
2080
|
}
|
|
2025
2081
|
|
|
2082
|
+
/** 生图接口测试状态(模型 id → 结果)。 */
|
|
2083
|
+
const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({})
|
|
2084
|
+
/** 测试一条生图模型:连通 + 延迟。 */
|
|
2085
|
+
const testImageModel = async (m: ImageModelConfig): Promise<void> => {
|
|
2086
|
+
if (m.baseURL.trim() === '' || m.apiKey.trim() === '') {
|
|
2087
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } }))
|
|
2088
|
+
return
|
|
2089
|
+
}
|
|
2090
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }))
|
|
2091
|
+
try {
|
|
2092
|
+
const r = await api.imageTest({ baseURL: m.baseURL, apiKey: m.apiKey, model: m.model })
|
|
2093
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: r.ok, ms: r.ms, message: r.message } }))
|
|
2094
|
+
} catch (err) {
|
|
2095
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: (err as Error).message } }))
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
/** 更新一条生图模型配置。 */
|
|
2099
|
+
const updateImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
|
|
2100
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
|
|
2101
|
+
}
|
|
2102
|
+
/** 新增一条生图模型(第一条默认启用)。 */
|
|
2103
|
+
const addImageModel = (): void => {
|
|
2104
|
+
setConfigDraft(prev => prev === null ? prev : {
|
|
2105
|
+
...prev,
|
|
2106
|
+
imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }],
|
|
2107
|
+
})
|
|
2108
|
+
}
|
|
2109
|
+
/** 删除一条生图模型。 */
|
|
2110
|
+
const removeImageModel = (id: string): void => {
|
|
2111
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
|
|
2112
|
+
}
|
|
2113
|
+
/** 启用一条(其余自动停用)。 */
|
|
2114
|
+
const enableImageModel = (id: string): void => {
|
|
2115
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2026
2118
|
/** Export the book. */
|
|
2027
2119
|
const handleExport = async (format: 'txt' | 'md'): Promise<void> => {
|
|
2028
2120
|
setBusy(true)
|
|
@@ -2274,7 +2366,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2274
2366
|
})()
|
|
2275
2367
|
|
|
2276
2368
|
return (
|
|
2277
|
-
<div className={css.panel} data-nf-theme={panelTheme}>
|
|
2369
|
+
<div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
|
|
2278
2370
|
{viewMode === 'shelf' ? (
|
|
2279
2371
|
/* 书架首页:默认视图,选择一本书进入工作台或阅读页 */
|
|
2280
2372
|
<ShelfView
|
|
@@ -2376,7 +2468,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2376
2468
|
{tab.id === 'workflow' && chapters.length > 0 && (
|
|
2377
2469
|
<span className={`${css.navTabBadge} ${css.navTabBadgeDone}`}>{journeyPercent}%</span>
|
|
2378
2470
|
)}
|
|
2379
|
-
{/*
|
|
2471
|
+
{/* 任务进行中:AI进度导航显示呼吸绿点(悬停看任务名),不自动弹窗 */}
|
|
2380
2472
|
{tab.id === 'progress' && busy && (
|
|
2381
2473
|
<span
|
|
2382
2474
|
className={`${css.navTabBadge} ${css.navTabBadgeLive}`}
|
|
@@ -2914,7 +3006,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2914
3006
|
</div>
|
|
2915
3007
|
)}
|
|
2916
3008
|
|
|
2917
|
-
{/* 待办队列(活动输出已移入「工具 →
|
|
3009
|
+
{/* 待办队列(活动输出已移入「工具 → AI进度」悬浮窗) */}
|
|
2918
3010
|
<div className={css.card}>
|
|
2919
3011
|
<span className={css.cardTitle}>待办队列</span>
|
|
2920
3012
|
{todos.length === 0 ? (
|
|
@@ -3690,236 +3782,302 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3690
3782
|
|
|
3691
3783
|
{activeTab === 'settings' && configDraft !== null && (
|
|
3692
3784
|
<>
|
|
3693
|
-
{/*
|
|
3694
|
-
<div className={`${css.card} ${css.settingsCard}`}>
|
|
3695
|
-
<
|
|
3696
|
-
|
|
3697
|
-
<
|
|
3698
|
-
<input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
|
|
3699
|
-
</div>
|
|
3700
|
-
<div className={css.field}>
|
|
3701
|
-
<label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
|
|
3702
|
-
<input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
|
|
3785
|
+
{/* 设置页内子导航(大容器) */}
|
|
3786
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ padding: '20px 22px', gap: 14 }}>
|
|
3787
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
|
3788
|
+
<span className={css.cardTitle} style={{ fontSize: 20, fontWeight: 700 }}>⚙️ 设置</span>
|
|
3789
|
+
<span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
|
|
3703
3790
|
</div>
|
|
3704
|
-
<div className={css.row}>
|
|
3705
|
-
|
|
3791
|
+
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
3792
|
+
{SETTINGS_SECTIONS.map(s => (
|
|
3793
|
+
<button
|
|
3794
|
+
key={s.id}
|
|
3795
|
+
type="button"
|
|
3796
|
+
className={`${css.button} ${settingsTab === s.id ? css.buttonPrimary : ''}`}
|
|
3797
|
+
style={{ fontSize: 14, padding: '8px 12px', flex: 1, minWidth: 104, justifyContent: 'center' }}
|
|
3798
|
+
onClick={() => { changeSettingsTab(s.id) }}
|
|
3799
|
+
>
|
|
3800
|
+
{s.icon} {s.label}
|
|
3801
|
+
</button>
|
|
3802
|
+
))}
|
|
3706
3803
|
</div>
|
|
3707
3804
|
</div>
|
|
3708
3805
|
|
|
3709
|
-
{
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
className={css.input}
|
|
3721
|
-
value={modelCustomMode ? '__custom__' : configDraft.model}
|
|
3722
|
-
onChange={e => {
|
|
3723
|
-
const v = e.target.value
|
|
3724
|
-
if (v === '__custom__') {
|
|
3725
|
-
setModelCustomMode(true)
|
|
3726
|
-
} else {
|
|
3727
|
-
setModelCustomMode(false)
|
|
3728
|
-
setConfigDraft({ ...configDraft, model: v })
|
|
3729
|
-
}
|
|
3730
|
-
}}
|
|
3731
|
-
>
|
|
3732
|
-
{MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
|
|
3733
|
-
<option value="__custom__">{tt('settings.modelCustom')}</option>
|
|
3734
|
-
</select>
|
|
3735
|
-
{modelCustomMode && (
|
|
3736
|
-
<input
|
|
3806
|
+
{settingsTab === 'model' && (
|
|
3807
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 26 }}>
|
|
3808
|
+
<span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
|
|
3809
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
|
3810
|
+
<div className={css.field}>
|
|
3811
|
+
<label className={css.fieldLabel}>{tt('settings.provider')}</label>
|
|
3812
|
+
<input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
|
|
3813
|
+
</div>
|
|
3814
|
+
<div className={css.field}>
|
|
3815
|
+
<label className={css.fieldLabel}>{tt('settings.model')}</label>
|
|
3816
|
+
<select
|
|
3737
3817
|
className={css.input}
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3818
|
+
value={modelCustomMode ? '__custom__' : configDraft.model}
|
|
3819
|
+
onChange={e => {
|
|
3820
|
+
const v = e.target.value
|
|
3821
|
+
if (v === '__custom__') {
|
|
3822
|
+
setModelCustomMode(true)
|
|
3823
|
+
} else {
|
|
3824
|
+
setModelCustomMode(false)
|
|
3825
|
+
setConfigDraft({ ...configDraft, model: v })
|
|
3826
|
+
}
|
|
3827
|
+
}}
|
|
3828
|
+
>
|
|
3829
|
+
{MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
|
|
3830
|
+
<option value="__custom__">{tt('settings.modelCustom')}</option>
|
|
3831
|
+
</select>
|
|
3832
|
+
{modelCustomMode && (
|
|
3833
|
+
<input
|
|
3834
|
+
className={css.input}
|
|
3835
|
+
style={{ marginTop: 6 }}
|
|
3836
|
+
value={configDraft.model}
|
|
3837
|
+
placeholder={tt('settings.modelCustomPlaceholder')}
|
|
3838
|
+
onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
|
|
3839
|
+
/>
|
|
3840
|
+
)}
|
|
3841
|
+
</div>
|
|
3842
|
+
<div className={css.field}>
|
|
3843
|
+
<label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
|
|
3844
|
+
<select
|
|
3845
|
+
className={css.input}
|
|
3846
|
+
value={configDraft.reasoningEffort ?? 'off'}
|
|
3847
|
+
onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
|
|
3848
|
+
>
|
|
3849
|
+
{REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
|
|
3850
|
+
</select>
|
|
3851
|
+
<span className={css.meta}>{tt('settings.reasoningHint')}</span>
|
|
3852
|
+
</div>
|
|
3853
|
+
<div className={css.field}>
|
|
3854
|
+
<label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label>
|
|
3855
|
+
<select
|
|
3856
|
+
className={css.input}
|
|
3857
|
+
value={configDraft.analysisReasoning ?? 'low'}
|
|
3858
|
+
onChange={e => { setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] }) }}
|
|
3859
|
+
>
|
|
3860
|
+
{REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
|
|
3861
|
+
</select>
|
|
3862
|
+
<span className={css.meta}>{tt('settings.analysisReasoningHint')}</span>
|
|
3863
|
+
</div>
|
|
3744
3864
|
</div>
|
|
3745
3865
|
</div>
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
{
|
|
3755
|
-
</
|
|
3756
|
-
<
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
<
|
|
3761
|
-
className={css.
|
|
3762
|
-
value={configDraft.
|
|
3763
|
-
|
|
3764
|
-
>
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3866
|
+
)}
|
|
3867
|
+
|
|
3868
|
+
{settingsTab === 'writing' && (
|
|
3869
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 26 }}>
|
|
3870
|
+
<span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
|
|
3871
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
|
3872
|
+
<div className={css.field}>
|
|
3873
|
+
<label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
|
|
3874
|
+
<input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
|
|
3875
|
+
</div>
|
|
3876
|
+
<div className={css.field}>
|
|
3877
|
+
<label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
|
|
3878
|
+
<input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
|
|
3879
|
+
</div>
|
|
3880
|
+
<div className={css.field}>
|
|
3881
|
+
<label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
|
|
3882
|
+
<input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
|
|
3883
|
+
<span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
|
|
3884
|
+
</div>
|
|
3885
|
+
<div className={css.field}>
|
|
3886
|
+
<label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
|
|
3887
|
+
<select
|
|
3888
|
+
className={css.input}
|
|
3889
|
+
value={configDraft.autoReview ? '1' : '0'}
|
|
3890
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
|
|
3891
|
+
>
|
|
3892
|
+
<option value="1">✓ 是</option>
|
|
3893
|
+
<option value="0">✗ 否</option>
|
|
3894
|
+
</select>
|
|
3895
|
+
</div>
|
|
3896
|
+
<div className={css.field}>
|
|
3897
|
+
<label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
|
|
3898
|
+
<select
|
|
3899
|
+
className={css.input}
|
|
3900
|
+
value={configDraft.autoAuthorReview ? '1' : '0'}
|
|
3901
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
|
|
3902
|
+
>
|
|
3903
|
+
<option value="1">✓ 是</option>
|
|
3904
|
+
<option value="0">✗ 否</option>
|
|
3905
|
+
</select>
|
|
3906
|
+
<span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
|
|
3907
|
+
</div>
|
|
3908
|
+
<div className={css.field}>
|
|
3909
|
+
<label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label>
|
|
3910
|
+
<select
|
|
3911
|
+
className={css.input}
|
|
3912
|
+
value={configDraft.autoReviewAfterRevise ? '1' : '0'}
|
|
3913
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' }) }}
|
|
3914
|
+
>
|
|
3915
|
+
<option value="1">✓ 是</option>
|
|
3916
|
+
<option value="0">✗ 否</option>
|
|
3917
|
+
</select>
|
|
3918
|
+
<span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
|
|
3919
|
+
</div>
|
|
3920
|
+
<div className={css.field}>
|
|
3921
|
+
<label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label>
|
|
3922
|
+
<select
|
|
3923
|
+
className={css.input}
|
|
3924
|
+
value={editorFontSize}
|
|
3925
|
+
onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
|
|
3926
|
+
>
|
|
3927
|
+
{[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
|
|
3928
|
+
<option key={v} value={v}>{v}px</option>
|
|
3929
|
+
))}
|
|
3930
|
+
</select>
|
|
3931
|
+
<span className={css.meta}>更改的是章节正文编辑区与润色/修订工作区(原稿 / 草稿 / diff)的显示字号,不影响面板主题。</span>
|
|
3932
|
+
</div>
|
|
3768
3933
|
</div>
|
|
3769
3934
|
</div>
|
|
3770
|
-
|
|
3935
|
+
)}
|
|
3771
3936
|
|
|
3772
|
-
{
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3937
|
+
{settingsTab === 'image' && (
|
|
3938
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 24 }}>
|
|
3939
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
3940
|
+
<span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span>
|
|
3941
|
+
<span className={css.row} style={{ gap: 8, alignItems: 'center' }}>
|
|
3942
|
+
<span className={css.meta}>启用生图功能</span>
|
|
3943
|
+
<button
|
|
3944
|
+
type="button"
|
|
3945
|
+
role="switch"
|
|
3946
|
+
aria-checked={configDraft.imageApiEnabled === true}
|
|
3947
|
+
className={`${css.switch} ${configDraft.imageApiEnabled === true ? css.switchOn : ''}`}
|
|
3948
|
+
onClick={() => { setConfigDraft(prev => prev === null ? prev : { ...prev, imageApiEnabled: prev.imageApiEnabled !== true }) }}
|
|
3949
|
+
>
|
|
3950
|
+
<span className={css.switchKnob} />
|
|
3951
|
+
</button>
|
|
3952
|
+
</span>
|
|
3779
3953
|
</div>
|
|
3780
|
-
<
|
|
3781
|
-
|
|
3782
|
-
|
|
3954
|
+
<span className={css.meta}>支持多套 OpenAI 兼容生图接口(豆包 / 即梦 / 其他平台);启用一条作为生成模型,保存后角色详情显示「AI 生图」按钮。</span>
|
|
3955
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
3956
|
+
{(configDraft.imageModels ?? []).map(m => (
|
|
3957
|
+
<div key={m.id} style={{ border: m.enabled ? '1px solid var(--nf-accent)' : '1px solid var(--nf-border)', borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 12, background: m.enabled ? 'var(--nf-accent-soft)' : 'transparent' }}>
|
|
3958
|
+
<div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
3959
|
+
<div className={css.field} style={{ flex: 1, minWidth: 180 }}>
|
|
3960
|
+
<label className={css.fieldLabel}>名称</label>
|
|
3961
|
+
<input className={css.input} placeholder="如:豆包 Seedream" value={m.name} onChange={e => { updateImageModel(m.id, { name: e.target.value }) }} />
|
|
3962
|
+
</div>
|
|
3963
|
+
<div className={css.field} style={{ flex: 1, minWidth: 180 }}>
|
|
3964
|
+
<label className={css.fieldLabel}>模型 id</label>
|
|
3965
|
+
<input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={m.model} onChange={e => { updateImageModel(m.id, { model: e.target.value }) }} />
|
|
3966
|
+
</div>
|
|
3967
|
+
<div className={css.field} style={{ flex: 1, minWidth: 200 }}>
|
|
3968
|
+
<label className={css.fieldLabel}>接口地址</label>
|
|
3969
|
+
<input className={css.input} placeholder="https://ark.cn-beijing.volces.com/api/v3" value={m.baseURL} onChange={e => { updateImageModel(m.id, { baseURL: e.target.value }) }} />
|
|
3970
|
+
</div>
|
|
3971
|
+
</div>
|
|
3972
|
+
<div className={css.field}>
|
|
3973
|
+
<label className={css.fieldLabel}>API Key</label>
|
|
3974
|
+
<input className={css.input} type="password" placeholder="ark-..." value={m.apiKey} onChange={e => { updateImageModel(m.id, { apiKey: e.target.value }) }} />
|
|
3975
|
+
</div>
|
|
3976
|
+
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
3977
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${m.enabled ? css.buttonPrimary : ''}`} onClick={() => { enableImageModel(m.id) }}>
|
|
3978
|
+
{m.enabled ? '✓ 已启用' : '设为启用'}
|
|
3979
|
+
</button>
|
|
3980
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={imageTestState[m.id]?.testing === true} onClick={() => { void testImageModel(m) }}>
|
|
3981
|
+
<PlugZap size={13} style={{ verticalAlign: -2 }} /> {imageTestState[m.id]?.testing === true ? '测试中…' : '测试'}
|
|
3982
|
+
</button>
|
|
3983
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { removeImageModel(m.id) }}>🗑 删除</button>
|
|
3984
|
+
<span className={css.meta}>{m.enabled ? '当前生效' : '未启用'}</span>
|
|
3985
|
+
{(() => {
|
|
3986
|
+
const st = imageTestState[m.id]
|
|
3987
|
+
if (st === undefined || st.testing) return null
|
|
3988
|
+
return st.ok === true
|
|
3989
|
+
? <span style={{ color: 'var(--nf-success)', fontSize: 12, fontWeight: 600 }}>✓ 连通 · {st.ms}ms{st.modelFound === true ? '' : st.modelFound === false ? '(模型未在列表)' : ''}</span>
|
|
3990
|
+
: <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>✗ {st.message ?? '失败'}</span>
|
|
3991
|
+
})()}
|
|
3992
|
+
</div>
|
|
3993
|
+
</div>
|
|
3994
|
+
))}
|
|
3995
|
+
{(configDraft.imageModels ?? []).length === 0 && (
|
|
3996
|
+
<div className={css.meta}>还没有生图模型——点下方「+ 添加生图模型」添加第一条(默认启用)。</div>
|
|
3997
|
+
)}
|
|
3783
3998
|
</div>
|
|
3784
|
-
<div className={css.
|
|
3785
|
-
<
|
|
3786
|
-
<select
|
|
3787
|
-
className={css.input}
|
|
3788
|
-
value={configDraft.imageApiEnabled ? '1' : '0'}
|
|
3789
|
-
onChange={e => { setConfigDraft({ ...configDraft, imageApiEnabled: e.target.value === '1' }) }}
|
|
3790
|
-
>
|
|
3791
|
-
<option value="0">关(默认)</option>
|
|
3792
|
-
<option value="1">开</option>
|
|
3793
|
-
</select>
|
|
3794
|
-
<span className={css.meta}>开启后角色详情显示「豆包生成」参考图按钮</span>
|
|
3999
|
+
<div className={css.row}>
|
|
4000
|
+
<button type="button" className={css.button} onClick={addImageModel}>+ 添加生图模型</button>
|
|
3795
4001
|
</div>
|
|
3796
4002
|
</div>
|
|
3797
|
-
|
|
4003
|
+
)}
|
|
3798
4004
|
|
|
3799
|
-
{
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
<
|
|
3805
|
-
<input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
|
|
3806
|
-
</div>
|
|
3807
|
-
<div className={css.field} style={{ flex: 1 }}>
|
|
3808
|
-
<label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
|
|
3809
|
-
<input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
|
|
3810
|
-
</div>
|
|
3811
|
-
<div className={css.field} style={{ flex: 1 }}>
|
|
3812
|
-
<label className={css.fieldLabel}>{tt('settings.editorFontSize')}</label>
|
|
3813
|
-
<select
|
|
3814
|
-
className={css.input}
|
|
3815
|
-
value={editorFontSize}
|
|
3816
|
-
onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
|
|
3817
|
-
>
|
|
3818
|
-
{[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
|
|
3819
|
-
<option key={v} value={v}>{v}px</option>
|
|
3820
|
-
))}
|
|
3821
|
-
</select>
|
|
3822
|
-
<span className={css.meta}>{tt('settings.editorFontSizeHint')}</span>
|
|
4005
|
+
{settingsTab === 'files' && (
|
|
4006
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 24 }}>
|
|
4007
|
+
<span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
|
|
4008
|
+
<div className={css.field}>
|
|
4009
|
+
<label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
|
|
4010
|
+
<input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
|
|
3823
4011
|
</div>
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
<label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
|
|
3828
|
-
<input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
|
|
3829
|
-
<span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
|
|
4012
|
+
<div className={css.field}>
|
|
4013
|
+
<label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
|
|
4014
|
+
<input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
|
|
3830
4015
|
</div>
|
|
3831
|
-
<div className={css.
|
|
3832
|
-
<
|
|
3833
|
-
<select
|
|
3834
|
-
className={css.input}
|
|
3835
|
-
value={configDraft.autoReview ? '1' : '0'}
|
|
3836
|
-
onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
|
|
3837
|
-
>
|
|
3838
|
-
<option value="1">✓ 是</option>
|
|
3839
|
-
<option value="0">✗ 否</option>
|
|
3840
|
-
</select>
|
|
4016
|
+
<div className={css.row}>
|
|
4017
|
+
<button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button>
|
|
3841
4018
|
</div>
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
<option value="0">✗ 否</option>
|
|
3851
|
-
</select>
|
|
3852
|
-
<span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
|
|
4019
|
+
|
|
4020
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', margin: '14px 0 12px' }} />
|
|
4021
|
+
|
|
4022
|
+
<span className={css.cardTitle} style={{ fontSize: 13 }}>💾 保存与导出</span>
|
|
4023
|
+
<div className={css.row}>
|
|
4024
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
|
|
4025
|
+
{tt('settings.save')}
|
|
4026
|
+
</button>
|
|
3853
4027
|
</div>
|
|
3854
|
-
<div className={css.
|
|
3855
|
-
<
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
>
|
|
3861
|
-
<option value="1">✓ 是</option>
|
|
3862
|
-
<option value="0">✗ 否</option>
|
|
3863
|
-
</select>
|
|
3864
|
-
<span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
|
|
4028
|
+
<div className={css.row}>
|
|
4029
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
|
|
4030
|
+
{tt('settings.exportTxt')}
|
|
4031
|
+
</button>
|
|
4032
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
|
|
4033
|
+
{tt('settings.exportMd')}
|
|
4034
|
+
</button>
|
|
3865
4035
|
</div>
|
|
3866
4036
|
</div>
|
|
3867
|
-
|
|
4037
|
+
)}
|
|
3868
4038
|
|
|
3869
|
-
{
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
<
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
{
|
|
3884
|
-
|
|
4039
|
+
{settingsTab === 'appearance' && (
|
|
4040
|
+
<div className={`${css.card} ${css.settingsCard}`}>
|
|
4041
|
+
<span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
|
|
4042
|
+
<span className={css.meta}>选择适合长时间创作的界面颜色和显示密度。主题只保存在当前设备,不影响小说内容和任务状态。</span>
|
|
4043
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
|
4044
|
+
<div className={css.field}>
|
|
4045
|
+
<label className={css.fieldLabel} style={{ fontSize: 13 }}>显示模式</label>
|
|
4046
|
+
<select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={themeMode} onChange={e => { changeThemeMode(e.target.value as 'system' | 'light' | 'dark') }}>
|
|
4047
|
+
<option value="system">跟随系统</option>
|
|
4048
|
+
<option value="light">浅色</option>
|
|
4049
|
+
<option value="dark">深色</option>
|
|
4050
|
+
</select>
|
|
4051
|
+
<span className={css.meta}>强制浅色/深色只作用于小说工坊面板</span>
|
|
4052
|
+
</div>
|
|
4053
|
+
<div className={css.field}>
|
|
4054
|
+
<label className={css.fieldLabel} style={{ fontSize: 13 }}>主题风格</label>
|
|
4055
|
+
<select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={panelTheme} onChange={e => { changePanelTheme(e.target.value as 'liquid' | 'classic' | 'neumorph') }}>
|
|
4056
|
+
<option value="liquid">液态玻璃 · 清新绿(默认)</option>
|
|
4057
|
+
<option value="classic">经典毛玻璃 · 沉静蓝</option>
|
|
4058
|
+
<option value="neumorph">新拟物 · 柔和浅色</option>
|
|
4059
|
+
</select>
|
|
4060
|
+
<span className={css.meta}>{tt('settings.themeHint')}</span>
|
|
4061
|
+
</div>
|
|
4062
|
+
<div className={css.field}>
|
|
4063
|
+
<label className={css.fieldLabel} style={{ fontSize: 13 }}>界面密度</label>
|
|
4064
|
+
<select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={themeDensity} onChange={e => { changeThemeDensity(e.target.value as 'comfort' | 'compact' | 'spacious') }}>
|
|
4065
|
+
<option value="comfort">舒适(默认)</option>
|
|
4066
|
+
<option value="compact">紧凑</option>
|
|
4067
|
+
<option value="spacious">宽松</option>
|
|
4068
|
+
</select>
|
|
4069
|
+
<span className={css.meta}>卡片 / 导航 / 内容的间距密度</span>
|
|
4070
|
+
</div>
|
|
4071
|
+
</div>
|
|
4072
|
+
<div className={css.row} style={{ justifyContent: 'flex-end' }}>
|
|
4073
|
+
<button type="button" className={css.button} style={{ padding: '9px 18px', fontSize: 13.5 }} onClick={resetTheme}>
|
|
4074
|
+
<RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题
|
|
4075
|
+
</button>
|
|
4076
|
+
</div>
|
|
3885
4077
|
</div>
|
|
3886
|
-
|
|
4078
|
+
)}
|
|
3887
4079
|
</>
|
|
3888
4080
|
)}
|
|
3889
|
-
|
|
3890
|
-
{activeTab === 'settings' && (
|
|
3891
|
-
<div className={`${css.card} ${css.settingsCard}`}>
|
|
3892
|
-
<span className={css.cardTitle}>{tt('settings.theme')}</span>
|
|
3893
|
-
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
3894
|
-
<button
|
|
3895
|
-
type="button"
|
|
3896
|
-
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'liquid' ? css.buttonPrimary : ''}`}
|
|
3897
|
-
onClick={() => { changePanelTheme('liquid') }}
|
|
3898
|
-
title="iOS 液态玻璃质感 · 绿色强调(当前默认)"
|
|
3899
|
-
>
|
|
3900
|
-
🧊 {tt('settings.themeLiquid')}
|
|
3901
|
-
</button>
|
|
3902
|
-
<button
|
|
3903
|
-
type="button"
|
|
3904
|
-
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'classic' ? css.buttonPrimary : ''}`}
|
|
3905
|
-
onClick={() => { changePanelTheme('classic') }}
|
|
3906
|
-
title="经典 iOS 毛玻璃 · 蓝色强调"
|
|
3907
|
-
>
|
|
3908
|
-
💠 {tt('settings.themeClassic')}
|
|
3909
|
-
</button>
|
|
3910
|
-
<button
|
|
3911
|
-
type="button"
|
|
3912
|
-
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'neumorph' ? css.buttonPrimary : ''}`}
|
|
3913
|
-
onClick={() => { changePanelTheme('neumorph') }}
|
|
3914
|
-
title="新拟物派 · 双阴影立体(仅浅色;深色下自动回退液态)"
|
|
3915
|
-
>
|
|
3916
|
-
🔘 {tt('settings.themeNeumorph')}
|
|
3917
|
-
</button>
|
|
3918
|
-
<span className={css.meta}>{tt('settings.themeHint')}</span>
|
|
3919
|
-
</div>
|
|
3920
|
-
</div>
|
|
3921
|
-
)}
|
|
3922
|
-
|
|
3923
4081
|
{activeTab === 'book' && bookTab === 'facts' && (
|
|
3924
4082
|
<div className={css.card}>
|
|
3925
4083
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
@@ -4307,7 +4465,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4307
4465
|
)}
|
|
4308
4466
|
|
|
4309
4467
|
{activeTab === 'manhua' && (
|
|
4310
|
-
<MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} imageApiEnabled={config?.imageApiEnabled === true} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
|
|
4468
|
+
<MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} imageApiEnabled={config?.imageApiEnabled === true} imageModels={config?.imageModels} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
|
|
4311
4469
|
)}
|
|
4312
4470
|
|
|
4313
4471
|
{activeTab === 'breakdown' && (
|
|
@@ -4413,7 +4571,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4413
4571
|
</div>
|
|
4414
4572
|
)}
|
|
4415
4573
|
|
|
4416
|
-
{/*
|
|
4574
|
+
{/* AI进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
|
|
4417
4575
|
{progressOpen && (
|
|
4418
4576
|
<div
|
|
4419
4577
|
className={css.assistantFloat}
|
|
@@ -4427,7 +4585,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4427
4585
|
}}
|
|
4428
4586
|
>
|
|
4429
4587
|
<span>
|
|
4430
|
-
📊
|
|
4588
|
+
📊 AI进度
|
|
4431
4589
|
{busy && <span style={{ color: 'var(--nf-accent)' }}> 🟢 任务进行中</span>}
|
|
4432
4590
|
<span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span>
|
|
4433
4591
|
</span>
|
|
@@ -4437,7 +4595,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4437
4595
|
清空
|
|
4438
4596
|
</button>
|
|
4439
4597
|
)}
|
|
4440
|
-
<button type="button" className={css.iconButton} title="关闭" aria-label="
|
|
4598
|
+
<button type="button" className={css.iconButton} title="关闭" aria-label="关闭AI进度" onClick={() => { setProgressOpen(false) }}>×</button>
|
|
4441
4599
|
</span>
|
|
4442
4600
|
</div>
|
|
4443
4601
|
<div className={css.assistantFloatBody} style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
|