diff --git a/.gitignore b/.gitignore index dcb0290..2669a76 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ CLAUDE.md docs/compose/plans/2026-07-10-p0-optimizations.md .mimocode/ .mimocode/* + +# local secrets never commit +**/.secrets/ + diff --git a/electron/ipc/books.ts b/electron/ipc/books.ts index 2a6226d..3582da8 100644 --- a/electron/ipc/books.ts +++ b/electron/ipc/books.ts @@ -11,10 +11,62 @@ const { existsSync, mkdirSync, readFileSync, readdirSync, copyFileSync } = requi const log = createLogger('ipc:books') + +/** 清理 books 下已无数据库记录的孤儿目录 */ +function cleanupOrphanBookDirs() { + const { readdirSync, rmSync, existsSync: fExists, statSync } = require('fs') + const { resolve: pathResolve, join: pJoin } = require('path') + let known = new Set() + try { + const rows = getDB().listBooks() || [] + for (const b of rows) if (b?.id) known.add(String(b.id)) + } catch (e: any) { + log.error('cleanupOrphanBookDirs:list', e) + return { removed: [], errors: ['无法读取书籍列表'] } + } + + const roots = [ + pathResolve(join(GUI_DATA_DIR, 'books')), + pathResolve(join(home, '.ainovel-gui', 'books')), + ] + const seen = new Set() + const removed: string[] = [] + const errors: string[] = [] + + for (const root of roots) { + try { + if (!fExists(root)) continue + const keyRoot = process.platform === 'win32' ? root.toLowerCase() : root + if (seen.has(keyRoot)) continue + seen.add(keyRoot) + for (const name of readdirSync(root)) { + // 只处理 UUID 形式目录 + if (!/^[0-9a-fA-F-]{8,64}$/.test(name)) continue + if (known.has(name)) continue + const full = pJoin(root, name) + try { + if (!statSync(full).isDirectory()) continue + rmSync(full, { recursive: true, force: true, maxRetries: 8, retryDelay: 120 }) + removed.push(full) + log.info('cleanupOrphanBookDirs:removed', full) + } catch (e: any) { + errors.push((e && e.message) || String(e)) + log.error('cleanupOrphanBookDirs:rm', e) + } + } + } catch (e: any) { + errors.push((e && e.message) || String(e)) + } + } + return { removed, errors } +} + function register(ipcMain: Electron.IpcMain) { ipcMain.handle('list-books', () => { - try { return getDB().listBooks() } - catch (e: any) { log.error('list-books', e); return [] } + try { + try { cleanupOrphanBookDirs() } catch (e: any) { log.warn('list-books:orphan', e?.message || e) } + return getDB().listBooks() + } catch (e: any) { log.error('list-books', e); return [] } }) ipcMain.handle('create-book', (_e: Electron.IpcMainInvokeEvent, name: string, style: string, phase: string, premise: string, tags: string) => { @@ -28,9 +80,94 @@ function register(ipcMain: Electron.IpcMain) { return { ...book, completedCount: 0 } }) - ipcMain.handle('delete-book', (_e: Electron.IpcMainInvokeEvent, id: string) => { + ipcMain.handle('delete-book', async (_e: Electron.IpcMainInvokeEvent, id: string) => { + if (typeof id !== 'string' || !/^[0-9a-fA-F-]{8,64}$/.test(id)) { + throw new Error('无效的书籍 ID') + } + + // 若正在写这本书,先停引擎 + if (state.activeWritingBookId === id && state.ainovelProcess) { + try { state.ainovelProcess.kill('SIGTERM') } catch {} + state.ainovelProcess = null + state.activeWritingBookId = '' + } + + // 先删数据库 getDB().deleteBook(id) - return true + + // 强制删除托管目录 books/(处理 junction:~/.ainovel-gui -> zayang) + const { rmSync, existsSync: fExists, realpathSync } = require('fs') + const { resolve: pathResolve } = require('path') + const candidates = [ + join(GUI_DATA_DIR, 'books', id), + join(home, '.ainovel-gui', 'books', id), + ] + const seen = new Set() + const removed: string[] = [] + const errors: string[] = [] + + for (const dir of candidates) { + try { + const abs = pathResolve(dir) + const key = process.platform === 'win32' ? abs.toLowerCase() : abs + if (seen.has(key)) continue + seen.add(key) + if (!fExists(abs)) continue + // 确认 basename 是 book id,防止误删 + if (require('path').basename(abs) !== id) continue + rmSync(abs, { recursive: true, force: true, maxRetries: 8, retryDelay: 120 }) + removed.push(abs) + log.info('delete-book:removed-dir', abs) + // 也尝试 realpath 后的路径(junction 另一端若仍残留) + try { + const real = realpathSync(abs) + if (real && real !== abs && fExists(real)) { + rmSync(real, { recursive: true, force: true, maxRetries: 8, retryDelay: 120 }) + removed.push(real) + log.info('delete-book:removed-realpath', real) + } + } catch {} + } catch (e: any) { + // 若 abs 已在 rm 中被删掉,realpath 会失败,可忽略 + const msg = e?.message || String(e) + // ENOENT 不算失败 + if (!/ENOENT|no such file/i.test(msg)) { + errors.push(msg) + log.error('delete-book:rm', msg) + } + } + } + + // 再扫一遍确认 + for (const dir of candidates) { + if (fExists(pathResolve(dir))) { + errors.push('文件夹仍存在: ' + dir) + } + } + + if (errors.length) { + // 再试一次孤儿清理 + try { + const orphan = cleanupOrphanBookDirs() + removed.push(...(orphan.removed || [])) + } catch {} + if (errors.length && removed.length === 0) { + throw new Error('书籍记录已删除,但文件夹未删干净: ' + errors[0]) + } + } + + // 顺手清掉其它已不在库里的 books 残留目录 + try { + const orphan = cleanupOrphanBookDirs() + for (const p of (orphan.removed || [])) { + if (!removed.includes(p)) removed.push(p) + } + for (const e of (orphan.errors || [])) errors.push(e) + } catch (e: any) { + log.error('delete-book:orphan-cleanup', e) + } + + return { ok: true, removed, errors } }) ipcMain.handle('get-book', (_e: Electron.IpcMainInvokeEvent, id: string) => { @@ -103,26 +240,25 @@ function register(ipcMain: Electron.IpcMain) { const name = progress?.novelName || bookJson?.name || '未命名作品' const style = bookJson?.style || progress?.style || 'default' const phase = progress?.phase || 'init' + // 始终落到托管目录 books/<新id>,删除时才能干净清掉 + const destDir = join(GUI_DATA_DIR, 'books', id) + if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }) + try { + require('fs').cpSync(dir, destDir, { recursive: true, force: true }) + log.info('import-workspace:copied', { from: dir, to: destDir }) + } catch (e: any) { + log.error('import-workspace:copy', e) + throw new Error('导入失败:无法复制到托管目录 ' + (e?.message || e)) + } const book = { id, name, premise: bookJson?.premise || '', style, planning_tier: bookJson?.planningTier || bookJson?.planning_tier || 'short', phase, flow: 'writing', layered: progress?.layered ? 1 : 0, total_word_count: progress?.totalWordCount || 0, - workspace_dir: dir, + workspace_dir: destDir, created_at: now, updated_at: now, last_opened_at: now, } getDB().createBook(book) - // Copy cover - const coverExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp'] - for (const ext of coverExts) { - const coverFile = join(dir, 'cover' + ext) - if (existsSync(coverFile)) { - const destDir = join(GUI_DATA_DIR, 'books', id) - if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }) - copyFileSync(coverFile, join(destDir, 'cover' + ext)) - break - } - } // Sync data to SQLite const db = getDB() try { diff --git a/electron/ipc/cocreate.ts b/electron/ipc/cocreate.ts new file mode 100644 index 0000000..cfd03da --- /dev/null +++ b/electron/ipc/cocreate.ts @@ -0,0 +1,849 @@ +export {} + +/** + * 阶段共创 IPC:对齐 ainovel-cli TUI 的 /cocreate + * - 打开时由渲染层先停写 + * - 弹窗内多轮对话(reply/draft/ready/suggestions) + * - 用户确认后再把 draft 作为阶段规划注入并 resume + */ +const { state, getDB, GUI_DATA_DIR, home } = require('../context') +const { createLogger } = require('../logger') +const { join } = require('path') +const { existsSync, readFileSync } = require('fs') + +const log = createLogger('ipc:cocreate') + +const STAGE_OPENER = '我先暂停一下,想和你一起规划接下来的走向。' + +const STAGE_SYSTEM_PROMPT = `你是一个小说"阶段共创"助手。这本小说已经写了一部分(进度见下方"当前故事状态")。用户暂停下来,想和你一起规划"后续阶段"的走向,再继续创作。 + +你的任务不是续写正文,而是通过多轮简短对话帮用户想清楚后面这一段(接下来若干章 / 下一弧 / 下一卷)要往哪走,并持续整理出一段"后续方向 brief",供创作引擎据此推进。 + +铁律:所有建议必须与"当前故事状态"里已发生的剧情、人物、伏笔一致,绝不推翻或忽略已写内容;只规划"后续怎么走",不重新设计整本书。 + +每一轮回复严格按以下 XML 格式输出,包含四个标签,依次出现,每个标签都必须有正确的开闭标签: + + +给用户看的中文自然回复:先回应用户的输入,再最多提出 1 到 2 个当前最关键的问题。如果后续方向已足够清晰,告诉用户可以点击「完成并开始写作」。 + + + +当前完整的"后续方向 brief",使用 Markdown:直接从二级标题开始,例如 "## 后续走向"、"## 第四至第十二章章纲"、"## 能力熟练度"、"## 检查项"。 +每一轮都要在已有结论上**累积更新**,吸收用户最新意图;即使用户已经写得很完整,也要把用户方向**完整写入 draft**(可整理结构,不可省略关键约束)。 +如果用户要求输出章纲/检查项,必须写在 draft 里,不要只在 reply 里口头答应。 + + +false + + +1-3 条"用户接下来可能想说的话",每行一条以 "- " 开头。这是用户卡壳时的引导。 +要求: +- 站在用户口吻,像用户对你说的话,不要写成助手反问。 +- 每条不超过 25 字,多样化句式。 + + +输出规范: +- 必须使用四个 XML 标签: / / / ,每个都必须完整开闭。 +- 标签外不要添加任何说明、思考或代码围栏。 +- 只写 true 或 false。信息已足够时填 true。 +- 不要编造“更新日期/版本日期/文档修订日”。除非用户明确给出日期,否则 draft 里禁止出现具体年月日。 +- 只写“后续故事方向 brief”正文,直接从 Markdown 标题开始(如 ## 后续走向)。 +- 禁止写入对话口吻或元说明,例如:“收到”“以下为检查结果”“更新后的完整 brief”“请把字数标准写入右侧brief”“暂时不要开始创作正文”。 +- 不要整段粘贴字数规范、审核手册、编辑规则原文;字数约束最多用一句话概括。 +- 只写给用户看的自然语言,禁止在 reply 里出现任何 XML 标签名;过程说明只放 reply,不放 draft。 +- 严禁输出“如何填写 reply/draft/ready/suggestions”的思考过程;不要讨论协议本身,只输出协议结果。 +- 如果信息还不够,ready=false,但 draft 仍应是故事方向 brief,而不是对格式的分析。` + +function readJsonSafe(path: string): any | null { + try { + if (!existsSync(path)) return null + return JSON.parse(readFileSync(path, 'utf8')) + } catch { + return null + } +} + +function resolveBookDir(bookId: string): string { + if (bookId) { + try { + const book = getDB().getBook(bookId) + if (book?.workspace_dir) return book.workspace_dir + } catch {} + return join(home, '.ainovel-gui', 'books', bookId) + } + return state.outputDir || join(GUI_DATA_DIR, 'books', 'default') +} + +function buildStoryStateSummary(bookId: string): string { + const dir = resolveBookDir(bookId) + const lines: string[] = [] + try { + const book = bookId ? getDB().getBook(bookId) : null + if (book) { + const rawName = String(book.name || '').trim() + const premiseFull = String(book.premise || '').replace(/\s+/g, ' ').trim() + let displayName = rawName || '未命名共创' + if (!rawName || rawName.length > 40) displayName = '未命名共创' + else { + const bare = rawName.replace(/[….]+$/, '') + if (premiseFull && bare.length >= 12 && (premiseFull === bare || premiseFull.startsWith(bare))) { + displayName = '未命名共创' + } + } + lines.push(`- 书名:《${displayName}》`) + } + if (book?.phase) lines.push(`- 阶段:${book.phase}`) + if (book?.premise) { + const p = String(book.premise) + lines.push(`- 前提:${p.length > 220 ? p.slice(0, 220) + '…' : p}`) + } + } catch {} + + const progress = + readJsonSafe(join(dir, 'output', 'novel', 'meta', 'progress.json')) || + readJsonSafe(join(dir, 'meta', 'progress.json')) + if (progress) { + const name = String(progress.novel_name || progress.novelName || '').trim() + if (name) { + let dn = name + if (name.length > 40) dn = '未命名共创' + lines.push(`- 书名:《${dn}》`) + } + const completed = Array.isArray(progress.completed_chapters) + ? progress.completed_chapters.length + : Array.isArray(progress.completedChapters) + ? progress.completedChapters.length + : Number(progress.completedCount || 0) + const total = Number(progress.total_chapters || progress.totalChapters || 0) + const words = Number(progress.total_word_count || progress.totalWordCount || 0) + const phase = String(progress.phase || '') + lines.push(`- 进度:阶段 ${phase || 'unknown'},已完成 ${completed}${total ? ' / 规划 ' + total : ''} 章,约 ${words} 字`) + const next = Number(progress.current_chapter || progress.currentChapter || progress.in_progress_chapter || 0) + if (next > 0) lines.push(`- 当前章节关注点:第 ${next} 章`) + } + + try { + const chars = bookId ? (getDB().getCharacters?.(bookId) || []) : [] + if (Array.isArray(chars) && chars.length) { + const names = chars.slice(0, 8).map((c: any) => { + const n = c.name || c.Name || '' + const r = c.role || c.Role || '' + return r ? `${n}(${r})` : n + }).filter(Boolean) + if (names.length) lines.push(`- 主要人物:${names.join('、')}`) + } + } catch {} + + // unique keep order + const seen = new Set() + const out: string[] = [] + for (const l of lines) { + if (seen.has(l)) continue + seen.add(l) + out.push(l) + } + return out.join('\n') +} + +const PROTOCOL_TAGS = ['reply', 'draft', 'ready', 'suggestions'] as const + +function normalizeProtocolText(raw: string): string { + return String(raw || '') + .replace(/```(?:xml|markdown|md|text)?/gi, '') + .replace(/```/g, '') + .replace(/\uFEFF/g, '') + .trim() +} + +/** 去掉协议标签,避免泄漏到聊天窗/brief */ +function stripProtocolMarkup(text: string): string { + let s = String(text || '') + // 完整标签对 + for (const tag of PROTOCOL_TAGS) { + const re = new RegExp(`<\\s*\\/?\\s*${tag}\\s*>`, 'gi') + s = s.replace(re, '') + } + // 残留半截协议行 + s = s + .replace(/^\s*<\/?(?:reply|draft|ready|suggestions)>\s*$/gim, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + return s +} + +/** 去掉模型幻觉的“文档更新日期” */ +function stripInventedDocDates(text: string): string { + return String(text || '') + // 标题里的(2025-04-16 更新) + .replace(/[((]\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s*更新\s*[))]/g, '') + // 行内:更新于 2025-04-16 / 修订日期:... + .replace(/(?:更新于|修订于|修订日期|更新日期|文档日期|版本日期)\s*[::]?\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/g, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +/** + * 从 brief 里压缩“字数规范/审核手册”大段: + * 只保留一句摘要,避免把规则原文塞进后续方向。 + */ +function isMetaNoiseLine(line: string): boolean { + const t = String(line || '').trim() + if (!t) return false + if (t === '---' || t === '***' || t === '___') return true + // 对话口吻 / 过程说明,不应进入 brief + if (/^(?:收到|好的|明白|了解)[。.!!]/.test(t)) return true + if (/以下为(?:检查结果|更新后|完整\s*brief)/i.test(t)) return true + if (/(?:检查结果|完整\s*brief|更新后的完整)/i.test(t) && t.length < 80) return true + if (/请把.{0,20}写入右侧\s*brief/i.test(t)) return true + if (/暂时不要(?:开始)?创作正文/.test(t)) return true + if (/旧标准已全部删除|字数标准已全部更新/.test(t)) return true + if (/确认旧标准已全部删除/.test(t)) return true + if (/列出brief中所有涉及章节字数/.test(t)) return true + // 协议元思考 + if (/我们(?:可以|应该|需要)在\s*reply/i.test(t)) return true + if (/\bdraft\b.*应该|\bready\b.*false|\bsuggestions\b/i.test(t)) return true + if (/输出格式|多轮对话形式|作为助手/.test(t)) return true + return false +} + +function isProtocolMetaDocument(text: string): boolean { + const s = String(text || '').trim() + if (!s) return false + const mention = (s.match(/\b(?:reply|draft|ready|suggestions)\b/gi) || []).length + const metaHits = [ + /我们(?:可以|应该|需要)在\s*reply/i, + /draft\s*应该/i, + /ready\s*(?:可能|还是|为)?\s*false/i, + /suggestions/i, + /输出格式/i, + /更新后的\s*brief/i, + /作为助手,需要以多轮对话/i, + /用户要求“?先输出”?/i, + ].filter((re) => re.test(s)).length + const hasStoryStructure = + /^#{1,3}\s+后续走向/m.test(s) || + /^#{1,3}\s+前三章/m.test(s) || + /^#{1,3}\s+第一卷/m.test(s) || + /^#{1,3}\s+关键转折/m.test(s) + if (metaHits >= 2) return true + if (mention >= 5 && !hasStoryStructure) return true + if (/更新后的brief/i.test(s) && /false/i.test(s) && mention >= 2) return true + return false +} + +function extractBriefCore(text: string): string { + let s = String(text || '').trim() + if (!s) return '' + // 优先从第一个真正的 brief 标题开始 + const headingRe = /^(#{1,3}\s+.+)$/m + const m = headingRe.exec(s) + if (m && m.index > 0) { + const head = s.slice(0, m.index) + // 前面如果只是噪声/寒暄,则丢掉 + const headUseful = head + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .some((l) => !isMetaNoiseLine(l) && !/^[-*]\s+章节字数/.test(l)) + if (!headUseful) s = s.slice(m.index) + } + return s.trim() +} + +function sanitizeDraftBrief(draft: string): string { + let s = stripInventedDocDates(stripProtocolMarkup(draft)) + if (!s) return '' + s = extractBriefCore(s) + + const lines = s.split(/\r?\n/) + const out: string[] = [] + let skippingRuleBlock = false + let keptRuleSummary = false + + for (const line of lines) { + const t = line.trim() + if (isMetaNoiseLine(t)) continue + + const isRuleHeading = /^(?:#{1,6}\s*)?(?:全书)?(?:章节)?字数规则|审核(?:规则|手册)|编辑规范|字数(?:统计)?规则/.test(t) + if (isRuleHeading) { + skippingRuleBlock = true + if (!keptRuleSummary) { + out.push('- 章节字数:正文约 4000-5000 字/章(以用户规则为准,此处不展开细则)') + keptRuleSummary = true + } + continue + } + if (skippingRuleBlock) { + if (/^#{1,3}\s+/.test(t) && !/字数|审核|规范|规则/.test(t)) { + skippingRuleBlock = false + } else if (!t) { + continue + } else if (/^#{1,3}\s+/.test(t) || /^##\s+/.test(t)) { + skippingRuleBlock = false + } else { + continue + } + } + if (!skippingRuleBlock) out.push(line) + } + + s = out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + // 再次确保不以寒暄开头 + s = extractBriefCore(s) + // 整篇都是协议元思考则丢弃 + if (isProtocolMetaDocument(s)) return '' + return s +} + +function extractTag(raw: string, tag: string): string { + const src = normalizeProtocolText(raw) + // 大小写不敏感 + const openRe = new RegExp(`<\\s*${tag}\\s*>`, 'i') + const closeRe = new RegExp(`<\\s*/\\s*${tag}\\s*>`, 'i') + const openMatch = openRe.exec(src) + if (!openMatch) { + // 无开标签时,不要再“取到闭标签前的全部文本”——那会把别的段落误吃进来 + return '' + } + const start = openMatch.index + openMatch[0].length + const rest = src.slice(start) + const closeMatch = closeRe.exec(rest) + if (closeMatch) { + return rest.slice(0, closeMatch.index).trim() + } + // 有开无闭:切到下一个协议开标签 + let end = rest.length + for (const other of PROTOCOL_TAGS) { + if (other === tag) continue + const m = new RegExp(`<\\s*${other}\\s*>`, 'i').exec(rest) + if (m && m.index < end) end = m.index + } + return rest.slice(0, end).trim() +} + +function parseSuggestions(text: string): string[] { + if (!text) return [] + const cleaned = stripProtocolMarkup(text) + const out: string[] = [] + for (const line0 of cleaned.split(/\r?\n/)) { + let line = line0.trim() + if (!line) continue + if (line.startsWith('<') && line.endsWith('>')) continue + if (line.startsWith('- ')) line = line.slice(2).trim() + else if (line.startsWith('* ')) line = line.slice(2).trim() + else if (/^\d+\.\s+/.test(line)) line = line.replace(/^\d+\.\s+/, '').trim() + line = stripProtocolMarkup(line) + if ([...line].length < 2) continue + // 过滤明显协议残留 + if (/<\/?(?:reply|draft|ready|suggestions)>/i.test(line)) continue + out.push(line) + if (out.length >= 3) break + } + return out +} + +/** reply 缺失时,尽量抢救可读文本,绝不回传 raw 全文 */ +function salvageReply(raw: string, draft: string): string { + let s = normalizeProtocolText(raw) + // 砍掉 draft/ready/suggestions 段 + s = s + .replace(/<\s*draft\s*>[\s\S]*?(?:<\s*\/\s*draft\s*>|(?=<\s*(?:ready|suggestions)\s*>)|$)/ig, '') + .replace(/<\s*ready\s*>[\s\S]*?(?:<\s*\/\s*ready\s*>|(?=<\s*suggestions\s*>)|$)/ig, '') + .replace(/<\s*suggestions\s*>[\s\S]*?(?:<\s*\/\s*suggestions\s*>|$)/ig, '') + s = stripProtocolMarkup(s) + // 如果还和 draft 高度重合,给一个中性摘要,避免把 brief 当聊天 + if (draft && s && (s.includes(draft.slice(0, Math.min(40, draft.length))) || draft.includes(s.slice(0, Math.min(40, s.length))))) { + return '本轮已更新右侧后续方向 brief。你可以直接确认,或继续补充想调整的点。' + } + if (!s) { + return draft + ? '本轮已更新右侧后续方向 brief。你可以直接确认,或继续补充想调整的点。' + : '我已收到,请继续补充你希望调整的方向。' + } + // 仍含大量 markdown 标题时,截成更像对话的前几句 + if (/^#{1,3}\s+/m.test(s) && s.length > 280) { + const firstPara = s.split(/\n\s*\n/)[0] || s + return stripProtocolMarkup(firstPara).slice(0, 400) + } + return s +} + +function parseCoCreateResponse(raw: string): { message: string; draft: string; ready: boolean; suggestions: string[]; raw: string } { + const text = normalizeProtocolText(raw) + if (!text) return { message: '', draft: '', ready: false, suggestions: [], raw: text } + + let reply = extractTag(text, 'reply') + let draft = extractTag(text, 'draft') + const readyStr = extractTag(text, 'ready').toLowerCase() + let ready = readyStr === 'true' || readyStr === 'yes' || /\btrue\b/i.test(readyStr) + let suggestions = parseSuggestions(extractTag(text, 'suggestions')) + + // 兼容模型把标签写错大小写/夹代码块后的二次清洗 + reply = stripProtocolMarkup(reply) + draft = sanitizeDraftBrief(draft) + + // 若 draft 标签没吃到,但正文里有明显 markdown brief,尝试弱提取 + if (!draft) { + const maybe = sanitizeDraftBrief( + text + .replace(/<\s*reply\s*>[\s\S]*?<\s*\/\s*reply\s*>/ig, '') + .replace(/<\s*ready\s*>[\s\S]*?<\s*\/\s*ready\s*>/ig, '') + .replace(/<\s*suggestions\s*>[\s\S]*?<\s*\/\s*suggestions\s*>/ig, ''), + ) + // 只有看起来像“故事 brief”才采用;协议元思考一律不要 + if ( + maybe && + !isProtocolMetaDocument(maybe) && + (/^#{1,3}\s+后续走向/m.test(maybe) || + /^#{1,3}\s+前三章/m.test(maybe) || + /^#{1,3}\s+关键转折/m.test(maybe) || + /^#{1,3}\s+第一卷/m.test(maybe)) + ) { + draft = maybe + } + } + + // draft 若仍是协议元思考,清空 + if (draft && isProtocolMetaDocument(draft)) draft = '' + + if (!reply) { + // 整段若是元思考,不要 salvage 成用户可见长文 + if (isProtocolMetaDocument(text) || looksLikeProtocolMetaThinking(text)) { + reply = draft + ? '本轮已更新右侧后续方向 brief。你可以直接确认,或继续补充想调整的点。' + : '我已收到你的方向要求。请再发一句你最想先确定的点(例如前三章重点或能力升级节奏)。' + } else { + reply = salvageReply(text, draft) + } + } else { + reply = stripInventedDocDates(reply) + if (isProtocolMetaDocument(reply) || looksLikeProtocolMetaThinking(reply)) { + reply = draft + ? '本轮已更新右侧后续方向 brief。你可以直接确认,或继续补充想调整的点。' + : '我已收到。为避免格式串台,请直接补充你的故事方向(前三章/能力线/冲突)。' + } + } + + // 再兜底:message 里绝不能出现协议标签 + reply = stripProtocolMarkup(reply) + draft = stripProtocolMarkup(draft) + suggestions = suggestions + .map((s) => stripProtocolMarkup(s)) + .filter((s) => s && !isProtocolMetaDocument(s) && !/reply|draft|ready/i.test(s)) + + // 空响应保护 + if (!reply && !draft) { + reply = '这一轮模型没有给出有效共创结果。请再发送一次你的方向,或换更具体的一句话。' + } + + return { message: reply, draft, ready, suggestions, raw: text } +} + +function loadProvider(): { apiUrl: string; apiKey: string; model: string; providerKey: string } | { error: string } { + // prefer CLI home config (actual writing model), fallback GUI db + let cfg: any = null + try { + const p = join(home, '.ainovel', 'config.json') + if (existsSync(p)) cfg = JSON.parse(readFileSync(p, 'utf8')) + } catch (e: any) { + log.warn('loadProvider:home-config', e?.message || e) + } + if (!cfg) { + try { cfg = getDB().getConfig('provider_config') } catch {} + } + if (!cfg) return { error: '未找到模型配置(~/.ainovel/config.json)' } + + const providerKey = cfg.provider || '' + const model = cfg.model || '' + if (!providerKey || !model) return { error: '未配置写作 Provider/Model' } + const provider = cfg.providers?.[providerKey] + if (!provider?.api_key) return { error: `Provider "${providerKey}" 未设置 API Key` } + const baseUrl = String(provider.base_url || '').replace(/\/+$/, '') + if (!baseUrl) return { error: 'Provider base_url 为空' } + // 与 batch-audit 一致:base_url 后直接拼 /chat/completions + // 你的配置通常已是 .../v1 + const apiUrl = baseUrl.includes('/chat/completions') + ? baseUrl + : `${baseUrl}/chat/completions` + return { + apiUrl, + apiKey: provider.api_key, + model, + providerKey, + } +} + +function looksLikeProtocolAnswer(text: string): boolean { + const s = String(text || '') + // 必须像“正式答案”:有协议标签,或有后续方向标题 + const hasTags = /<\s*reply\s*>/i.test(s) || /<\s*draft\s*>/i.test(s) + const hasBriefHead = /^#{1,3}\s+后续走向/m.test(s) || /^#{1,3}\s+关键转折/m.test(s) + return hasTags || hasBriefHead +} + +function looksLikeProtocolMetaThinking(text: string): boolean { + const s = String(text || '') + if (!s.trim()) return false + // 模型在“思考怎么填 XML/协议”,不是故事 brief + const hits = [ + /我们(?:可以|应该|需要)在\s*reply/i, + /在\s*reply\s*中/i, + /draft\s*应该/i, + /ready\s*(?:可能|还是|设为|为)?\s*false/i, + /suggestions\s*建议/i, + /输出格式/i, + /协议/i, + /先讨论.*ready/i, + /更新后的\s*brief\s*,\s*false/i, + /用户要求“?先输出”?/i, + /作为助手,需要以多轮对话形式/i, + ].filter((re) => re.test(s)).length + // 大量提及 reply/draft/ready 且缺少故事标题 + const mentionCount = (s.match(/\b(?:reply|draft|ready|suggestions)\b/gi) || []).length + const hasStoryHead = /后续走向|前三章详细|能力进化|关键转折|第一卷核心/.test(s) + if (hits >= 2) return true + if (mentionCount >= 4 && !hasStoryHead) return true + if (/更新后的brief/i.test(s) && /ready/i.test(s)) return true + return false +} + +async function callChat(messages: { role: string; content: string }[]): Promise { + const p = loadProvider() + if ('error' in p) throw new Error(p.error) + const body = { + model: p.model, + messages, + temperature: 0.7, + stream: false, + } + const resp = await fetch(p.apiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${p.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + if (!resp.ok) { + const t = await resp.text().catch(() => '') + throw new Error(`模型请求失败 ${resp.status}: ${t.slice(0, 300)}`) + } + const data: any = await resp.json() + const msg = data?.choices?.[0]?.message || {} + let content = String(msg.content || data?.choices?.[0]?.text || data?.output_text || '').trim() + const reasoning = String(msg.reasoning_content || msg.reasoning || '').trim() + + // 优先 content;只有 content 空且 reasoning 看起来是正式答案时才回退 + if (!content) { + if (reasoning && looksLikeProtocolAnswer(reasoning) && !looksLikeProtocolMetaThinking(reasoning)) { + log.warn('callChat: fallback to reasoning_content as answer') + content = reasoning + } else if (reasoning) { + log.warn('callChat: ignore meta reasoning_content', { reasoningLen: reasoning.length }) + content = '' + } + } else if (looksLikeProtocolMetaThinking(content) && reasoning && looksLikeProtocolAnswer(reasoning)) { + // content 是胡思乱想、reasoning 反而是答案的罕见情况 + log.warn('callChat: content looks meta, prefer reasoning answer') + content = reasoning + } + + return content +} + + +/** 用户是否在直接投递“完整方向说明/章纲要求” */ +function isDirectionSpec(text: string): boolean { + const s = String(text || '').trim() + if (s.length < 80) return false + const hits = [ + /第[一二三四五六七八九十\d]+[至到\-—~]第?[一二三四五六七八九十\d]+章/, + /第一卷|前三章|前十二章|章纲|后续方向|能力进化|超限/, + /完整写入|写入.*brief|不要开始创作正文|仍然不要开始创作正文/, + /主动目标|冲突|现实奖励|幽默点|小队关系/, + ].filter((re) => re.test(s)).length + if (hits >= 2) return true + // 长文 + 章节规划口吻 + if (s.length >= 300 && /章/.test(s) && /主角|能力|异兽|选拔|任务/.test(s)) return true + return false +} + +/** 把用户方向整理成可用 brief(模型失败时的硬兜底) */ +function sectionTitles(md: string): string[] { + return String(md || '') + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => /^#{1,3}\s+/.test(l)) + .map((l) => l.replace(/^#{1,3}\s+/, '').trim()) +} + +function hasSubstantialStoryBrief(draft: string): boolean { + const s = String(draft || '').trim() + if (!s || isProtocolMetaDocument(s) || looksLikeProtocolMetaThinking(s)) return false + // 有章节/走向结构,或足够长 + if (/^#{1,3}\s+/.test(s) && s.length >= 120) return true + if (/第[一二三四五六七八九十\d]+章|后续走向|能力进化|选拔|异兽/.test(s) && s.length >= 160) return true + return s.length >= 240 +} + +/** + * 合并 brief:旧规划优先保留,新内容按标题合并/追加,绝不无脑整篇覆盖。 + */ +function mergeBriefs(prevDraft: string, nextDraft: string): string { + const prev = sanitizeDraftBrief(prevDraft || '') + const next = sanitizeDraftBrief(nextDraft || '') + if (!prev) return next + if (!next) return prev + if (isProtocolMetaDocument(next) || looksLikeProtocolMetaThinking(next)) return prev + if (isProtocolMetaDocument(prev) || looksLikeProtocolMetaThinking(prev)) return next + + // next 已完整包含 prev 关键句,视为模型在旧稿上重写 + const prevHead = prev.replace(/\s+/g, '').slice(0, 80) + if (prevHead && next.replace(/\s+/g, '').includes(prevHead) && next.length >= prev.length * 0.8) { + return next + } + + // 按二级/三级标题分块合并 + const splitSections = (md: string) => { + const lines = md.split(/\r?\n/) + const sections: { title: string; body: string }[] = [] + let title = '' + let buf: string[] = [] + const push = () => { + const body = buf.join('\n').trim() + if (title || body) sections.push({ title, body }) + title = '' + buf = [] + } + for (const line of lines) { + if (/^#{1,3}\s+/.test(line.trim())) { + push() + title = line.trim().replace(/^#{1,3}\s+/, '') + } else { + buf.push(line) + } + } + push() + return sections + } + + const a = splitSections(prev) + const b = splitSections(next) + if (!b.length) return prev + if (!a.length) return next + + const norm = (t: string) => t.replace(/\s+/g, '').toLowerCase() + const map = new Map() + for (const s of a) map.set(norm(s.title || '前言'), s) + for (const s of b) { + const key = norm(s.title || '前言') + const old = map.get(key) + if (!old) { + map.set(key, s) + } else { + // 同标题:取更长/更新者,但若新 body 太短则保留旧 + if (s.body.length >= Math.max(40, old.body.length * 0.6)) map.set(key, { title: s.title || old.title, body: s.body }) + } + } + + // 保持旧顺序,再追加新标题 + const out: string[] = [] + const seen = new Set() + for (const s of a) { + const key = norm(s.title || '前言') + const cur = map.get(key) + if (!cur) continue + seen.add(key) + if (cur.title) out.push(`## ${cur.title}`) + if (cur.body) out.push(cur.body) + out.push('') + } + for (const s of b) { + const key = norm(s.title || '前言') + if (seen.has(key)) continue + const cur = map.get(key) + if (!cur) continue + if (cur.title) out.push(`## ${cur.title}`) + if (cur.body) out.push(cur.body) + out.push('') + } + return sanitizeDraftBrief(out.join('\n').trim()) +} + +/** 仅在模型失败时,把用户方向作为“补充材料”附加,不覆盖旧规划 */ +function buildFallbackSupplement(userText: string, prevDraft = ''): string { + const body = String(userText || '').trim() + if (!body) return prevDraft || '' + const supplement = sanitizeDraftBrief( + `## 用户补充方向\n\n${body}`, + ) + if (!prevDraft) { + // 无旧稿才允许以用户方向做底稿 + return sanitizeDraftBrief(/^#{1,3}\s+/m.test(body) ? body : `## 后续走向\n\n${body}`) + } + return mergeBriefs(prevDraft, supplement) +} + +function buildDirectionUserPayload(userText: string): string { + return ( + String(userText || '').trim() + + '\n\n[系统附加要求]\n' + + '1. 把以上方向完整写入 ,不要省略关键约束。\n' + + '2. 若用户要求章纲/能力变化/关系/幽默点/检查项,必须写入 对应小节。\n' + + '3. 只做简短确认与最多 1-2 个关键问题。\n' + + '4. 禁止讨论 reply/draft/ready/suggestions 协议本身,只输出协议标签结果。\n' + + '5. 不要开始创作正文。' + ) +} + +function register(ipcMain: Electron.IpcMain) { + ipcMain.handle('cocreate-get-context', async (_e: Electron.IpcMainInvokeEvent, bookId: string) => { + try { + const summary = buildStoryStateSummary(bookId || '') + return { + opener: STAGE_OPENER, + summary, + bookId: bookId || '', + } + } catch (e: any) { + log.error('cocreate-get-context', e) + return { opener: STAGE_OPENER, summary: '', bookId: bookId || '', error: e?.message || String(e) } + } + }) + + ipcMain.handle( + 'cocreate-chat', + async ( + _e: Electron.IpcMainInvokeEvent, + payload: { bookId?: string; history?: { role: string; content: string }[]; userText?: string; kickoff?: boolean }, + ) => { + try { + const bookId = payload?.bookId || '' + const summary = buildStoryStateSummary(bookId) + const system = STAGE_SYSTEM_PROMPT + ( + summary + ? `\n\n---\n## 当前故事状态\n(以下是已写内容的客观摘要,供你规划后续时参照,不要在 里照抄原文)\n${summary}` + : '\n\n---\n## 当前故事状态\n暂无详细进度文件,请先基于用户输入与前提信息规划。' + ) + + const history = Array.isArray(payload?.history) ? payload.history : [] + const messages: { role: string; content: string }[] = [ + { role: 'system', content: system }, + ] + for (const m of history) { + if (!m || !m.content) continue + const role = m.role === 'assistant' ? 'assistant' : 'user' + messages.push({ role, content: String(m.content) }) + } + + const userText = String(payload?.userText || '').trim() + const directionMode = !payload?.kickoff && isDirectionSpec(userText) + const prevDraft = String((payload as any)?.prevDraft || '').trim() + + if (payload?.kickoff && !userText) { + messages.push({ role: 'user', content: STAGE_OPENER }) + } else if (userText) { + // 若已有旧 brief,明确要求模型在旧稿上更新,而不是重开一份 + let content = directionMode ? buildDirectionUserPayload(userText) : userText + if (prevDraft && hasSubstantialStoryBrief(prevDraft)) { + content += ( + '\n\n[系统附加要求-旧稿保留]\n' + + '右侧已有后续方向 brief。请在旧 brief 基础上累积更新:保留仍有效内容,只修订冲突点并补充新增章节/约束。\n' + + '禁止清空重写导致旧规划丢失。请输出更新后的完整 。\n\n' + + '【当前 brief 旧稿】\n' + prevDraft + ) + } + messages.push({ role: 'user', content }) + } else { + return { error: '缺少用户输入', message: '', draft: '', ready: false, suggestions: [] } + } + + log.info('cocreate-chat', { + bookId, + turns: messages.length, + kickoff: !!payload?.kickoff, + directionMode, + userLen: userText.length, + prevDraftLen: prevDraft.length, + }) + + // 关键原则:brief 由模型更新。本地只在模型失败/元思考时兜底,且与旧稿合并,不抢先覆盖。 + let raw = '' + try { + raw = await callChat(messages) + } catch (e: any) { + if (directionMode) { + const fallback = buildFallbackSupplement(userText, prevDraft) + return { + error: null, + message: '模型暂时不可用。已把你的补充并入 brief(保留旧规划),待模型恢复后再精细整理。', + draft: fallback, + ready: false, + suggestions: ['模型恢复后继续整理章纲', '先确认旧规划是否保留完整'], + summary, + raw: '', + } + } + throw e + } + + const parsed = parseCoCreateResponse(raw) + let message = parsed.message + let draft = parsed.draft + let ready = parsed.ready + let suggestions = parsed.suggestions + + const draftMeta = + !!draft && (isProtocolMetaDocument(draft) || looksLikeProtocolMetaThinking(draft)) + if (draftMeta) draft = '' + + if (draft) { + // 模型有效 draft:与旧稿合并,避免覆盖丢失 + draft = prevDraft ? mergeBriefs(prevDraft, draft) : draft + } else if (prevDraft && hasSubstantialStoryBrief(prevDraft)) { + // 模型没给出有效 draft:保留旧稿(绝不清空) + draft = prevDraft + if (!message || isProtocolMetaDocument(message) || looksLikeProtocolMetaThinking(message)) { + message = '本轮未得到有效 brief 更新,已保留右侧原有规划。请再发一次更明确的补充点。' + } + } else if (directionMode) { + // 无旧稿且模型失败:才用用户方向做底稿 + draft = buildFallbackSupplement(userText, '') + if (!message || isProtocolMetaDocument(message) || looksLikeProtocolMetaThinking(message)) { + message = '已根据你的方向生成初始 brief。你可以继续让我细化章纲,或确认后开始写作。' + } + } + + if (!suggestions.length && directionMode) { + suggestions = ['继续补第4-12章逐章章纲', '检查重复结构风险', '确认方向并准备开写'] + } + + if (!message && !draft) { + return { error: '模型返回为空', message: '', draft: '', ready: false, suggestions: [], raw } + } + return { + error: null, + message: message || '本轮已处理。', + draft: draft || '', + ready, + suggestions, + summary, + raw: parsed.raw, + } + } catch (e: any) { + log.error('cocreate-chat', e) + return { + error: e?.message || String(e), + message: '', + draft: '', + ready: false, + suggestions: [], + } + } + }, + ) +} + +module.exports = { register } diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 3615533..21fe13f 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -87,7 +87,16 @@ function register(ipcMain: Electron.IpcMain) { }) ipcMain.handle('get-directory', async () => state.outputDir) - ipcMain.handle('open-directory', async (_e: Electron.IpcMainInvokeEvent, dir: string) => { require('electron').shell.openPath(validatePath(dir)) }) + ipcMain.handle('open-directory', async (_e: Electron.IpcMainInvokeEvent, dir: string) => { + const safeDir = validatePath(dir) + if (!existsSync(safeDir)) mkdirSync(safeDir, { recursive: true }) + const err = await require('electron').shell.openPath(safeDir) + if (err) { + log.error('open-directory', err) + throw new Error(err) + } + return true + }) // ── CLI 二进制检查 ── ipcMain.handle('check-binary', async () => { @@ -445,21 +454,42 @@ ${navItems} } catch (e: any) { return { error: e.message || '请求失败' } } }) + function sanitizeProviderConfig(config: any) { + if (!config || typeof config !== 'object') return config + if (config.roles && typeof config.roles === 'object') { + const valid = new Set(['coordinator', 'architect', 'writer', 'editor']) + const cleaned: Record = {} + for (const [k, v] of Object.entries(config.roles)) { + if (valid.has(k)) cleaned[k] = v + } + if (Object.keys(cleaned).length > 0) config.roles = cleaned + else delete config.roles + } + delete config.role_models + return config + } + ipcMain.handle('load-provider-config', async () => { try { const config = getDB().getConfig('provider_config') - if (config) return config + if (config) return sanitizeProviderConfig(config) } catch (e: any) { log.error('load-provider-config:db', e) } if (!existsSync(CONFIG_PATH)) return null try { - const jsonConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) + const jsonConfig = sanitizeProviderConfig(JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))) getDB().setConfig('provider_config', jsonConfig) return jsonConfig } catch (e: any) { log.error('load-provider-config:file', e); return null } }) ipcMain.handle('save-provider-config', async (_e: Electron.IpcMainInvokeEvent, config: any) => { - try { getDB().setConfig('provider_config', config) } catch (e: any) { log.error('save-provider-config:db', e) } + try { + config = sanitizeProviderConfig(config || {}) + } catch (e: any) { + log.warn('save-provider-config:sanitize', e?.message || e) + return false + } + try { getDB().setConfig('provider_config', config) } catch (e: any) { log.error('save-provider-config:db', e); return false } const dir = dirname(CONFIG_PATH) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)) diff --git a/electron/ipc/writing.ts b/electron/ipc/writing.ts index 4e97b08..686714b 100644 --- a/electron/ipc/writing.ts +++ b/electron/ipc/writing.ts @@ -30,7 +30,7 @@ function parseStderrLine(line: string) { state._lastStderrEvent = { category, summary } const now = new Date() const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${timeStr}` - state.engineEvents.push({ time: ts, category, summary, detail: '', agent: '', depth: 0, level: category === 'ERROR' ? 'error' : category === 'WARN' ? 'warn' : 'info', duration: 0 }) + state.engineEvents.push({ time: ts, category, summary, detail: '', agent: '', depth: 0, level: category === 'ERROR' ? 'error' : category === 'WARN' ? 'warn' : 'info', duration: 0, bookId: state.activeWritingBookId || '' }) } function parseStderr(text: string) { @@ -40,133 +40,313 @@ function parseStderr(text: string) { if (state.engineEvents.length > 2000) state.engineEvents.splice(0, state.engineEvents.length - 2000) } -function register(ipcMain: Electron.IpcMain) { - ipcMain.handle('start-writing', async (_e: Electron.IpcMainInvokeEvent, _prompt: string, bookId: string) => { - await stopAinovelProcess() - const binary = getAinovelBinary() - if (!existsSync(binary)) return false - let cwd = state.outputDir || require('electron').app.getPath('documents') - if (bookId) { - try { - const book = getDB().getBook(bookId) - if (book?.workspace_dir) cwd = book.workspace_dir - } catch (e: any) { log.error('start-writing:getBook', e) } +/** 启动成功判定窗口:spawn 后需保持运行,秒退即失败 */ +const STARTUP_HEALTH_MS = 2800 + +type WritingStartResult = { ok: boolean; error?: string } + +/** CLI 真配置路径:output/novel/meta/run.json */ +function bookRunJsonPath(cwd: string): string { + const { join: pJoin } = require('path') + return pJoin(cwd, 'output', 'novel', 'meta', 'run.json') +} + +/** 从 stderr / last-error 提取真实失败原因 */ +function extractCliError(stderrData: string): string { + const { join: pJoin } = require('path') + const { existsSync: fExists, readFileSync: fRead } = require('fs') + const { homedir } = require('os') + const lines = String(stderrData || '') + .split(/\r?\n/) + .map((l: string) => l.trim()) + .filter(Boolean) + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i] + if (/error:\s*/i.test(line) || /config error/i.test(line) || /失败|错误/.test(line)) { + return line.replace(/^\[.*?\]\s*/, '').slice(0, 300) } - if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }) + } + + try { + const lastErrPath = pJoin(homedir(), '.ainovel', 'last-error.log') + if (fExists(lastErrPath)) { + const content = fRead(lastErrPath, 'utf8').trim() + const last = content.split(/\r?\n/).filter(Boolean).pop() || '' + if (last) return last.replace(/^\[[^\]]*\]\s*/, '').replace(/^error:\s*/i, 'error: ').slice(0, 300) + } + } catch {} + + if (lines.length) return lines.slice(-2).join(' | ').slice(0, 300) + return '' +} + +/** + * 启动 CLI 并等待健康窗口: + * - 窗口内退出 => ok:false + 真实错误 + * - 窗口后仍存活 => ok:true + */ +function spawnWritingEngine(opts: { + binary: string + args: string[] + cwd: string + bookId: string + logLabel: string +}): Promise { + const { binary, args, cwd, bookId, logLabel } = opts + return new Promise((resolve) => { + let startupSettled = false + let stderrData = '' + let healthTimer: ReturnType | null = null + + const settle = (result: WritingStartResult) => { + if (startupSettled) return + startupSettled = true + if (healthTimer) clearTimeout(healthTimer) + resolve(result) + } + + let proc: any + try { + proc = spawn(binary, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }) + } catch (e: any) { + resolve({ ok: false, error: e?.message || 'spawn 失败' }) + return + } + + state.ainovelProcess = proc state.outputDir = cwd state.activeWritingBookId = bookId || '' state.lastWritingExitCode = null - const args = ['--headless'] - if (state.configPath) args.push('--config', state.configPath) + + proc.stderr.on('data', (data: any) => { + const text = data.toString() + stderrData += text + parseStderr(text) + }) + + let streamMode = 'content' + let streamBuf = '' + let streamTimer: ReturnType | null = null + proc.stdout.on('data', (data: any) => { + const text = data.toString() + const clean = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') + if (!clean || !state.mainWindow || state.mainWindow.isDestroyed()) return + const tIdx = clean.indexOf('[T]') + const cIdx = clean.indexOf('[C]') + if (tIdx >= 0 || cIdx >= 0) { + if (streamBuf && streamBuf.trim()) { + state.mainWindow.webContents.send( + 'stream-output', + JSON.stringify({ type: streamMode, text: streamBuf.trim() }), + ) + streamBuf = '' + } + if (tIdx >= 0) streamMode = 'thinking' + if (cIdx >= 0) streamMode = 'content' + const idx = tIdx >= 0 ? tIdx + 3 : cIdx + 3 + const rest = clean.substring(idx).trim() + if (rest) streamBuf = rest + } else { + streamBuf += clean + } + if (streamBuf.length > 100 && streamBuf.trim()) { + state.mainWindow.webContents.send( + 'stream-output', + JSON.stringify({ type: streamMode, text: streamBuf.trim() }), + ) + streamBuf = '' + } + if (!streamTimer) { + streamTimer = setTimeout(() => { + streamTimer = null + if (streamBuf && streamBuf.trim()) { + state.mainWindow.webContents.send( + 'stream-output', + JSON.stringify({ type: streamMode, text: streamBuf.trim() }), + ) + streamBuf = '' + } + }, 500) + } + }) + + proc.on('exit', (code: number | null) => { + state.lastWritingExitCode = code + state.ainovelProcess = null + stopRuntimeSync() + if (code !== 0 && stderrData) { + log.error(logLabel + ':exit', { code, stderr: stderrData.slice(-2000) }) + } + if (!startupSettled) { + const detail = extractCliError(stderrData) + settle({ + ok: false, + error: detail || ('引擎启动失败(退出码 ' + (code ?? -1) + ')'), + }) + } + sendProcessExited(code) + }) + + proc.on('error', (err: Error) => { + log.error(logLabel + ':error', err.message) + state.lastWritingExitCode = -1 + state.ainovelProcess = null + stopRuntimeSync() + if (!startupSettled) settle({ ok: false, error: err.message }) + sendProcessExited(-1) + }) + + startRuntimeSync() + + healthTimer = setTimeout(() => { + if (state.ainovelProcess === proc && proc.exitCode === null) { + settle({ ok: true }) + return + } + if (!startupSettled) { + const detail = extractCliError(stderrData) + settle({ ok: false, error: detail || '引擎未能保持运行' }) + } + }, STARTUP_HEALTH_MS) + }) +} + +/** 解析书籍工作目录 */ +function resolveBookCwd(bookId: string): string { + const { join: pJoin } = require('path') + const { existsSync: fExists, mkdirSync: fMkdir } = require('fs') + const { homedir } = require('os') + let cwd = '' + if (bookId) { try { - state.ainovelProcess = spawn(binary, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } }) - let stderrData = '' - state.ainovelProcess.stderr.on('data', (data: any) => { - const text = data.toString(); stderrData += text - parseStderr(text) - }) - let streamMode = 'content', streamBuf = '', streamTimer: ReturnType | null = null - state.ainovelProcess.stdout.on('data', (data: any) => { - const text = data.toString() - const clean = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') - if (!clean || !state.mainWindow || state.mainWindow.isDestroyed()) return - const tIdx = clean.indexOf('[T]'), cIdx = clean.indexOf('[C]') - if (tIdx >= 0 || cIdx >= 0) { - if (streamBuf && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } - if (tIdx >= 0) streamMode = 'thinking' - if (cIdx >= 0) streamMode = 'content' - const idx = tIdx >= 0 ? tIdx + 3 : cIdx + 3 - const rest = clean.substring(idx).trim() - if (rest) streamBuf = rest - } else { streamBuf += clean } - if (streamBuf.length > 100 && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } - if (!streamTimer) { streamTimer = setTimeout(() => { streamTimer = null; if (streamBuf && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } }, 500) } - }) - state.ainovelProcess.on('exit', (code: number | null) => { - state.lastWritingExitCode = code - state.ainovelProcess = null - stopRuntimeSync() - if (code !== 0 && stderrData) log.error('start-writing:exit', { code, stderr: stderrData.slice(-2000) }) - sendProcessExited(code) - }) - state.ainovelProcess.on('error', (err: Error) => { - log.error('start-writing:error', err.message) - state.lastWritingExitCode = -1 - state.ainovelProcess = null - stopRuntimeSync() - sendProcessExited(-1) - }) - startRuntimeSync() - return true - } catch (e: any) { log.error('start-writing:spawn', e); return false } + const book = getDB().getBook(bookId) + if (book?.workspace_dir) cwd = book.workspace_dir + } catch (e: any) { + log.error('resolveBookCwd:getBook', e) + } + if (!cwd) { + cwd = pJoin(homedir(), '.ainovel-gui', 'books', bookId) + try { getDB().updateBook(bookId, { workspace_dir: cwd }) } catch {} + } + } + if (!cwd) cwd = state.outputDir || pJoin(homedir(), '.ainovel-gui', 'books', 'default') + if (!fExists(cwd)) fMkdir(cwd, { recursive: true }) + return cwd +} + +/** + * headless 启动参数: + * - 引擎 Resume 仅在 label 非空时成立;phase=init/complete 或无 progress 必须带 --prompt + * - 可恢复阶段:仅 --headless + * - 新建/init:--headless --prompt + */ +function buildHeadlessArgs(bookId: string, promptHint?: string): { args: string[]; cwd: string; mode: 'resume' | 'prompt'; phase: string; error?: string } { + const { join: pJoin } = require('path') + const { existsSync: fExists, readFileSync: fRead } = require('fs') + const cwd = resolveBookCwd(bookId) + const progressCandidates = [ + pJoin(cwd, 'output', 'novel', 'meta', 'progress.json'), + pJoin(cwd, 'meta', 'progress.json'), + ] + let phase = '' + try { + for (const progressPath of progressCandidates) { + if (!fExists(progressPath)) continue + const progress = JSON.parse(fRead(progressPath, 'utf8')) + phase = String(progress?.phase || '').trim() + break + } + } catch (e: any) { + log.warn('buildHeadlessArgs:progress-parse', e?.message || e) + } + + // 与 engine/internal/host/resume.go 对齐: + // - 无 progress / phase=complete -> 不可 resume,必须 --prompt + // - phase=init 仍可 resume(引擎 label="恢复"),前提是 progress.json 真实存在 + const canResume = !!phase && phase !== 'complete' + + const args = ['--headless'] + if (state.configPath) args.push('--config', state.configPath) + + if (canResume) { + log.info('buildHeadlessArgs', { bookId, mode: 'resume', phase, cwd }) + return { args, cwd, mode: 'resume', phase } + } + + let prompt = (promptHint || '').trim() + if (!prompt && bookId) { + try { + const book = getDB().getBook(bookId) + // 仅使用 premise,不用书名冒充需求 + prompt = String(book?.premise || '').trim() + } catch {} + } + if (!prompt) { + log.error('buildHeadlessArgs:missing-prompt', { bookId, phase: phase || 'none', cwd }) + return { + args: [], + cwd, + mode: 'prompt' as const, + phase: phase || 'none', + error: '无法启动:没有可恢复会话,且未提供创作提示词(premise/prompt)', + } + } + args.push('--prompt', prompt) + log.info('buildHeadlessArgs', { bookId, mode: 'prompt', phase: phase || 'none', cwd, promptLen: prompt.length }) + return { args, cwd, mode: 'prompt' as const, phase: phase || 'none' } +} +function register(ipcMain: Electron.IpcMain) { + ipcMain.handle('start-writing', async (_e: Electron.IpcMainInvokeEvent, _prompt: string, bookId: string): Promise => { + await stopAinovelProcess() + const binary = getAinovelBinary() + if (!existsSync(binary)) { + return { ok: false, error: '未找到 ainovel-cli 可执行文件: ' + binary } + } + const built = buildHeadlessArgs(bookId || '', _prompt || '') + if (built.error) return { ok: false, error: built.error } + const cwd = built.cwd + if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }) + applyActiveProviderToBook(bookId || '', cwd) + log.info('start-writing', { bookId, mode: built.mode, phase: built.phase, cwd }) + return spawnWritingEngine({ + binary, + args: built.args, + cwd, + bookId: bookId || '', + logLabel: 'start-writing', + }) }) - ipcMain.handle('resume-writing', async (_e: Electron.IpcMainInvokeEvent, bookId: string) => { + ipcMain.handle('resume-writing', async (_e: Electron.IpcMainInvokeEvent, bookId: string): Promise => { await stopAinovelProcess() const binary = getAinovelBinary() - if (!existsSync(binary)) return false - let cwd = state.outputDir || require('electron').app.getPath('documents') - if (bookId) { - try { - const book = getDB().getBook(bookId) - if (book?.workspace_dir) cwd = book.workspace_dir - } catch (e: any) { log.error('resume-writing:getBook', e) } + if (!existsSync(binary)) { + return { ok: false, error: '未找到 ainovel-cli 可执行文件: ' + binary } } - if (!existsSync(cwd)) { mkdirSync(cwd, { recursive: true }); return false } - const path = require('path') - const sep = path.sep - const outputPattern = new RegExp(`${sep}output${sep}[^${sep}]+$`) - const outputParent = cwd.replace(outputPattern, '') - if (outputParent !== cwd && existsSync(join(outputParent, 'output'))) cwd = outputParent - state.outputDir = cwd - state.activeWritingBookId = bookId || '' - state.lastWritingExitCode = null - const args = ['--headless'] - if (state.configPath) args.push('--config', state.configPath) - try { - state.ainovelProcess = spawn(binary, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } }) - let stderrData = '' - state.ainovelProcess.stderr.on('data', (data: any) => { - const text = data.toString(); stderrData += text - parseStderr(text) - }) - let streamMode = 'content', streamBuf = '', streamTimer: ReturnType | null = null - state.ainovelProcess.stdout.on('data', (data: any) => { - const text = data.toString() - const clean = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') - if (!clean || !state.mainWindow || state.mainWindow.isDestroyed()) return - const tIdx = clean.indexOf('[T]'), cIdx = clean.indexOf('[C]') - if (tIdx >= 0 || cIdx >= 0) { - if (streamBuf && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } - if (tIdx >= 0) streamMode = 'thinking' - if (cIdx >= 0) streamMode = 'content' - const idx = tIdx >= 0 ? tIdx + 3 : cIdx + 3 - const rest = clean.substring(idx).trim() - if (rest) streamBuf = rest - } else { streamBuf += clean } - if (streamBuf.length > 100 && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } - if (!streamTimer) { streamTimer = setTimeout(() => { streamTimer = null; if (streamBuf && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } }, 500) } - }) - state.ainovelProcess.on('exit', (code: number | null) => { - if (code !== 0 && stderrData) log.error('resume exit:', code, stderrData) - state.lastWritingExitCode = code - stopRuntimeSync(); state.ainovelProcess = null - sendProcessExited(code) - }) - state.ainovelProcess.on('error', (err: Error) => { - log.error('resume-writing:error', err.message) - state.lastWritingExitCode = -1 - stopRuntimeSync(); state.ainovelProcess = null - sendProcessExited(-1) - }) - startRuntimeSync() - return true - } catch (e: any) { log.error('resume-writing:spawn', e); return false } + const built = buildHeadlessArgs(bookId || '') + if (built.error) return { ok: false, error: built.error } + const cwd = built.cwd + if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }) + applyActiveProviderToBook(bookId || '', cwd) + log.info('resume-writing', { bookId, mode: built.mode, phase: built.phase, cwd }) + return spawnWritingEngine({ + binary, + args: built.args, + cwd, + bookId: bookId || '', + logLabel: 'resume-writing', + }) }) - ipcMain.handle('send-input', async (_e: Electron.IpcMainInvokeEvent, text: string) => { + ipcMain.handle('send-input', async (_e: Electron.IpcMainInvokeEvent, text: string, bookIdArg?: string) => { const now = new Date() const timeStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}` + const bookId = bookIdArg || getActiveWritingBookId() // 进程正在运行:写入 stdin if (state.ainovelProcess && state.ainovelProcess.stdin && state.ainovelProcess.exitCode === null) { @@ -180,39 +360,33 @@ function register(ipcMain: Electron.IpcMain) { // 短暂等待让 CLI 进入输入处理状态 await new Promise(r => setTimeout(r, 100)) state.ainovelProcess.stdin.write(text + '\n') - state.engineEvents.push({ time: timeStr, category: 'USER', summary: text.slice(0, 120), detail: text, agent: '', depth: 0, level: 'info', duration: 0 }) + state.engineEvents.push({ time: timeStr, category: 'USER', summary: text.slice(0, 120), detail: text, agent: '', depth: 0, level: 'info', duration: 0, bookId: state.activeWritingBookId || bookId || '' }) return true } catch (e: any) { log.error('send-input', e); return false } } // 进程未运行(规划暂停/已退出):保存为 pending_steer,下次恢复时生效 - const bookId = getActiveWritingBookId() if (bookId) { try { const db = getDB() const existing = db.getRunMeta(bookId) || {} db.saveRunMeta(bookId, { ...existing, pending_steer: text, pending_steer_at: timeStr }) - // 同时写入 JSON 文件(CLI 从 meta/run.json 读取) - const { join: pJoin } = require('path') - const { existsSync: fExists, writeFileSync: fWrite } = require('fs') - const { homedir } = require('os') - const dir = pJoin(homedir(), '.ainovel-gui', 'books', bookId) - const runJsonPath = pJoin(dir, 'meta', 'run.json') + // 只写 CLI 主路径 output/novel/meta/run.json + const { existsSync: fExists, writeFileSync: fWrite, mkdirSync: fMkdir, readFileSync: fRead } = require('fs') + const { dirname: fDirname } = require('path') + const dir = resolveBookCwd(bookId) + const runJsonPath = bookRunJsonPath(dir) if (fExists(runJsonPath)) { - try { - const raw = JSON.parse(require('fs').readFileSync(runJsonPath, 'utf8')) - raw.pending_steer = text - raw.pending_steer_at = timeStr - fWrite(runJsonPath, JSON.stringify(raw, null, 2), 'utf8') - } catch (e: any) { /* 文件可能不存在,忽略 */ } + const raw = JSON.parse(fRead(runJsonPath, 'utf8') || '{}') || {} + raw.pending_steer = text + raw.pending_steer_at = timeStr + fWrite(runJsonPath, JSON.stringify(raw, null, 2), 'utf8') } else { - // 创建 meta 目录和 run.json - const { mkdirSync } = require('fs') - const metaDir = pJoin(dir, 'meta') - if (!fExists(metaDir)) mkdirSync(metaDir, { recursive: true }) + const metaDir = fDirname(runJsonPath) + if (!fExists(metaDir)) fMkdir(metaDir, { recursive: true }) fWrite(runJsonPath, JSON.stringify({ pending_steer: text, pending_steer_at: timeStr }, null, 2), 'utf8') } - state.engineEvents.push({ time: timeStr, category: 'USER', summary: text.slice(0, 120), detail: text, agent: '', depth: 0, level: 'info', duration: 0 }) + state.engineEvents.push({ time: timeStr, category: 'USER', summary: text.slice(0, 120), detail: text, agent: '', depth: 0, level: 'info', duration: 0, bookId: state.activeWritingBookId || bookId || '' }) log.info('send-input:saved-as-pending-steer', { bookId, text: text.slice(0, 60) }) return true } catch (e: any) { log.error('send-input:save-pending-steer', e); return false } @@ -232,68 +406,41 @@ function register(ipcMain: Electron.IpcMain) { * 规划完成后用户确认继续 * 重置 pendingUserConfirm 标志,重新 spawn CLI 恢复创作 */ - ipcMain.handle('confirm-continue-writing', async (_e: Electron.IpcMainInvokeEvent, bookId: string) => { - // 确保旧进程已清理(规划暂停后进程可能尚未完全退出) + ipcMain.handle('confirm-continue-writing', async (_e: Electron.IpcMainInvokeEvent, bookId: string): Promise => { await stopAinovelProcess() pendingUserConfirm = false - lastPhase = '' // 重置相位追踪,避免重复暂停 + lastPhase = '' log.info('confirm-continue-writing: 用户确认继续', { bookId }) - // 通过 resume-writing 恢复 CLI 进程 const binary = getAinovelBinary() - if (!existsSync(binary)) return false - let cwd = state.outputDir || require('electron').app.getPath('documents') - if (bookId) { - try { - const book = getDB().getBook(bookId) - if (book?.workspace_dir) cwd = book.workspace_dir - } catch (e: any) { log.error('confirm-continue-writing:getBook', e) } + if (!existsSync(binary)) { + return { ok: false, error: '未找到 ainovel-cli 可执行文件: ' + binary } } - if (!existsSync(cwd)) { mkdirSync(cwd, { recursive: true }); return false } - state.outputDir = cwd - state.activeWritingBookId = bookId || '' - state.lastWritingExitCode = null - const args = ['--headless'] - if (state.configPath) args.push('--config', state.configPath) + const built = buildHeadlessArgs(bookId || '') + if (built.error) return { ok: false, error: built.error } + const cwd = built.cwd + if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }) + applyActiveProviderToBook(bookId || '', cwd) + return spawnWritingEngine({ + binary, + args: built.args, + cwd, + bookId: bookId || '', + logLabel: 'confirm-continue-writing', + }) + }) + + // 模型切换后:同步当前启用模型到指定书籍(run_meta + run.json) + ipcMain.handle('apply-provider-to-book', async (_e: Electron.IpcMainInvokeEvent, bookId?: string) => { + const id = bookId || state.activeWritingBookId || '' + if (!id) return false try { - state.ainovelProcess = spawn(binary, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } }) - const { join: pJoin } = require('path') - let stderrData = '' - state.ainovelProcess.stderr.on('data', (data: any) => { - const text = data.toString(); stderrData += text - parseStderr(text) - }) - let streamMode = 'content', streamBuf = '', streamTimer: ReturnType | null = null - state.ainovelProcess.stdout.on('data', (data: any) => { - const text = data.toString() - const clean = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') - if (!clean || !state.mainWindow || state.mainWindow.isDestroyed()) return - const tIdx = clean.indexOf('[T]'), cIdx = clean.indexOf('[C]') - if (tIdx >= 0 || cIdx >= 0) { - if (streamBuf && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } - if (tIdx >= 0) streamMode = 'thinking' - if (cIdx >= 0) streamMode = 'content' - const idx = tIdx >= 0 ? tIdx + 3 : cIdx + 3 - const rest = clean.substring(idx).trim() - if (rest) streamBuf = rest - } else { streamBuf += clean } - if (streamBuf.length > 100 && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } - if (!streamTimer) { streamTimer = setTimeout(() => { streamTimer = null; if (streamBuf && streamBuf.trim()) { state.mainWindow.webContents.send('stream-output', JSON.stringify({type: streamMode, text: streamBuf.trim()})); streamBuf = '' } }, 500) } - }) - state.ainovelProcess.on('exit', (code: number | null) => { - if (code !== 0 && stderrData) log.error('confirm-continue-writing exit:', code, stderrData) - state.lastWritingExitCode = code - stopRuntimeSync(); state.ainovelProcess = null; pendingUserConfirm = false - sendProcessExited(code) - }) - state.ainovelProcess.on('error', (err: Error) => { - log.error('confirm-continue-writing:error', err.message) - state.lastWritingExitCode = -1 - stopRuntimeSync(); state.ainovelProcess = null; pendingUserConfirm = false - sendProcessExited(-1) - }) - startRuntimeSync() + const cwd = resolveBookCwd(id) + applyActiveProviderToBook(id, cwd) return true - } catch (e: any) { log.error('confirm-continue-writing:spawn', e); return false } + } catch (e: any) { + log.error('apply-provider-to-book', e) + return false + } }) // ── 运行时读取(快照/事件/章节)── @@ -303,24 +450,25 @@ function register(ipcMain: Electron.IpcMain) { state.engineEvents.length = 0 return true }) - ipcMain.handle('read-chapter', async (_e: Electron.IpcMainInvokeEvent, ch: string) => { - if (!state.outputDir) return '' - const bookDir = findActiveBookDir() + ipcMain.handle('read-chapter', async (_e: Electron.IpcMainInvokeEvent, ch: string, bookId?: string) => { + const bookDir = resolveBookDirReadOnly(bookId || state.activeWritingBookId || undefined) if (!bookDir) return '' - const f = join(bookDir, 'chapters', `${String(ch).padStart(2, '0')}.md`) - if (!existsSync(f)) { - const fallback1 = join(bookDir, 'output', 'novel', 'chapters', `${String(ch).padStart(2, '0')}.md`) - if (existsSync(fallback1)) try { return readFileSync(fallback1, 'utf8') } catch (e: any) { return '' } - const fallback2 = join(state.outputDir, 'output', 'chapters', `${String(ch).padStart(2, '0')}.md`) - if (existsSync(fallback2)) try { return readFileSync(fallback2, 'utf8') } catch (e: any) { return '' } - return '' + const candidates = [ + join(bookDir, 'chapters', `${String(ch).padStart(2, '0')}.md`), + join(bookDir, 'output', 'novel', 'chapters', `${String(ch).padStart(2, '0')}.md`), + join(bookDir, 'output', 'chapters', `${String(ch).padStart(2, '0')}.md`), + ] + for (const f of candidates) { + if (existsSync(f)) { + try { return readFileSync(f, 'utf8') } catch (e: any) { return '' } + } } - try { return readFileSync(f, 'utf8') } catch (e: any) { return '' } + return '' }) - ipcMain.handle('list-chapters', async () => { - if (!state.outputDir) return [] - const bookDir = findActiveBookDir() + ipcMain.handle('list-chapters', async (_e: Electron.IpcMainInvokeEvent, bookId?: string) => { + const targetId = bookId || state.activeWritingBookId || '' + const bookDir = resolveBookDirReadOnly(targetId || undefined) if (!bookDir) return [] let chDir = join(bookDir, 'chapters') if (!existsSync(chDir)) { @@ -329,8 +477,11 @@ function register(ipcMain: Electron.IpcMain) { } const { readdirSync } = require('fs') const files = readdirSync(chDir).filter((f: string) => f.endsWith('.md')).sort() - const progress = readStoreJSON('meta/progress.json') - const titles = progress?.chapterTitles || {} + const progress = readStoreJSONAny( + ['meta/progress.json', 'output/novel/meta/progress.json', 'progress.json'], + bookDir, + ) + const titles = progress?.chapterTitles || progress?.chapter_titles || {} return files.map((file: string) => { const num = parseInt(file.replace('.md', ''), 10) if (isNaN(num)) return null @@ -435,6 +586,38 @@ function register(ipcMain: Electron.IpcMain) { }) startRuntimeSync() + const health = await new Promise<{ ok: boolean; error?: string }>((resolve) => { + let settled = false + const done = (r: { ok: boolean; error?: string }) => { + if (settled) return + settled = true + clearTimeout(t) + resolve(r) + } + const t = setTimeout(() => { + if (state.ainovelProcess && state.ainovelProcess.exitCode === null) done({ ok: true }) + else done({ ok: false, error: extractCliError(stderrData) || '引擎未能保持运行' }) + }, STARTUP_HEALTH_MS) + const proc = state.ainovelProcess + if (!proc) { + done({ ok: false, error: '引擎进程未创建' }) + return + } + if (proc.exitCode !== null) { + done({ ok: false, error: extractCliError(stderrData) || ('引擎退出码 ' + proc.exitCode) }) + return + } + proc.once('exit', (code: number | null) => { + done({ + ok: false, + error: extractCliError(stderrData) || ('引擎启动失败(退出码 ' + (code ?? -1) + ')'), + }) + }) + }) + if (!health.ok) { + log.error('create-book-auto:startup-failed', health.error) + return { book: null, error: health.error || '引擎启动失败' } + } log.info('create-book-auto: CLI 已启动', { id, bookDir }) return { book: { ...book, completedCount: 0 }, error: null } } catch (e: any) { @@ -492,70 +675,203 @@ function readStoreJSONAny(relativePaths: string[], baseDir?: string) { function getBookForSnapshot(bookId?: string) { const db = getDB() + // 指定 bookId 时只查该书,禁止回落到其它书籍(切书串数据的根因之一) if (bookId) { - const book = db.getBook(bookId) - if (book) return book + return db.getBook(bookId) || null } if (state.activeWritingBookId) { const book = db.getBook(state.activeWritingBookId) if (book) return book } - return db.listBooks()?.[0] || null + return null +} + +/** 只读解析书籍目录,不创建、不回落到上一本书的 outputDir */ +function resolveBookDirReadOnly(bookId?: string): string | null { + if (!bookId) { + if (state.activeWritingBookId) return resolveBookDirReadOnly(state.activeWritingBookId) + return state.outputDir || null + } + try { + const book = getDB().getBook(bookId) + if (book?.workspace_dir) return book.workspace_dir + } catch (e: any) { + log.error('resolveBookDirReadOnly:getBook', e) + } + try { + const { join: pJoin } = require('path') + const { homedir } = require('os') + const { existsSync: fExists } = require('fs') + const candidate = pJoin(homedir(), '.ainovel-gui', 'books', bookId) + if (fExists(candidate)) return candidate + } catch {} + return null } function getBookRuntimeDir(bookId?: string) { - const book = getBookForSnapshot(bookId) - if (book?.id && state.activeWritingBookId && book.id === state.activeWritingBookId) { - return findActiveBookDir() || state.outputDir || book.workspace_dir || null + const id = bookId || state.activeWritingBookId || '' + if (!id) return state.outputDir || null + // 当前正在写作的书:优先进程 cwd / outputDir + if (state.activeWritingBookId && id === state.activeWritingBookId) { + return findActiveBookDir() || state.outputDir || resolveBookDirReadOnly(id) + } + // 其它书:绝不能回落到 state.outputDir(那是上一本的目录) + return resolveBookDirReadOnly(id) +} + + +/** 当前全局启用的 provider/model(模型管理/切换弹窗写入) */ +function getActiveProviderDisplay(): { provider: string; model: string; name: string } { + try { + const cfg = getDB().getConfig('provider_config') || {} + const key = String(cfg.provider || '') + const p = (cfg.providers && key) ? cfg.providers[key] : null + const name = String((p && p.name) || key || '') + const model = String(cfg.model || (p && p.model) || '') + return { provider: key, model, name: name || key } + } catch { + return { provider: '', model: '', name: '' } + } +} + +/** + * 开始/继续写作前:把当前启用模型写入书籍 run_meta + run.json, + * 避免 CLI/顶栏继续用上一轮的 custom-xxx 残留。 + */ +function applyActiveProviderToBook(bookId: string, cwd?: string) { + if (!bookId) return + const cur = getActiveProviderDisplay() + if (!cur.provider && !cur.model) return + + try { + const db = getDB() + const existing = db.getRunMeta(bookId) || {} + db.saveRunMeta(bookId, { + ...existing, + provider: cur.provider || existing.provider || '', + model: cur.model || existing.model || '', + started_at: existing.started_at || new Date().toISOString(), + }) + } catch (e: any) { + log.warn('applyActiveProviderToBook:db', e?.message || e) + } + + // 单一真源:书籍 cwd 下 CLI 路径 output/novel/meta/run.json + let root = cwd || '' + if (!root) { + try { + const book = getDB().getBook(bookId) + if (book?.workspace_dir) root = book.workspace_dir + } catch {} + } + if (!root) root = resolveBookCwd(bookId) + + const { existsSync: fExists, mkdirSync: fMkdir, readFileSync: fRead, writeFileSync: fWrite } = require('fs') + const { dirname: fDirname } = require('path') + const runPath = bookRunJsonPath(root) + try { + let data: any = {} + if (fExists(runPath)) { + data = JSON.parse(fRead(runPath, 'utf8') || '{}') || {} + } else { + const dir = fDirname(runPath) + if (!fExists(dir)) fMkdir(dir, { recursive: true }) + } + data.provider = cur.provider || data.provider || '' + data.model = cur.model || data.model || '' + if (!data.started_at) data.started_at = new Date().toISOString() + fWrite(runPath, JSON.stringify(data, null, 2), 'utf8') + log.info('applyActiveProviderToBook:run.json', { bookId, runPath, provider: data.provider, model: data.model }) + } catch (e: any) { + log.warn('applyActiveProviderToBook:run.json', e?.message || e) } - return book?.workspace_dir || state.outputDir || null } function createSnapshotHandler() { return async (_e: Electron.IpcMainInvokeEvent, bookId?: string) => { const snap = createEmptySnapshot() const activeBook = getBookForSnapshot(bookId) - const activeBookId = activeBook?.id || '' - const isAlive = state.ainovelProcess !== null && state.ainovelProcess.exitCode === null - snap.runtimeState = isAlive ? 'running' : 'idle' - snap.isRunning = isAlive + const activeBookId = activeBook?.id || bookId || '' + const processAlive = state.ainovelProcess !== null && state.ainovelProcess.exitCode === null + // 仅当查看的书就是当前写作书时,才显示 running + const isThisBookRunning = processAlive && !!activeBookId && activeBookId === (state.activeWritingBookId || '') + snap.runtimeState = isThisBookRunning ? 'running' : 'idle' + snap.isRunning = isThisBookRunning try { if (activeBook) { snap.novelName = activeBook.name || ''; snap.style = activeBook.style || '' snap.phase = activeBook.phase || 'init'; snap.totalWordCount = activeBook.totalWordCount || 0; snap.completedCount = activeBook.completedCount || 0 } } catch (e: any) { log.error('snapshot:book', e) } - if (isAlive) { fillRunningSnapshot(snap, activeBookId) } - else { fillDbSnapshot(snap, activeBookId) } - try { if (activeBookId) applyUsageSnapshot(snap, getDB().getUsageStats(activeBookId)) } catch (e: any) { log.error('snapshot:usage', e) } - snap.statusLabel = deriveStatusLabel(snap) - fillFallbackData(snap, activeBookId) + if (activeBookId) { + if (isThisBookRunning) { fillRunningSnapshot(snap, activeBookId) } + else { fillDbSnapshot(snap, activeBookId) } + try { applyUsageSnapshot(snap, getDB().getUsageStats(activeBookId)) } catch (e: any) { log.error('snapshot:usage', e) } + snap.statusLabel = deriveStatusLabel(snap) + fillFallbackData(snap, activeBookId) + } + // 运行中:显示本书 run 模型;空闲:显示全局当前启用 + { + const cur = getActiveProviderDisplay() + if (!isThisBookRunning && cur.provider) { + snap.provider = cur.name || cur.provider + if (cur.model) snap.modelName = cur.model + } else if (snap.provider) { + try { + const cfg = getDB().getConfig('provider_config') || {} + const p = cfg.providers?.[snap.provider] + if (p?.name) snap.provider = p.name + } catch {} + } else if (cur.provider) { + snap.provider = cur.name || cur.provider + if (cur.model) snap.modelName = cur.model + } + } return snap } } +function loadCheckpointEvents(bookDir: string) { + const { existsSync, readFileSync } = require('fs') + const { join } = require('path') + const candidates = [ + join(bookDir, 'meta', 'checkpoints.jsonl'), + join(bookDir, 'output', 'meta', 'checkpoints.jsonl'), + join(bookDir, 'output', 'novel', 'meta', 'checkpoints.jsonl'), + ] + const finalPath = candidates.find((p: string) => existsSync(p)) + if (!finalPath) return [] + try { + const raw = readFileSync(finalPath, 'utf8') + return raw.split('\n').filter(Boolean).slice(-500).map((line: string) => { + try { + const p = JSON.parse(line) + return { time: p.time || '', category: p.category || 'SYSTEM', summary: p.summary || '', detail: p.detail || '', agent: p.agent || '', depth: p.depth || 0, level: p.level || 'info', duration: p.duration || 0, bookId: p.bookId || '' } + } catch (e: any) { + return { time: '', category: 'SYSTEM', summary: line, detail: '', agent: '', depth: 0, level: 'info', duration: 0, bookId: '' } + } + }) + } catch (e: any) { + log.warn('get-events:read', e?.message || e) + return [] + } +} + function createEventsHandler() { - return async () => { - if (state.engineEvents.length > 0) return [...state.engineEvents].slice(-500) - if (!state.outputDir) return [] - const bookDir = findActiveBookDir() - if (!bookDir) return [] - const { existsSync, readFileSync } = require('fs') - const { join } = require('path') - const cpPath = join(bookDir, 'meta', 'checkpoints.jsonl') - if (!existsSync(cpPath)) { - const altPath = join(bookDir, 'output', 'meta', 'checkpoints.jsonl') - if (!existsSync(altPath)) return [] - state.cpPathAlt = altPath - } - const finalPath = existsSync(cpPath) ? cpPath : state.cpPathAlt - try { - const raw = readFileSync(finalPath || cpPath, 'utf8') - return raw.split('\n').filter(Boolean).slice(-500).map((line: string) => { - try { const p = JSON.parse(line); return { time: p.time || '', category: p.category || 'SYSTEM', summary: p.summary || '', detail: p.detail || '', agent: p.agent || '', depth: p.depth || 0, level: p.level || 'info', duration: p.duration || 0 } } - catch (e: any) { return { time: '', category: 'SYSTEM', summary: line, detail: '', agent: '', depth: 0, level: 'info', duration: 0 } } + return async (_e: Electron.IpcMainInvokeEvent, bookId?: string) => { + const targetId = bookId || state.activeWritingBookId || '' + // 内存事件:按 bookId 过滤,避免切书后仍显示上一本日志 + if (state.engineEvents.length > 0) { + const filtered = state.engineEvents.filter((ev: any) => { + if (!targetId) return true + if (!ev.bookId) return targetId === (state.activeWritingBookId || '') + return ev.bookId === targetId }) - } catch (e: any) { log.warn('get-events:read', e?.message || e); return [] } + if (filtered.length > 0) return filtered.slice(-500) + } + const bookDir = resolveBookDirReadOnly(targetId || undefined) + if (!bookDir) return [] + return loadCheckpointEvents(bookDir) } } @@ -834,9 +1150,8 @@ function stopAinovelProcess() { } function getActiveWritingBookId() { - if (state.activeWritingBookId) return state.activeWritingBookId - try { return getDB().listBooks()?.[0]?.id || '' } - catch (e: any) { log.error('getActiveWritingBookId', e); return '' } + // 禁止回落到「书库第一本」,否则会把指令/元数据写到错误书籍 + return state.activeWritingBookId || '' } function sendProcessExited(code: number | null) { @@ -1208,4 +1523,4 @@ function syncPlanningData(bookDir: string, db: any, bookId: string) { } catch (e: any) { log.debug('runtime-sync:plan-skip', { key: 'user_directives', error: e.message }) } } -module.exports = { register, stopAinovelProcess } +module.exports = { register, stopAinovelProcess, applyActiveProviderToBook, getActiveProviderDisplay } diff --git a/electron/main.ts b/electron/main.ts index 7e5047e..19d2741 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -9,6 +9,7 @@ const { register: registerBooks } = require('./ipc/books') const { register: registerWorkspace } = require('./ipc/workspace') const { register: registerWriting } = require('./ipc/writing') const { register: registerSystem } = require('./ipc/system') +const { register: registerCocreate } = require('./ipc/cocreate') const log = createLogger('main') @@ -32,13 +33,23 @@ function createWindow() { if (isDev) { state.mainWindow.loadURL('http://localhost:5173') - state.mainWindow.webContents.openDevTools({ mode: 'detach' }) } else { state.mainWindow.loadFile(join(__dirname, '../dist/index.html')) } state.mainWindow.on('ready-to-show', () => state.mainWindow?.show()) - state.mainWindow.on('closed', () => { state.mainWindow = null }) + // 点叉号后强制整应用退出,避免 dev 残留/启动 cmd 不结束 + state.mainWindow.on('close', () => { + if (process.platform !== 'darwin') { + app.quit() + } + }) + state.mainWindow.on('closed', () => { + state.mainWindow = null + if (process.platform !== 'darwin') { + app.quit() + } + }) const homeConfig = join(app.getPath('home'), '.ainovel', 'config.json') if (existsSync(homeConfig)) state.configPath = homeConfig @@ -51,6 +62,7 @@ app.whenReady().then(() => { registerWorkspace(ipcMain) registerWriting(ipcMain) registerSystem(ipcMain) + registerCocreate(ipcMain) log.info('All IPC modules registered') createWindow() @@ -66,8 +78,27 @@ app.on('window-all-closed', () => { app.on('before-quit', async () => { // 停止 ainovel 进程并清理运行时同步定时器 - const { stopAinovelProcess } = require('./ipc/writing') - if (typeof stopAinovelProcess === 'function') { - await stopAinovelProcess() + try { + const { stopAinovelProcess } = require('./ipc/writing') + if (typeof stopAinovelProcess === 'function') { + // 给一点时间优雅停写,但不无限卡住退出 + await Promise.race([ + stopAinovelProcess(), + new Promise((r) => setTimeout(r, 3000)), + ]) + } + } catch (e) { + log.warn('before-quit stopAinovelProcess failed', e) } }) + +// 确保进程退出时尽量结束子进程,让 concurrently/cmd 一并结束 +app.on('will-quit', () => { + try { + const { stopAinovelProcess } = require('./ipc/writing') + if (typeof stopAinovelProcess === 'function') { + // fire-and-forget best effort + stopAinovelProcess() + } + } catch {} +}) diff --git a/electron/preload.ts b/electron/preload.ts index 3a38385..9567b3d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -59,6 +59,7 @@ contextBridge.exposeInMainWorld('electronAPI', { fetchModels: (baseUrl: string, apiKey: string, protocol: string) => ipcRenderer.invoke('fetch-models', baseUrl, apiKey, protocol), loadProviderConfig: () => ipcRenderer.invoke('load-provider-config'), saveProviderConfig: (config: any) => ipcRenderer.invoke('save-provider-config', config), + applyProviderToBook: (bookId?: string) => ipcRenderer.invoke('apply-provider-to-book', bookId), // 全局配置 saveConfigValue: (key: string, value: any) => ipcRenderer.invoke('save-config-value', key, value), @@ -66,18 +67,20 @@ contextBridge.exposeInMainWorld('electronAPI', { // 快照和状态 getSnapshot: (bookId?: string) => ipcRenderer.invoke('get-snapshot', bookId), - getEvents: () => ipcRenderer.invoke('get-events'), + getEvents: (bookId?: string) => ipcRenderer.invoke('get-events', bookId), clearEvents: () => ipcRenderer.invoke('clear-events'), - readChapter: (chapterNum: number) => ipcRenderer.invoke('read-chapter', chapterNum), - listChapters: () => ipcRenderer.invoke('list-chapters'), + readChapter: (chapterNum: number, bookId?: string) => ipcRenderer.invoke('read-chapter', chapterNum, bookId), + listChapters: (bookId?: string) => ipcRenderer.invoke('list-chapters', bookId), // 创作控制 startWriting: (prompt: string, bookId?: string) => ipcRenderer.invoke('start-writing', prompt, bookId), createBookAuto: (premise: string, style?: string) => ipcRenderer.invoke('create-book-auto', premise, style), resumeWriting: (bookId: string) => ipcRenderer.invoke('resume-writing', bookId), - sendInput: (text: string) => ipcRenderer.invoke('send-input', text), + sendInput: (text: string, bookId?: string) => ipcRenderer.invoke('send-input', text, bookId), pauseWriting: () => ipcRenderer.invoke('pause-writing'), stopWriting: () => ipcRenderer.invoke('stop-writing'), + cocreateGetContext: (bookId?: string) => ipcRenderer.invoke('cocreate-get-context', bookId), + cocreateChat: (payload: any) => ipcRenderer.invoke('cocreate-chat', payload), // 诊断 runDiag: () => ipcRenderer.invoke('run-diag'), diff --git a/package-lock.json b/package-lock.json index 5cf45f4..67ce16d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -669,474 +669,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -3525,7 +3057,7 @@ }, "node_modules/better-sqlite3": { "version": "12.11.1", - "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", "hasInstallScript": true, "license": "MIT", @@ -5057,50 +4589,6 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/escalade": { "version": "3.2.0", "dev": true, diff --git a/src/App.css b/src/App.css index e3ae8c0..3ba4e20 100644 --- a/src/App.css +++ b/src/App.css @@ -258,6 +258,21 @@ html, body, #root { border-radius: var(--radius); min-width: 400px; max-width: 700px; max-height: 80vh; overflow-y: auto; padding: 20px; } +.modal-content.modal-lg { + max-width: min(960px, 96vw); + width: min(960px, 96vw); + max-height: 94vh; + height: min(820px, 94vh); + overflow: hidden; + display: flex; + flex-direction: column; +} +.modal-content.modal-lg .modal-close { + float: none; + position: absolute; + top: 12px; + right: 14px; +} .modal-title { font-size: 16px; font-weight: bold; color: var(--color-accent); margin-bottom: 16px; font-family: var(--font-mono); diff --git a/src/components/BookCard.tsx b/src/components/BookCard.tsx index 90482f2..67fe010 100644 --- a/src/components/BookCard.tsx +++ b/src/components/BookCard.tsx @@ -8,9 +8,10 @@ interface BookCardProps { onClick: () => void onEdit: (e: React.MouseEvent) => void onDelete: (e: React.MouseEvent) => void + onOpenFolder: (e: React.MouseEvent) => void } -export default function BookCard({ book, viewMode, onClick, onEdit, onDelete }: BookCardProps) { +export default function BookCard({ book, viewMode, onClick, onEdit, onDelete, onOpenFolder }: BookCardProps) { return (
{book.name}
+
- +
) } diff --git a/src/components/BookNavSidebar.tsx b/src/components/BookNavSidebar.tsx index 8b239cf..01fe739 100644 --- a/src/components/BookNavSidebar.tsx +++ b/src/components/BookNavSidebar.tsx @@ -40,11 +40,16 @@ export default function BookNavSidebar({ bookId }: { bookId: string }) {
{items.map((item) => { const isActive = (() => { - if (location.pathname === item.path) return true - if (item.path === `/books/${bookId}`) return false - const locParts = location.pathname.split('/') - const itemParts = item.path.split('/') - return itemParts.every((part, i) => part === locParts[i]) + // 导出管理是弹窗,没有路由,绝不能因为 path 为空误判为高亮 + if (item.action === 'pushExport' || !item.path) return false + // 导航项 path 可能带 query(如 workspace?mode=writing),只按 pathname 匹配 + const itemPath = item.path.split('?')[0] + const pathname = location.pathname + if (pathname === itemPath) return true + if (pathname.startsWith(itemPath + '/')) return true + // 章节详情页归入「书籍简介/章节」 + if (itemPath.endsWith('/intro') && pathname.startsWith(`/books/${bookId}/chapters`)) return true + return false })() return (
void } +type ChatRole = 'system' | 'user' | 'assistant' + +interface ChatMessage { + role: ChatRole + content: string +} + +const PHASE_LABEL: Record = { + init: '初始化', + premise: '设定/前提', + outline: '大纲规划', + writing: '正文写作', + complete: '已完成', +} + +function phaseLabel(phase?: string) { + const key = String(phase || '').trim() + return PHASE_LABEL[key] || key || '未知' +} + + +function stripVisibleProtocol(text: string): string { + return String(text || '') + .replace(/<\/?\s*(?:reply|draft|ready|suggestions)\s*>/gi, '') + .replace(/^\s*<\/?(?:reply|draft|ready|suggestions)>\s*$/gim, '') + .replace(/[((]\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s*更新\s*[))]/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function isMetaBriefDoc(text: string): boolean { + const s = String(text || '') + const mention = (s.match(/\b(?:reply|draft|ready|suggestions)\b/gi) || []).length + const meta = [ + /我们(?:可以|应该|需要)在\s*reply/i, + /draft\s*应该/i, + /ready\s*(?:可能|还是|为)?\s*false/i, + /更新后的\s*brief/i, + /作为助手,需要以多轮对话/i, + /输出格式/i, + ].filter((re) => re.test(s)).length + const hasStory = + /^#{1,3}\s+后续走向/m.test(s) || + /^#{1,3}\s+前三章/m.test(s) || + /^#{1,3}\s+关键转折/m.test(s) || + /^#{1,3}\s+第一卷/m.test(s) + return meta >= 2 || (mention >= 5 && !hasStory) +} + +function sanitizeBriefForDisplay(text: string): string { + let s = stripVisibleProtocol(text) + if (!s) return '' + if (isMetaBriefDoc(s)) return '' + const m = /^(#{1,3}\s+.+)$/m.exec(s) + if (m && m.index > 0) { + const head = s.slice(0, m.index) + const noisy = head + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .every((l) => + l === '---' || + /^(?:收到|好的|明白)/.test(l) || + /完整\s*brief|检查结果|字数标准已全部更新|旧标准已全部删除|请把.+写入右侧|reply|draft|ready/.test(l), + ) + if (noisy) s = s.slice(m.index) + } + s = s + .split(/\r?\n/) + .filter((line) => { + const t = line.trim() + if (!t) return true + if (t === '---') return false + if (/^(?:收到|好的|明白)[。.!!]/.test(t)) return false + if (/以下为(?:检查结果|更新后|完整\s*brief)/.test(t)) return false + if (/字数标准已全部更新|旧标准已全部删除/.test(t)) return false + if (/请把.{0,20}写入右侧\s*brief/.test(t)) return false + if (/暂时不要(?:开始)?创作正文/.test(t)) return false + if (/我们(?:可以|应该|需要)在\s*reply/i.test(t)) return false + if (/\bdraft\b.*应该|\bready\b.*false|\bsuggestions\b/i.test(t)) return false + if (/输出格式|多轮对话形式|作为助手/.test(t)) return false + return true + }) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + if (isMetaBriefDoc(s)) return '' + return s +} + +function looksLikeProtocolLeak(text: string): boolean { + return /<\/?\s*(?:reply|draft|ready|suggestions)\s*>/i.test(String(text || '')) +} + +/** display name helper: avoid premise-as-title */ +function displayNovelName(name?: string, premise?: string): string { + const n = String(name || '').trim() + if (!n) return '未命名共创' + if (n === '未命名共创' || n === '共创规划中') return n + if (n === '未命名' || n === '未定书名') return '未命名共创' + if (n.length > 40) return '未命名共创' + const p = String(premise || '').replace(/\s+/g, ' ').trim() + const bare = n.replace(/[…\.]+$/, '') + if (p && bare.length >= 12 && (p === bare || p.startsWith(bare))) return '未命名共创' + return n +} + +function compactStatusLines(snap: UISnapshot, summaryText?: string): string[] { + const lines: string[] = [] + const name = displayNovelName(snap.novelName, snap.premise) + lines.push(`书名:《${name}》`) + lines.push(`阶段:${phaseLabel(snap.phase)}${snap.flow ? ` · ${snap.flow}` : ''}`) + const completed = Number(snap.completedCount || 0) + const words = Number(snap.totalWordCount || 0) + lines.push(`进度:已完成 ${completed} 章,约 ${words.toLocaleString()} 字`) + + // summary 优先取短句,避免把 premise 按行炸开 + const premise = String(snap.premise || '').replace(/\s+/g, ' ').trim() + if (premise) lines.push(`前提:${premise.length > 120 ? premise.slice(0, 120) + '…' : premise}`) + + if (summaryText) { + const extra = summaryText + .split(/\r?\n/) + .map((l) => l.replace(/^\-\s*/, '').trim()) + .filter(Boolean) + .filter((l) => !l.startsWith('书名') && !l.startsWith('阶段') && !l.startsWith('进度') && !l.startsWith('前提')) + .slice(0, 3) + for (const e of extra) { + const one = e.replace(/\s+/g, ' ') + lines.push(one.length > 100 ? one.slice(0, 100) + '…' : one) + } + } + return lines +} + export default function CoCreateModal({ onClose }: CoCreateModalProps) { const toggleCoCreate = useUIStore((s) => s.toggleCoCreate) const handleClose = onClose || toggleCoCreate - const setInputValue = useUIStore((s) => s.setInputValue) + const addToast = useUIStore((s) => s.addToast) + const sendInput = useWritingStore((s) => s.sendInput) + const pauseWriting = useWritingStore((s) => s.pauseWriting) + const stopWriting = useWritingStore((s) => s.stopWriting) + const resumeWriting = useWritingStore((s) => s.resumeWriting) + const activeBookId = useAppStore((s) => s.activeBookId) + const snapshot = useBookStore((s) => s.snapshot) + const refreshSnapshot = useBookStore((s) => s.refreshSnapshot) + + const [preparing, setPreparing] = useState(true) + const [prepareNote, setPrepareNote] = useState('正在暂停写作并读取小说状态…') + const [storySummary, setStorySummary] = useState('') + const [messages, setMessages] = useState([]) + const [history, setHistory] = useState<{ role: string; content: string }[]>([]) + const [draftBrief, setDraftBrief] = useState('') + const [ready, setReady] = useState(false) + const [suggestions, setSuggestions] = useState([]) + const [input, setInput] = useState('') + const [chatting, setChatting] = useState(false) + const [finishing, setFinishing] = useState(false) + const chatEndRef = useRef(null) + + const statusLines = useMemo( + () => compactStatusLines(snapshot, storySummary), + [snapshot, storySummary], + ) + + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, chatting]) - const suggestions = [ - '我想写一部现代都市背景的悬疑小说', - '主角是一名退役刑警,性格沉稳', - '我希望故事节奏紧凑,每章都有反转', - ] + // 打开:暂停写作 + 读状态 + kickoff 共创助手 + useEffect(() => { + let alive = true + ;(async () => { + try { + const snap = useBookStore.getState().snapshot + const running = snap.runtimeState === 'running' || !!snap.isRunning + if (running) { + setPrepareNote('正在暂停当前创作…') + await pauseWriting() + await new Promise((r) => setTimeout(r, 250)) + await stopWriting() + } else { + await stopWriting() + } + await refreshSnapshot() + + const api = window.electronAPI + if (!api?.cocreateGetContext || !api?.cocreateChat) { + if (alive) { + setPrepareNote('共创接口未就绪,请重启应用后再试') + setPreparing(false) + } + return + } + + setPrepareNote('正在读取当前小说状态…') + const ctx = await api.cocreateGetContext(activeBookId || undefined) + if (!alive) return + setStorySummary(ctx?.summary || '') + setMessages([ + { + role: 'system', + content: '已暂停创作,进入阶段共创。AI 会先检查当前进度,再和你一起规划后续方向。共创结束后再开始写作。', + }, + ]) + + setPrepareNote('正在让共创助手检查小说状态…') + setChatting(true) + const kick = await api.cocreateChat({ + bookId: activeBookId || undefined, + history: [], + kickoff: true, + }) + if (!alive) return + if (kick?.error) { + setMessages((prev) => [ + ...prev, + { role: 'system', content: `状态检查失败:${kick.error}` }, + ]) + setPrepareNote('可直接输入你的方向,再点发送讨论') + } else { + const opener = ctx?.opener || '我先暂停一下,想和你一起规划接下来的走向。' + // 模型侧 history 需要 opener + assistant reply + const kickMsg = stripVisibleProtocol(kick.message || '') + const kickDraft = sanitizeBriefForDisplay(kick.draft || '') + const kickSugs = (Array.isArray(kick.suggestions) ? kick.suggestions : []) + .map((s: string) => stripVisibleProtocol(s)) + .filter(Boolean) + setHistory([ + { role: 'user', content: opener }, + { role: 'assistant', content: kickMsg }, + ]) + setMessages((prev) => [ + ...prev, + { role: 'assistant', content: kickMsg || '已完成状态检查,可以开始共创。' }, + ]) + if (kickDraft) setDraftBrief(kickDraft) + setReady(!!kick.ready) + setSuggestions(kickSugs) + if (kick.summary) setStorySummary(kick.summary) + setPrepareNote(kick.ready ? '方向已较清晰,可继续讨论或完成并开始写作' : '可继续对话完善方向') + } + } catch (e: any) { + if (alive) { + setMessages([{ role: 'system', content: `共创初始化失败:${e?.message || e}` }]) + setPrepareNote('初始化失败,仍可手动输入方向') + } + } finally { + if (alive) { + setChatting(false) + setPreparing(false) + } + } + })() + return () => { + alive = false + } + }, [activeBookId, pauseWriting, stopWriting, refreshSnapshot]) + + const sendChat = async (text: string) => { + const value = text.trim() + if (!value || chatting || finishing || preparing) return + const api = window.electronAPI + if (!api?.cocreateChat) { + addToast({ id: Date.now(), message: '共创接口不可用,请重启应用', type: 'error' }) + return + } + + setInput('') + setChatting(true) + setMessages((prev) => [...prev, { role: 'user', content: value }]) + try { + const resp = await api.cocreateChat({ + + bookId: activeBookId || undefined, + history, + userText: value, + prevDraft: draftBrief, + }) + if (resp?.error) { + setMessages((prev) => [...prev, { role: 'system', content: `共创失败:${resp.error}` }]) + return + } + const msg = stripVisibleProtocol(resp.message || '') + const draft = sanitizeBriefForDisplay(resp.draft || '') + const sugs = (Array.isArray(resp.suggestions) ? resp.suggestions : []) + .map((s: string) => stripVisibleProtocol(s)) + .filter(Boolean) + // 若后端仍泄漏协议,前端兜底成可读提示 + const safeMsg = looksLikeProtocolLeak(resp.message || '') + ? (msg || '本轮已更新右侧后续方向 brief。你可继续补充,或确认后开始写作。') + : (msg || '本轮已更新右侧后续方向 brief。') + setHistory((prev) => [ + ...prev, + { role: 'user', content: value }, + { role: 'assistant', content: safeMsg }, + ]) + setMessages((prev) => [...prev, { role: 'assistant', content: safeMsg }]) + if (draft) setDraftBrief(draft) + setReady(!!resp.ready) + setSuggestions(sugs) + if (resp.summary) setStorySummary(resp.summary) + setPrepareNote(resp.ready ? '方向已较清晰,可完成并开始写作' : '可继续对话完善方向') + } catch (e: any) { + setMessages((prev) => [...prev, { role: 'system', content: `共创异常:${e?.message || e}` }]) + } finally { + setChatting(false) + } + } + + const finishAndStart = async () => { + if (finishing || chatting) return + const brief = draftBrief.trim() || input.trim() + if (!brief) { + addToast({ id: Date.now(), message: '还没有可执行的后续方向,请先对话完善', type: 'error' }) + return + } + if (!activeBookId) { + addToast({ id: Date.now(), message: '没有当前书籍,无法开始写作', type: 'error' }) + return + } + + setFinishing(true) + try { + // 确保停机后写入 pending_steer,再 resume + await stopWriting() + const snap = useBookStore.getState().snapshot + const payload = + `[阶段规划] 阶段共创已完成,请按下面的后续方向 brief 落地并继续创作。\n` + + `当前阶段:${phaseLabel(snap.phase)};已完成 ${Number(snap.completedCount || 0)} 章。\n\n` + + `${brief}` + + const saved = await sendInput(payload, activeBookId) + if (!saved) { + addToast({ id: Date.now(), message: '共创结果保存失败', type: 'error' }) + return + } + await new Promise((r) => setTimeout(r, 200)) + const resumed = await resumeWriting(activeBookId) + if (resumed.ok) { + addToast({ + id: Date.now(), + message: '共创结束,已按新方向开始写作', + type: 'success', + }) + } + // 失败时 store 已 toast 真实错误,不再二次模糊提示 + handleClose() + } finally { + setFinishing(false) + } + } return (
e.stopPropagation()} - style={{ minWidth: 600, minHeight: 400, display: 'flex', flexDirection: 'column' }} + style={{ + width: 'min(960px, 96vw)', + maxWidth: '96vw', + height: 'min(820px, 94vh)', + maxHeight: '94vh', + minHeight: 560, + display: 'flex', + flexDirection: 'column', + gap: 0, + overflow: 'hidden', + // 覆盖全局 .modal-content 的 overflow-y:auto / max-width:700,避免底栏被内容顶掉 + overflowY: 'hidden', + paddingBottom: 12, + position: 'relative', + }} > - -
共创规划
+ + + {/* Header */} +
+
阶段共创
+
+ 先暂停写作 → 弹窗内多轮共创 → 确认后再开始写作 +
+
+ {/* Body: two columns, must shrink inside modal */}
-
- ✎ AI:你好!我来帮你规划小说创作。请告诉我你的想法——比如题材、背景、主角设定,或者任何你感兴趣的元素。 -
-
- 你可以按数字键 1/2/3 快速填入建议,或者直接输入你的想法。 -
+ {/* Left: chat */} +
+
+ {messages.length === 0 && ( +
{preparing ? prepareNote : '等待共创助手…'}
+ )} + {messages.map((m, i) => ( +
+
+ {m.role === 'user' ? '你' : m.role === 'assistant' ? '共创助手' : '系统'} +
+
+ {m.content} +
+
+ ))} + {chatting &&
共创助手思考中…
} +
+
- {/* 建议 */} -
-
建议方向:
- {suggestions.map((s, i) => ( - /* index acceptable: static constant array, never changes */ + {suggestions.length > 0 && (
setInputValue(s)} + style={{ + flex: '0 0 auto', + display: 'flex', + flexWrap: 'wrap', + gap: 6, + maxHeight: 72, + overflow: 'auto', + }} > - [{i + 1}] {s} + {suggestions.map((s, i) => ( + + ))} +
+ )} + +