@muyichengshayu/promptx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,909 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { pipeline } from 'node:stream/promises'
4
+ import Fastify from 'fastify'
5
+ import cors from '@fastify/cors'
6
+ import multipart from '@fastify/multipart'
7
+ import fastifyStatic from '@fastify/static'
8
+ import { Jimp } from 'jimp'
9
+ import { nanoid } from 'nanoid'
10
+ import { EXPIRY_OPTIONS, VISIBILITY_OPTIONS } from '../../../packages/shared/src/index.js'
11
+ import {
12
+ buildTaskExports,
13
+ canEditTask,
14
+ clearTaskCodexSessionReferences,
15
+ createTask,
16
+ deleteTask,
17
+ getTaskBySlug,
18
+ listTasks,
19
+ purgeExpiredTasks,
20
+ updateTaskCodexSession,
21
+ updateTask,
22
+ } from './repository.js'
23
+ import {
24
+ listKnownCodexWorkspaces,
25
+ } from './codex.js'
26
+ import {
27
+ getTaskGitDiffReview,
28
+ getWorkspaceGitDiffReviewByCwd,
29
+ } from './gitDiff.js'
30
+ import {
31
+ createPromptxCodexSession,
32
+ deletePromptxCodexSession,
33
+ getPromptxCodexSessionById,
34
+ listPromptxCodexSessions,
35
+ updatePromptxCodexSession,
36
+ } from './codexSessions.js'
37
+ import {
38
+ createCodexRun,
39
+ deleteTaskCodexRuns,
40
+ getCodexRunById,
41
+ getRunningCodexRunBySessionId,
42
+ getRunningCodexRunByTaskSlug,
43
+ listCodexRunEvents,
44
+ listRunningCodexSessionIds,
45
+ listRunningCodexTaskSlugs,
46
+ listTaskCodexRunsWithOptions,
47
+ markInterruptedCodexRuns,
48
+ updateCodexRun,
49
+ } from './codexRuns.js'
50
+ import { createCodexRunRuntime } from './codexRunRuntime.js'
51
+ import { importPdfBlocks } from './pdf.js'
52
+ import { createTempFilePath, normalizeUploadFileName } from './upload.js'
53
+ import {
54
+ listDirectoryPickerTree,
55
+ listWorkspaceTree,
56
+ searchDirectoryPickerEntries,
57
+ searchWorkspaceEntries,
58
+ } from './workspaceFiles.js'
59
+ import { ensurePromptxStorageReady, serverRootDir } from './appPaths.js'
60
+ import { createSseHub } from './sseHub.js'
61
+
62
+ const app = Fastify({ logger: true })
63
+ const port = Number(process.env.PORT || 3000)
64
+ const host = process.env.HOST || '127.0.0.1'
65
+ const { tmpDir, uploadsDir } = ensurePromptxStorageReady()
66
+ const workspaceRootDir = path.resolve(serverRootDir, '..', '..')
67
+ const workspaceParentDir = path.dirname(workspaceRootDir)
68
+ const webDistDir = path.resolve(serverRootDir, '..', 'web', 'dist')
69
+ const webIndexFile = path.join(webDistDir, 'index.html')
70
+ const hasBuiltWebApp = fs.existsSync(webIndexFile)
71
+
72
+ let lastExpiredPurgeAt = 0
73
+ const sseHub = createSseHub()
74
+
75
+ function broadcastServerEvent(type, payload = {}) {
76
+ sseHub.broadcast(type, payload)
77
+ }
78
+
79
+ function getRunningSessionIdSet() {
80
+ return new Set(listRunningCodexSessionIds())
81
+ }
82
+
83
+ function decorateCodexSession(session, runningSessionIds = getRunningSessionIdSet()) {
84
+ if (!session) {
85
+ return null
86
+ }
87
+
88
+ return {
89
+ ...session,
90
+ running: runningSessionIds.has(session.id),
91
+ }
92
+ }
93
+
94
+ function decorateCodexSessionList(items = []) {
95
+ const runningSessionIds = getRunningSessionIdSet()
96
+ return items.map((item) => decorateCodexSession(item, runningSessionIds))
97
+ }
98
+
99
+ function getRunningTaskSlugSet() {
100
+ return new Set(listRunningCodexTaskSlugs())
101
+ }
102
+
103
+ function decorateTask(task, runningTaskSlugs = getRunningTaskSlugSet()) {
104
+ if (!task) {
105
+ return null
106
+ }
107
+
108
+ return {
109
+ ...task,
110
+ running: runningTaskSlugs.has(task.slug),
111
+ }
112
+ }
113
+
114
+ function decorateTaskList(items = []) {
115
+ const runningTaskSlugs = getRunningTaskSlugSet()
116
+ return items.map((item) => decorateTask(item, runningTaskSlugs))
117
+ }
118
+
119
+ function createEmptyWorkspaceDiffSummary() {
120
+ return {
121
+ supported: false,
122
+ fileCount: 0,
123
+ additions: 0,
124
+ deletions: 0,
125
+ }
126
+ }
127
+
128
+ function toWorkspaceDiffSummary(payload = null) {
129
+ if (!payload?.supported) {
130
+ return createEmptyWorkspaceDiffSummary()
131
+ }
132
+
133
+ return {
134
+ supported: true,
135
+ fileCount: Math.max(0, Number(payload.summary?.fileCount) || 0),
136
+ additions: Math.max(0, Number(payload.summary?.additions) || 0),
137
+ deletions: Math.max(0, Number(payload.summary?.deletions) || 0),
138
+ }
139
+ }
140
+
141
+ function attachTaskWorkspaceDiffSummaries(items = []) {
142
+ const summaryByWorkspaceKey = new Map()
143
+ const emptySummary = createEmptyWorkspaceDiffSummary()
144
+
145
+ return items.map((task) => {
146
+ const sessionId = String(task?.codexSessionId || '').trim()
147
+ if (!sessionId) {
148
+ return {
149
+ ...task,
150
+ workspaceDiffSummary: emptySummary,
151
+ }
152
+ }
153
+
154
+ const session = getPromptxCodexSessionById(sessionId)
155
+ const workspaceKey = String(session?.cwd || sessionId).trim()
156
+ if (!summaryByWorkspaceKey.has(workspaceKey)) {
157
+ const payload = session?.cwd ? getWorkspaceGitDiffReviewByCwd(session.cwd) : null
158
+ summaryByWorkspaceKey.set(workspaceKey, toWorkspaceDiffSummary(payload))
159
+ }
160
+
161
+ return {
162
+ ...task,
163
+ workspaceDiffSummary: summaryByWorkspaceKey.get(workspaceKey) || emptySummary,
164
+ }
165
+ })
166
+ }
167
+
168
+ function listTaskWorkspaceDiffSummaries(limit = 30) {
169
+ return attachTaskWorkspaceDiffSummaries(listTasks(limit)).map((task) => ({
170
+ slug: String(task?.slug || '').trim(),
171
+ workspaceDiffSummary: task?.workspaceDiffSummary || createEmptyWorkspaceDiffSummary(),
172
+ }))
173
+ }
174
+
175
+ const codexRunRuntime = createCodexRunRuntime({
176
+ decorateSession: decorateCodexSession,
177
+ onRunEvent({ taskSlug, runId, event }) {
178
+ broadcastServerEvent('run.event', {
179
+ taskSlug,
180
+ runId,
181
+ event,
182
+ })
183
+ },
184
+ onRunUpdated({ taskSlug, runId }) {
185
+ broadcastServerEvent('runs.changed', {
186
+ taskSlug,
187
+ runId,
188
+ })
189
+ },
190
+ onSessionChanged({ sessionId }) {
191
+ broadcastServerEvent('sessions.changed', {
192
+ sessionId,
193
+ })
194
+ },
195
+ })
196
+
197
+ function buildServerAccessUrls(hostname, currentPort) {
198
+ const normalizedHost = String(hostname || '').trim()
199
+
200
+ if (!normalizedHost || normalizedHost === '0.0.0.0' || normalizedHost === '::' || normalizedHost === '127.0.0.1') {
201
+ return [`本机: http://127.0.0.1:${currentPort}`]
202
+ }
203
+
204
+ if (normalizedHost === 'localhost') {
205
+ return [`本机: http://localhost:${currentPort}`]
206
+ }
207
+
208
+ return [`访问地址: http://${normalizedHost}:${currentPort}`]
209
+ }
210
+
211
+ function resolveUploadPath(assetPath = '') {
212
+ const normalized = String(assetPath || '').replace(/^\/+/, '')
213
+ if (!normalized.startsWith('uploads/')) {
214
+ return null
215
+ }
216
+
217
+ const absolutePath = path.resolve(process.cwd(), normalized)
218
+ return absolutePath.startsWith(`${uploadsDir}${path.sep}`) ? absolutePath : null
219
+ }
220
+
221
+ function removeAssetFiles(assetPaths = []) {
222
+ const uniquePaths = [...new Set(assetPaths)]
223
+ uniquePaths.forEach((assetPath) => {
224
+ const targetPath = resolveUploadPath(assetPath)
225
+ if (targetPath) {
226
+ fs.rmSync(targetPath, { force: true })
227
+ }
228
+ })
229
+ }
230
+
231
+ function purgeExpiredContent(force = false) {
232
+ const now = Date.now()
233
+ if (!force && now - lastExpiredPurgeAt < 60 * 1000) {
234
+ return
235
+ }
236
+
237
+ lastExpiredPurgeAt = now
238
+ const result = purgeExpiredTasks(new Date(now).toISOString())
239
+ if (result.removedAssets.length) {
240
+ removeAssetFiles(result.removedAssets)
241
+ }
242
+ }
243
+
244
+ function listSiblingWorkspaceDirs(baseDir) {
245
+ if (!baseDir || !fs.existsSync(baseDir)) {
246
+ return []
247
+ }
248
+
249
+ return fs.readdirSync(baseDir, { withFileTypes: true })
250
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
251
+ .map((entry) => path.join(baseDir, entry.name))
252
+ .sort((a, b) => a.localeCompare(b, 'zh-CN'))
253
+ }
254
+
255
+ function listWorkspaceSuggestions(limit = 24) {
256
+ const seen = new Set()
257
+ const suggestions = []
258
+
259
+ const addPath = (targetPath) => {
260
+ const value = String(targetPath || '').trim()
261
+ if (!value || seen.has(value) || !fs.existsSync(value)) {
262
+ return
263
+ }
264
+
265
+ try {
266
+ if (!fs.statSync(value).isDirectory()) {
267
+ return
268
+ }
269
+ } catch {
270
+ return
271
+ }
272
+
273
+ seen.add(value)
274
+ suggestions.push(value)
275
+ }
276
+
277
+ addPath(workspaceRootDir)
278
+ listSiblingWorkspaceDirs(workspaceParentDir).forEach(addPath)
279
+ listPromptxCodexSessions(limit).forEach((session) => addPath(session.cwd))
280
+ listKnownCodexWorkspaces(limit * 2).forEach(addPath)
281
+
282
+ return suggestions.slice(0, Math.max(1, Number(limit) || 24))
283
+ }
284
+
285
+ await app.register(cors, {
286
+ origin: true,
287
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
288
+ allowedHeaders: ['Content-Type'],
289
+ })
290
+
291
+ await app.register(multipart, {
292
+ limits: {
293
+ fileSize: 30 * 1024 * 1024,
294
+ files: 1,
295
+ },
296
+ })
297
+
298
+ app.addContentTypeParser(
299
+ 'application/x-www-form-urlencoded',
300
+ { parseAs: 'string' },
301
+ (request, body, done) => {
302
+ done(null, {})
303
+ }
304
+ )
305
+
306
+ await app.register(fastifyStatic, {
307
+ root: uploadsDir,
308
+ prefix: '/uploads/',
309
+ })
310
+
311
+ if (hasBuiltWebApp) {
312
+ await app.register(fastifyStatic, {
313
+ root: webDistDir,
314
+ prefix: '/',
315
+ decorateReply: false,
316
+ wildcard: false,
317
+ index: false,
318
+ })
319
+ }
320
+
321
+ app.get('/health', async () => ({ ok: true }))
322
+
323
+ app.get('/api/meta', async () => ({
324
+ expiryOptions: EXPIRY_OPTIONS,
325
+ visibilityOptions: VISIBILITY_OPTIONS,
326
+ }))
327
+
328
+ app.get('/api/events/stream', async (request, reply) => {
329
+ reply.hijack()
330
+ const requestOrigin = request.headers.origin
331
+ reply.raw.writeHead(200, {
332
+ 'Content-Type': 'text/event-stream; charset=utf-8',
333
+ 'Cache-Control': 'no-cache, no-transform',
334
+ Connection: 'keep-alive',
335
+ 'X-Accel-Buffering': 'no',
336
+ ...(requestOrigin ? {
337
+ 'Access-Control-Allow-Origin': requestOrigin,
338
+ Vary: 'Origin',
339
+ } : {}),
340
+ })
341
+ reply.raw.socket?.setNoDelay?.(true)
342
+ reply.raw.flushHeaders?.()
343
+
344
+ const removeClient = sseHub.addClient(reply.raw)
345
+ sseHub.write(reply.raw, {
346
+ type: 'ready',
347
+ sentAt: new Date().toISOString(),
348
+ })
349
+
350
+ const handleClose = () => {
351
+ removeClient()
352
+ }
353
+
354
+ reply.raw.on('close', handleClose)
355
+ })
356
+
357
+ app.get('/api/tasks', async () => {
358
+ purgeExpiredContent()
359
+ return {
360
+ items: decorateTaskList(listTasks()),
361
+ }
362
+ })
363
+
364
+ app.get('/api/tasks/workspace-diff-summaries', async (request) => {
365
+ purgeExpiredContent()
366
+ return {
367
+ items: listTaskWorkspaceDiffSummaries(request.query?.limit),
368
+ }
369
+ })
370
+
371
+ app.post('/api/tasks', async (request, reply) => {
372
+ purgeExpiredContent()
373
+ const task = createTask(request.body || {})
374
+ broadcastServerEvent('tasks.changed', {
375
+ taskSlug: task.slug,
376
+ reason: 'created',
377
+ })
378
+ return reply.code(201).send(decorateTask(task))
379
+ })
380
+
381
+ app.get('/api/tasks/:slug', async (request, reply) => {
382
+ purgeExpiredContent()
383
+ const task = getTaskBySlug(request.params.slug)
384
+ if (!task) {
385
+ return reply.code(404).send({ message: '任务不存在。' })
386
+ }
387
+ if (task.expired) {
388
+ return reply.code(410).send({ message: '任务已过期。' })
389
+ }
390
+
391
+ return {
392
+ ...decorateTask(task),
393
+ canEdit: canEditTask(request.params.slug),
394
+ }
395
+ })
396
+
397
+ app.put('/api/tasks/:slug', async (request, reply) => {
398
+ purgeExpiredContent()
399
+ const result = updateTask(request.params.slug, request.body || {})
400
+ if (result.error === 'not_found') {
401
+ return reply.code(404).send({ message: '任务不存在。' })
402
+ }
403
+ broadcastServerEvent('tasks.changed', {
404
+ taskSlug: request.params.slug,
405
+ reason: 'updated',
406
+ })
407
+ return decorateTask(result)
408
+ })
409
+
410
+ app.delete('/api/tasks/:slug', async (request, reply) => {
411
+ purgeExpiredContent()
412
+ if (getRunningCodexRunByTaskSlug(request.params.slug)) {
413
+ return reply.code(409).send({ message: '当前任务正在执行中,请先停止后再删除。' })
414
+ }
415
+ const result = deleteTask(request.params.slug)
416
+ if (result.error === 'not_found') {
417
+ return reply.code(404).send({ message: '任务不存在。' })
418
+ }
419
+ removeAssetFiles(result.removedAssets)
420
+ broadcastServerEvent('tasks.changed', {
421
+ taskSlug: request.params.slug,
422
+ reason: 'deleted',
423
+ })
424
+ return reply.code(204).send()
425
+ })
426
+
427
+ app.post('/api/tasks/:slug/codex-session', async (request, reply) => {
428
+ purgeExpiredContent()
429
+ const task = getTaskBySlug(request.params.slug)
430
+ if (!task || task.expired) {
431
+ return reply.code(404).send({ message: '任务不存在。' })
432
+ }
433
+
434
+ const sessionId = String(request.body?.sessionId || '').trim()
435
+ const taskSessionLocked = Boolean(task.codexSessionId && Number(task.codexRunCount || 0) > 0)
436
+ if (taskSessionLocked && sessionId !== String(task.codexSessionId || '').trim()) {
437
+ return reply.code(409).send({
438
+ message: '该任务已有项目历史,不能再切换项目;如需使用新项目,请新建任务。',
439
+ })
440
+ }
441
+
442
+ if (sessionId) {
443
+ const session = getPromptxCodexSessionById(sessionId)
444
+ if (!session) {
445
+ return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
446
+ }
447
+ }
448
+
449
+ const updatedTask = updateTaskCodexSession(request.params.slug, sessionId)
450
+ if (!updatedTask) {
451
+ return reply.code(404).send({ message: '任务不存在。' })
452
+ }
453
+
454
+ broadcastServerEvent('tasks.changed', {
455
+ taskSlug: request.params.slug,
456
+ reason: sessionId ? 'session-linked' : 'session-cleared',
457
+ })
458
+
459
+ return {
460
+ task: {
461
+ ...decorateTask(updatedTask),
462
+ canEdit: canEditTask(request.params.slug),
463
+ },
464
+ }
465
+ })
466
+
467
+ app.get('/api/tasks/:slug/codex-runs', async (request, reply) => {
468
+ purgeExpiredContent()
469
+ const task = getTaskBySlug(request.params.slug)
470
+ if (!task || task.expired) {
471
+ return reply.code(404).send({ message: '任务不存在。' })
472
+ }
473
+
474
+ const includeEvents = String(request.query?.includeEvents || '').trim() === 'true'
475
+
476
+ return {
477
+ items: listTaskCodexRunsWithOptions(request.params.slug, {
478
+ limit: request.query?.limit,
479
+ includeEvents,
480
+ }),
481
+ }
482
+ })
483
+
484
+ app.get('/api/tasks/:slug/git-diff', async (request, reply) => {
485
+ purgeExpiredContent()
486
+ const task = getTaskBySlug(request.params.slug)
487
+ if (!task || task.expired) {
488
+ return reply.code(404).send({ message: '任务不存在。' })
489
+ }
490
+
491
+ const scope = String(request.query?.scope || 'workspace').trim()
492
+ if (scope !== 'workspace' && scope !== 'task' && scope !== 'run') {
493
+ return reply.code(400).send({ message: '无效的 diff 范围。' })
494
+ }
495
+
496
+ return getTaskGitDiffReview(request.params.slug, {
497
+ scope,
498
+ runId: request.query?.runId,
499
+ filePath: request.query?.filePath,
500
+ includeFiles: String(request.query?.includeFiles || '').trim() !== 'false',
501
+ includeStats: String(request.query?.includeStats || '').trim() !== 'false',
502
+ })
503
+ })
504
+
505
+ app.post('/api/tasks/:slug/codex-runs', async (request, reply) => {
506
+ purgeExpiredContent()
507
+ const task = getTaskBySlug(request.params.slug)
508
+ if (!task || task.expired) {
509
+ return reply.code(404).send({ message: '任务不存在。' })
510
+ }
511
+
512
+ const sessionId = String(request.body?.sessionId || '').trim()
513
+ const prompt = String(request.body?.prompt || '').trim()
514
+
515
+ if (!sessionId) {
516
+ return reply.code(400).send({ message: '请先选择一个 PromptX 项目。' })
517
+ }
518
+ if (!prompt) {
519
+ return reply.code(400).send({ message: '没有可发送的提示词。' })
520
+ }
521
+
522
+ const session = getPromptxCodexSessionById(sessionId)
523
+ if (!session) {
524
+ return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
525
+ }
526
+
527
+ const runningRunOnSession = getRunningCodexRunBySessionId(sessionId)
528
+ if (runningRunOnSession) {
529
+ return reply.code(409).send({ message: '当前项目正在执行中,请等待完成后再发送。' })
530
+ }
531
+
532
+ const runRecord = createCodexRun({
533
+ taskSlug: request.params.slug,
534
+ sessionId,
535
+ prompt,
536
+ })
537
+
538
+ updateTaskCodexSession(request.params.slug, sessionId)
539
+ codexRunRuntime.start(runRecord)
540
+
541
+ broadcastServerEvent('tasks.changed', {
542
+ taskSlug: request.params.slug,
543
+ reason: 'session-linked',
544
+ })
545
+ broadcastServerEvent('runs.changed', {
546
+ taskSlug: request.params.slug,
547
+ runId: runRecord.id,
548
+ })
549
+ broadcastServerEvent('sessions.changed', {
550
+ sessionId,
551
+ })
552
+
553
+ return reply.code(201).send({
554
+ run: getCodexRunById(runRecord.id),
555
+ session: decorateCodexSession(getPromptxCodexSessionById(sessionId)),
556
+ })
557
+ })
558
+
559
+ app.delete('/api/tasks/:slug/codex-runs', async (request, reply) => {
560
+ purgeExpiredContent()
561
+ const task = getTaskBySlug(request.params.slug)
562
+ if (!task || task.expired) {
563
+ return reply.code(404).send({ message: '任务不存在。' })
564
+ }
565
+
566
+ const runningRun = getRunningCodexRunByTaskSlug(request.params.slug)
567
+ if (runningRun) {
568
+ return reply.code(409).send({ message: '当前任务正在执行中,请先停止后再清空记录。' })
569
+ }
570
+
571
+ deleteTaskCodexRuns(request.params.slug)
572
+ broadcastServerEvent('runs.changed', {
573
+ taskSlug: request.params.slug,
574
+ })
575
+ return reply.code(204).send()
576
+ })
577
+
578
+ app.post('/api/uploads', async (request, reply) => {
579
+ const part = await request.file()
580
+ if (!part) {
581
+ return reply.code(400).send({ message: '没有收到上传文件。' })
582
+ }
583
+ if (!String(part.mimetype || '').startsWith('image/')) {
584
+ return reply.code(400).send({ message: '只支持上传图片文件。' })
585
+ }
586
+
587
+ const tempPath = createTempFilePath(tmpDir, part.filename)
588
+ let outputPath = ''
589
+ let completed = false
590
+
591
+ try {
592
+ await pipeline(part.file, fs.createWriteStream(tempPath))
593
+
594
+ const image = await Jimp.read(tempPath)
595
+ image.scaleToFit({ w: 1600, h: 1600 })
596
+
597
+ const outputName = `${nanoid(16)}.jpg`
598
+ outputPath = path.join(uploadsDir, outputName)
599
+ const outputBuffer = await image.getBuffer('image/jpeg', { quality: 82 })
600
+ fs.writeFileSync(outputPath, outputBuffer)
601
+
602
+ const stats = fs.statSync(outputPath)
603
+ completed = true
604
+ return reply.code(201).send({
605
+ url: `/uploads/${outputName}`,
606
+ width: image.bitmap.width,
607
+ height: image.bitmap.height,
608
+ mimeType: 'image/jpeg',
609
+ size: stats.size,
610
+ })
611
+ } finally {
612
+ fs.rmSync(tempPath, { force: true })
613
+ if (outputPath && !completed) {
614
+ fs.rmSync(outputPath, { force: true })
615
+ }
616
+ }
617
+ })
618
+
619
+ app.post('/api/imports/pdf', async (request, reply) => {
620
+ const part = await request.file()
621
+ if (!part) {
622
+ return reply.code(400).send({ message: '没有收到 PDF 文件。' })
623
+ }
624
+
625
+ const fileName = normalizeUploadFileName(part.filename, 'task.pdf')
626
+ const mimetype = String(part.mimetype || '').toLowerCase()
627
+ if (mimetype !== 'application/pdf' && !fileName.toLowerCase().endsWith('.pdf')) {
628
+ return reply.code(400).send({ message: '只支持导入 PDF 文件。' })
629
+ }
630
+
631
+ const tempPath = createTempFilePath(tmpDir, fileName, '.pdf')
632
+ let createdAssets = []
633
+
634
+ try {
635
+ await pipeline(part.file, fs.createWriteStream(tempPath))
636
+ const buffer = fs.readFileSync(tempPath)
637
+ const imported = await importPdfBlocks(buffer, {
638
+ uploadsDir,
639
+ })
640
+ createdAssets = imported.createdAssets || []
641
+
642
+ if (!imported.blocks.length) {
643
+ removeAssetFiles(createdAssets)
644
+ return reply.code(422).send({ message: '没有从 PDF 中提取到可导入的文本或图片。' })
645
+ }
646
+
647
+ return reply.code(201).send({
648
+ fileName,
649
+ pageCount: imported.pageCount,
650
+ blocks: imported.blocks,
651
+ })
652
+ } catch (error) {
653
+ removeAssetFiles(error.createdAssets || createdAssets)
654
+ throw error
655
+ } finally {
656
+ fs.rmSync(tempPath, { force: true })
657
+ }
658
+ })
659
+
660
+ app.get('/api/codex/sessions', async () => ({
661
+ items: decorateCodexSessionList(listPromptxCodexSessions()),
662
+ }))
663
+
664
+ app.get('/api/codex/workspaces', async () => ({
665
+ items: listWorkspaceSuggestions(),
666
+ }))
667
+
668
+ app.get('/api/codex/directories/tree', async (request) => (
669
+ listDirectoryPickerTree({
670
+ path: request.query?.path,
671
+ limit: request.query?.limit,
672
+ })
673
+ ))
674
+
675
+ app.get('/api/codex/directories/search', async (request) => (
676
+ searchDirectoryPickerEntries({
677
+ path: request.query?.path,
678
+ query: request.query?.q,
679
+ limit: request.query?.limit,
680
+ })
681
+ ))
682
+
683
+ app.get('/api/codex/sessions/:sessionId/files/tree', async (request, reply) => {
684
+ const session = getPromptxCodexSessionById(request.params.sessionId)
685
+ if (!session) {
686
+ return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
687
+ }
688
+
689
+ const payload = listWorkspaceTree(session.cwd, {
690
+ path: request.query?.path,
691
+ limit: request.query?.limit,
692
+ })
693
+
694
+ return payload
695
+ })
696
+
697
+ app.get('/api/codex/sessions/:sessionId/files/search', async (request, reply) => {
698
+ const session = getPromptxCodexSessionById(request.params.sessionId)
699
+ if (!session) {
700
+ return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
701
+ }
702
+
703
+ const payload = searchWorkspaceEntries(session.cwd, {
704
+ query: request.query?.q,
705
+ limit: request.query?.limit,
706
+ })
707
+
708
+ return payload
709
+ })
710
+
711
+ app.post('/api/codex/sessions', async (request, reply) => {
712
+ const session = createPromptxCodexSession(request.body || {})
713
+ broadcastServerEvent('sessions.changed', {
714
+ sessionId: session.id,
715
+ })
716
+ return reply.code(201).send(decorateCodexSession(session))
717
+ })
718
+
719
+ app.patch('/api/codex/sessions/:sessionId', async (request, reply) => {
720
+ const session = updatePromptxCodexSession(request.params.sessionId, request.body || {})
721
+ if (!session) {
722
+ return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
723
+ }
724
+
725
+ broadcastServerEvent('sessions.changed', {
726
+ sessionId: session.id,
727
+ })
728
+ return decorateCodexSession(session)
729
+ })
730
+
731
+ app.delete('/api/codex/sessions/:sessionId', async (request, reply) => {
732
+ if (getRunningCodexRunBySessionId(request.params.sessionId)) {
733
+ return reply.code(409).send({ message: '当前项目正在执行中,请先停止后再删除。' })
734
+ }
735
+ const affectedTaskSlugs = clearTaskCodexSessionReferences(request.params.sessionId)
736
+ const session = deletePromptxCodexSession(request.params.sessionId)
737
+ if (!session) {
738
+ return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
739
+ }
740
+
741
+ broadcastServerEvent('sessions.changed', {
742
+ sessionId: request.params.sessionId,
743
+ })
744
+ if (affectedTaskSlugs.length) {
745
+ affectedTaskSlugs.forEach((taskSlug) => {
746
+ broadcastServerEvent('tasks.changed', {
747
+ taskSlug,
748
+ reason: 'session-cleared',
749
+ })
750
+ })
751
+ } else {
752
+ broadcastServerEvent('tasks.changed', {
753
+ reason: 'session-cleared',
754
+ })
755
+ }
756
+ return reply.code(204).send()
757
+ })
758
+
759
+ app.post('/api/codex/runs/:runId/stop', async (request, reply) => {
760
+ const runRecord = getCodexRunById(request.params.runId)
761
+ if (!runRecord) {
762
+ return reply.code(404).send({ message: '没有找到对应的执行记录。' })
763
+ }
764
+
765
+ if (runRecord.status !== 'running') {
766
+ return { run: runRecord }
767
+ }
768
+
769
+ const controller = codexRunRuntime.getController(request.params.runId)
770
+ if (!controller) {
771
+ const stoppedRun = updateCodexRun(request.params.runId, {
772
+ status: 'stopped',
773
+ finishedAt: new Date().toISOString(),
774
+ })
775
+ broadcastServerEvent('runs.changed', {
776
+ taskSlug: stoppedRun?.taskSlug,
777
+ runId: request.params.runId,
778
+ })
779
+ broadcastServerEvent('sessions.changed', {
780
+ sessionId: stoppedRun?.sessionId,
781
+ })
782
+ return { run: stoppedRun }
783
+ }
784
+
785
+ controller.cancel()
786
+ return reply.code(202).send({
787
+ run: getCodexRunById(request.params.runId),
788
+ })
789
+ })
790
+
791
+ app.get('/api/codex/runs/:runId/stream', async (request, reply) => {
792
+ const runRecord = getCodexRunById(request.params.runId)
793
+ if (!runRecord) {
794
+ return reply.code(404).send({ message: '没有找到对应的执行记录。' })
795
+ }
796
+
797
+ reply.hijack()
798
+ const requestOrigin = request.headers.origin
799
+ reply.raw.writeHead(200, {
800
+ 'Content-Type': 'application/x-ndjson; charset=utf-8',
801
+ 'Cache-Control': 'no-cache, no-transform',
802
+ Connection: 'keep-alive',
803
+ 'X-Accel-Buffering': 'no',
804
+ ...(requestOrigin ? {
805
+ 'Access-Control-Allow-Origin': requestOrigin,
806
+ Vary: 'Origin',
807
+ } : {}),
808
+ })
809
+ reply.raw.socket?.setNoDelay?.(true)
810
+ reply.raw.flushHeaders?.()
811
+
812
+ const writeMessage = (payload) => {
813
+ if (reply.raw.destroyed || reply.raw.writableEnded) {
814
+ return
815
+ }
816
+
817
+ try {
818
+ reply.raw.write(`${JSON.stringify(payload)}
819
+ `)
820
+ } catch {
821
+ // Ignore write failures after the client disconnects.
822
+ }
823
+ }
824
+
825
+ const closeStream = () => {
826
+ if (!reply.raw.destroyed && !reply.raw.writableEnded) {
827
+ reply.raw.end()
828
+ }
829
+ }
830
+
831
+ writeMessage({
832
+ type: 'run',
833
+ run: runRecord,
834
+ })
835
+
836
+ const afterSeq = Math.max(0, Number(request.query?.afterSeq) || 0)
837
+ const existingEvents = listCodexRunEvents(request.params.runId, {
838
+ afterSeq,
839
+ }) || []
840
+
841
+ existingEvents.forEach((event) => {
842
+ writeMessage({
843
+ type: 'event',
844
+ event,
845
+ })
846
+ })
847
+
848
+ const latestRun = getCodexRunById(request.params.runId)
849
+ if (!latestRun || latestRun.status !== 'running' || !codexRunRuntime.getController(request.params.runId)) {
850
+ writeMessage({
851
+ type: 'run',
852
+ run: latestRun || runRecord,
853
+ })
854
+ closeStream()
855
+ return
856
+ }
857
+
858
+ const unsubscribe = codexRunRuntime.subscribe(request.params.runId, (payload) => {
859
+ writeMessage(payload)
860
+ if (payload.type === 'close') {
861
+ closeStream()
862
+ }
863
+ })
864
+
865
+ const handleAbort = () => {
866
+ unsubscribe()
867
+ }
868
+
869
+ reply.raw.on('close', handleAbort)
870
+ })
871
+
872
+ app.get('/api/tasks/:slug/raw', async (request, reply) => {
873
+ purgeExpiredContent()
874
+ const task = getTaskBySlug(request.params.slug)
875
+ if (!task || task.expired) {
876
+ return reply.code(404).type('text/plain; charset=utf-8').send('任务不存在。')
877
+ }
878
+
879
+ const exports = buildTaskExports(task)
880
+ return reply.type('text/plain; charset=utf-8').send(exports.raw)
881
+ })
882
+
883
+ if (hasBuiltWebApp) {
884
+ app.get('/', async (request, reply) => reply.sendFile('index.html', webDistDir))
885
+ app.get('/*', async (request, reply) => {
886
+ const requestPath = String(request.raw.url || '').split('?')[0]
887
+ if (requestPath.startsWith('/api/') || requestPath.startsWith('/uploads/')) {
888
+ return reply.code(404).send({ message: '资源不存在。' })
889
+ }
890
+ return reply.sendFile('index.html', webDistDir)
891
+ })
892
+ }
893
+
894
+ app.setErrorHandler((error, request, reply) => {
895
+ request.log.error(error)
896
+ const message = error.statusCode === 413 ? '文件太大了。' : error.message || '发生了意外错误。'
897
+ reply.code(error.statusCode || 500).send({ message })
898
+ })
899
+
900
+ markInterruptedCodexRuns()
901
+ purgeExpiredContent(true)
902
+
903
+ app.listen({ port, host }).then(() => {
904
+ app.log.info(`server running at http://${host}:${port}`)
905
+ buildServerAccessUrls(host, port).forEach((message) => {
906
+ app.log.info(message)
907
+ })
908
+ })
909
+