@waterwx/dsh-novel-forge 1.7.3 → 1.9.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 +3627 -447
- package/lib/client.js.map +1 -1
- package/lib/index.js +1127 -141
- package/lib/index.js.map +1 -1
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/client/api.d.ts +31 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +30 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/ModelPicker.d.ts +11 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +14 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/engine.d.ts +39 -1
- package/lib/types/index.d.ts +8 -0
- package/lib/types/protocol.d.ts +339 -0
- package/package.json +1 -1
- package/src/author-assets.ts +166 -0
- package/src/client/api.ts +82 -0
- package/src/client/locales.ts +32 -0
- package/src/client/mount.tsx._new +97 -0
- package/src/client/mount.tsx._nf +97 -0
- package/src/client/mount.tsx._tmp +97 -0
- package/src/client/panel/AdaptModeView.tsx +202 -0
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +107 -0
- package/src/client/panel/ImportModal.tsx +2 -1
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +73 -79
- package/src/client/panel/ProgressConsole.tsx +64 -0
- package/src/client/panel/ProgressHomeView.tsx +87 -0
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/SettingsView.tsx +292 -0
- package/src/client/panel/panel.module.css +325 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/engine.ts +442 -0
- package/src/index.ts +20 -0
- package/src/protocol.ts +356 -0
- package/src/routes.ts +323 -0
|
@@ -9,10 +9,13 @@ 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 { ModelManager } from './ModelManager.tsx'
|
|
13
|
+
import { ReasoningSection } from './ReasoningSection.tsx'
|
|
12
14
|
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'
|
|
13
15
|
import { AssistantTab } from './AssistantTab.tsx'
|
|
14
16
|
import { AssetsTab } from './AssetsTab.tsx'
|
|
15
17
|
import { ShelfView } from './ShelfView.tsx'
|
|
18
|
+
import { AuthorHome } from './AuthorHome.tsx'
|
|
16
19
|
import { ReaderView } from './ReaderView.tsx'
|
|
17
20
|
import { RunPanel } from './RunPanel.tsx'
|
|
18
21
|
import { CreateBookView } from './CreateBookView.tsx'
|
|
@@ -73,40 +76,37 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
73
76
|
id: 'create',
|
|
74
77
|
label: '创作',
|
|
75
78
|
items: [
|
|
76
|
-
{ id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={
|
|
77
|
-
{ id: 'overview', label: tt('tab.overview'), icon: <FileText size={
|
|
78
|
-
{ id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={
|
|
79
|
-
{ id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={
|
|
80
|
-
{ id: 'plotlines', label: '长线管理', icon: <ScrollText size={
|
|
81
|
-
{ id: 'book', label: '本书设定', icon: <Library size={
|
|
79
|
+
{ id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={18} /> },
|
|
80
|
+
{ id: 'overview', label: tt('tab.overview'), icon: <FileText size={18} /> },
|
|
81
|
+
{ id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={18} /> },
|
|
82
|
+
{ id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={18} /> },
|
|
83
|
+
{ id: 'plotlines', label: '长线管理', icon: <ScrollText size={18} /> },
|
|
84
|
+
{ id: 'book', label: '本书设定', icon: <Library size={18} /> },
|
|
82
85
|
],
|
|
83
86
|
},
|
|
84
87
|
{
|
|
85
88
|
id: 'tools',
|
|
86
89
|
label: '工具',
|
|
87
90
|
items: [
|
|
88
|
-
{ id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={
|
|
89
|
-
{ id: 'progress', label: 'AI 进度', icon: <BarChart3 size={
|
|
90
|
-
{ id: 'breakdown', label: '拆书分析', icon: <Search size={
|
|
91
|
-
{ id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={
|
|
92
|
-
{ id: 'run', label: '生产单', icon: <Factory size={
|
|
91
|
+
{ id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={18} /> },
|
|
92
|
+
{ id: 'progress', label: 'AI 进度', icon: <BarChart3 size={18} /> },
|
|
93
|
+
{ id: 'breakdown', label: '拆书分析', icon: <Search size={18} /> },
|
|
94
|
+
{ id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={18} /> },
|
|
95
|
+
{ id: 'run', label: '生产单', icon: <Factory size={18} /> },
|
|
93
96
|
],
|
|
94
97
|
},
|
|
95
98
|
{
|
|
96
99
|
id: 'assets',
|
|
97
100
|
label: '资产',
|
|
98
101
|
items: [
|
|
99
|
-
{ id: 'assets', label: '创作资产', icon: <Wrench size={
|
|
102
|
+
{ id: 'assets', label: '创作资产', icon: <Wrench size={18} /> },
|
|
100
103
|
],
|
|
101
104
|
},
|
|
102
105
|
]
|
|
103
106
|
|
|
104
107
|
/** Settings tab — pinned to the bottom of the nav rail. */
|
|
105
|
-
const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={
|
|
108
|
+
const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={18} /> }
|
|
106
109
|
|
|
107
|
-
/** Common DeepSeek model presets shown in settings; users can also type any model id. */
|
|
108
|
-
const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
|
|
109
|
-
const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
|
|
110
110
|
|
|
111
111
|
/** 设置页内子导航分组。 */
|
|
112
112
|
const SETTINGS_SECTIONS: ReadonlyArray<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: ReactElement }> = [
|
|
@@ -316,6 +316,12 @@ function DiffList({ original, draft, fontSize }: { original: string; draft: stri
|
|
|
316
316
|
export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
317
317
|
const [activeTab, setActiveTab] = useState<NovelTab>('workflow')
|
|
318
318
|
const [config, setConfig] = useState<NovelConfig | null>(null)
|
|
319
|
+
/** 自定义背景(URL / dataURL),覆盖主题默认背景。 */
|
|
320
|
+
const [themeBg, setThemeBg] = useState<string | undefined>(undefined)
|
|
321
|
+
/** 自定义背景遮罩/模糊强度 0-80。 */
|
|
322
|
+
const [themeBgBlur, setThemeBgBlur] = useState(0)
|
|
323
|
+
/** 玻璃透明度 0-100(100=主题原样)。 */
|
|
324
|
+
const [themeOpacity, setThemeOpacity] = useState(100)
|
|
319
325
|
const [project, setProject] = useState<ProjectState | null>(null)
|
|
320
326
|
const [generatedFiles, setGeneratedFiles] = useState<string[]>([])
|
|
321
327
|
const [outlineText, setOutlineText] = useState('')
|
|
@@ -330,7 +336,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
330
336
|
const [notice, setNotice] = useState('')
|
|
331
337
|
const [progress, setProgress] = useState<ProgressLine[]>([])
|
|
332
338
|
const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
|
|
333
|
-
const [modelCustomMode, setModelCustomMode] = useState(false)
|
|
334
339
|
const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
|
|
335
340
|
/** 复盘记录页:当前展开的章节号。 */
|
|
336
341
|
const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
|
|
@@ -699,7 +704,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
699
704
|
const status = await api.status()
|
|
700
705
|
setConfig(status.config)
|
|
701
706
|
setConfigDraft(status.config)
|
|
702
|
-
setModelCustomMode(!(MODEL_PRESETS as readonly string[]).includes(status.config.model))
|
|
703
707
|
setAuditStatus(status.audit ?? null)
|
|
704
708
|
setProject(status.project ?? null)
|
|
705
709
|
// 人物志存档同步:有 roleStatus 存档直接显示,无需重新刷新计算。
|
|
@@ -748,6 +752,17 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
748
752
|
}
|
|
749
753
|
}, [api, refresh, refreshShelf])
|
|
750
754
|
|
|
755
|
+
/** 首页「设置」与书内设置同入口:激活当前/首选书并跳到设置页。 */
|
|
756
|
+
const openSettingsFromHome = async (): Promise<void> => {
|
|
757
|
+
const targetId = shelf?.activeBookId ?? shelf?.books[0]?.id ?? null
|
|
758
|
+
if (targetId === null || targetId === undefined) {
|
|
759
|
+
setError('请先在书架开一本书;设置页以书籍工作台为入口')
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
await activateBook(targetId, 'workspace')
|
|
763
|
+
setActiveTab('settings')
|
|
764
|
+
}
|
|
765
|
+
|
|
751
766
|
/** Handle a docx file (pick or drag): parse locally, save outline. */
|
|
752
767
|
const handleDocxFile = useCallback(async (file: File) => {
|
|
753
768
|
setBusy(true)
|
|
@@ -2066,6 +2081,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2066
2081
|
imageApiModel: configDraft.imageApiModel,
|
|
2067
2082
|
imageApiEnabled: configDraft.imageApiEnabled ?? false,
|
|
2068
2083
|
imageModels: configDraft.imageModels ?? [],
|
|
2084
|
+
savedModels: configDraft.savedModels ?? [],
|
|
2069
2085
|
})
|
|
2070
2086
|
setConfig(result.config)
|
|
2071
2087
|
setConfigDraft(result.config)
|
|
@@ -2364,12 +2380,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2364
2380
|
n += assets?.antiAiRules?.length ?? 0
|
|
2365
2381
|
return n
|
|
2366
2382
|
})()
|
|
2383
|
+
/** 自定义背景:优先用户显式选择(themeBg),否则用 config 里的持久化值。 */
|
|
2384
|
+
const effectiveBg = themeBg !== undefined && themeBg !== '' ? themeBg : (config?.themeBackground ?? '')
|
|
2385
|
+
const effectiveBgBlur = themeBgBlur > 0 ? themeBgBlur : (config?.themeBackgroundBlur ?? 0)
|
|
2386
|
+
const effectiveOpacity = themeOpacity > 0 ? themeOpacity : (config?.themeOpacity ?? 100)
|
|
2387
|
+
const panelBgStyle: Record<string, string> = {}
|
|
2388
|
+
if (effectiveBg !== '') panelBgStyle['--nf-glass-bg-image' as string] = 'url(' + effectiveBg + ')'
|
|
2389
|
+
if (effectiveBgBlur > 0) panelBgStyle['--nf-glass-bg-dim' as string] = String(effectiveBgBlur)
|
|
2390
|
+
panelBgStyle['--nf-glass-opacity' as string] = String(effectiveOpacity) + '%'
|
|
2367
2391
|
|
|
2368
2392
|
return (
|
|
2369
|
-
<div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
|
|
2393
|
+
<div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity} style={panelBgStyle}>
|
|
2370
2394
|
{viewMode === 'shelf' ? (
|
|
2371
|
-
/*
|
|
2372
|
-
<
|
|
2395
|
+
/* 书架首页:作者级左侧导航 + 书架/改编/资产库/设置 */
|
|
2396
|
+
<AuthorHome
|
|
2373
2397
|
api={api}
|
|
2374
2398
|
shelf={shelf ?? { books: [], activeBookId: null }}
|
|
2375
2399
|
onOpenBook={async (id) => {
|
|
@@ -2380,6 +2404,16 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2380
2404
|
}}
|
|
2381
2405
|
onAddBook={() => { setViewMode('create') }}
|
|
2382
2406
|
onImportBook={() => { setShowImport(true) }}
|
|
2407
|
+
onOpenSettings={() => { void openSettingsFromHome() }}
|
|
2408
|
+
onTheme={(t, m, d) => { changePanelTheme(t); changeThemeMode(m); changeThemeDensity(d) }}
|
|
2409
|
+
onBackground={(bg, blur) => { setThemeBg(bg); setThemeBgBlur(blur) }}
|
|
2410
|
+
onOpacity={(n) => { setThemeOpacity(n) }}
|
|
2411
|
+
adaptEnabled={config?.enableAdaptMode === true}
|
|
2412
|
+
progress={progress}
|
|
2413
|
+
busy={busy}
|
|
2414
|
+
busyLabel={busyLabel}
|
|
2415
|
+
liveBar={liveBar}
|
|
2416
|
+
onClearProgress={() => { setProgress([]) }}
|
|
2383
2417
|
/>
|
|
2384
2418
|
) : viewMode === 'create' ? (
|
|
2385
2419
|
/* 开书向导:独立页面 */
|
|
@@ -3804,65 +3838,25 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3804
3838
|
</div>
|
|
3805
3839
|
|
|
3806
3840
|
{settingsTab === 'model' && (
|
|
3807
|
-
|
|
3808
|
-
<
|
|
3809
|
-
|
|
3810
|
-
<
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
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: 'var(--nf-space-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>
|
|
3841
|
+
<>
|
|
3842
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
|
|
3843
|
+
<span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
|
|
3844
|
+
<ModelManager
|
|
3845
|
+
api={api}
|
|
3846
|
+
provider={configDraft.provider}
|
|
3847
|
+
model={configDraft.model}
|
|
3848
|
+
savedModels={configDraft.savedModels ?? []}
|
|
3849
|
+
onProvider={v => { setConfigDraft({ ...configDraft, provider: v }) }}
|
|
3850
|
+
onModel={v => { setConfigDraft({ ...configDraft, model: v }) }}
|
|
3851
|
+
onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
|
|
3852
|
+
/>
|
|
3864
3853
|
</div>
|
|
3865
|
-
|
|
3854
|
+
<ReasoningSection
|
|
3855
|
+
reasoningEffort={configDraft.reasoningEffort ?? 'off'}
|
|
3856
|
+
analysisReasoning={configDraft.analysisReasoning ?? 'low'}
|
|
3857
|
+
onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
|
|
3858
|
+
/>
|
|
3859
|
+
</>
|
|
3866
3860
|
)}
|
|
3867
3861
|
|
|
3868
3862
|
{settingsTab === 'writing' && (
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享「AI 进度」实时控制台:当前任务大进度条 + 全量活动日志。
|
|
3
|
+
* 书内与首页共用一个表现,内容来自同一份进度状态。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
6
|
+
import css from './panel.module.css'
|
|
7
|
+
|
|
8
|
+
export interface ProgressLine {
|
|
9
|
+
id: number;
|
|
10
|
+
text: string;
|
|
11
|
+
kind: 'info' | 'done' | 'error';
|
|
12
|
+
live?: boolean;
|
|
13
|
+
ratio?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ProgressConsole({ progress, busy, busyLabel, liveBar, onClear }: {
|
|
17
|
+
progress: ProgressLine[];
|
|
18
|
+
busy: boolean;
|
|
19
|
+
busyLabel: string;
|
|
20
|
+
liveBar: { text: string; ratio?: number } | null;
|
|
21
|
+
onClear: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
const endRef = useRef<HTMLDivElement | null>(null);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
26
|
+
}, [progress]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', height: '100%', minHeight: 0 }}>
|
|
30
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
31
|
+
<span style={{ fontWeight: 600 }}>📊 AI 进度</span>
|
|
32
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={onClear} title="清空活动记录">清空</button>
|
|
33
|
+
</div>
|
|
34
|
+
{(busy && (busyLabel !== '' || liveBar !== null)) && (
|
|
35
|
+
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
|
|
36
|
+
<span style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 600, color: 'var(--nf-accent)' }}>✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…</span>
|
|
37
|
+
{liveBar?.ratio !== undefined && (
|
|
38
|
+
<div className={css.bigProgressBar}><div className={css.bigProgressBarFill} style={{ width: Math.round(liveBar.ratio * 100) + '%' }} /></div>
|
|
39
|
+
)}
|
|
40
|
+
{liveBar?.text !== undefined && <span className={css.liveText}>{liveBar.text}</span>}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
<div className={css.progress} style={{ flex: 1, minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8)', display: 'flex', flexDirection: 'column' }}>
|
|
44
|
+
{progress.length === 0 ? (
|
|
45
|
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--nf-space-4)', color: 'var(--nf-text-3)', fontSize: 'var(--nf-fs-12)', minHeight: 120 }}>
|
|
46
|
+
<span style={{ fontSize: 'var(--nf-fs-22)' }}>📭</span>
|
|
47
|
+
<span>暂无活动记录</span>
|
|
48
|
+
<span>生成、审稿等操作会显示在这里</span>
|
|
49
|
+
</div>
|
|
50
|
+
) : (
|
|
51
|
+
progress.map(line => (
|
|
52
|
+
<div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
|
|
53
|
+
{line.live === true && (
|
|
54
|
+
<span className={css.progressBar}><span className={css.progressBarFill} style={{ width: Math.round((line.ratio ?? 0) * 100) + '%' }} /></span>
|
|
55
|
+
)}
|
|
56
|
+
{line.text}
|
|
57
|
+
</div>
|
|
58
|
+
))
|
|
59
|
+
)}
|
|
60
|
+
<div ref={endRef} />
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首页 AI 进度视图:书架级聚合进度——总览 + 每本书进度条 + 进行中高亮。
|
|
3
|
+
* 读取 BookshelfSnapshot,不依赖打开某本书。
|
|
4
|
+
*/
|
|
5
|
+
import { useMemo } from 'react'
|
|
6
|
+
import { BarChart3, BookOpen, PenLine, CheckCircle2, Clock, RefreshCw } from 'lucide-react'
|
|
7
|
+
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
function relativeTime(iso: string): string {
|
|
11
|
+
const t = new Date(iso).getTime()
|
|
12
|
+
if (!Number.isFinite(t)) return '—'
|
|
13
|
+
const diff = Date.now() - t
|
|
14
|
+
if (diff < 60_000) return '刚刚'
|
|
15
|
+
if (diff < 3_600_000) return Math.floor(diff / 60_000) + ' 分钟前'
|
|
16
|
+
if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + ' 小时前'
|
|
17
|
+
if (diff < 7 * 86_400_000) return Math.floor(diff / 86_400_000) + ' 天前'
|
|
18
|
+
return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
|
|
22
|
+
if (!book.hasProject) return 'none'
|
|
23
|
+
return book.total > 0 && book.done >= book.total ? 'done' : 'active'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ProgressHomeView({ shelf, onOpenBook }: {
|
|
27
|
+
shelf: BookshelfSnapshot;
|
|
28
|
+
onOpenBook: (id: string) => void;
|
|
29
|
+
}) {
|
|
30
|
+
const stats = useMemo(() => {
|
|
31
|
+
let active = 0, done = 0, none = 0, chapters = 0, written = 0;
|
|
32
|
+
for (const b of shelf.books) {
|
|
33
|
+
const s = bookStatus(b)
|
|
34
|
+
if (s === 'none') none++; else if (s === 'done') done++; else active++;
|
|
35
|
+
chapters += b.total;
|
|
36
|
+
written += b.done;
|
|
37
|
+
}
|
|
38
|
+
return { active, done, none, chapters, written }
|
|
39
|
+
}, [shelf.books]);
|
|
40
|
+
|
|
41
|
+
const progressPct = stats.chapters > 0 ? Math.round((stats.written / stats.chapters) * 100) : 0;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={css.authorPageBody}>
|
|
45
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}><h2 className={css.panelTitle} style={{ margin: 0 }}>📊 AI 进度</h2><span className={css.meta}>跨书架聚合进度与最近活动</span></div>
|
|
46
|
+
|
|
47
|
+
<div className={css.progressOverview}>
|
|
48
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{shelf.books.length}</span><span className={css.meta}>总书数</span></div>
|
|
49
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.active}</span><span className={css.meta}>进行中</span></div>
|
|
50
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.done}</span><span className={css.meta}>已完结</span></div>
|
|
51
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.written}/{stats.chapters}</span><span className={css.meta}>已完成章节</span></div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className={css.progressTotalBar}><div className={css.progressTotalFill} style={{ width: progressPct + '%' }} /><span className={css.meta}>全书架整体进度 {progressPct}%</span></div>
|
|
55
|
+
|
|
56
|
+
{shelf.books.length === 0 ? (
|
|
57
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
58
|
+
<span className={css.shelfEmptyIcon}><BarChart3 size={30} /></span>
|
|
59
|
+
<span className={css.shelfEmptyTitle}>还没有书</span>
|
|
60
|
+
<span className={css.meta}>去书架开一本书或导入全文后,这里会显示进度。</span>
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<div className={css.progressBookList}>
|
|
64
|
+
{shelf.books.map(book => {
|
|
65
|
+
const s = bookStatus(book)
|
|
66
|
+
const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0;
|
|
67
|
+
const statusLabel = s === 'none' ? '未开书' : s === 'done' ? '已完结' : '进行中';
|
|
68
|
+
return (
|
|
69
|
+
<div key={book.id} className={css.progressBook + (s === 'active' ? ' ' + css.progressBookActive : '')}>
|
|
70
|
+
<div className={css.progressBookHead}>
|
|
71
|
+
<span className={css.progressBookName} title={book.bookName}>{book.bookName}</span>
|
|
72
|
+
<span className={css.badge + (s === 'done' ? ' ' + css.badgeDone : s === 'active' ? ' ' + css.badgeWritten : ' ' + css.badgePending)}>{statusLabel}</span>
|
|
73
|
+
{s === 'active' && <span className={css.meta + ' ' + css.progressLive}><RefreshCw size={12} style={{ verticalAlign: -2 }} /> 创作中</span>}
|
|
74
|
+
</div>
|
|
75
|
+
<div className={css.progressBookBar}><div className={css.progressBookFill} style={{ width: Math.round(ratio * 100) + '%' }} /></div>
|
|
76
|
+
<div className={css.rowEnd} style={{ justifyContent: 'space-between' }}>
|
|
77
|
+
<span className={css.meta}>{book.total > 0 ? '已完成 ' + book.done + ' / ' + book.total + ' 章' : '尚未规划章节'} · 更新于 {relativeTime(book.updatedAt)}</span>
|
|
78
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => onOpenBook(book.id)}><PenLine size={12} style={{ verticalAlign: -2 }} /> 进入</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 推理强度(思考强度)独立容器:写作类/分析类两个 reasoningEffort 下拉。
|
|
3
|
+
*/
|
|
4
|
+
import { Brain } from 'lucide-react'
|
|
5
|
+
import type { NovelConfig } from '../../protocol.ts'
|
|
6
|
+
import { tt } from './helpers.ts'
|
|
7
|
+
import css from './panel.module.css'
|
|
8
|
+
|
|
9
|
+
const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
|
|
10
|
+
const REASONING_LABEL: Record<string, string> = {
|
|
11
|
+
off: tt('settings.reasoning.off'),
|
|
12
|
+
low: tt('settings.reasoning.low'),
|
|
13
|
+
high: tt('settings.reasoning.high'),
|
|
14
|
+
max: tt('settings.reasoning.max'),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ReasoningSectionProps {
|
|
18
|
+
reasoningEffort: NovelConfig['reasoningEffort']
|
|
19
|
+
analysisReasoning: NovelConfig['analysisReasoning']
|
|
20
|
+
onChange: (patch: { reasoningEffort?: NovelConfig['reasoningEffort']; analysisReasoning?: NovelConfig['analysisReasoning'] }) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ReasoningSection({ reasoningEffort, analysisReasoning, onChange }: ReasoningSectionProps): JSX.Element {
|
|
24
|
+
return (
|
|
25
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
|
|
26
|
+
<span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> {tt('settings.reasoningSection')}</span>
|
|
27
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label><select className={css.input} value={reasoningEffort ?? 'off'} onChange={e => onChange({ reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] })}>{REASONING_OPTIONS.map(v => <option key={v} value={v}>{REASONING_LABEL[v]}</option>)}</select><span className={css.meta}>{tt('settings.reasoningHint')}</span></div>
|
|
28
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label><select className={css.input} value={analysisReasoning ?? 'low'} onChange={e => onChange({ analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] })}>{REASONING_OPTIONS.map(v => <option key={v} value={v}>{REASONING_LABEL[v]}</option>)}</select><span className={css.meta}>{tt('settings.analysisReasoningHint')}</span></div>
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|