返回 oh-my-ppt
handlers.ts
根目录 / src / main / speech / handlers.ts
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
426 lines TYPESCRIPT