@waterwx/dsh-novel-forge 1.7.3 → 1.8.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.
package/src/routes.ts CHANGED
@@ -13,6 +13,7 @@ import { spawn } from 'node:child_process'
13
13
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
14
  import { join, basename, extname } from 'node:path'
15
15
  import { homedir } from 'node:os'
16
+ import { randomBytes } from 'node:crypto'
16
17
  import { fileURLToPath } from 'node:url'
17
18
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
18
19
  import type { Context } from '@deepseek-ai/cordis'
@@ -105,10 +106,20 @@ import {
105
106
  type SummaryRequest,
106
107
  type VolumesRequest,
107
108
  type VolumesResponse,
109
+ type AuthorAssetsResponse,
110
+ type AuthorAssetUpsertRequest,
111
+ type AuthorAssetRemoveRequest,
112
+ type AdaptAnalyzeRequest,
113
+ type AdaptAnalyzeResponse,
114
+ type AdaptProposeRequest,
115
+ type AdaptProposeResponse,
116
+ type AdaptExecuteRequest,
117
+ type AdaptExecuteResponse,
108
118
  } from './protocol.ts'
109
119
  import { readOutlineFromDocx } from './docx.ts'
110
120
  import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
111
121
  import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
122
+ import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-assets.ts'
112
123
  import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
113
124
  import {
114
125
  chapterFileName,
@@ -153,6 +164,9 @@ import {
153
164
  suggestOutlines,
154
165
  reverseOutlineFromChapters,
155
166
  breakdownBook,
167
+ analyzeAdaptation,
168
+ proposeAdaptation,
169
+ applyAdaptationReplacements,
156
170
  generateRoleReferenceImage,
157
171
  generateMangaRoleReferenceImage,
158
172
  testImageEndpoint,
@@ -2754,6 +2768,174 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2754
2768
  },
2755
2769
  }
2756
2770
 
