| 1 | import { ipcMain, shell } from 'electron' |
| 2 | import fs from 'fs' |
| 3 | import path from 'path' |
| 4 | import * as cheerio from 'cheerio' |
| 5 | import { HumanMessage, SystemMessage } from '@langchain/core/messages' |
| 6 | import log from 'electron-log/main.js' |
| 7 | import { resolveModelTimeoutMs } from '@shared/model-timeout' |
| 8 | import type { SpeechLength, SpeechStyle } from '@shared/speech' |
| 9 | import type { IpcContext } from '../ipc/context' |
| 10 | import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../config/model-config-utils' |
| 11 | import { extractModelText, resolveModel } from '../agent-runtime/model' |
| 12 | import { readAppLocale, uiText } from '../config/locale-utils' |
| 13 | |
| 14 | const SPEECH_DIR = 'speech' |
| 15 | const SPEECH_SCRIPT_FILE = 'speech-script.md' |
| 16 | |
| 17 | function resolveSpeechScriptPath(projectDir: string): string { |
| 18 | return path.join(projectDir, SPEECH_DIR, SPEECH_SCRIPT_FILE) |
| 19 | } |
| 20 | |
| 21 | async function ensureSpeechDir(projectDir: string): Promise<void> { |
| 22 | await fs.promises.mkdir(path.join(projectDir, SPEECH_DIR), { recursive: true }) |
| 23 | } |
| 24 | |
| 25 | async function removeSpeechScript(projectDir: string): Promise<void> { |
| 26 | try { |
| 27 | await fs.promises.unlink(resolveSpeechScriptPath(projectDir)) |
| 28 | } catch { |
| 29 | // file may not exist |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | async function readSpeechScript(projectDir: string): Promise<string | null> { |
| 34 | try { |
| 35 | return await fs.promises.readFile(resolveSpeechScriptPath(projectDir), 'utf-8') |
| 36 | } catch { |
| 37 | return null |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | function normalizeHeadingText(value: string): string { |
| 42 | return value.replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim().toLowerCase() |
| 43 | } |
| 44 | |
| 45 | function normalizeSpeechPartHeading(part: string, pageNumber: number, title: string): string { |
| 46 | const heading = `## Slide ${pageNumber}: ${title || 'Untitled'}` |
| 47 | const body = part |
| 48 | .replace(/^#{1,6}\s+[^\r\n]*(?:\r?\n)+/, '') |
| 49 | .replace(/^\s*---\s*$/gm, '') |
| 50 | .trim() |
| 51 | return body ? `${heading}\n\n${body}` : heading |
| 52 | } |
| 53 | |
| 54 | function isSpeechSectionForPage(section: string, pageNumber: number, title: string): boolean { |
| 55 | const heading = normalizeHeadingText( |
| 56 | section.split(/\r?\n/).find((line) => line.trim().length > 0) || '' |
| 57 | ) |
| 58 | if (!heading) return false |
| 59 | if (new RegExp(`(?:第\\s*${pageNumber}\\s*页|slide\\s*${pageNumber}\\b)`, 'i').test(heading)) { |
| 60 | return true |
| 61 | } |
| 62 | const normalizedTitle = title.replace(/\s+/g, ' ').trim().toLowerCase() |
| 63 | return Boolean(normalizedTitle) && heading.includes(normalizedTitle) |
| 64 | } |
| 65 | |
| 66 | function upsertSpeechSection(existingScript: string | null, pageNumber: number, title: string, section: string): string { |
| 67 | const nextSection = section.trim() |
| 68 | if (!existingScript?.trim()) return nextSection |
| 69 | |
| 70 | const sections = existingScript |
| 71 | .split(/\n\s*---\s*\n/g) |
| 72 | .map((item) => item.trim()) |
| 73 | .filter(Boolean) |
| 74 | const index = sections.findIndex((item) => isSpeechSectionForPage(item, pageNumber, title)) |
| 75 | if (index >= 0) { |
| 76 | sections[index] = nextSection |
| 77 | } else { |
| 78 | sections.push(nextSection) |
| 79 | sections.sort((a, b) => { |
| 80 | const getPageNumber = (item: string): number => { |
| 81 | const heading = normalizeHeadingText(item.split(/\r?\n/)[0] || '') |
| 82 | const zh = heading.match(/第\s*(\d+)\s*页/) |
| 83 | const en = heading.match(/slide\s*(\d+)\b/i) |
| 84 | return Number(zh?.[1] || en?.[1] || Number.MAX_SAFE_INTEGER) |
| 85 | } |
| 86 | return getPageNumber(a) - getPageNumber(b) |
| 87 | }) |
| 88 | } |
| 89 | return sections.join('\n\n---\n\n') |
| 90 | } |
| 91 | |
| 92 | function extractTextFromHtml(html: string): string { |
| 93 | const $ = cheerio.load(html, { scriptingEnabled: false }) |
| 94 | $('script, style').remove() |
| 95 | return $('body').text().replace(/\s+/g, ' ').trim() |
| 96 | } |
| 97 | |
| 98 | function buildLengthInstruction(length: SpeechLength, isZh: boolean): string { |
| 99 | if (isZh) { |
| 100 | switch (length) { |
| 101 | case 'short': |
| 102 | return '本页演讲稿控制在100-150字以内(约1分钟),只提炼最核心的一两个要点,语言简练有力,不要展开细节。' |
| 103 | case 'long': |
| 104 | return '本页演讲稿写400-500字(约3-4分钟),充分展开论述,提供背景、数据、案例或类比,让听众深入理解每个要点。' |
| 105 | default: |
| 106 | return '本页演讲稿写200-300字(约2分钟),覆盖主要要点并适度展开,保持节奏流畅。' |
| 107 | } |
| 108 | } else { |
| 109 | switch (length) { |
| 110 | case 'short': |
| 111 | return 'Keep this slide to 100–150 words (~1 minute). Distill the one or two most essential points. Be crisp and punchy — no elaboration.' |
| 112 | case 'long': |
| 113 | return 'Write 400–500 words (~3–4 minutes). Fully develop the ideas with background context, data, examples, or analogies so the audience deeply understands each point.' |
| 114 | default: |
| 115 | return 'Write 200–300 words (~2 minutes). Cover the main points with moderate elaboration and maintain a smooth pace.' |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | function buildStyleInstruction(style: SpeechStyle, isZh: boolean, customStyle?: string): string { |
| 121 | if (style === 'custom') { |
| 122 | const fallback = isZh |
| 123 | ? '语气轻松自然,口语化,像和听众直接对话一样,亲切易懂。' |
| 124 | : 'Use a relaxed, conversational tone as if speaking directly to the audience. Keep it approachable and natural.' |
| 125 | return customStyle?.trim() || fallback |
| 126 | } |
| 127 | if (isZh) { |
| 128 | switch (style) { |
| 129 | case 'formal': |
| 130 | return [ |
| 131 | '采用正式、严谨的演讲风格,适合商务汇报、学术答辩或政务场合。', |
| 132 | '语言精准,措辞规范,句式完整,避免口语化、缩写或随意的表达。', |
| 133 | '每个要点层次分明,逻辑严密,体现专业深度与权威性。', |
| 134 | '开场可用数据或引言定调,结尾给出明确结论或建议。' |
| 135 | ].join('') |
| 136 | case 'storytelling': |
| 137 | return [ |
| 138 | '采用叙事驱动的演讲风格,用故事、场景或真实案例作为切入点,让听众产生画面感和代入感。', |
| 139 | '开场设置悬念或情境(谁、在哪、发生了什么),通过情节推进自然引出幻灯片的核心信息。', |
| 140 | '适当加入细节、对话或情感转折,让内容有温度、有记忆点。', |
| 141 | '结尾将故事与要点收拢,给听众留下深刻印象。' |
| 142 | ].join('') |
| 143 | default: |
| 144 | return [ |
| 145 | '采用轻松自然的对话风格,像和朋友聊天一样和听众交流,拉近距离感。', |
| 146 | '多用短句、口语化词汇和第一/二人称("我们"、"你可能会想……")。', |
| 147 | '适当加入反问或小幽默调动气氛,让内容易于接受和记忆。', |
| 148 | '避免过于书面化,保持真实、有人情味的语调。' |
| 149 | ].join('') |
| 150 | } |
| 151 | } else { |
| 152 | switch (style) { |
| 153 | case 'formal': |
| 154 | return [ |
| 155 | 'Use a formal, authoritative tone appropriate for business presentations, academic defenses, or official settings.', |
| 156 | 'Choose precise, professional vocabulary. Write in complete sentences. Avoid contractions, slang, or casual phrasing.', |
| 157 | 'Structure each point with clear logic — state the claim, support it with evidence or reasoning, and draw a conclusion.', |
| 158 | 'Open with a strong framing statement (a statistic, a quote, or a clear thesis) and close with a definitive takeaway or recommendation.' |
| 159 | ].join(' ') |
| 160 | case 'storytelling': |
| 161 | return [ |
| 162 | 'Use a narrative-driven style. Open each slide by dropping the audience into a scene, anecdote, or real-world case — set up who, where, and what happened.', |
| 163 | 'Let the story unfold naturally to reveal the slide\'s core insight, rather than stating it upfront.', |
| 164 | 'Include vivid details, dialogue snippets, or an emotional beat to make the content memorable and human.', |
| 165 | 'Close by tying the story back to the key point, leaving the audience with a lasting image or feeling.' |
| 166 | ].join(' ') |
| 167 | default: |
| 168 | return [ |
| 169 | 'Use a warm, conversational tone — speak to the audience like a knowledgeable colleague sharing insights, not a lecturer reciting facts.', |
| 170 | 'Prefer short sentences, contractions, and first/second-person language ("we", "you might be thinking…", "here\'s the thing").', |
| 171 | 'Occasionally pose a rhetorical question or light observation to keep the audience engaged.', |
| 172 | 'Keep it genuine and approachable — avoid overly formal or stiff phrasing.' |
| 173 | ].join(' ') |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | const activeSpeechGenerations = new Set<string>() |
| 179 | |
| 180 | export function registerSpeechHandlers(ctx: IpcContext): void { |
| 181 | ipcMain.handle('speech:generateScript', async (event, payload) => { |
| 182 | const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : '' |
| 183 | if (!sessionId) throw new Error('Session ID is required') |
| 184 | |
| 185 | const scope: 'all' | 'single' = payload?.scope === 'single' ? 'single' : 'all' |
| 186 | const currentPageId: string = |
| 187 | typeof payload?.currentPageId === 'string' ? payload.currentPageId.trim() : '' |
| 188 | const length: SpeechLength = |
| 189 | payload?.length === 'short' || payload?.length === 'long' ? payload.length : 'medium' |
| 190 | const style: SpeechStyle = |
| 191 | payload?.style === 'formal' || payload?.style === 'storytelling' || payload?.style === 'custom' |
| 192 | ? payload.style |
| 193 | : 'conversational' |
| 194 | const customStyle: string = |
| 195 | style === 'custom' && typeof payload?.customStyle === 'string' ? payload.customStyle : '' |
| 196 | |
| 197 | const locale = await readAppLocale(ctx) |
| 198 | const isZh = locale === 'zh' |
| 199 | |
| 200 | if (scope === 'single' && !currentPageId) { |
| 201 | throw new Error(uiText(locale, '单页模式需要提供当前页面 ID', 'currentPageId is required for single-page scope')) |
| 202 | } |
| 203 | |
| 204 | if (activeSpeechGenerations.has(sessionId)) { |
| 205 | throw new Error(uiText(locale, '正在生成中,请稍候', 'Generation already in progress')) |
| 206 | } |
| 207 | activeSpeechGenerations.add(sessionId) |
| 208 | |
| 209 | try { |
| 210 | const session = await ctx.db.getSession(sessionId) |
| 211 | if (!session) { |
| 212 | throw new Error(uiText(locale, '找不到会话', 'Session not found')) |
| 213 | } |
| 214 | |
| 215 | const pages = await ctx.db.listSessionPages(sessionId) |
| 216 | if (pages.length === 0) { |
| 217 | throw new Error(uiText(locale, '该会话没有幻灯片页面', 'No pages found in this session')) |
| 218 | } |
| 219 | |
| 220 | const projectDir = await ctx.resolveSessionProjectDir(sessionId) |
| 221 | |
| 222 | const filteredPages = |
| 223 | scope === 'single' && currentPageId ? pages.filter((p) => p.id === currentPageId) : pages |
| 224 | |
| 225 | if (filteredPages.length === 0) { |
| 226 | throw new Error(uiText(locale, '找不到指定页面', 'Specified page not found')) |
| 227 | } |
| 228 | |
| 229 | const slideContents: Array<{ pageNumber: number; title: string; text: string }> = [] |
| 230 | for (const p of filteredPages) { |
| 231 | if (!p.html_path) continue |
| 232 | const rawHtmlPath = path.isAbsolute(p.html_path) |
| 233 | ? p.html_path |
| 234 | : path.resolve(projectDir, p.html_path) |
| 235 | let safeHtmlPath: string |
| 236 | try { |
| 237 | safeHtmlPath = await ctx.assertPathInAllowedRoots({ |
| 238 | filePath: rawHtmlPath, |
| 239 | mode: 'read', |
| 240 | sessionId, |
| 241 | htmlOnly: true |
| 242 | }) |
| 243 | } catch { |
| 244 | log.warn('[speech] skipping page with unsafe htmlPath', { rawHtmlPath, projectDir }) |
| 245 | continue |
| 246 | } |
| 247 | try { |
| 248 | const html = await fs.promises.readFile(safeHtmlPath, 'utf-8') |
| 249 | const text = extractTextFromHtml(html) |
| 250 | slideContents.push({ |
| 251 | pageNumber: p.page_number, |
| 252 | title: p.title || '', |
| 253 | text: text || uiText(locale, '(本页主要为图片或视觉内容,请结合上下文发挥)', '(This slide is mainly visual; improvise based on context.)') |
| 254 | }) |
| 255 | } catch (err) { |
| 256 | log.warn('[speech] failed to read page html', { htmlPath: p.html_path, err }) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | if (slideContents.length === 0) { |
| 261 | throw new Error(uiText(locale, '没有可读取的幻灯片内容', 'No readable slide content found')) |
| 262 | } |
| 263 | |
| 264 | const modelConfig = await resolveModelConfigForTask(ctx, { |
| 265 | modelConfigId: payload?.modelConfigId, |
| 266 | purpose: 'speech:generateScript' |
| 267 | }) |
| 268 | const timeouts = await resolveGlobalModelTimeouts(ctx) |
| 269 | const timeoutMs = resolveModelTimeoutMs(timeouts['document'], 'document') |
| 270 | const model = resolveModel( |
| 271 | modelConfig.provider, |
| 272 | modelConfig.apiKey, |
| 273 | modelConfig.model, |
| 274 | modelConfig.baseUrl, |
| 275 | 0.7, |
| 276 | modelConfig.maxTokens, |
| 277 | ctx.modelRuntime |
| 278 | ) |
| 279 | |
| 280 | const lengthInstruction = buildLengthInstruction(length, isZh) |
| 281 | const styleInstruction = buildStyleInstruction(style, isZh, customStyle) |
| 282 | const total = slideContents.length |
| 283 | const sessionTitle = session.title || session.topic || (isZh ? '未命名' : 'Untitled') |
| 284 | |
| 285 | const scriptPath = resolveSpeechScriptPath(projectDir) |
| 286 | if (scope === 'all') { |
| 287 | // Full generation replaces the whole speech artifact. |
| 288 | await removeSpeechScript(projectDir) |
| 289 | } |
| 290 | |
| 291 | const systemPrompt = uiText( |
| 292 | locale, |
| 293 | `你是一位经验丰富的演讲稿撰写人,擅长将幻灯片内容转化为自然流畅、打动人心的演讲词。 |
| 294 | |
| 295 | **任务规则:** |
| 296 | - 每次仅为当前一页幻灯片生成演讲稿,不要提前引用后续页面内容。 |
| 297 | - 输出以 "## Slide N: {标题}" 开头,正文直接是演讲词,不要加任何说明性注释或括号提示。 |
| 298 | - 演讲词是演讲者直接开口说的话,用第一人称,不要写成旁白或摘要。 |
| 299 | - 不要逐字复读幻灯片上的文字,而是将关键信息转化为自然的口语表达,做到"讲"而非"念"。 |
| 300 | |
| 301 | **字数与时长:** |
| 302 | ${lengthInstruction} |
| 303 | |
| 304 | **演讲风格:** |
| 305 | ${styleInstruction} |
| 306 | |
| 307 | **页面衔接:** |
| 308 | 如提供了上一页的结尾内容,请在开头自然地加入过渡语句,使演讲整体连贯,不显突兀。`, |
| 309 | `You are an experienced speech writer who transforms slide content into natural, compelling spoken words. |
| 310 | |
| 311 | **Rules:** |
| 312 | - Generate speaker notes for the current slide only. Do not reference future slides. |
| 313 | - Begin your response with "## Slide N: {Title}", then deliver the speech directly — no meta-commentary, annotations, or bracketed notes. |
| 314 | - Write in first person as the speaker's actual spoken words, not a summary or narration. |
| 315 | - Do not read the slide verbatim. Translate key information into natural spoken language — the goal is to "tell", not "recite". |
| 316 | |
| 317 | **Length & Pacing:** |
| 318 | ${lengthInstruction} |
| 319 | |
| 320 | **Style:** |
| 321 | ${styleInstruction} |
| 322 | |
| 323 | **Transitions:** |
| 324 | If the previous slide's ending is provided, open with a smooth transition sentence that connects the two slides naturally.` |
| 325 | ) |
| 326 | |
| 327 | const scriptParts: string[] = [] |
| 328 | let prevEnding = '' |
| 329 | |
| 330 | for (let i = 0; i < slideContents.length; i++) { |
| 331 | const slide = slideContents[i] |
| 332 | const current = i + 1 |
| 333 | event.sender.send('speech:progress', { sessionId, current, total }) |
| 334 | |
| 335 | const contextPart = prevEnding |
| 336 | ? uiText(locale, `上一页结尾:${prevEnding}\n\n`, `Previous slide ending: ${prevEnding}\n\n`) |
| 337 | : '' |
| 338 | |
| 339 | const progressZh = total > 1 ? `【生成进度】${current} / ${total}\n` : '' |
| 340 | const progressEn = total > 1 ? `[Generation Progress] ${current} / ${total}\n` : '' |
| 341 | |
| 342 | const userPrompt = uiText( |
| 343 | locale, |
| 344 | `${contextPart}【演示文稿】${sessionTitle} |
| 345 | ${progressZh}【Slide】Slide ${slide.pageNumber} |
| 346 | 【本页标题】${slide.title || '(无标题)'} |
| 347 | |
| 348 | 【幻灯片文字内容】 |
| 349 | ${slide.text} |
| 350 | |
| 351 | 请为本页生成演讲稿。`, |
| 352 | `${contextPart}[Presentation] ${sessionTitle} |
| 353 | ${progressEn}[Slide] Slide ${slide.pageNumber} |
| 354 | [Slide Title] ${slide.title || '(no title)'} |
| 355 | |
| 356 | [Slide Text Content] |
| 357 | ${slide.text} |
| 358 | |
| 359 | Please generate the speaker script for this slide.` |
| 360 | ) |
| 361 | |
| 362 | log.info('[speech] generating slide', { sessionId, current, total }) |
| 363 | |
| 364 | const response = await model.invoke( |
| 365 | [new SystemMessage(systemPrompt), new HumanMessage(userPrompt)], |
| 366 | { signal: AbortSignal.timeout(timeoutMs) } |
| 367 | ) |
| 368 | const rawPart = extractModelText(response).trim() |
| 369 | if (!rawPart) { |
| 370 | throw new Error(uiText(locale, '模型返回为空', 'Model returned empty content')) |
| 371 | } |
| 372 | const part = normalizeSpeechPartHeading(rawPart, slide.pageNumber, slide.title) |
| 373 | scriptParts.push(part) |
| 374 | |
| 375 | prevEnding = part.slice(-100).replace(/\s+/g, ' ').trim() |
| 376 | } |
| 377 | |
| 378 | const script = |
| 379 | scope === 'single' |
| 380 | ? upsertSpeechSection( |
| 381 | await readSpeechScript(projectDir), |
| 382 | slideContents[0].pageNumber, |
| 383 | slideContents[0].title, |
| 384 | scriptParts[0] |
| 385 | ) |
| 386 | : scriptParts.join('\n\n---\n\n') |
| 387 | await ensureSpeechDir(projectDir) |
| 388 | await fs.promises.writeFile(scriptPath, script, 'utf-8') |
| 389 | |
| 390 | log.info('[speech] script saved', { sessionId, scriptPath }) |
| 391 | return { success: true } |
| 392 | } finally { |
| 393 | activeSpeechGenerations.delete(sessionId) |
| 394 | } |
| 395 | }) |
| 396 | |
| 397 | ipcMain.handle('speech:getScript', async (_event, payload) => { |
| 398 | const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : '' |
| 399 | if (!sessionId) throw new Error('Session ID is required') |
| 400 | |
| 401 | const projectDir = await ctx.resolveSessionProjectDir(sessionId) |
| 402 | const script = await readSpeechScript(projectDir) |
| 403 | return { success: true, script } |
| 404 | }) |
| 405 | |
| 406 | ipcMain.handle('speech:openScriptFile', async (_event, payload) => { |
| 407 | const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : '' |
| 408 | if (!sessionId) throw new Error('Session ID is required') |
| 409 | |
| 410 | const projectDir = await ctx.resolveSessionProjectDir(sessionId) |
| 411 | const scriptPath = resolveSpeechScriptPath(projectDir) |
| 412 | await fs.promises.access(scriptPath, fs.constants.R_OK) |
| 413 | shell.showItemInFolder(scriptPath) |
| 414 | return { success: true, path: scriptPath } |
| 415 | }) |
| 416 | |
| 417 | ipcMain.handle('speech:clearScript', async (_event, payload) => { |
| 418 | const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : '' |
| 419 | if (!sessionId) throw new Error('Session ID is required') |
| 420 | |
| 421 | const projectDir = await ctx.resolveSessionProjectDir(sessionId) |
| 422 | await removeSpeechScript(projectDir) |
| 423 | return { success: true } |
| 424 | }) |
| 425 | } |
| 426 |