返回 oh-my-ppt
thinking-handlers.ts
根目录 / src / main / ipc / thinking / thinking-handlers.ts
1 import { ipcMain, BrowserWindow, shell } from 'electron'
2 import path from 'path'
3 import fs from 'fs'
4 import log from 'electron-log/main.js'
5 import type { IpcContext } from '../context'
6 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../../config/model-config-utils'
7 import {
8 createWorkspace,
9 deleteWorkspace,
10 readWorkspace,
11 scanLatestWorkspace,
12 scanWorkspaceList,
13 resolveThinkingDir,
14 replaceThinkingPageOutline,
15 writeThinkingMd,
16 writeMessagesList
17 } from '../../thinking/workspace'
18 import {
19 extractPendingImageTextSources,
20 prepareMultipleSources
21 } from '../../thinking/source-prepare'
22 import { invalidateRuntime, runThinkingChat } from '../../thinking/thinking-agent'
23 import { buildThinkingSourceBrief } from '../../thinking/source-brief'
24 import { buildThinkingSourcePlan } from '../../thinking/source-plan'
25 import { normalizeFontSelection } from '@shared/generation'
26 import type {
27 ThinkingChatMessage,
28 ThinkingPageOutlineUpdate,
29 ThinkingPrepareGenerationResult
30 } from '@shared/thinking'
31
32 async function updateSourcesManifest(
33 thinkingDir: string,
34 sources: Array<{ id: string; name: string; kind: string; fileName: string }>
35 ): Promise<void> {
36 const manifestPath = path.join(thinkingDir, 'sources.json')
37 let existing: Array<{ id: string; name: string; kind: string; fileName: string }> = []
38 try {
39 const raw = await fs.promises.readFile(manifestPath, 'utf-8')
40 const parsed = JSON.parse(raw)
41 if (Array.isArray(parsed)) {
42 existing = parsed.filter((item) => item && typeof item === 'object')
43 }
44 } catch {
45 existing = []
46 }
47
48 const byId = new Map(existing.map((item) => [item.id, item]))
49 for (const source of sources) {
50 byId.set(source.id, source)
51 }
52 await fs.promises.writeFile(
53 manifestPath,
54 JSON.stringify(Array.from(byId.values()), null, 2),
55 'utf-8'
56 )
57 }
58
59 async function removeSourceFromManifest(
60 thinkingDir: string,
61 sourceId: string
62 ): Promise<{
63 removed: boolean
64 fileName?: string
65 }> {
66 const manifestPath = path.join(thinkingDir, 'sources.json')
67 let existing: Array<{ id: string; name: string; kind: string; fileName: string }> = []
68 try {
69 const raw = await fs.promises.readFile(manifestPath, 'utf-8')
70 const parsed = JSON.parse(raw)
71 if (Array.isArray(parsed)) {
72 existing = parsed.filter((item) => item && typeof item === 'object')
73 }
74 } catch {
75 existing = []
76 }
77
78 const target = existing.find((item) => item.id === sourceId)
79 if (!target) return { removed: false }
80 await fs.promises.writeFile(
81 manifestPath,
82 JSON.stringify(
83 existing.filter((item) => item.id !== sourceId),
84 null,
85 2
86 ),
87 'utf-8'
88 )
89 return { removed: true, fileName: target.fileName }
90 }
91
92 function parseThinkingAssetPath(content: string): string {
93 const match = content.match(/^- thinkingAssetPath:\s*(.+)$/m)
94 return match?.[1]?.trim() || ''
95 }
96
97 async function removeCopiedSourceFiles(thinkingDir: string, fileName: string): Promise<void> {
98 const sourcePath = path.join(thinkingDir, 'sources', fileName)
99 let imageAssetPath = ''
100 try {
101 const content = await fs.promises.readFile(sourcePath, 'utf-8')
102 imageAssetPath = parseThinkingAssetPath(content)
103 } catch {
104 imageAssetPath = ''
105 }
106
107 await fs.promises.rm(sourcePath, { force: true })
108 if (imageAssetPath) {
109 const assetsDir = path.join(thinkingDir, 'assets')
110 const resolvedAssetPath = path.resolve(imageAssetPath)
111 const relative = path.relative(assetsDir, resolvedAssetPath)
112 if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
113 await fs.promises.rm(resolvedAssetPath, { force: true })
114 }
115 }
116 }
117
118 function parseTopicFromThinkingMd(thinkingMd: string): string {
119 // Try "## Topic: xxx" (inline) first, then "## Topic\nxxx" (next line)
120 const inline = thinkingMd.match(/^##\s*Topic\s*:\s*(.+)/m)
121 if (inline) return inline[1].trim()
122 const newline = thinkingMd.match(/^##\s*Topic\s*\n\s*(.+)/m)
123 return newline ? newline[1].trim() : ''
124 }
125
126 function parsePageCountFromThinkingMd(thinkingMd: string): number {
127 const matches = thinkingMd.match(/^##\s*Page\s+\d+\s*:/gm)
128 return matches ? matches.length : 0
129 }
130
131 function readMarkdownSectionBlock(markdown: string, heading: string): string {
132 const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
133 const inline = markdown.match(new RegExp(`^##\\s*${escaped}\\s*:\\s*(.+)`, 'm'))
134 if (inline) return inline[1].trim()
135 const block = markdown.match(
136 new RegExp(`^##\\s*${escaped}\\s*\\n([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, 'm')
137 )
138 return block?.[1]?.trim() || ''
139 }
140
141 function parseFontFromThinkingMd(thinkingMd: string): unknown {
142 const match = thinkingMd.match(/^##\s*Font\s*\n\s*(.+)/m)
143 if (!match) return { mode: 'auto' }
144 const fontText = match[1].trim().toLowerCase()
145 if (fontText === 'auto') return { mode: 'auto' }
146 // For now, if the user specified fonts, try to parse as JSON
147 try {
148 return JSON.parse(match[1].trim())
149 } catch {
150 return { mode: 'auto' }
151 }
152 }
153
154 export function registerThinkingHandlers(ctx: IpcContext): void {
155 const { resolveStoragePath } = ctx
156
157 ipcMain.handle('thinking:createWorkspace', async () => {
158 const storagePath = await resolveStoragePath()
159 return createWorkspace(storagePath)
160 })
161
162 ipcMain.handle('thinking:getWorkspace', async (_event, thinkingId: string) => {
163 const storagePath = await resolveStoragePath()
164 return readWorkspace(storagePath, thinkingId)
165 })
166
167 ipcMain.handle('thinking:getLatestWorkspace', async () => {
168 const storagePath = await resolveStoragePath()
169 const latest = await scanLatestWorkspace(storagePath)
170 if (!latest) return null
171 return readWorkspace(storagePath, latest.thinkingId)
172 })
173
174 ipcMain.handle('thinking:listWorkspaces', async (_event, payload?: { limit?: unknown }) => {
175 const storagePath = await resolveStoragePath()
176 const limit = Math.max(1, Math.min(100, Math.floor(Number(payload?.limit) || 50)))
177 return scanWorkspaceList(storagePath, limit)
178 })
179
180 ipcMain.handle('thinking:deleteWorkspace', async (_event, thinkingId: string) => {
181 const storagePath = await resolveStoragePath()
182 await deleteWorkspace(storagePath, thinkingId)
183 invalidateRuntime(thinkingId)
184 return { success: true }
185 })
186
187 ipcMain.handle('thinking:revealWorkspace', async (_event, thinkingId: string) => {
188 const storagePath = await resolveStoragePath()
189 await readWorkspace(storagePath, thinkingId)
190 const dir = resolveThinkingDir(storagePath, thinkingId)
191 const result = await shell.openPath(dir)
192 if (result) throw new Error(result)
193 return { success: true }
194 })
195
196 ipcMain.handle(
197 'thinking:updatePageOutline',
198 async (_event, payload: { thinkingId: string; page: ThinkingPageOutlineUpdate }) => {
199 const thinkingId = String(payload?.thinkingId || '').trim()
200 if (!thinkingId || !payload?.page) {
201 throw new Error('Invalid page outline update')
202 }
203
204 const storagePath = await resolveStoragePath()
205 const workspace = await readWorkspace(storagePath, thinkingId)
206 const dir = resolveThinkingDir(storagePath, thinkingId)
207 const thinkingMd = replaceThinkingPageOutline(workspace.thinkingMd, payload.page)
208 await writeThinkingMd(dir, thinkingMd)
209 invalidateRuntime(thinkingId)
210
211 log.info('[thinking] page outline updated', {
212 thinkingId,
213 pageNumber: payload.page.pageNumber
214 })
215
216 return { success: true, thinkingMd }
217 }
218 )
219
220 ipcMain.handle(
221 'thinking:uploadSources',
222 async (
223 _event,
224 payload: { thinkingId: string; files: Array<{ path: string; name?: string }> }
225 ) => {
226 const { thinkingId, files } = payload
227 const storagePath = await resolveStoragePath()
228 await readWorkspace(storagePath, thinkingId)
229 const dir = resolveThinkingDir(storagePath, thinkingId)
230
231 const filePaths = files
232 .map((f) => f.path)
233 .filter((p) => typeof p === 'string' && p.trim().length > 0)
234
235 if (filePaths.length === 0) {
236 throw new Error('No valid file paths provided')
237 }
238 if (filePaths.length > 10) {
239 throw new Error('Upload at most 10 files at a time')
240 }
241
242 const prepared = await prepareMultipleSources(filePaths, dir)
243
244 const sources = prepared.map((p) => ({
245 id: p.id,
246 name: p.name,
247 kind: p.kind
248 }))
249 await updateSourcesManifest(
250 dir,
251 prepared.map((p) => ({
252 id: p.id,
253 name: p.name,
254 kind: p.kind,
255 fileName: path.basename(p.sourcePath)
256 }))
257 )
258
259 log.info('[thinking] sources uploaded', {
260 thinkingId,
261 count: sources.length,
262 kinds: sources.map((s) => s.kind)
263 })
264
265 return { sources }
266 }
267 )
268
269 ipcMain.handle(
270 'thinking:removeSource',
271 async (_event, payload: { thinkingId: string; sourceId: string }) => {
272 const thinkingId = String(payload?.thinkingId || '').trim()
273 const sourceId = String(payload?.sourceId || '').trim()
274 if (!thinkingId || !sourceId) throw new Error('Invalid source removal request')
275
276 const storagePath = await resolveStoragePath()
277 await readWorkspace(storagePath, thinkingId)
278 const dir = resolveThinkingDir(storagePath, thinkingId)
279 const removed = await removeSourceFromManifest(dir, sourceId)
280 if (removed.fileName) {
281 await removeCopiedSourceFiles(dir, removed.fileName)
282 }
283
284 log.info('[thinking] source removed', {
285 thinkingId,
286 sourceId,
287 removed: removed.removed
288 })
289
290 return { success: true, removed: removed.removed }
291 }
292 )
293
294 ipcMain.handle(
295 'thinking:chat',
296 async (
297 _event,
298 payload: {
299 thinkingId: string
300 modelConfigId?: string
301 userMessage: string
302 recentMessages?: ThinkingChatMessage[]
303 attachments?: ThinkingChatMessage['attachments']
304 }
305 ) => {
306 const { thinkingId, userMessage, recentMessages, attachments } = payload
307 const storagePath = await resolveStoragePath()
308 const dir = resolveThinkingDir(storagePath, thinkingId)
309
310 const workspace = await readWorkspace(storagePath, thinkingId)
311 const activeModel = await resolveModelConfigForTask(ctx, {
312 modelConfigId: payload.modelConfigId,
313 purpose: 'thinking:chat'
314 })
315 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
316 await extractPendingImageTextSources(dir, {
317 provider: activeModel.provider,
318 apiKey: activeModel.apiKey,
319 model: activeModel.model,
320 baseUrl: activeModel.baseUrl,
321 maxTokens: activeModel.maxTokens,
322 modelRuntime: ctx.modelRuntime,
323 modelTimeoutMs: modelTimeouts.document
324 })
325
326 const emitThinkingEvent = (event: {
327 type: string
328 toolName: string
329 summary: string
330 }): void => {
331 const windows = BrowserWindow.getAllWindows()
332 for (const win of windows) {
333 if (win.isDestroyed() || win.webContents.isDestroyed()) continue
334 try {
335 win.webContents.send('thinking:stream:thinking', { thinkingId, ...event })
336 } catch {
337 /* window may have closed */
338 }
339 }
340 }
341
342 const documentAttachments = Array.isArray(attachments)
343 ? attachments.filter((attachment) => attachment.kind !== 'image')
344 : []
345 const sourceBrief =
346 documentAttachments.length > 0
347 ? await buildThinkingSourceBrief({
348 thinkingDir: dir,
349 attachments: documentAttachments
350 })
351 : ''
352 const thinkingUserMessage = [userMessage, sourceBrief]
353 .filter((part) => part.trim().length > 0)
354 .join('\n\n')
355
356 const result = await runThinkingChat({
357 thinkingId,
358 thinkingDir: dir,
359 stage: workspace.stage,
360 thinkingMd: workspace.thinkingMd,
361 contextMd: workspace.contextMd,
362 sourcesDir: `${dir}/sources`,
363 userMessage: thinkingUserMessage,
364 recentMessages: Array.isArray(recentMessages)
365 ? recentMessages.slice(-8)
366 : workspace.messages.slice(-8),
367 provider: activeModel.provider,
368 apiKey: activeModel.apiKey,
369 model: activeModel.model,
370 baseUrl: activeModel.baseUrl,
371 maxTokens: activeModel.maxTokens,
372 modelRuntime: ctx.modelRuntime,
373 modelTimeoutMs: modelTimeouts.agent,
374 onThinkingEvent: emitThinkingEvent
375 })
376
377 await writeMessagesList(dir, [
378 ...workspace.messages,
379 {
380 role: 'user',
381 content: userMessage,
382 timestamp: Date.now(),
383 ...(Array.isArray(attachments) && attachments.length > 0 ? { attachments } : {})
384 },
385 {
386 role: 'assistant',
387 content: result.reply,
388 timestamp: Date.now()
389 }
390 ])
391
392 // Send final result with the full reply for typing animation
393 const windows = BrowserWindow.getAllWindows()
394 for (const win of windows) {
395 if (win.isDestroyed() || win.webContents.isDestroyed()) continue
396 try {
397 win.webContents.send('thinking:stream:end', {
398 thinkingId,
399 reply: result.reply,
400 thinkingMd: result.thinkingMd,
401 contextMd: result.contextMd,
402 stage: result.stage
403 })
404 } catch {
405 /* window may have closed */
406 }
407 }
408
409 log.info('[thinking] chat result', {
410 thinkingId,
411 stage: result.stage,
412 replyLength: result.reply.length
413 })
414
415 return result
416 }
417 )
418
419 ipcMain.handle('thinking:prepareGeneration', async (_event, payload: { thinkingId: string }) => {
420 const { thinkingId } = payload
421 const storagePath = await resolveStoragePath()
422 const dir = resolveThinkingDir(storagePath, thinkingId)
423
424 const workspace = await readWorkspace(storagePath, thinkingId)
425
426 const topic = parseTopicFromThinkingMd(workspace.thinkingMd)
427 const pageCount = parsePageCountFromThinkingMd(workspace.thinkingMd)
428 const styleText = readMarkdownSectionBlock(workspace.thinkingMd, 'Style')
429 const rawFont = parseFontFromThinkingMd(workspace.thinkingMd)
430 const fontSelection = normalizeFontSelection(rawFont)
431
432 if (!topic) {
433 throw new Error('thinking.md is missing ## Topic. Please complete the thinking brief first.')
434 }
435 if (pageCount < 1) {
436 throw new Error(
437 'thinking.md has no pages. Please create a page-by-page thinking brief first.'
438 )
439 }
440
441 const thinkingDocumentPath = path.join(dir, 'thinking.md')
442 const sourcePlan = buildThinkingSourcePlan(workspace.thinkingMd, thinkingDocumentPath)
443
444 const result: ThinkingPrepareGenerationResult = {
445 thinkingDocumentPath,
446 topic,
447 pageCount: Math.max(1, Math.min(500, pageCount)),
448 styleId: '',
449 styleText,
450 fontSelection,
451 ...(sourcePlan ? { sourcePlan } : {})
452 }
453
454 log.info('[thinking] prepareGeneration', {
455 thinkingId,
456 topic: result.topic,
457 pageCount: result.pageCount,
458 styleId: result.styleId,
459 fontMode: result.fontSelection.mode,
460 sourcePlanPages: result.sourcePlan?.pageSkeleton.length ?? 0
461 })
462
463 return result
464 })
465
466 log.info('[thinking] handlers registered')
467 }
468
468 lines TYPESCRIPT