返回 oh-my-ppt
local-files.ts
根目录 / src / main / ipc / runtime / local-files.ts
1 import fs from 'fs'
2 import path from 'path'
3 import dayjs from 'dayjs'
4 import { nanoid } from 'nanoid'
5 import type { UploadedAsset } from '@shared/generation'
6 import type { PPTDatabase } from '../../db/database'
7 import type { SessionProjectResolver } from './session-project'
8
9 type UploadedFile = { path?: unknown; name?: unknown }
10 type UploadTarget = 'images' | 'videos' | 'docs'
11
12 export type RuntimeLocalFiles = {
13 resolveStoragePath(): Promise<string>
14 normalizeSessionId(value: unknown): string | undefined
15 parsePathPayload(
16 payload: unknown,
17 preferredKey?: 'path' | 'htmlPath'
18 ): { filePath: string; sessionId?: string; hash?: string }
19 formatImagePathsForPrompt(imagePaths?: string[], videoPaths?: string[]): string
20 buildAssetTimestamp(): string
21 uploadSessionFiles(
22 sessionId: string,
23 files: UploadedFile[],
24 target: UploadTarget
25 ): Promise<UploadedAsset[]>
26 uploadImageAssets(sessionId: string, files: UploadedFile[]): Promise<UploadedAsset[]>
27 uploadMediaAssets(sessionId: string, files: UploadedFile[]): Promise<UploadedAsset[]>
28 resolveExistingFileRealPath(filePath: string): Promise<string>
29 resolveWritableFileRealPath(filePath: string): Promise<string>
30 resolveAllowedRoots(sessionId?: string): Promise<string[]>
31 assertPathInAllowedRoots(args: {
32 filePath: string
33 mode: 'read' | 'write'
34 sessionId?: string
35 htmlOnly?: boolean
36 }): Promise<string>
37 }
38
39 const ALLOWED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'])
40 const ALLOWED_VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.ogg'])
41 const ALLOWED_DOC_EXTENSIONS = new Set(['.md', '.txt', '.text'])
42 const IMAGE_MIME_BY_EXT: Record<string, string> = {
43 '.png': 'image/png',
44 '.jpg': 'image/jpeg',
45 '.jpeg': 'image/jpeg',
46 '.webp': 'image/webp',
47 '.gif': 'image/gif',
48 '.svg': 'image/svg+xml'
49 }
50 const DOC_MIME_BY_EXT: Record<string, string> = {
51 '.md': 'text/markdown',
52 '.txt': 'text/plain',
53 '.text': 'text/plain'
54 }
55 const VIDEO_MIME_BY_EXT: Record<string, string> = {
56 '.mp4': 'video/mp4',
57 '.webm': 'video/webm',
58 '.ogg': 'video/ogg'
59 }
60
61 export function createRuntimeLocalFiles(args: {
62 db: PPTDatabase
63 sessionProject: SessionProjectResolver
64 }): RuntimeLocalFiles {
65 const { db, sessionProject } = args
66
67 const resolveStoragePath = async (): Promise<string> => {
68 const saved = await db.getSetting<string>('storage_path')
69 if (typeof saved === 'string' && saved.trim().length > 0) {
70 const normalized = saved.trim()
71 await db.setStoragePath(normalized)
72 return normalized
73 }
74 throw new Error('请先前往系统设置选择存储目录。')
75 }
76
77 const normalizeSessionId = (value: unknown): string | undefined => {
78 if (typeof value !== 'string') return undefined
79 const trimmed = value.trim()
80 return trimmed.length > 0 ? trimmed : undefined
81 }
82
83 const parsePathPayload = (
84 payload: unknown,
85 preferredKey: 'path' | 'htmlPath' = 'path'
86 ): { filePath: string; sessionId?: string; hash?: string } => {
87 if (typeof payload === 'string') return { filePath: payload.trim() }
88 if (!payload || typeof payload !== 'object') return { filePath: '' }
89 const record = payload as Record<string, unknown>
90 const candidate =
91 typeof record[preferredKey] === 'string'
92 ? String(record[preferredKey])
93 : typeof record.path === 'string'
94 ? String(record.path)
95 : typeof record.htmlPath === 'string'
96 ? String(record.htmlPath)
97 : ''
98 return {
99 filePath: candidate.trim(),
100 sessionId: normalizeSessionId(record.sessionId),
101 hash: typeof record.hash === 'string' ? record.hash : undefined
102 }
103 }
104
105 const formatImagePathsForPrompt = (imagePaths?: string[], videoPaths?: string[]): string => {
106 const validPaths = Array.isArray(imagePaths)
107 ? imagePaths
108 .map((item) => String(item || '').trim())
109 .filter((item) => item.startsWith('./images/'))
110 .slice(0, 10)
111 : []
112 const validVideoPaths = Array.isArray(videoPaths)
113 ? videoPaths
114 .map((item) => String(item || '').trim())
115 .filter((item) => item.startsWith('./videos/'))
116 .slice(0, 10)
117 : []
118 if (validPaths.length === 0 && validVideoPaths.length === 0) return ''
119 return [
120 '',
121 validPaths.length > 0 ? '本次消息可用图片路径:' : '',
122 ...validPaths.map((imagePath, index) => `- ${index + 1}. ${imagePath}`),
123 validPaths.length > 0 ? '' : '',
124 validVideoPaths.length > 0 ? '本次消息可用视频路径:' : '',
125 ...validVideoPaths.map((videoPath, index) => `- ${index + 1}. ${videoPath}`),
126 validVideoPaths.length > 0 ? '' : '',
127 '素材使用规则:',
128 '- 如需使用图片或视频,请引用上面的相对路径。',
129 '- 禁止使用 file://、绝对路径或 base64。',
130 '- 不要重新引入远程资源,优先使用这些本地素材。',
131 '- 插入视频时必须使用 HTML <video> 标签,并包含 controls playsinline preload="metadata"。',
132 '- 视频默认不要添加 autoplay 或 muted,让用户点击控件后播放并保留声音;只有明确要求循环背景视频时才使用 muted/loop。'
133 ]
134 .filter(Boolean)
135 .join('\n')
136 }
137
138 const buildAssetTimestamp = (): string => dayjs().format('YYYYMMDD-HHmmss')
139
140 const uploadSessionFiles = async (
141 sessionId: string,
142 files: UploadedFile[],
143 target: UploadTarget
144 ): Promise<UploadedAsset[]> => {
145 if (!sessionId) throw new Error('sessionId 不能为空')
146 if (files.length === 0) return []
147
148 const projectDir = await sessionProject.resolveSessionProjectDir(sessionId)
149 const targetRoot = path.join(projectDir, target)
150 await fs.promises.mkdir(targetRoot, { recursive: true })
151 const uploadedAssets: UploadedAsset[] = []
152
153 for (const file of files) {
154 const sourcePath = typeof file.path === 'string' ? file.path.trim() : ''
155 if (!sourcePath) throw new Error('素材路径不能为空')
156 const stat = await fs.promises.stat(sourcePath)
157 if (!stat.isFile()) throw new Error(`素材不是文件: ${sourcePath}`)
158 if (stat.size > 20 * 1024 * 1024) throw new Error('单个素材不能超过 20MB')
159
160 const ext = path.extname(sourcePath).toLowerCase()
161 if (target === 'images' && !ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
162 throw new Error('暂只支持 png、jpg、jpeg、webp、gif、svg 图片素材')
163 }
164 if (target === 'docs' && !ALLOWED_DOC_EXTENSIONS.has(ext)) {
165 throw new Error('暂只支持 md、txt 文档素材')
166 }
167 if (target === 'videos' && !ALLOWED_VIDEO_EXTENSIONS.has(ext)) {
168 throw new Error('暂只支持 mp4、webm、ogg 视频素材')
169 }
170
171 const originalName =
172 typeof file.name === 'string' && file.name.trim().length > 0
173 ? file.name.trim()
174 : path.basename(sourcePath)
175 const id = nanoid(10)
176 const baseNameWithoutExt = sessionProject.toSafeAssetBaseName(
177 originalName.replace(/\.[^.]+$/, '')
178 )
179 const fileName = `${baseNameWithoutExt}-${id}${ext}`
180 const targetPath = path.join(targetRoot, fileName)
181 if (!sessionProject.isPathInside(path.resolve(targetPath), targetRoot)) {
182 throw new Error('素材目标路径不合法')
183 }
184 await fs.promises.copyFile(sourcePath, targetPath)
185
186 uploadedAssets.push({
187 id,
188 fileName,
189 originalName,
190 relativePath: `./${target}/${fileName}`,
191 absolutePath: targetPath,
192 mimeType:
193 target === 'images'
194 ? IMAGE_MIME_BY_EXT[ext] || 'application/octet-stream'
195 : target === 'videos'
196 ? VIDEO_MIME_BY_EXT[ext] || 'application/octet-stream'
197 : DOC_MIME_BY_EXT[ext] || 'text/plain',
198 size: stat.size,
199 createdAt: Math.floor(Date.now() / 1000)
200 })
201 }
202
203 return uploadedAssets
204 }
205
206 const uploadImageAssets = (sessionId: string, files: UploadedFile[]): Promise<UploadedAsset[]> =>
207 uploadSessionFiles(sessionId, files, 'images')
208
209 const uploadMediaAssets = async (
210 sessionId: string,
211 files: UploadedFile[]
212 ): Promise<UploadedAsset[]> => {
213 const mediaAssets: UploadedAsset[] = []
214 const imageFiles: UploadedFile[] = []
215 const videoFiles: UploadedFile[] = []
216 for (const file of files) {
217 const sourcePath = typeof file.path === 'string' ? file.path.trim() : ''
218 const ext = path.extname(sourcePath).toLowerCase()
219 if (ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
220 imageFiles.push(file)
221 continue
222 }
223 if (ALLOWED_VIDEO_EXTENSIONS.has(ext)) {
224 videoFiles.push(file)
225 continue
226 }
227 throw new Error('暂只支持 png/jpg/webp/gif/svg 图片,或 mp4/webm/ogg 视频素材')
228 }
229 if (imageFiles.length > 0) {
230 mediaAssets.push(...(await uploadSessionFiles(sessionId, imageFiles, 'images')))
231 }
232 if (videoFiles.length > 0) {
233 mediaAssets.push(...(await uploadSessionFiles(sessionId, videoFiles, 'videos')))
234 }
235 return mediaAssets
236 }
237
238 const resolveExistingFileRealPath = async (filePath: string): Promise<string> => {
239 const absolutePath = path.resolve(filePath)
240 if (!fs.existsSync(absolutePath)) throw new Error(`文件不存在: ${absolutePath}`)
241 const stat = await fs.promises.stat(absolutePath)
242 if (!stat.isFile()) throw new Error(`目标不是文件: ${absolutePath}`)
243 return fs.promises.realpath(absolutePath)
244 }
245
246 const resolveWritableFileRealPath = async (filePath: string): Promise<string> => {
247 const absolutePath = path.resolve(filePath)
248 if (fs.existsSync(absolutePath)) {
249 const stat = await fs.promises.stat(absolutePath)
250 if (!stat.isFile()) throw new Error(`目标不是文件: ${absolutePath}`)
251 return fs.promises.realpath(absolutePath)
252 }
253 const parentDir = path.dirname(absolutePath)
254 if (!fs.existsSync(parentDir)) throw new Error(`目标目录不存在: ${parentDir}`)
255 const parentRealPath = await fs.promises.realpath(parentDir)
256 return path.join(parentRealPath, path.basename(absolutePath))
257 }
258
259 const resolveAllowedRoots = async (sessionId?: string): Promise<string[]> => {
260 const roots = new Set<string>()
261 const storagePath = await resolveStoragePath()
262 const storageRoot = fs.existsSync(storagePath)
263 ? await fs.promises.realpath(storagePath)
264 : path.resolve(storagePath)
265 roots.add(storageRoot)
266
267 if (sessionId) {
268 const project = await db.getProject(sessionId)
269 const rootPath = typeof project?.root_path === 'string' ? project.root_path : ''
270 if (rootPath) {
271 const resolvedRootPath = fs.existsSync(rootPath)
272 ? await fs.promises.realpath(rootPath)
273 : path.resolve(rootPath)
274 roots.add(resolvedRootPath)
275 }
276 }
277 return [...roots]
278 }
279
280 const assertPathInAllowedRoots = async (args: {
281 filePath: string
282 mode: 'read' | 'write'
283 sessionId?: string
284 htmlOnly?: boolean
285 }): Promise<string> => {
286 const { filePath, mode, sessionId, htmlOnly } = args
287 if (typeof filePath !== 'string' || filePath.trim().length === 0) {
288 throw new Error('文件路径不能为空')
289 }
290 const extension = path.extname(filePath).toLowerCase()
291 if (htmlOnly && extension !== '.html' && extension !== '.htm') {
292 throw new Error(`仅允许访问 HTML 文件,当前扩展名: ${extension || '(none)'}`)
293 }
294 const resolveSessionHtmlFallbackPath = async (): Promise<string | null> => {
295 if (mode !== 'read' || !sessionId) return null
296 if (extension !== '.html' && extension !== '.htm') return null
297 const fileName = path.basename(filePath)
298 if (!fileName) return null
299 const projectDir = await sessionProject.resolveSessionProjectDir(sessionId)
300 const fallbackPath = path.join(projectDir, fileName)
301 if (path.resolve(fallbackPath) === path.resolve(filePath)) return null
302 return fs.existsSync(fallbackPath) ? fallbackPath : null
303 }
304
305 let targetPath: string
306 if (mode === 'read') {
307 try {
308 targetPath = await resolveExistingFileRealPath(filePath)
309 } catch (error) {
310 const fallbackPath = await resolveSessionHtmlFallbackPath()
311 if (!fallbackPath) throw error
312 targetPath = await resolveExistingFileRealPath(fallbackPath)
313 }
314 } else {
315 targetPath = await resolveWritableFileRealPath(filePath)
316 }
317 const allowedRoots = await resolveAllowedRoots(sessionId)
318 if (!allowedRoots.some((root) => sessionProject.isPathInside(targetPath, root))) {
319 throw new Error(`文件路径不在允许目录内: ${targetPath}`)
320 }
321 return targetPath
322 }
323
324 return {
325 resolveStoragePath,
326 normalizeSessionId,
327 parsePathPayload,
328 formatImagePathsForPrompt,
329 buildAssetTimestamp,
330 uploadSessionFiles,
331 uploadImageAssets,
332 uploadMediaAssets,
333 resolveExistingFileRealPath,
334 resolveWritableFileRealPath,
335 resolveAllowedRoots,
336 assertPathInAllowedRoots
337 }
338 }
339
339 lines TYPESCRIPT