2771
+ // -------------------------------------------------- author assets (总数据)
2772
+ const authorAssetsRoute: WebRoute = {
2773
+ kind: 'exact',
2774
+ path: NOVEL_API.authorAssets,
2775
+ handler: (req, res) => {
2776
+ if (!guard(req, res, 'GET')) return
2777
+ const response: AuthorAssetsResponse = { assets: loadAuthorAssets() }
2778
+ writeJson(res, 200, response)
2779
+ },
2780
+ }
2781
+
2782
+ const authorAssetsUpsertRoute: WebRoute = {
2783
+ kind: 'exact',
2784
+ path: NOVEL_API.authorAssetsUpsert,
2785
+ handler: async (req, res) => {
2786
+ if (!guard(req, res, 'POST')) return
2787
+ const body = await readJsonBody<AuthorAssetUpsertRequest>(req)
2788
+ const asset = body?.asset
2789
+ if (asset === undefined || asset.name.trim() === '' || asset.content.trim() === '') {
2790
+ writeJson(res, 400, { error: 'asset 须含 name / content' })
2791
+ return
2792
+ }
2793
+ const response: AuthorAssetsResponse = { assets: upsertAuthorAsset(asset) }
2794
+ writeJson(res, 200, response)
2795
+ },
2796
+ }
2797
+
2798
+ const authorAssetsRemoveRoute: WebRoute = {
2799
+ kind: 'exact',
2800
+ path: NOVEL_API.authorAssetsRemove,
2801
+ handler: async (req, res) => {
2802
+ if (!guard(req, res, 'POST')) return
2803
+ const body = await readJsonBody<AuthorAssetRemoveRequest>(req)
2804
+ const id = body?.id ?? ''
2805
+ if (id === '') { writeJson(res, 400, { error: 'id 必填' }); return }
2806
+ const response: AuthorAssetsResponse = { assets: removeAuthorAsset(id) }
2807
+ writeJson(res, 200, response)
2808
+ },
2809
+ }
2810
+
2811
+ const authorAssetsImportDefaultRoute: WebRoute = {
2812
+ kind: 'exact',
2813
+ path: NOVEL_API.authorAssetsImportDefault,
2814
+ handler: (req, res) => {
2815
+ if (!guard(req, res, 'POST')) return
2816
+ const response: AuthorAssetsResponse = { assets: importDefaultAuthorAssets() }
2817
+ writeJson(res, 200, response)
2818
+ },
2819
+ }
2820
+
2821
+ // --------------------------------------------------- adaptation (改编 P0 分析)
2822
+ const adaptAnalyzeRoute: WebRoute = {
2823
+ kind: 'exact',
2824
+ path: NOVEL_API.adaptAnalyze,
2825
+ handler: async (req, res) => {
2826
+ if (!guard(req, res, 'POST')) return
2827
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
2828
+ const body = await readJsonBody<AdaptAnalyzeRequest>(req)
2829
+ const config = getConfig()
2830
+ let text = body?.text?.trim() ?? ''
2831
+ if (text === '' && body?.filePath !== undefined && body.filePath !== '') {
2832
+ try {
2833
+ text = readFileSync(body.filePath, 'utf8')
2834
+ } catch (err) {
2835
+ writeJson(res, 400, { error: '读取文件失败:' + (err as Error).message })
2836
+ return
2837
+ }
2838
+ }
2839
+ if (text.length < 200) {
2840
+ writeJson(res, 400, { error: '全文内容过短(<200 字符),请上传完整小说文本' })
2841
+ return
2842
+ }
2843
+ try {
2844
+ const response: AdaptAnalyzeResponse = await analyzeAdaptation(ctx, config, text)
2845
+ writeJson(res, 200, response)
2846
+ } catch (err) {
2847
+ writeJson(res, 400, { error: (err as Error).message })
2848
+ }
2849
+ },
2850
+ }
2851
+
2852
+ const adaptProposeRoute: WebRoute = {
2853
+ kind: 'exact',
2854
+ path: NOVEL_API.adaptPropose,
2855
+ handler: async (req, res) => {
2856
+ if (!guard(req, res, 'POST')) return
2857
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
2858
+ const body = await readJsonBody<AdaptProposeRequest>(req)
2859
+ const config = getConfig()
2860
+ const text = (body?.text ?? '').trim()
2861
+ const selections = body?.selections ?? []
2862
+ if (text.length < 200 || selections.length === 0) {
2863
+ writeJson(res, 400, { error: '请提供全文与至少一条要改的维度' })
2864
+ return
2865
+ }
2866
+ try {
2867
+ const response: AdaptProposeResponse = await proposeAdaptation(ctx, config, text, selections, body?.dimensions)
2868
+ writeJson(res, 200, response)
2869
+ } catch (err) {
2870
+ writeJson(res, 400, { error: (err as Error).message })
2871
+ }
2872
+ },
2873
+ }
2874
+
2875
+ const adaptExecuteRoute: WebRoute = {
2876
+ kind: 'exact',
2877
+ path: NOVEL_API.adaptExecute,
2878
+ handler: async (req, res) => {
2879
+ if (!guard(req, res, 'POST')) return
2880
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
2881
+ const body = await readJsonBody<AdaptExecuteRequest>(req)
2882
+ const text = body?.text ?? ''
2883
+ const mappings = body?.mappings ?? []
2884
+ if (text.length < 200 || mappings.length === 0) {
2885
+ writeJson(res, 400, { error: '请提供全文与映射表' })
2886
+ return
2887
+ }
2888
+ if (body?.mode === 'rewrite') {
2889
+ writeJson(res, 400, { error: 'rewrite 模式尚未实现,请使用 replace(术语替换)' })
2890
+ return
2891
+ }
2892
+ const { adaptedText, hits } = applyAdaptationReplacements(text, mappings)
2893
+ const response: AdaptExecuteResponse = { adaptedText, mappings: mappings.length, hits }
2894
+ writeJson(res, 200, response)
2895
+ },
2896
+ }
2897
+
2898
+ // ------------------------------------------ theme custom background (upload + serve)
2899
+ const THEME_BG_DIR = join(homedir(), '.dsh', 'dsh-novel-forge-assets')
2900
+ const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
2901
+
2902
+ const themeBackgroundUploadRoute: WebRoute = {
2903
+ kind: 'exact',
2904
+ path: NOVEL_API.themeBackgroundUpload,
2905
+ handler: async (req, res) => {
2906
+ if (!guard(req, res, 'POST')) return
2907
+ const body = await readJsonBody<{ dataUrl?: string }>(req)
2908
+ const dataUrl = body?.dataUrl ?? ''
2909
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl)
2910
+ if (m === null) { writeJson(res, 400, { error: '仅支持 PNG/JPG/WebP/GIF 图片' }); return }
2911
+ const ext = m[1]!.toLowerCase() === 'jpg' ? 'jpeg' : m[1]!.toLowerCase()
2912
+ const buf = Buffer.from(m[2]!, 'base64')
2913
+ if (buf.length === 0) { writeJson(res, 400, { error: '图片数据为空' }); return }
2914
+ mkdirSync(THEME_BG_DIR, { recursive: true })
2915
+ const name = 'theme-bg-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex') + '.' + ext
2916
+ writeFileSync(join(THEME_BG_DIR, name), buf)
2917
+ writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + '/' + name })
2918
+ },
2919
+ }
2920
+
2921
+ const themeBackgroundGetRoute: WebRoute = {
2922
+ kind: 'prefix',
2923
+ path: NOVEL_API.themeBackgroundGet,
2924
+ handler: (req, res) => {
2925
+ if (!guard(req, res, 'GET')) return
2926
+ let pathName = ''
2927
+ try { pathName = new URL(req.url ?? '', 'http://localhost').pathname } catch { /* ignore */ }
2928
+ const name = basename(pathName)
2929
+ if (!name.startsWith('theme-bg-')) { writeJson(res, 404, { error: 'not found' }); return }
2930
+ const file = join(THEME_BG_DIR, name)
2931
+ if (!existsSync(file)) { writeJson(res, 404, { error: 'not found' }); return }
2932
+ const ext = extname(file).slice(1)
2933
+ const type = MIME_BY_EXT[ext] ?? 'application/octet-stream'
2934
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=31536000' })
2935
+ res.end(readFileSync(file))
2936
+ },
2937
+ }
2938
+
2757
2939
  return [
2758
2940
  statusRoute,
2759
2941
  loadOutlineRoute,
@@ -2816,6 +2998,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2816
2998
  runStartRoute,
2817
2999
  runControlRoute,
2818
3000
  runStatusRoute,
3001
+ authorAssetsRoute,
3002
+ authorAssetsUpsertRoute,
3003
+ authorAssetsRemoveRoute,
3004
+ authorAssetsImportDefaultRoute,
3005
+ adaptAnalyzeRoute,
3006
+ adaptProposeRoute,
3007
+ adaptExecuteRoute,
3008
+ themeBackgroundUploadRoute,
3009
+ themeBackgroundGetRoute,
2819
3010
  ]
2820
3011
  }
2821
3012