File size: 11,109 Bytes
55847d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Sparkles, Hash, FileText } from 'lucide-react'
import { useLLMStore } from '../../stores/llm-store'
import { useWorkflowStore } from '../../stores/workflow-store'
import { useProjectStore } from '../../stores/project-store'
import { createConfigGenerationWorkflow } from '../../services/workflows/architecture-workflow'
import { confirm } from '../ui/Confirm'
import { toast } from '../ui/Toast'
import {
Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription,
} from '../ui/Dialog'
import { Button } from '../ui/Button'
import { Input } from '../ui/Input'
import { Textarea } from '../ui/Textarea'
import type { NovelConfig } from '../../shared/ipc-channels'
interface Props {
isOpen: boolean
onClose: () => void
/** 生成完成后回调(传入 AI 生成的配置) */
onGenerated: (config: Partial<NovelConfig>) => void
}
/** AI 生成配置对话框 — 用户输入脑洞,AI 自动生成所有配置字段 */
export default function GenerateConfigDialog({ isOpen, onClose, onGenerated }: Props) {
const { t } = useTranslation('dialogs')
const defaultModelId = useLLMStore(s => s.defaultModelId)
// ✅ 用 getState() 获取 action,不订阅 workflow store 的 globalLogs 高频更新
const addLog = useWorkflowStore.getState().addLog
const startWorkflow = useWorkflowStore.getState().startWorkflow
const currentProject = useProjectStore(s => s.currentProject)
const updateNovelConfig = useProjectStore(s => s.updateNovelConfig)
const [idea, setIdea] = useState('')
// 控制当外部 Confirm 弹窗显示时,阻止本 Dialog 因为"点击外部"而意外关闭
const [confirming, setConfirming] = useState(false)
// 直接从 Store 读取规模参数 — 单一数据源,无需 local state 镜像
const totalChapters = currentProject?.novelConfig.totalChapters ?? 100
const wordsPerChapter = currentProject?.novelConfig.wordsPerChapter ?? 3000
/** 修改总章数:直接写 Store,允许清空(失焦时由 Input 组件全局兜底) */
const handleTotalChaptersChange = (val: string) => {
if (val === '') {
updateNovelConfig({ totalChapters: '' as unknown as number })
return
}
const n = parseInt(val, 10)
if (isNaN(n)) return
updateNovelConfig({ totalChapters: n })
}
/** 修改每章字数:直接写 Store,允许清空(失焦时由 Input 组件全局兜底) */
const handleWordsPerChapterChange = (val: string) => {
if (val === '') {
updateNovelConfig({ wordsPerChapter: '' as unknown as number })
return
}
const n = parseInt(val, 10)
if (isNaN(n)) return
updateNovelConfig({ wordsPerChapter: n })
}
const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false)
const handleGenerate = async () => {
if (!idea.trim() || isSubmittingRef.current) return
if (!defaultModelId) {
addLog('error', t('generateConfig.needModel'))
return
}
isSubmittingRef.current = true
setIsSubmitting(true)
try {
// 检测已填写的核心字段,提示用户确认覆盖
const cfg = currentProject?.novelConfig
const filledFields: string[] = []
if (cfg?.coreOutline?.trim()) filledFields.push(t('generateConfig.fields.outline'))
if (cfg?.worldSetting?.trim()) filledFields.push(t('generateConfig.fields.worldbuilding'))
if (cfg?.goldenFinger?.trim()) filledFields.push(t('generateConfig.fields.goldenFinger'))
if (cfg?.protagonistProfile?.trim()) filledFields.push(t('generateConfig.fields.protagonist'))
if (cfg?.globalGuidance?.trim()) filledFields.push(t('generateConfig.fields.writingRequirements'))
if (cfg?.subGenre?.trim()) filledFields.push(t('generateConfig.fields.subGenre'))
if (filledFields.length > 0) {
setConfirming(true)
const fieldList = filledFields.map(f => `• ${f}`).join('\n')
const ok = await confirm(
t('generateConfig.overwriteConfirm', { fields: fieldList }),
{ title: t('generateConfig.configExists'), confirmText: t('common.override', { ns: 'common' }), cancelText: t('common.cancel', { ns: 'common' }) }
)
setConfirming(false)
if (!ok) return
}
// 覆盖确认通过后,立即关闭弹窗
onClose()
toast.info(t('generateConfig.generating'))
addLog('info', t('generateConfig.generatingLog', { chapters: totalChapters, words: wordsPerChapter }))
// 后台执行 LLM 调用(由 WorkflowEngine 接管并显示全局状态面板)
startWorkflow(
createConfigGenerationWorkflow({
idea,
totalChapters: totalChapters || 100,
wordsPerChapter: wordsPerChapter || 3000,
onGenerated,
})
)
} finally {
isSubmittingRef.current = false
setIsSubmitting(false)
}
}
const handleOpenChange = (open: boolean) => {
// 确认弹框期间不允许关闭
if (!open && !confirming) onClose()
}
// 每次打开时预填当前项目的核心大纲
const defaultIdea = currentProject?.novelConfig.coreOutline || ''
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-[520px]"
onInteractOutside={e => {
// 当全局 Confirm 弹窗弹出时,点击 Confirm (由于渲染在 Body)
// 会被 Radix 误认为是 Interact Outside。因此此时屏蔽关闭事件
if (confirming) e.preventDefault()
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles size={16} className="text-[var(--color-accent)]" />
{t('generateConfig.title')}
</DialogTitle>
<DialogDescription>
{t('generateConfig.description')}
</DialogDescription>
</DialogHeader>
<div className="px-5 py-4 space-y-4">
{/* 规模参数(联动小说配置)*/}
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{/* 标题行:左侧「规模参数」,右侧「全书约 x 字」 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
}}
>
{t('generateConfig.scaleParams')}
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{t('generateConfig.totalApprox')}{' '}
<strong style={{ color: 'var(--color-text-secondary)', fontVariantNumeric: 'tabular-nums' }}>
{((Number(totalChapters) || 0) * (Number(wordsPerChapter) || 0)).toLocaleString()}
</strong>{' '}
{t('generateConfig.wordsUnit')}
</span>
</div>
{/* 两列输入框 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{/* 总章数 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<Hash size={11} />
{t('generateConfig.totalChaptersLabel')}
</label>
<Input
type="number"
min={1}
max={10000}
value={totalChapters}
onChange={e => handleTotalChaptersChange(e.target.value)}
onBlur={() => {
if (!totalChapters || totalChapters < 1) {
updateNovelConfig({ totalChapters: 100 })
}
}}
/>
</div>
{/* 每章字数 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<FileText size={11} />
{t('generateConfig.wordsPerChapterLabel')}
</label>
<Input
type="number"
min={100}
max={20000}
step={100}
value={wordsPerChapter}
onChange={e => handleWordsPerChapterChange(e.target.value)}
onBlur={() => {
if (!wordsPerChapter || wordsPerChapter < 100) {
updateNovelConfig({ wordsPerChapter: 3000 })
}
}}
/>
</div>
</div>
</div>
{/* 创作脑洞输入框 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<label
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
}}
>
{t('generateConfig.creativeIdea')}
</label>
<Textarea
autoFocus
rows={5}
placeholder={defaultIdea || t('generateConfig.ideaPlaceholder')}
value={idea}
onChange={e => setIdea(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate()
}}
/>
</div>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{t('generateConfig.ideaHint')}
</p>
</div>
<DialogFooter className="sm:justify-between items-center">
<span className="text-xs text-[var(--color-text-muted)] mt-2 sm:mt-0">
{t('generateConfig.shortcutHint')}
</span>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onClose}>{t('common.cancel', { ns: 'common' })}</Button>
<Button
variant="ai"
size="lg"
onClick={handleGenerate}
disabled={!idea.trim() || isSubmitting}
>
<><Sparkles size={13} /> {t('generateConfig.generateConfig')}</>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
|