@xiguanpm/inkos-web 1.0.6

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,1022 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const { execInkos, execInkosStream, setWorkDir, getWorkDir, parseTextOutput } = require('../utils/cli');
5
+ const router = express.Router();
6
+
7
+ router.post('/read-file', async (req, res) => {
8
+ try {
9
+ const { path: filePath } = req.body;
10
+ if (!filePath) {
11
+ return res.status(400).json({ success: false, error: 'File path is required' });
12
+ }
13
+ if (!fs.existsSync(filePath)) {
14
+ return res.status(404).json({ success: false, error: 'File not found' });
15
+ }
16
+ const content = fs.readFileSync(filePath, 'utf-8');
17
+ res.json({ success: true, data: { content }, stdout: '' });
18
+ } catch (error) {
19
+ res.status(500).json({ success: false, error: error.message });
20
+ }
21
+ });
22
+
23
+ router.get('/status', async (req, res) => {
24
+ try {
25
+ const result = await execInkos('status --json');
26
+ if (result.success) {
27
+ const data = result.data;
28
+ const response = {
29
+ projectName: data.projectName || path.basename(getWorkDir()),
30
+ projectPath: getWorkDir(),
31
+ books: data.books || [],
32
+ totalChapters: data.totalChapters || 0,
33
+ totalWords: data.totalWords || 0,
34
+ pendingReviews: data.pendingReviews || 0,
35
+ };
36
+ res.json({ success: true, data: response, stdout: result.stdout });
37
+ } else {
38
+ res.json(result);
39
+ }
40
+ } catch (error) {
41
+ res.status(500).json({ success: false, error: error.message });
42
+ }
43
+ });
44
+
45
+ router.get('/books', async (req, res) => {
46
+ try {
47
+ const result = await execInkos('status --json');
48
+ res.json(result.success ? { ...result, data: result.data.books || [] } : result);
49
+ } catch (error) {
50
+ res.status(500).json({ success: false, error: error.message });
51
+ }
52
+ });
53
+
54
+ router.post('/books', async (req, res) => {
55
+ try {
56
+ const { title, genre, platform, chapterWords, targetChapters, brief } = req.body;
57
+ const args = [
58
+ 'book', 'create',
59
+ '--title', title,
60
+ '--genre', genre || 'xuanhuan',
61
+ '--platform', platform || 'tomato',
62
+ '--chapter-words', String(chapterWords || 3000),
63
+ '--target-chapters', String(targetChapters || 200)
64
+ ];
65
+
66
+ if (brief) {
67
+ args.push('--brief', brief);
68
+ }
69
+
70
+ const result = await execInkos(args, { timeout: 300000 });
71
+ res.json(result);
72
+ } catch (error) {
73
+ res.status(500).json({ success: false, error: error.message });
74
+ }
75
+ });
76
+
77
+ router.delete('/books/:id', async (req, res) => {
78
+ try {
79
+ const { id } = req.params;
80
+ const result = await execInkos(['book', 'delete', id, '--force']);
81
+ res.json(result);
82
+ } catch (error) {
83
+ res.status(500).json({ success: false, error: error.message });
84
+ }
85
+ });
86
+
87
+ router.get('/books/:id', async (req, res) => {
88
+ try {
89
+ const { id } = req.params;
90
+ const result = await execInkos(['status', id, '--chapters']);
91
+
92
+ if (!result.success) {
93
+ return res.json(result);
94
+ }
95
+
96
+ const data = result.data;
97
+ if (data.books && data.books.length > 0) {
98
+ const bookData = data.books[0];
99
+
100
+ const workDir = getWorkDir();
101
+ const chapterDir = path.join(workDir, 'books', id, 'chapters');
102
+ let chaptersWithMtime = bookData.chapterList || [];
103
+
104
+ if (fs.existsSync(chapterDir)) {
105
+ const files = fs.readdirSync(chapterDir).filter(f => f.endsWith('.md'));
106
+ const mtimeMap = {};
107
+ for (const file of files) {
108
+ const match = file.match(/^(\d+)_/);
109
+ if (match) {
110
+ const num = parseInt(match[1], 10);
111
+ const filePath = path.join(chapterDir, file);
112
+ const stats = fs.statSync(filePath);
113
+ mtimeMap[num] = stats.mtime.toISOString();
114
+ }
115
+ }
116
+ chaptersWithMtime = chaptersWithMtime.map(ch => ({
117
+ ...ch,
118
+ updatedAt: mtimeMap[ch.number] || ch.updatedAt || null,
119
+ }));
120
+ }
121
+
122
+ res.json({
123
+ success: true,
124
+ data: {
125
+ book: {
126
+ id: bookData.id,
127
+ title: bookData.title,
128
+ status: bookData.status,
129
+ genre: bookData.genre,
130
+ platform: bookData.platform,
131
+ targetChapters: bookData.targetChapters,
132
+ chapterWordCount: bookData.avgWordsPerChapter || 3000,
133
+ createdAt: new Date().toISOString(),
134
+ updatedAt: new Date().toISOString(),
135
+ },
136
+ chapters: chaptersWithMtime,
137
+ },
138
+ stdout: result.stdout,
139
+ });
140
+ } else {
141
+ res.json({ success: false, error: 'Book not found' });
142
+ }
143
+ } catch (error) {
144
+ res.status(500).json({ success: false, error: error.message });
145
+ }
146
+ });
147
+
148
+ router.get('/books/:id/chapter/:chapter', async (req, res) => {
149
+ try {
150
+ const { id, chapter } = req.params;
151
+ const chapterNum = parseInt(chapter, 10);
152
+ const workDir = getWorkDir();
153
+ const chapterDir = path.join(workDir, 'books', id, 'chapters');
154
+
155
+ if (!fs.existsSync(chapterDir)) {
156
+ return res.status(404).json({ success: false, error: 'Chapters directory not found' });
157
+ }
158
+
159
+ const files = fs.readdirSync(chapterDir).filter(f => f.endsWith('.md'));
160
+
161
+ let chapterFile = null;
162
+ for (const file of files) {
163
+ const match = file.match(/^(\d+)_/);
164
+ if (match && parseInt(match[1], 10) === chapterNum) {
165
+ chapterFile = path.join(chapterDir, file);
166
+ break;
167
+ }
168
+ }
169
+
170
+ if (!chapterFile || !fs.existsSync(chapterFile)) {
171
+ return res.status(404).json({ success: false, error: `Chapter ${chapter} not found` });
172
+ }
173
+
174
+ const content = fs.readFileSync(chapterFile, 'utf-8');
175
+ res.json({ success: true, data: { content }, stdout: '' });
176
+ } catch (error) {
177
+ res.status(500).json({ success: false, error: error.message });
178
+ }
179
+ });
180
+
181
+ router.put('/books/:id/chapter/:chapter', async (req, res) => {
182
+ try {
183
+ const { id, chapter } = req.params;
184
+ const { content } = req.body;
185
+ const chapterNum = parseInt(chapter, 10);
186
+ const workDir = getWorkDir();
187
+ const chapterDir = path.join(workDir, 'books', id, 'chapters');
188
+
189
+ if (!fs.existsSync(chapterDir)) {
190
+ return res.status(404).json({ success: false, error: 'Chapters directory not found' });
191
+ }
192
+
193
+ const files = fs.readdirSync(chapterDir).filter(f => f.endsWith('.md'));
194
+
195
+ let chapterFile = null;
196
+ for (const file of files) {
197
+ const match = file.match(/^(\d+)_/);
198
+ if (match && parseInt(match[1], 10) === chapterNum) {
199
+ chapterFile = path.join(chapterDir, file);
200
+ break;
201
+ }
202
+ }
203
+
204
+ if (!chapterFile) {
205
+ return res.status(404).json({ success: false, error: `Chapter ${chapter} not found` });
206
+ }
207
+
208
+ fs.writeFileSync(chapterFile, content, 'utf-8');
209
+ res.json({ success: true, data: { path: chapterFile }, stdout: '' });
210
+ } catch (error) {
211
+ res.status(500).json({ success: false, error: error.message });
212
+ }
213
+ });
214
+
215
+ router.patch('/books/:id', async (req, res) => {
216
+ try {
217
+ const { id } = req.params;
218
+ const { chapterWords, targetChapters, status } = req.body;
219
+
220
+ const args = ['book', 'update', id];
221
+
222
+ if (chapterWords) args.push('--chapter-words', String(chapterWords));
223
+ if (targetChapters) args.push('--target-chapters', String(targetChapters));
224
+ if (status) args.push('--status', status);
225
+
226
+ const result = await execInkos(args);
227
+ res.json(result);
228
+ } catch (error) {
229
+ res.status(500).json({ success: false, error: error.message });
230
+ }
231
+ });
232
+
233
+ router.post('/books/:id/write', async (req, res) => {
234
+ try {
235
+ const { id } = req.params;
236
+ const { count = 1, words, context, quiet } = req.body;
237
+
238
+ const args = ['write', 'next', id, '--count', String(count)];
239
+
240
+ if (words) args.push('--words', String(words));
241
+ if (context) args.push('--context', context);
242
+ if (quiet) args.push('-q');
243
+
244
+ const result = await execInkos(args, { timeout: 600000 });
245
+ res.json(result);
246
+ } catch (error) {
247
+ res.status(500).json({ success: false, error: error.message });
248
+ }
249
+ });
250
+
251
+ router.post('/books/:id/rewrite', async (req, res) => {
252
+ try {
253
+ const { id } = req.params;
254
+ const { chapter, force, words } = req.body;
255
+
256
+ const args = ['write', 'rewrite', id, String(chapter)];
257
+
258
+ if (force) args.push('--force');
259
+ if (words) args.push('--words', String(words));
260
+
261
+ execInkos(args, { timeout: 900000 });
262
+
263
+ res.json({ success: true, message: '重写任务已提交' });
264
+ } catch (error) {
265
+ res.status(500).json({ success: false, error: error.message });
266
+ }
267
+ });
268
+
269
+ router.post('/books/:id/draft', async (req, res) => {
270
+ try {
271
+ const { id } = req.params;
272
+ const { words, quiet } = req.body;
273
+
274
+ const args = ['draft', id];
275
+
276
+ if (words) args.push('--words', String(words));
277
+ if (quiet) args.push('-q');
278
+
279
+ const result = await execInkos(args, { timeout: 600000 });
280
+ res.json(result);
281
+ } catch (error) {
282
+ res.status(500).json({ success: false, error: error.message });
283
+ }
284
+ });
285
+
286
+ router.get('/review', async (req, res) => {
287
+ try {
288
+ const { bookId } = req.query;
289
+ const args = ['review', 'list'];
290
+ if (bookId) args.push(bookId);
291
+
292
+ const result = await execInkos(args);
293
+ res.json(result);
294
+ } catch (error) {
295
+ res.status(500).json({ success: false, error: error.message });
296
+ }
297
+ });
298
+
299
+ router.post('/review/approve', async (req, res) => {
300
+ try {
301
+ const { bookId, chapter } = req.body;
302
+ const args = ['review', 'approve'];
303
+ if (bookId) args.push(bookId);
304
+ args.push(String(chapter));
305
+
306
+ const result = await execInkos(args);
307
+ res.json(result);
308
+ } catch (error) {
309
+ res.status(500).json({ success: false, error: error.message });
310
+ }
311
+ });
312
+
313
+ router.post('/review/approve-all', async (req, res) => {
314
+ try {
315
+ const { bookId } = req.body;
316
+ const args = ['review', 'approve-all'];
317
+ if (bookId) args.push(bookId);
318
+
319
+ const result = await execInkos(args);
320
+ res.json(result);
321
+ } catch (error) {
322
+ res.status(500).json({ success: false, error: error.message });
323
+ }
324
+ });
325
+
326
+ router.post('/review/reject', async (req, res) => {
327
+ try {
328
+ const { bookId, chapter, reason } = req.body;
329
+ const args = ['review', 'reject'];
330
+ if (bookId) args.push(bookId);
331
+ args.push(String(chapter));
332
+ if (reason) args.push('--reason', reason);
333
+
334
+ const result = await execInkos(args);
335
+ res.json(result);
336
+ } catch (error) {
337
+ res.status(500).json({ success: false, error: error.message });
338
+ }
339
+ });
340
+
341
+ router.get('/books/:id/audit', async (req, res) => {
342
+ try {
343
+ const { id } = req.params;
344
+ const { chapter } = req.query;
345
+
346
+ const args = ['audit', id];
347
+ if (chapter) args.push(String(chapter));
348
+
349
+ const result = await execInkos(args);
350
+ res.json(result);
351
+ } catch (error) {
352
+ res.status(500).json({ success: false, error: error.message });
353
+ }
354
+ });
355
+
356
+ router.post('/books/:id/revise', async (req, res) => {
357
+ try {
358
+ const { id } = req.params;
359
+ const { chapter, mode } = req.body;
360
+
361
+ const args = ['revise', id];
362
+ if (chapter) args.push(String(chapter));
363
+ if (mode) args.push('--mode', mode);
364
+
365
+ const result = await execInkos(args, { timeout: 300000 });
366
+ res.json(result);
367
+ } catch (error) {
368
+ res.status(500).json({ success: false, error: error.message });
369
+ }
370
+ });
371
+
372
+ router.post('/books/:id/export', async (req, res) => {
373
+ try {
374
+ const { id } = req.params;
375
+ const { format, output, approvedOnly } = req.body;
376
+
377
+ const args = ['export', id, '--format', format || 'txt'];
378
+ if (output) args.push('--output', output);
379
+ if (approvedOnly) args.push('--approved-only');
380
+
381
+ const result = await execInkos(args);
382
+ res.json(result);
383
+ } catch (error) {
384
+ res.status(500).json({ success: false, error: error.message });
385
+ }
386
+ });
387
+
388
+ router.post('/import/chapters', async (req, res) => {
389
+ try {
390
+ const { bookId, from, split, resumeFrom } = req.body;
391
+
392
+ const args = ['import', 'chapters', bookId, '--from', from];
393
+ if (split) args.push('--split', split);
394
+ if (resumeFrom) args.push('--resume-from', String(resumeFrom));
395
+
396
+ const result = await execInkos(args);
397
+ res.json(result);
398
+ } catch (error) {
399
+ res.status(500).json({ success: false, error: error.message });
400
+ }
401
+ });
402
+
403
+ router.post('/import/canon', async (req, res) => {
404
+ try {
405
+ const { targetBookId, from } = req.body;
406
+
407
+ const args = ['import', 'canon', targetBookId, '--from', from];
408
+
409
+ const result = await execInkos(args);
410
+ res.json(result);
411
+ } catch (error) {
412
+ res.status(500).json({ success: false, error: error.message });
413
+ }
414
+ });
415
+
416
+ router.post('/style/analyze', async (req, res) => {
417
+ try {
418
+ const { file } = req.body;
419
+ const result = await execInkos(['style', 'analyze', file]);
420
+ res.json(result);
421
+ } catch (error) {
422
+ res.status(500).json({ success: false, error: error.message });
423
+ }
424
+ });
425
+
426
+ router.post('/style/import', async (req, res) => {
427
+ try {
428
+ const { file, bookId, name } = req.body;
429
+
430
+ const args = ['style', 'import', file];
431
+ if (bookId) args.push(bookId);
432
+ if (name) args.push('--name', name);
433
+
434
+ const result = await execInkos(args);
435
+ res.json(result);
436
+ } catch (error) {
437
+ res.status(500).json({ success: false, error: error.message });
438
+ }
439
+ });
440
+
441
+ router.get('/books/:id/detect', async (req, res) => {
442
+ try {
443
+ const { id } = req.params;
444
+ const { chapter } = req.query;
445
+
446
+ const args = ['detect', id];
447
+ if (chapter) args.push(String(chapter));
448
+
449
+ const result = await execInkos(args);
450
+ res.json(result);
451
+ } catch (error) {
452
+ res.status(500).json({ success: false, error: error.message });
453
+ }
454
+ });
455
+
456
+ router.post('/books/:id/detect-all', async (req, res) => {
457
+ try {
458
+ const { id } = req.params;
459
+ const result = await execInkos(['detect', id, '--all'], { timeout: 600000 });
460
+ res.json(result);
461
+ } catch (error) {
462
+ res.status(500).json({ success: false, error: error.message });
463
+ }
464
+ });
465
+
466
+ router.get('/books/:id/detect-stats', async (req, res) => {
467
+ try {
468
+ const { id } = req.params;
469
+ const result = await execInkos(['detect', id, '--stats']);
470
+ res.json(result);
471
+ } catch (error) {
472
+ res.status(500).json({ success: false, error: error.message });
473
+ }
474
+ });
475
+
476
+ router.get('/books/:id/analytics', async (req, res) => {
477
+ try {
478
+ const { id } = req.params;
479
+ const result = await execInkos(['analytics', id]);
480
+ res.json(result);
481
+ } catch (error) {
482
+ res.status(500).json({ success: false, error: error.message });
483
+ }
484
+ });
485
+
486
+ router.post('/radar/scan', async (req, res) => {
487
+ try {
488
+ const result = await execInkos(['radar', 'scan'], { timeout: 600000 });
489
+ res.json(result);
490
+ } catch (error) {
491
+ res.status(500).json({ success: false, error: error.message });
492
+ }
493
+ });
494
+
495
+ router.get('/radar/history', async (req, res) => {
496
+ try {
497
+ const workDir = getWorkDir();
498
+ const radarDir = path.join(workDir, 'radar');
499
+
500
+ if (!fs.existsSync(radarDir)) {
501
+ return res.json({ success: true, data: [], stdout: '' });
502
+ }
503
+
504
+ const files = fs.readdirSync(radarDir).filter(f => f.endsWith('.json'));
505
+ const history = [];
506
+
507
+ for (const file of files) {
508
+ try {
509
+ const filePath = path.join(radarDir, file);
510
+ const content = fs.readFileSync(filePath, 'utf-8');
511
+ const data = JSON.parse(content);
512
+ const stats = fs.statSync(filePath);
513
+ history.push({
514
+ fileName: file,
515
+ filePath: filePath,
516
+ scannedAt: stats.mtime.toISOString(),
517
+ createdAt: stats.birthtime.toISOString(),
518
+ marketSummary: data.marketSummary || '',
519
+ recommendationCount: data.recommendations ? data.recommendations.length : 0,
520
+ });
521
+ } catch (e) {
522
+ console.error(`[DEBUG] Failed to parse radar file ${file}:`, e.message);
523
+ }
524
+ }
525
+
526
+ history.sort((a, b) => new Date(b.scannedAt).getTime() - new Date(a.scannedAt).getTime());
527
+
528
+ res.json({ success: true, data: history, stdout: '' });
529
+ } catch (error) {
530
+ res.status(500).json({ success: false, error: error.message });
531
+ }
532
+ });
533
+
534
+ router.get('/radar/:fileName', async (req, res) => {
535
+ try {
536
+ const { fileName } = req.params;
537
+ const workDir = getWorkDir();
538
+ const filePath = path.join(workDir, 'radar', fileName);
539
+
540
+ if (!filePath.startsWith(path.join(workDir, 'radar'))) {
541
+ return res.status(403).json({ success: false, error: 'Invalid file path' });
542
+ }
543
+
544
+ if (!fs.existsSync(filePath)) {
545
+ return res.status(404).json({ success: false, error: 'File not found' });
546
+ }
547
+
548
+ const content = fs.readFileSync(filePath, 'utf-8');
549
+ const data = JSON.parse(content);
550
+ res.json({ success: true, data, stdout: '' });
551
+ } catch (error) {
552
+ res.status(500).json({ success: false, error: error.message });
553
+ }
554
+ });
555
+
556
+ router.get('/genres', async (req, res) => {
557
+ try {
558
+ const result = await execInkos(['genre', 'list'], { json: false });
559
+ if (result.success) {
560
+ const lines = result.stdout.split('\n');
561
+ const genres = [];
562
+ for (const line of lines) {
563
+ const trimmed = line.trim();
564
+ if (trimmed && !trimmed.startsWith('Available') && !trimmed.startsWith('Total:') && trimmed !== '') {
565
+ const match = trimmed.match(/^(\S+)/);
566
+ if (match) {
567
+ genres.push(match[1]);
568
+ }
569
+ }
570
+ }
571
+ res.json({ success: true, data: genres, stdout: result.stdout });
572
+ } else {
573
+ res.json(result);
574
+ }
575
+ } catch (error) {
576
+ res.status(500).json({ success: false, error: error.message });
577
+ }
578
+ });
579
+
580
+ router.get('/genres/:id', async (req, res) => {
581
+ try {
582
+ const { id } = req.params;
583
+ const workDir = getWorkDir();
584
+ const genrePath = path.join(workDir, 'genres', `${id}.md`);
585
+ console.log(`[DEBUG] Getting genre ${id}, workDir: ${workDir}, genrePath: ${genrePath}`);
586
+ if (fs.existsSync(genrePath)) {
587
+ const content = fs.readFileSync(genrePath, 'utf-8');
588
+ res.json({ success: true, data: { content }, stdout: content });
589
+ } else {
590
+ console.log(`[DEBUG] Genre file not found: ${genrePath}, falling back to inkos CLI`);
591
+ const result = await execInkos(['genre', 'show', id], { json: false });
592
+ res.json({ success: true, data: { content: result.stdout }, stdout: result.stdout });
593
+ }
594
+ } catch (error) {
595
+ res.status(500).json({ success: false, error: error.message });
596
+ }
597
+ });
598
+
599
+ router.post('/genres', async (req, res) => {
600
+ try {
601
+ const { name, from } = req.body;
602
+ const args = ['genre', 'create', name];
603
+ if (from) {
604
+ args.push('--from', from);
605
+ }
606
+ const result = await execInkos(args, { json: false });
607
+ res.json(result);
608
+ } catch (error) {
609
+ res.status(500).json({ success: false, error: error.message });
610
+ }
611
+ });
612
+
613
+ router.put('/genres/:id', async (req, res) => {
614
+ try {
615
+ const { id } = req.params;
616
+ const { content } = req.body;
617
+ const workDir = getWorkDir();
618
+ const genrePath = path.join(workDir, 'genres', `${id}.md`);
619
+ fs.writeFileSync(genrePath, content, 'utf-8');
620
+ res.json({ success: true, data: { path: genrePath } });
621
+ } catch (error) {
622
+ res.status(500).json({ success: false, error: error.message });
623
+ }
624
+ });
625
+
626
+ router.post('/init', async (req, res) => {
627
+ try {
628
+ const { name, path: projectPath, createExample, llmConfig } = req.body;
629
+
630
+ console.log('[DEBUG] /init request body:', req.body);
631
+ console.log('[DEBUG] projectPath:', projectPath);
632
+ console.log('[DEBUG] name:', name);
633
+
634
+ let initPath = projectPath;
635
+ let cwd = getWorkDir();
636
+
637
+ if (projectPath) {
638
+ cwd = path.dirname(projectPath);
639
+ initPath = path.basename(projectPath);
640
+ }
641
+
642
+ const args = ['init'];
643
+ if (initPath) {
644
+ args.push(initPath);
645
+ }
646
+
647
+ console.log('[DEBUG] Final command: inkos', args.join(' '), 'in', cwd);
648
+
649
+ const result = await execInkos(args, { json: false, cwd });
650
+
651
+ if (result.success && llmConfig && projectPath) {
652
+ const envFilePath = path.join(projectPath, '.env');
653
+ const envContent = [];
654
+
655
+ // 读取原有 .env 文件内容(如果存在)
656
+ let existingContent = '';
657
+ if (fs.existsSync(envFilePath)) {
658
+ existingContent = fs.readFileSync(envFilePath, 'utf-8');
659
+ }
660
+
661
+ // 构建新的 LLM 配置
662
+ const newConfig = [];
663
+ if (llmConfig.provider) {
664
+ newConfig.push(`INKOS_LLM_PROVIDER=${llmConfig.provider}`);
665
+ }
666
+ if (llmConfig.baseUrl) {
667
+ newConfig.push(`INKOS_LLM_BASE_URL=${llmConfig.baseUrl}`);
668
+ }
669
+ if (llmConfig.apiKey) {
670
+ newConfig.push(`INKOS_LLM_API_KEY=${llmConfig.apiKey}`);
671
+ }
672
+ if (llmConfig.model) {
673
+ newConfig.push(`INKOS_LLM_MODEL=${llmConfig.model}`);
674
+ }
675
+ if (llmConfig.temperature) {
676
+ newConfig.push(`INKOS_LLM_TEMPERATURE=${llmConfig.temperature}`);
677
+ }
678
+ if (llmConfig.maxTokens) {
679
+ newConfig.push(`INKOS_LLM_MAX_TOKENS=${llmConfig.maxTokens}`);
680
+ }
681
+ if (llmConfig.thinkingBudget !== undefined && llmConfig.thinkingBudget !== null) {
682
+ newConfig.push(`INKOS_LLM_THINKING_BUDGET=${llmConfig.thinkingBudget}`);
683
+ }
684
+
685
+ // 合并原有内容和新配置
686
+ if (existingContent) {
687
+ envContent.push(existingContent);
688
+ }
689
+ if (newConfig.length > 0) {
690
+ envContent.push(newConfig.join('\n'));
691
+ }
692
+
693
+ if (envContent.length > 0) {
694
+ fs.writeFileSync(envFilePath, envContent.join('\n'), 'utf-8');
695
+ }
696
+ }
697
+
698
+ res.json(result);
699
+ } catch (error) {
700
+ res.status(500).json({ success: false, error: error.message });
701
+ }
702
+ });
703
+
704
+ router.get('/doctor', async (req, res) => {
705
+ try {
706
+ const result = await execInkos(['doctor'], { json: false });
707
+ res.json(result);
708
+ } catch (error) {
709
+ res.status(500).json({ success: false, error: error.message });
710
+ }
711
+ });
712
+
713
+ router.get('/config/show', async (req, res) => {
714
+ try {
715
+ const result = await execInkos(['config', 'show']);
716
+ res.json(result);
717
+ } catch (error) {
718
+ res.status(500).json({ success: false, error: error.message });
719
+ }
720
+ });
721
+
722
+ router.get('/config/show-global', async (req, res) => {
723
+ try {
724
+ const result = await execInkos(['config', 'show-global'], { json: false });
725
+ res.json(result);
726
+ } catch (error) {
727
+ res.status(500).json({ success: false, error: error.message });
728
+ }
729
+ });
730
+
731
+ router.get('/config/models', async (req, res) => {
732
+ try {
733
+ const result = await execInkos(['config', 'show-models']);
734
+ res.json(result);
735
+ } catch (error) {
736
+ res.status(500).json({ success: false, error: error.message });
737
+ }
738
+ });
739
+
740
+ router.post('/config/set', async (req, res) => {
741
+ try {
742
+ const { key, value } = req.body;
743
+ const result = await execInkos(['config', 'set', key, value], { json: false });
744
+ res.json(result);
745
+ } catch (error) {
746
+ res.status(500).json({ success: false, error: error.message });
747
+ }
748
+ });
749
+
750
+ router.post('/config/set-global', async (req, res) => {
751
+ try {
752
+ const { provider, baseUrl, apiKey, model } = req.body;
753
+
754
+ const args = ['config', 'set-global'];
755
+ if (provider) args.push('--provider', provider);
756
+ if (baseUrl) args.push('--base-url', baseUrl);
757
+ if (apiKey) args.push('--api-key', apiKey);
758
+ if (model) args.push('--model', model);
759
+
760
+ const result = await execInkos(args, { json: false });
761
+ res.json(result);
762
+ } catch (error) {
763
+ res.status(500).json({ success: false, error: error.message });
764
+ }
765
+ });
766
+
767
+ router.post('/config/set-model', async (req, res) => {
768
+ try {
769
+ const { agent, model, provider, baseUrl, apiKeyEnv } = req.body;
770
+
771
+ const args = ['config', 'set-model', agent, model];
772
+ if (provider) args.push('--provider', provider);
773
+ if (baseUrl) args.push('--base-url', baseUrl);
774
+ if (apiKeyEnv) args.push('--api-key-env', apiKeyEnv);
775
+
776
+ const result = await execInkos(args, { json: false });
777
+ res.json(result);
778
+ } catch (error) {
779
+ res.status(500).json({ success: false, error: error.message });
780
+ }
781
+ });
782
+
783
+ router.delete('/config/remove-model', async (req, res) => {
784
+ try {
785
+ const { agent } = req.body;
786
+ const result = await execInkos(['config', 'remove-model', agent], { json: false });
787
+ res.json(result);
788
+ } catch (error) {
789
+ res.status(500).json({ success: false, error: error.message });
790
+ }
791
+ });
792
+
793
+ router.get('/config/project-env', async (req, res) => {
794
+ try {
795
+ const projectPath = getWorkDir();
796
+ const envFilePath = path.join(projectPath, '.env');
797
+
798
+ if (!fs.existsSync(envFilePath)) {
799
+ return res.json({ success: true, data: {} });
800
+ }
801
+
802
+ const envContent = fs.readFileSync(envFilePath, 'utf-8');
803
+ const envConfig = {};
804
+
805
+ envContent.split('\n').forEach(line => {
806
+ const trimmedLine = line.trim();
807
+ if (trimmedLine && !trimmedLine.startsWith('#')) {
808
+ const [key, ...valueParts] = trimmedLine.split('=');
809
+ const value = valueParts.join('=');
810
+ if (key && value !== undefined) {
811
+ envConfig[key.trim()] = value.trim();
812
+ }
813
+ }
814
+ });
815
+
816
+ res.json({ success: true, data: envConfig });
817
+ } catch (error) {
818
+ res.status(500).json({ success: false, error: error.message });
819
+ }
820
+ });
821
+
822
+ router.post('/config/project-env', async (req, res) => {
823
+ try {
824
+ const projectPath = getWorkDir();
825
+ const envFilePath = path.join(projectPath, '.env');
826
+ const updates = req.body;
827
+
828
+ let existingContent = '';
829
+ if (fs.existsSync(envFilePath)) {
830
+ existingContent = fs.readFileSync(envFilePath, 'utf-8');
831
+ }
832
+
833
+ const existingLines = existingContent.split('\n');
834
+ const updatedKeys = new Set(Object.keys(updates));
835
+ const preservedLines = [];
836
+
837
+ existingLines.forEach(line => {
838
+ const trimmedLine = line.trim();
839
+ if (trimmedLine && !trimmedLine.startsWith('#')) {
840
+ const [key] = trimmedLine.split('=');
841
+ if (key && !updatedKeys.has(key.trim())) {
842
+ preservedLines.push(line);
843
+ }
844
+ } else if (trimmedLine.startsWith('#') || trimmedLine === '') {
845
+ preservedLines.push(line);
846
+ }
847
+ });
848
+
849
+ const newLines = Object.entries(updates).map(([key, value]) => {
850
+ if (value !== null && value !== undefined && value !== '') {
851
+ return `${key}=${value}`;
852
+ }
853
+ return null;
854
+ }).filter(line => line !== null);
855
+
856
+ const finalContent = [...preservedLines, ...newLines].join('\n');
857
+ fs.writeFileSync(envFilePath, finalContent, 'utf-8');
858
+
859
+ res.json({ success: true, message: '配置已保存' });
860
+ } catch (error) {
861
+ res.status(500).json({ success: false, error: error.message });
862
+ }
863
+ });
864
+
865
+ router.post('/daemon/up', async (req, res) => {
866
+ try {
867
+ const { quiet } = req.body;
868
+ const args = ['up'];
869
+ if (quiet) args.push('-q');
870
+
871
+ const result = await execInkos(args, { json: false });
872
+ res.json(result);
873
+ } catch (error) {
874
+ res.status(500).json({ success: false, error: error.message });
875
+ }
876
+ });
877
+
878
+ router.post('/daemon/down', async (req, res) => {
879
+ try {
880
+ const result = await execInkos(['down'], { json: false });
881
+ res.json(result);
882
+ } catch (error) {
883
+ res.status(500).json({ success: false, error: error.message });
884
+ }
885
+ });
886
+
887
+ router.post('/agent', async (req, res) => {
888
+ try {
889
+ const { instruction } = req.body;
890
+ const result = await execInkos(['agent', instruction], { timeout: 600000 });
891
+ res.json(result);
892
+ } catch (error) {
893
+ res.status(500).json({ success: false, error: error.message });
894
+ }
895
+ });
896
+
897
+ router.get('/version', async (req, res) => {
898
+ try {
899
+ const result = await execInkos(['--version'], { json: false });
900
+ res.json(result);
901
+ } catch (error) {
902
+ res.status(500).json({ success: false, error: error.message });
903
+ }
904
+ });
905
+
906
+ router.post('/update', async (req, res) => {
907
+ try {
908
+ const result = await execInkos(['update'], { json: false });
909
+ res.json(result);
910
+ } catch (error) {
911
+ res.status(500).json({ success: false, error: error.message });
912
+ }
913
+ });
914
+
915
+ router.post('/work-dir', (req, res) => {
916
+ try {
917
+ const { path: workPath } = req.body;
918
+ if (setWorkDir(workPath)) {
919
+ res.json({ success: true, data: { workDir: getWorkDir() } });
920
+ } else {
921
+ res.json({ success: false, error: 'Invalid directory path' });
922
+ }
923
+ } catch (error) {
924
+ res.status(500).json({ success: false, error: error.message });
925
+ }
926
+ });
927
+
928
+ router.get('/work-dir', (req, res) => {
929
+ res.json({ success: true, data: { workDir: getWorkDir() } });
930
+ });
931
+
932
+ router.get('/books/:id/truth-files', async (req, res) => {
933
+ try {
934
+ const { id } = req.params;
935
+ const workDir = getWorkDir();
936
+ const truthDir = path.join(workDir, 'books', id, 'story');
937
+
938
+ if (!fs.existsSync(truthDir)) {
939
+ return res.json({ success: true, data: [], stdout: '' });
940
+ }
941
+
942
+ const truthFiles = [
943
+ 'current_state.md',
944
+ 'particle_ledger.md',
945
+ 'pending_hooks.md',
946
+ 'chapter_summaries.md',
947
+ 'subplot_board.md',
948
+ 'emotional_arcs.md',
949
+ 'character_matrix.md',
950
+ ];
951
+
952
+ const files = [];
953
+ for (const file of truthFiles) {
954
+ const filePath = path.join(truthDir, file);
955
+ if (fs.existsSync(filePath)) {
956
+ const stats = fs.statSync(filePath);
957
+ files.push({
958
+ name: file,
959
+ path: filePath,
960
+ size: stats.size,
961
+ mtime: stats.mtime.toISOString(),
962
+ });
963
+ }
964
+ }
965
+
966
+ res.json({ success: true, data: files, stdout: '' });
967
+ } catch (error) {
968
+ res.status(500).json({ success: false, error: error.message });
969
+ }
970
+ });
971
+
972
+ router.get('/books/:id/truth-file', async (req, res) => {
973
+ try {
974
+ const { id } = req.params;
975
+ const { file } = req.query;
976
+
977
+ if (!file) {
978
+ return res.status(400).json({ success: false, error: 'File name is required' });
979
+ }
980
+
981
+ const workDir = getWorkDir();
982
+ const filePath = path.join(workDir, 'books', id, 'story', file);
983
+
984
+ if (!filePath.startsWith(path.join(workDir, 'books', id, 'story'))) {
985
+ return res.status(403).json({ success: false, error: 'Invalid file path' });
986
+ }
987
+
988
+ if (!fs.existsSync(filePath)) {
989
+ return res.status(404).json({ success: false, error: 'File not found' });
990
+ }
991
+
992
+ const content = fs.readFileSync(filePath, 'utf-8');
993
+ res.json({ success: true, data: { content, path: filePath }, stdout: '' });
994
+ } catch (error) {
995
+ res.status(500).json({ success: false, error: error.message });
996
+ }
997
+ });
998
+
999
+ router.put('/books/:id/truth-file', async (req, res) => {
1000
+ try {
1001
+ const { id } = req.params;
1002
+ const { file, content } = req.body;
1003
+
1004
+ if (!file) {
1005
+ return res.status(400).json({ success: false, error: 'File name is required' });
1006
+ }
1007
+
1008
+ const workDir = getWorkDir();
1009
+ const filePath = path.join(workDir, 'books', id, 'story', file);
1010
+
1011
+ if (!filePath.startsWith(path.join(workDir, 'books', id, 'story'))) {
1012
+ return res.status(403).json({ success: false, error: 'Invalid file path' });
1013
+ }
1014
+
1015
+ fs.writeFileSync(filePath, content, 'utf-8');
1016
+ res.json({ success: true, data: { path: filePath }, stdout: '' });
1017
+ } catch (error) {
1018
+ res.status(500).json({ success: false, error: error.message });
1019
+ }
1020
+ });
1021
+
1022
+ module.exports = router;