dsh-prompt-customizer 0.2.6 → 0.3.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/LICENSE +21 -21
- package/README.en.md +13 -32
- package/README.i18n.yaml +2 -2
- package/README.md +45 -38
- package/client/client.js +41 -16
- package/client/client.js.map +1 -1
- package/cordis.patch.yml +8 -8
- package/lib/index.js +270 -254
- package/lib/schema.js +51 -51
- package/package.json +61 -61
- package/src/client/PresetsTab.tsx +110 -110
- package/src/client/PreviewTab.tsx +61 -59
- package/src/client/PreviewTools.tsx +31 -31
- package/src/client/SectionsTab.tsx +202 -201
- package/src/client/ToolsTab.tsx +48 -48
- package/src/client/index.ts +116 -115
- package/src/client/locales.ts +101 -97
- package/src/client/presets.ts +268 -268
- package/src/client/styles.ts +41 -41
- package/src/client/types.ts +50 -50
- package/lib/client.js +0 -344
package/lib/index.js
CHANGED
|
@@ -1,255 +1,271 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-prompt-customizer — host half.
|
|
3
|
-
*
|
|
4
|
-
* - Registers the `prompt-customizer` settings namespace (live-applied).
|
|
5
|
-
* - Hooks the `system-prompt/assemble` waterfall to block / replace / inject
|
|
6
|
-
* prompt sections and to hide tools from the model catalog by name. Only the
|
|
7
|
-
* model-facing list is affected; tools and routes keep working.
|
|
8
|
-
* - Serves `GET /api/prompt-customizer/inventory` returning the effective
|
|
9
|
-
* section and tool lists (for the `standard` agent when a roster exists),
|
|
10
|
-
* each marked with whether it is currently blocked / hidden.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
14
|
-
import { Config, NS } from './schema.js'
|
|
15
|
-
|
|
16
|
-
export const name = 'prompt-customizer'
|
|
17
|
-
export const inject = ['settings', 'systemPrompt', 'tools']
|
|
18
|
-
|
|
19
|
-
export function apply(ctx, entry = {}) {
|
|
20
|
-
// 1) settings namespace: base = composition entry, live-applied.
|
|
21
|
-
const scope = ctx.settings.register(settingsNamespace(NS), Config, {
|
|
22
|
-
base: entry,
|
|
23
|
-
applies: 'live',
|
|
24
|
-
})
|
|
25
|
-
const read = () => scope.get() ?? {}
|
|
26
|
-
|
|
27
|
-
// 2) assemble waterfall: apply filter/replace/inject live.
|
|
28
|
-
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
29
|
-
const assembled = await next()
|
|
30
|
-
const cfg = read()
|
|
31
|
-
const denied = new Set(cfg.sections ?? [])
|
|
32
|
-
const replace = cfg.replace ?? {}
|
|
33
|
-
const injectList = cfg.inject ?? []
|
|
34
|
-
const te = cfg.tools ?? {}
|
|
35
|
-
const exclude = new Set(te.exclude ?? [])
|
|
36
|
-
const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
|
|
37
|
-
|
|
38
|
-
let sections = assembled.sections
|
|
39
|
-
.filter((section) => !denied.has(section.name))
|
|
40
|
-
.map((section) =>
|
|
41
|
-
Object.hasOwn(replace, section.name) ? { ...section, text: replace[section.name] } : section,
|
|
42
|
-
)
|
|
43
|
-
for (const item of injectList) {
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-prompt-customizer — host half.
|
|
3
|
+
*
|
|
4
|
+
* - Registers the `prompt-customizer` settings namespace (live-applied).
|
|
5
|
+
* - Hooks the `system-prompt/assemble` waterfall to block / replace / inject
|
|
6
|
+
* prompt sections and to hide tools from the model catalog by name. Only the
|
|
7
|
+
* model-facing list is affected; tools and routes keep working.
|
|
8
|
+
* - Serves `GET /api/prompt-customizer/inventory` returning the effective
|
|
9
|
+
* section and tool lists (for the `standard` agent when a roster exists),
|
|
10
|
+
* each marked with whether it is currently blocked / hidden.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
14
|
+
import { Config, NS } from './schema.js'
|
|
15
|
+
|
|
16
|
+
export const name = 'prompt-customizer'
|
|
17
|
+
export const inject = ['settings', 'systemPrompt', 'tools']
|
|
18
|
+
|
|
19
|
+
export function apply(ctx, entry = {}) {
|
|
20
|
+
// 1) settings namespace: base = composition entry, live-applied.
|
|
21
|
+
const scope = ctx.settings.register(settingsNamespace(NS), Config, {
|
|
22
|
+
base: entry,
|
|
23
|
+
applies: 'live',
|
|
24
|
+
})
|
|
25
|
+
const read = () => scope.get() ?? {}
|
|
26
|
+
|
|
27
|
+
// 2) assemble waterfall: apply filter/replace/inject live.
|
|
28
|
+
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
29
|
+
const assembled = await next()
|
|
30
|
+
const cfg = read()
|
|
31
|
+
const denied = new Set(cfg.sections ?? [])
|
|
32
|
+
const replace = cfg.replace ?? {}
|
|
33
|
+
const injectList = cfg.inject ?? []
|
|
34
|
+
const te = cfg.tools ?? {}
|
|
35
|
+
const exclude = new Set(te.exclude ?? [])
|
|
36
|
+
const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
|
|
37
|
+
|
|
38
|
+
let sections = assembled.sections
|
|
39
|
+
.filter((section) => !denied.has(section.name))
|
|
40
|
+
.map((section) =>
|
|
41
|
+
Object.hasOwn(replace, section.name) ? { ...section, text: replace[section.name] } : section,
|
|
42
|
+
)
|
|
43
|
+
for (const item of injectList) {
|
|
44
|
+
// Skip sections that are blocked — they were already filtered out above.
|
|
45
|
+
if (denied.has(item.name)) continue
|
|
46
|
+
const index = sections.findIndex((section) => section.name === item.name)
|
|
47
|
+
if (index >= 0) {
|
|
48
|
+
// Existing section: apply the order override to control splice order.
|
|
49
|
+
// Preserve any replacement text set above; only use item.text when no
|
|
50
|
+
// replacement exists and the user actually provided inject text.
|
|
51
|
+
const finalText = Object.hasOwn(replace, item.name)
|
|
52
|
+
? sections[index].text
|
|
53
|
+
: (item.text ? item.text : sections[index].text)
|
|
54
|
+
sections[index] = { ...sections[index], text: finalText, order: item.order }
|
|
55
|
+
} else {
|
|
56
|
+
sections.push({ name: item.name, order: item.order, text: Object.hasOwn(replace, item.name) ? replace[item.name] : item.text })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
sections.sort((a, b) => {
|
|
60
|
+
// If injectList is non-empty, use it as the authoritative order.
|
|
61
|
+
// Sections in injectList come first (in injectList order), then remaining
|
|
62
|
+
// sections sorted by their original order.
|
|
63
|
+
if (injectList.length > 0) {
|
|
64
|
+
const aIdx = injectList.findIndex((item) => item.name === a.name)
|
|
65
|
+
const bIdx = injectList.findIndex((item) => item.name === b.name)
|
|
66
|
+
if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx
|
|
67
|
+
if (aIdx >= 0) return -1
|
|
68
|
+
if (bIdx >= 0) return 1
|
|
69
|
+
}
|
|
70
|
+
return a.order - b.order
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
let tools = assembled.tools
|
|
74
|
+
if (include) tools = tools.filter((tool) => include.has(tool.name))
|
|
75
|
+
else if (exclude.size > 0) tools = tools.filter((tool) => !exclude.has(tool.name))
|
|
76
|
+
|
|
77
|
+
return { ...assembled, sections, tools }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// 3) inventory route on the shared webserver (present in every web profile).
|
|
81
|
+
// Use ctx.get() so this stays optional — accessing ctx.webServer without
|
|
82
|
+
// declaring it in `inject` would throw "cannot get property without inject".
|
|
83
|
+
const webserver = ctx.get('webServer')
|
|
84
|
+
if (webserver) {
|
|
85
|
+
const dispose = webserver.register({
|
|
86
|
+
kind: 'exact',
|
|
87
|
+
path: '/api/prompt-customizer/inventory',
|
|
88
|
+
handler: async (_req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
writeJson(res, 200, await buildInventory(ctx, read()))
|
|
91
|
+
} catch (error) {
|
|
92
|
+
writeJson(res, 500, {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: String(error && error.message ? error.message : error),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
ctx.effect(() => dispose, 'prompt-customizer: inventory route')
|
|
100
|
+
|
|
101
|
+
// Preview route: render the FINAL assembled system prompt (after every
|
|
102
|
+
// plugin, including our own block/replace/inject filters) so the user can
|
|
103
|
+
// confirm no injected section from other plugins pollutes it.
|
|
104
|
+
// Optional `?scope=<agentPresetId>` previews a specific agent preset's
|
|
105
|
+
// assembly; omitted, it uses the standard agent (same as the inventory).
|
|
106
|
+
const disposePreview = webserver.register({
|
|
107
|
+
kind: 'exact',
|
|
108
|
+
path: '/api/prompt-customizer/preview',
|
|
109
|
+
handler: async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const scopeKey = await previewScope(ctx, req)
|
|
112
|
+
writeJson(res, 200, await buildPreview(ctx, scopeKey))
|
|
113
|
+
} catch (error) {
|
|
114
|
+
writeJson(res, 500, {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: String(error && error.message ? error.message : error),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
ctx.effect(() => disposePreview, 'prompt-customizer: preview route')
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolve `value` but never wait longer than `ms`; fall back to `fallback`. */
|
|
126
|
+
function withTimeout(value, ms, fallback) {
|
|
127
|
+
return Promise.race([
|
|
128
|
+
Promise.resolve(value),
|
|
129
|
+
new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
|
|
130
|
+
])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the `standard` agent's standing scope key when a roster is present.
|
|
135
|
+
*
|
|
136
|
+
* `standingKeyFor` mounts a whole agent preset (compose plugins) which can be
|
|
137
|
+
* slow or depend on services that are not ready yet — for a read-only inventory
|
|
138
|
+
* browse we must never let that hang the request. If it does not settle within
|
|
139
|
+
* a short budget we fall back to the global layer (scopeKey = undefined).
|
|
140
|
+
*/
|
|
141
|
+
async function resolveStandardScope(ctx) {
|
|
142
|
+
try {
|
|
143
|
+
const presets = ctx.get('agentPresets')
|
|
144
|
+
if (presets && typeof presets.standingKeyFor === 'function') {
|
|
145
|
+
return await withTimeout(presets.standingKeyFor('standard'), 1500, undefined)
|
|
146
|
+
}
|
|
147
|
+
} catch { /* no roster — fall back to the global layer */ }
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Enumerate the effective sections and tools with their blocked/hidden flags. */
|
|
152
|
+
async function buildInventory(ctx, cfg) {
|
|
153
|
+
const denied = new Set(cfg.sections ?? [])
|
|
154
|
+
const replace = cfg.replace ?? {}
|
|
155
|
+
const injectList = cfg.inject ?? []
|
|
156
|
+
const injectOrder = new Map(injectList.map((item) => [item.name, item.order]))
|
|
157
|
+
const te = cfg.tools ?? {}
|
|
158
|
+
const exclude = new Set(te.exclude ?? [])
|
|
159
|
+
const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
|
|
160
|
+
|
|
161
|
+
const scopeKey = await resolveStandardScope(ctx)
|
|
162
|
+
|
|
163
|
+
// Resolve a section's text. Dynamic sections register a function; call it
|
|
164
|
+
// with a best-effort context (same shape the assemble passes) so the
|
|
165
|
+
// inventory can echo the real generated content for replacement. Fall back
|
|
166
|
+
// to a marker if the function throws or returns a pending value.
|
|
167
|
+
const resolveText = (section) => {
|
|
168
|
+
if (typeof section.text === 'function') {
|
|
169
|
+
try {
|
|
170
|
+
const value = section.text({ scope: scopeKey })
|
|
171
|
+
// Async dynamic sections return a thenable we cannot echo synchronously.
|
|
172
|
+
if (value && typeof value.then === 'function') return '<动态生成>'
|
|
173
|
+
return String(value ?? '')
|
|
174
|
+
} catch { return '<动态生成>' }
|
|
175
|
+
}
|
|
176
|
+
return String(section.text ?? '')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const sectionsMap = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
|
|
180
|
+
const sections = [...sectionsMap.entries()]
|
|
181
|
+
.map(([name, section]) => ({
|
|
182
|
+
name,
|
|
183
|
+
order: injectOrder.has(name) ? injectOrder.get(name) : (typeof section.order === 'number' ? section.order : 0),
|
|
184
|
+
text: resolveText(section),
|
|
185
|
+
active: !denied.has(name),
|
|
186
|
+
replaced: Object.hasOwn(replace, name),
|
|
187
|
+
}))
|
|
188
|
+
.sort((a, b) => a.order - b.order)
|
|
189
|
+
|
|
190
|
+
let tools = []
|
|
191
|
+
// Enumerate tools from the resolved scope, falling back to the global layer
|
|
192
|
+
// when the scope key was unavailable (e.g. the standing mount timed out), so
|
|
193
|
+
// the catalog is never empty just because agent composition was not ready.
|
|
194
|
+
try {
|
|
195
|
+
const byName = new Map()
|
|
196
|
+
for (const scope of [scopeKey, undefined]) {
|
|
197
|
+
let schemas = []
|
|
198
|
+
try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
|
|
199
|
+
for (const tool of schemas) {
|
|
200
|
+
if (tool.name && tool.name !== undefined && !byName.has(tool.name)) byName.set(tool.name, tool)
|
|
201
|
+
}
|
|
202
|
+
if (byName.size > 0) break
|
|
203
|
+
}
|
|
204
|
+
tools = [...byName.values()]
|
|
205
|
+
.map((tool) => ({
|
|
206
|
+
name: tool.name,
|
|
207
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
208
|
+
hidden: include ? !include.has(tool.name) : exclude.has(tool.name),
|
|
209
|
+
}))
|
|
210
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
211
|
+
} catch { /* tools registry unresolved — return empty list */ }
|
|
212
|
+
|
|
213
|
+
return { sections, tools, config: cfg }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve the scope key a preview request targets. An explicit `?scope=<id>`
|
|
218
|
+
* requests that agent preset's standing scope; otherwise fall back to the
|
|
219
|
+
* `standard` agent (same default the inventory uses). Always resolves within
|
|
220
|
+
* the same short timeout so a slow standing mount cannot hang the request.
|
|
221
|
+
*/
|
|
222
|
+
async function previewScope(ctx, req) {
|
|
223
|
+
const id = req.query && req.query.scope
|
|
224
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
225
|
+
try {
|
|
226
|
+
const presets = ctx.get('agentPresets')
|
|
227
|
+
if (presets && typeof presets.standingKeyFor === 'function') {
|
|
228
|
+
const key = await withTimeout(presets.standingKeyFor(id), 1500, undefined)
|
|
229
|
+
if (key) return key
|
|
230
|
+
}
|
|
231
|
+
} catch { /* unknown preset — fall back to the standard agent */ }
|
|
232
|
+
}
|
|
233
|
+
return resolveStandardScope(ctx)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Render the FINAL system prompt for a scope: run the full assemble waterfall
|
|
238
|
+
* (so every plugin's filter, including our own, is applied), then render the
|
|
239
|
+
* surviving sections to text. This is exactly what the model would see.
|
|
240
|
+
*/
|
|
241
|
+
async function buildPreview(ctx, scopeKey) {
|
|
242
|
+
const assembled = await ctx.systemPrompt.assemble({ scope: scopeKey })
|
|
243
|
+
const sections = Array.isArray(assembled.sections)
|
|
244
|
+
? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
|
|
245
|
+
: []
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
scope: scopeKey,
|
|
249
|
+
sections,
|
|
250
|
+
text: renderPreviewText(assembled),
|
|
251
|
+
tools: Array.isArray(assembled.tools)
|
|
252
|
+
? assembled.tools.map((tool) => ({
|
|
253
|
+
name: typeof tool.name === 'string' ? tool.name : String(tool.name),
|
|
254
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
255
|
+
}))
|
|
256
|
+
: [],
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Join the surviving sections into the final prompt text (like dsh's renderPrompt). */
|
|
261
|
+
function renderPreviewText(assembled) {
|
|
262
|
+
return (Array.isArray(assembled.sections) ? assembled.sections : [])
|
|
263
|
+
.map((section) => String(section.text ?? '').trim())
|
|
264
|
+
.filter((text) => text.length > 0)
|
|
265
|
+
.join('\n\n')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function writeJson(res, status, body) {
|
|
269
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
270
|
+
res.end(JSON.stringify(body))
|
|
255
271
|
}
|