返回 oh-my-ppt
workspace.ts
根目录 / src / main / thinking / workspace.ts
1 import path from 'path'
2 import fs from 'fs'
3 import { nanoid } from 'nanoid'
4 import log from 'electron-log/main.js'
5 import { normalizeThinkingAssistantReply, normalizeThinkingMessages } from './reply-normalizer'
6 import type {
7 ThinkingChatMessage,
8 ThinkingPageOutlineUpdate,
9 ThinkingStage,
10 ThinkingSource,
11 ThinkingWorkspace,
12 ThinkingWorkspaceListItem
13 } from '@shared/thinking'
14
15 const THINKING_ID_RE = /^[a-zA-Z0-9_-]{6,32}$/
16
17 export function assertValidThinkingId(id: string): void {
18 if (!THINKING_ID_RE.test(id)) {
19 throw new Error(`Invalid thinkingId: ${id}`)
20 }
21 }
22
23 export function resolveThinkingDir(storagePath: string, thinkingId: string): string {
24 return path.join(storagePath, 'thinking', thinkingId)
25 }
26
27 export function buildInitialThinkingMd(): string {
28 return `# Thinking Brief
29
30 ## Topic
31
32 ## Audience
33
34 ## Setting
35
36 ## Tone
37
38 ## Style
39
40 ## Font
41 auto
42
43 ## Page Count
44 0
45 `
46 }
47
48 export function buildInitialContextMd(stage: ThinkingStage = 'collect'): string {
49 return `## Stage: collect
50
51 ## User Intent
52
53 ## Confirmed Decisions
54
55 ## Open Questions
56
57 ## Created: ${new Date().toISOString()}
58 `.replace(/^## Stage:\s*collect/m, `## Stage: ${stage}`)
59 }
60
61 export async function createWorkspace(storagePath: string): Promise<ThinkingWorkspace> {
62 const thinkingId = nanoid()
63 const dir = resolveThinkingDir(storagePath, thinkingId)
64 const sourcesDir = path.join(dir, 'sources')
65 const assetsDir = path.join(dir, 'assets')
66
67 await fs.promises.mkdir(sourcesDir, { recursive: true })
68 await fs.promises.mkdir(assetsDir, { recursive: true })
69
70 const thinkingMd = buildInitialThinkingMd()
71 const contextMd = buildInitialContextMd('collect')
72
73 const thinkingMdPath = path.join(dir, 'thinking.md')
74 const contextMdPath = path.join(dir, 'context.md')
75
76 await fs.promises.writeFile(thinkingMdPath, thinkingMd, 'utf-8')
77 await fs.promises.writeFile(contextMdPath, contextMd, 'utf-8')
78
79 log.info(`[thinking] workspace created: ${thinkingId}`)
80
81 return {
82 thinkingId,
83 thinkingMd,
84 contextMd,
85 stage: 'collect',
86 sources: [],
87 messages: []
88 }
89 }
90
91 export async function readWorkspace(
92 storagePath: string,
93 thinkingId: string
94 ): Promise<ThinkingWorkspace> {
95 assertValidThinkingId(thinkingId)
96 const dir = resolveThinkingDir(storagePath, thinkingId)
97
98 const thinkingMdPath = path.join(dir, 'thinking.md')
99 const contextMdPath = path.join(dir, 'context.md')
100
101 if (!fs.existsSync(thinkingMdPath)) {
102 throw new Error(`Thinking workspace not found: ${thinkingId}`)
103 }
104
105 const [thinkingMd, contextMd] = await Promise.all([
106 fs.promises.readFile(thinkingMdPath, 'utf-8'),
107 fs.promises.readFile(contextMdPath, 'utf-8')
108 ])
109
110 const stage = parseStageFromContextMd(contextMd)
111 const [sources, messages] = await Promise.all([parseSourcesList(dir), readMessagesList(dir)])
112
113 return { thinkingId, thinkingMd, contextMd, stage, sources, messages }
114 }
115
116 export async function deleteWorkspace(storagePath: string, thinkingId: string): Promise<void> {
117 await readWorkspace(storagePath, thinkingId)
118 const dir = resolveThinkingDir(storagePath, thinkingId)
119 await fs.promises.rm(dir, { recursive: true, force: true })
120 log.info(`[thinking] workspace deleted: ${thinkingId}`)
121 }
122
123 export async function writeThinkingMd(dir: string, content: string): Promise<void> {
124 const filePath = path.join(dir, 'thinking.md')
125 await fs.promises.writeFile(filePath, content, 'utf-8')
126 }
127
128 function normalizeSingleLine(value: string): string {
129 return value.trim().replace(/\s+/g, ' ')
130 }
131
132 export function replaceThinkingPageOutline(
133 thinkingMd: string,
134 update: ThinkingPageOutlineUpdate
135 ): string {
136 const pageNumber = Math.floor(Number(update.pageNumber))
137 const title = normalizeSingleLine(update.title).slice(0, 200)
138 const role = normalizeSingleLine(update.role).slice(0, 80)
139 const objective = normalizeSingleLine(update.objective).slice(0, 1000)
140 const summary = update.summary.trim().slice(0, 5000)
141 const keyPoints = update.keyPoints
142 .map((point) => normalizeSingleLine(point).slice(0, 1000))
143 .filter(Boolean)
144 .slice(0, 20)
145
146 if (!Number.isInteger(pageNumber) || pageNumber < 1) {
147 throw new Error('Invalid page number')
148 }
149 if (!title || !role || !objective || !summary || keyPoints.length === 0) {
150 throw new Error('Page outline fields cannot be empty')
151 }
152
153 const headingRegex = new RegExp(`^##\\s*Page\\s+${pageNumber}\\s*:.+$`, 'm')
154 const headingMatch = thinkingMd.match(headingRegex)
155 if (!headingMatch || typeof headingMatch.index !== 'number') {
156 throw new Error(`Page ${pageNumber} was not found in thinking.md`)
157 }
158
159 const start = headingMatch.index
160 const remaining = thinkingMd.slice(start + headingMatch[0].length)
161 const nextHeadingMatch = remaining.match(/^##\s+.+$/m)
162 const end =
163 nextHeadingMatch && typeof nextHeadingMatch.index === 'number'
164 ? start + headingMatch[0].length + nextHeadingMatch.index
165 : thinkingMd.length
166 const section = [
167 `## Page ${pageNumber}: ${title}`,
168 `- Role: ${role}`,
169 `- Objective: ${objective}`,
170 '',
171 summary,
172 '',
173 ...keyPoints.map((point) => `- ${point}`)
174 ].join('\n')
175
176 return (
177 `${thinkingMd.slice(0, start).trimEnd()}\n\n${section}\n\n${thinkingMd
178 .slice(end)
179 .trimStart()}`.trimEnd() + '\n'
180 )
181 }
182
183 export async function writeContextMd(dir: string, content: string): Promise<void> {
184 const filePath = path.join(dir, 'context.md')
185 await fs.promises.writeFile(filePath, content, 'utf-8')
186 }
187
188 export async function writeMessagesList(
189 dir: string,
190 messages: ThinkingChatMessage[]
191 ): Promise<void> {
192 const filePath = path.join(dir, 'messages.json')
193 const normalized = normalizeThinkingMessages(messages)
194 await fs.promises.writeFile(filePath, JSON.stringify(normalized, null, 2), 'utf-8')
195 }
196
197 export async function scanLatestWorkspace(
198 storagePath: string
199 ): Promise<{ thinkingId: string; updatedAt: number } | null> {
200 const thinkingRoot = path.join(storagePath, 'thinking')
201 if (!fs.existsSync(thinkingRoot)) return null
202
203 const entries = await fs.promises.readdir(thinkingRoot, { withFileTypes: true })
204 const dirs = entries
205 .filter((e) => e.isDirectory() && THINKING_ID_RE.test(e.name))
206 .map((e) => path.join(thinkingRoot, e.name))
207
208 if (dirs.length === 0) return null
209
210 let latestDir = ''
211 let latestMtime = 0
212
213 for (const dir of dirs) {
214 const thinkingMdPath = path.join(dir, 'thinking.md')
215 if (!fs.existsSync(thinkingMdPath)) continue
216 const stat = await fs.promises.stat(thinkingMdPath)
217 if (stat.mtimeMs > latestMtime) {
218 latestMtime = stat.mtimeMs
219 latestDir = dir
220 }
221 }
222
223 if (!latestDir) return null
224
225 return {
226 thinkingId: path.basename(latestDir),
227 updatedAt: latestMtime
228 }
229 }
230
231 export async function scanWorkspaceList(
232 storagePath: string,
233 limit = 50
234 ): Promise<ThinkingWorkspaceListItem[]> {
235 const thinkingRoot = path.join(storagePath, 'thinking')
236 if (!fs.existsSync(thinkingRoot)) return []
237
238 const entries = await fs.promises.readdir(thinkingRoot, { withFileTypes: true })
239 const items: ThinkingWorkspaceListItem[] = []
240
241 for (const entry of entries) {
242 if (!entry.isDirectory() || !THINKING_ID_RE.test(entry.name)) continue
243
244 const dir = path.join(thinkingRoot, entry.name)
245 const thinkingMdPath = path.join(dir, 'thinking.md')
246 const contextMdPath = path.join(dir, 'context.md')
247 if (!fs.existsSync(thinkingMdPath)) continue
248
249 try {
250 const [thinkingMd, thinkingStat] = await Promise.all([
251 fs.promises.readFile(thinkingMdPath, 'utf-8'),
252 fs.promises.stat(thinkingMdPath)
253 ])
254 let contextMd = ''
255 let contextMtime = 0
256 try {
257 const [content, stat] = await Promise.all([
258 fs.promises.readFile(contextMdPath, 'utf-8'),
259 fs.promises.stat(contextMdPath)
260 ])
261 contextMd = content
262 contextMtime = stat.mtimeMs
263 } catch {
264 contextMd = ''
265 }
266
267 items.push({
268 thinkingId: entry.name,
269 updatedAt: Math.max(thinkingStat.mtimeMs, contextMtime),
270 topic: parseTopicFromThinkingMd(thinkingMd),
271 stage: parseStageFromContextMd(contextMd)
272 })
273 } catch (error) {
274 log.warn('[thinking] failed to scan workspace list item', {
275 thinkingId: entry.name,
276 message: error instanceof Error ? error.message : String(error)
277 })
278 }
279 }
280
281 return items.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, Math.max(1, limit))
282 }
283
284 function parseTopicFromThinkingMd(thinkingMd: string): string {
285 const inline = thinkingMd.match(/^##\s*Topic\s*:\s*(.+)/m)
286 if (inline) return inline[1].trim()
287 const newline = thinkingMd.match(/^##\s*Topic\s*\n\s*(.+)/m)
288 return newline ? newline[1].trim() : ''
289 }
290
291 export function parseStageFromContextMd(content: string): ThinkingStage {
292 const match = content.match(/^## Stage:\s*(\S+)/m)
293 if (!match) return 'collect'
294 const stage = match[1] as ThinkingStage
295 const validStages: ThinkingStage[] = ['collect', 'outline', 'draft', 'refine', 'ready']
296 return validStages.includes(stage) ? stage : 'collect'
297 }
298
299 export async function parseSourcesList(dir: string): Promise<ThinkingSource[]> {
300 const sourcesDir = path.join(dir, 'sources')
301 if (!fs.existsSync(sourcesDir)) return []
302
303 const entries = await fs.promises.readdir(sourcesDir, { withFileTypes: true })
304 const manifestByFileName = new Map<string, ThinkingSource>()
305 try {
306 const rawManifest = await fs.promises.readFile(path.join(dir, 'sources.json'), 'utf-8')
307 const parsed = JSON.parse(rawManifest)
308 if (Array.isArray(parsed)) {
309 for (const item of parsed) {
310 if (!item || typeof item !== 'object') continue
311 const record = item as Record<string, unknown>
312 const id = typeof record.id === 'string' ? record.id : ''
313 const name = typeof record.name === 'string' ? record.name : ''
314 const kind = typeof record.kind === 'string' ? record.kind : ''
315 const fileName = typeof record.fileName === 'string' ? record.fileName : ''
316 if (!id || !name || !fileName) continue
317 if (!['markdown', 'text', 'csv', 'docx', 'image'].includes(kind)) continue
318 manifestByFileName.set(fileName, {
319 id,
320 name,
321 kind: kind as ThinkingSource['kind']
322 })
323 }
324 }
325 } catch {
326 // Older workspaces do not have a manifest; fall back to file names.
327 }
328 const sources: ThinkingSource[] = []
329
330 for (const entry of entries) {
331 if (entry.isDirectory()) continue
332 const manifestSource = manifestByFileName.get(entry.name)
333 if (manifestSource) {
334 sources.push(manifestSource)
335 continue
336 }
337 const ext = path.extname(entry.name).toLowerCase()
338 let kind: ThinkingSource['kind'] = 'text'
339 if (entry.name.endsWith('.image.md')) kind = 'image'
340 else if (ext === '.md') kind = 'markdown'
341 else if (ext === '.csv') kind = 'csv'
342 else if (ext === '.docx') kind = 'docx'
343 else if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) kind = 'image'
344
345 sources.push({
346 id: entry.name,
347 name: entry.name,
348 kind
349 })
350 }
351
352 return sources
353 }
354
355 async function readMessagesList(dir: string): Promise<ThinkingChatMessage[]> {
356 const filePath = path.join(dir, 'messages.json')
357 try {
358 const raw = await fs.promises.readFile(filePath, 'utf-8')
359 const parsed = JSON.parse(raw)
360 if (!Array.isArray(parsed)) return []
361 return parsed.flatMap((item): ThinkingChatMessage[] => {
362 if (!item || typeof item !== 'object') return []
363 const record = item as Record<string, unknown>
364 const role = record.role === 'user' || record.role === 'assistant' ? record.role : null
365 const rawContent = typeof record.content === 'string' ? record.content : ''
366 const content = role === 'assistant' ? normalizeThinkingAssistantReply(rawContent) : rawContent
367 const timestamp = Number(record.timestamp)
368 if (!role || !content.trim()) return []
369 const attachments = Array.isArray(record.attachments)
370 ? record.attachments.filter((source): source is ThinkingSource => {
371 if (!source || typeof source !== 'object') return false
372 const item = source as Record<string, unknown>
373 return (
374 typeof item.id === 'string' &&
375 typeof item.name === 'string' &&
376 (item.kind === 'markdown' ||
377 item.kind === 'text' ||
378 item.kind === 'csv' ||
379 item.kind === 'docx' ||
380 item.kind === 'image')
381 )
382 })
383 : undefined
384 return [
385 {
386 role,
387 content,
388 timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
389 ...(attachments && attachments.length > 0 ? { attachments } : {})
390 }
391 ]
392 })
393 } catch {
394 return []
395 }
396 }
397
397 lines TYPESCRIPT