| 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 |