返回 oh-my-ppt
source-brief.ts
根目录 / src / main / thinking / source-brief.ts
1 import fs from 'fs'
2 import path from 'path'
3 import log from 'electron-log/main.js'
4 import {
5 deriveOutlinePageCandidates,
6 estimateOutlinePageCount,
7 scanDocumentOutline,
8 type DocumentOutlineScan
9 } from '../io/document-outline-scan'
10 import { convertCsvTextToMarkdown } from '../io/document-csv-to-markdown'
11 import type { ThinkingChatMessage } from '@shared/thinking'
12
13 const MAX_BRIEF_ATTACHMENTS = 3
14 const MAX_VISIBLE_CANDIDATES = 18
15 const MAX_VISIBLE_HEADINGS = 24
16 const MAX_SOURCE_BYTES = 1_500_000
17
18 type SourceManifestItem = {
19 id: string
20 name: string
21 kind: string
22 fileName: string
23 }
24
25 const readManifest = async (thinkingDir: string): Promise<SourceManifestItem[]> => {
26 try {
27 const manifestPath = path.join(thinkingDir, 'sources.json')
28 const parsed = JSON.parse(await fs.promises.readFile(manifestPath, 'utf-8')) as unknown
29 return Array.isArray(parsed)
30 ? parsed.filter((item): item is SourceManifestItem => {
31 if (!item || typeof item !== 'object') return false
32 const record = item as Record<string, unknown>
33 return (
34 typeof record.id === 'string' &&
35 typeof record.name === 'string' &&
36 typeof record.kind === 'string' &&
37 typeof record.fileName === 'string'
38 )
39 })
40 : []
41 } catch {
42 return []
43 }
44 }
45
46 const flattenHeadingLines = (
47 nodes: DocumentOutlineScan['sectionTree'],
48 depth = 0
49 ): string[] =>
50 nodes.flatMap((node) => [
51 `${' '.repeat(depth)}- ${'#'.repeat(node.level)} ${node.title} (lines ${node.lineStart}-${node.lineEnd})`,
52 ...flattenHeadingLines(node.children, depth + 1)
53 ])
54
55 const resolveScanFormat = (kind: string): DocumentOutlineScan['format'] => {
56 if (kind === 'text') return 'text'
57 return 'markdown'
58 }
59
60 const formatSourceBriefSection = (args: {
61 source: SourceManifestItem
62 virtualPath: string
63 scan: DocumentOutlineScan
64 }): string => {
65 const candidates = deriveOutlinePageCandidates(args.scan)
66 const estimate = estimateOutlinePageCount(args.scan, candidates)
67 const headingLines = flattenHeadingLines(args.scan.sectionTree).slice(0, MAX_VISIBLE_HEADINGS)
68 const candidateLines = candidates.slice(0, MAX_VISIBLE_CANDIDATES).map((candidate, index) =>
69 [
70 `${index + 1}. [${candidate.role}] ${candidate.sourceHeading}`,
71 `(lines ${candidate.lineStart}-${candidate.lineEnd}; ${candidate.reason})`
72 ].join(' ')
73 )
74
75 return [
76 `### ${args.source.name}`,
77 `- Source file: ${args.virtualPath}`,
78 `- Detected format: ${args.scan.format}`,
79 args.scan.topLevelTitle ? `- Top-level title: ${args.scan.topLevelTitle}` : '',
80 `- Headings detected: ${args.scan.headingCount}`,
81 estimate
82 ? `- Deterministic slide-count estimate: prefer ${estimate.preferredPageCount}; range ${estimate.minPageCount}-${estimate.maxPageCount}.`
83 : '',
84 args.scan.recommendedSplitHints.length > 0 ? '- Split/merge hints:' : '',
85 ...args.scan.recommendedSplitHints.slice(0, 5).map((hint) => ` - ${hint}`),
86 candidateLines.length > 0
87 ? `- Page candidates (${candidateLines.length} visible of ${candidates.length}):`
88 : '',
89 ...candidateLines.map((line) => ` ${line}`),
90 candidates.length > candidateLines.length
91 ? ` ... ${candidates.length - candidateLines.length} more candidates. Use grep/read_file on the source file for the rest.`
92 : '',
93 headingLines.length > 0 ? `- Heading map (${headingLines.length} visible):` : '',
94 ...headingLines.map((line) => ` ${line}`),
95 args.scan.headingCount > headingLines.length
96 ? ` ... ${args.scan.headingCount - headingLines.length} more headings. Use grep/read_file on the source file for details.`
97 : ''
98 ]
99 .filter(Boolean)
100 .join('\n')
101 }
102
103 export const buildThinkingSourceBrief = async (args: {
104 thinkingDir: string
105 attachments?: ThinkingChatMessage['attachments']
106 }): Promise<string> => {
107 const attachments = (args.attachments || []).filter((item) => item.kind !== 'image')
108 if (attachments.length === 0) return ''
109
110 const startedAt = Date.now()
111 log.info('[thinking:source-brief] start', {
112 thinkingDir: args.thinkingDir,
113 attachmentCount: attachments.length,
114 attachmentIds: attachments.map((attachment) => attachment.id),
115 attachmentNames: attachments.map((attachment) => attachment.name)
116 })
117
118 const manifest = await readManifest(args.thinkingDir)
119 const sourcesDir = path.join(args.thinkingDir, 'sources')
120 const sections: string[] = []
121
122 for (const attachment of attachments.slice(0, MAX_BRIEF_ATTACHMENTS)) {
123 const source = manifest.find((item) => item.id === attachment.id)
124 if (!source) {
125 log.warn('[thinking:source-brief] attachment missing from manifest', {
126 sourceId: attachment.id,
127 sourceName: attachment.name
128 })
129 continue
130 }
131 const sourcePath = path.join(sourcesDir, source.fileName)
132 const virtualPath = `/sources/${source.fileName}`
133
134 try {
135 const stat = await fs.promises.stat(sourcePath)
136 if (!stat.isFile()) {
137 log.warn('[thinking:source-brief] source path is not a file', {
138 sourceId: source.id,
139 sourceName: source.name,
140 virtualPath
141 })
142 continue
143 }
144 if (stat.size > MAX_SOURCE_BYTES) {
145 log.info('[thinking:source-brief] source skipped because it is large', {
146 sourceId: source.id,
147 sourceName: source.name,
148 virtualPath,
149 bytes: stat.size
150 })
151 sections.push(
152 [
153 `### ${source.name}`,
154 `- Source file: ${virtualPath}`,
155 `- File is large (${stat.size} bytes), so no inline source brief was generated.`,
156 '- Use grep/read_file on the source file to inspect headings and relevant sections.'
157 ].join('\n')
158 )
159 continue
160 }
161 const rawContent = await fs.promises.readFile(sourcePath, 'utf-8')
162 const content =
163 source.kind === 'csv'
164 ? convertCsvTextToMarkdown(rawContent, { title: source.name })
165 : rawContent
166 if (!content.trim()) continue
167 const scan = scanDocumentOutline(content, resolveScanFormat(source.kind))
168 const candidates = deriveOutlinePageCandidates(scan)
169 const estimate = estimateOutlinePageCount(scan, candidates)
170 log.info('[thinking:source-brief] source scanned', {
171 sourceId: source.id,
172 sourceName: source.name,
173 kind: source.kind,
174 virtualPath,
175 bytes: stat.size,
176 headingCount: scan.headingCount,
177 pageCandidateCount: candidates.length,
178 preferredPageCount: estimate?.preferredPageCount ?? null
179 })
180 sections.push(formatSourceBriefSection({ source, virtualPath, scan }))
181 } catch (error) {
182 log.warn('[thinking:source-brief] scan failed', {
183 sourceId: source.id,
184 sourceName: source.name,
185 message: error instanceof Error ? error.message : String(error)
186 })
187 sections.push(
188 [
189 `### ${source.name}`,
190 `- Source file: ${virtualPath}`,
191 '- Source brief scan failed. Use grep/read_file on the source file when details are needed.'
192 ].join('\n')
193 )
194 }
195 }
196
197 if (sections.length === 0) {
198 log.info('[thinking:source-brief] end', {
199 sectionCount: 0,
200 briefLength: 0,
201 durationMs: Date.now() - startedAt
202 })
203 return ''
204 }
205
206 const brief = [
207 '## Source Brief',
208 'The following lightweight source brief was built deterministically from the files attached to this message. Use it as a map only; read the source file with grep/read_file when exact details are needed.',
209 '',
210 ...sections
211 ].join('\n')
212 log.info('[thinking:source-brief] end', {
213 sectionCount: sections.length,
214 briefLength: brief.length,
215 durationMs: Date.now() - startedAt
216 })
217 return brief
218 }
219
219 lines TYPESCRIPT