@lsfcy/dsh-composer-predictions 0.0.0-stage → 0.1.1
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 -0
- package/README.en.md +71 -0
- package/README.md +71 -2
- package/build.mjs +26 -0
- package/client.js +1286 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +8 -0
- package/index.js +891 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/ghost.js +317 -0
- package/src/main.js +958 -0
package/index.js
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
// Host half of the composer-predictions plugin.
|
|
2
|
+
//
|
|
3
|
+
// It owns exactly one thing: POST /composer-predictions/predict, a loopback
|
|
4
|
+
// route the browser half calls after a turn settles. The route reads the
|
|
5
|
+
// session's own derived messages, asks the configured model for ONE plausible
|
|
6
|
+
// next user message, and answers { text }. Nothing is written to the session
|
|
7
|
+
// log — a prediction is not history, it is a hint, so it must never enter the
|
|
8
|
+
// context the agent sees.
|
|
9
|
+
//
|
|
10
|
+
// Two details are easy to get wrong and are handled here instead of in the UI:
|
|
11
|
+
//
|
|
12
|
+
// * A session transcript contains synthetic `user` messages (runtime context,
|
|
13
|
+
// skill catalogue, plan/PTC mode, subagent notices). Only messages the human
|
|
14
|
+
// actually sent may feed a prediction, otherwise the model reads a wall of
|
|
15
|
+
// harness boilerplate and predicts harness instructions.
|
|
16
|
+
// * The selected model may be a reasoning model. Predictions are a tiny,
|
|
17
|
+
// latency-sensitive call, so the host asks the adapter for the cheapest
|
|
18
|
+
// supported reasoning effort ("off" when it exists) and prefers a cheap
|
|
19
|
+
// model on the user's provider.
|
|
20
|
+
//
|
|
21
|
+
// Loaded by the bundle row in cordis.patch.yml.
|
|
22
|
+
|
|
23
|
+
import z from '@deepseek-ai/schemastery'
|
|
24
|
+
|
|
25
|
+
export const name = 'composer-predictions'
|
|
26
|
+
export const GENERATOR_VERSION = '0.1.1'
|
|
27
|
+
// webServer: the route; sessions: the transcript; llm: the model call.
|
|
28
|
+
export const inject = ['webServer', 'sessions', 'llm']
|
|
29
|
+
|
|
30
|
+
const PREDICT_PATH = '/composer-predictions/predict'
|
|
31
|
+
const CONFIG_PATH = '/composer-predictions/config'
|
|
32
|
+
const OPTIONS_PATH = '/composer-predictions/options'
|
|
33
|
+
const BODY_LIMIT_BYTES = 8 * 1024
|
|
34
|
+
|
|
35
|
+
const CHEAP_MODEL = /\b(flash|mini|small|lite|fast|haiku|nano)\b/i
|
|
36
|
+
const EFFORT_PREFERENCE = ['off', 'none', 'minimal', 'low']
|
|
37
|
+
|
|
38
|
+
// Codex keeps the suggestion with the thread, so switching threads shows the
|
|
39
|
+
// suggestion that thread already earned instead of an empty composer. Mirror
|
|
40
|
+
// that: one entry per session, valid only while the session's last seq is the
|
|
41
|
+
// one the suggestion was generated from. In memory only — a hint is not
|
|
42
|
+
// history, and restarting the process should not resurrect a stale line.
|
|
43
|
+
const CACHE_LIMIT = 40
|
|
44
|
+
const CACHE_TTL_MS = 30 * 60 * 1000
|
|
45
|
+
const predictionCache = new Map()
|
|
46
|
+
|
|
47
|
+
export const DEFAULTS = Object.freeze({
|
|
48
|
+
// Master switch. false stops every request and hides the ghost.
|
|
49
|
+
enabled: true,
|
|
50
|
+
// Reasoning models spend these tokens before any text exists; below ~256 the
|
|
51
|
+
// answer is often an empty string even though the call "succeeded".
|
|
52
|
+
maxTokens: 512,
|
|
53
|
+
historyMessages: 12,
|
|
54
|
+
timeoutMs: 20_000,
|
|
55
|
+
perMessageChars: 1_600,
|
|
56
|
+
totalChars: 6_000,
|
|
57
|
+
maxPredictionChars: 160,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const SYSTEM_PROMPT = [
|
|
61
|
+
'You suggest the next message a developer will type in a coding-assistant chat.',
|
|
62
|
+
'',
|
|
63
|
+
"The excerpt ends with the assistant's last reply. Predict the one message the user is most likely to send next, as if they were typing it themselves.",
|
|
64
|
+
'',
|
|
65
|
+
'Rules:',
|
|
66
|
+
"- Write in the user's language and register, with a similar amount of detail.",
|
|
67
|
+
'- Be concrete: reuse the file names, functions, commands, error text, or numbers that appear in the excerpt.',
|
|
68
|
+
"- Write the user's own request or question. Never reply as the assistant, never mention predicting, and never add commentary about the request.",
|
|
69
|
+
"- Ground every claim in the context. Do not invent that the user has already observed a result, approved or sent anything, or that a fix is confirmed: an assistant's claim is not proof the user accepted it.",
|
|
70
|
+
'- A suggestion is one message, not an agenda: respond to what just happened or continue the task naturally. Do not append extra next steps the user never asked for.',
|
|
71
|
+
'- One line of plain text. Stay under roughly 60 characters for Chinese, Japanese, or Korean, and 120 characters otherwise.',
|
|
72
|
+
'- No greeting, no preamble, no quotes, no markdown, no trailing ellipsis.',
|
|
73
|
+
'- If no concrete next message is supported by the excerpt, output exactly: SKIP',
|
|
74
|
+
].join('\n')
|
|
75
|
+
|
|
76
|
+
const PROMPT_HEADER = 'Conversation excerpt (oldest first):\n\n'
|
|
77
|
+
const PROMPT_FOOTER = "\n\nWrite the user's next message."
|
|
78
|
+
|
|
79
|
+
/** Concatenate the text blocks of one message; non-text blocks are ignored. */
|
|
80
|
+
export function textOf(message) {
|
|
81
|
+
const content = message && message.content
|
|
82
|
+
if (typeof content === 'string') return content.trim()
|
|
83
|
+
if (!Array.isArray(content)) return ''
|
|
84
|
+
const parts = []
|
|
85
|
+
for (const block of content) {
|
|
86
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
87
|
+
}
|
|
88
|
+
return parts.join('\n').trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether one `user`-role message was actually written by the human.
|
|
93
|
+
*
|
|
94
|
+
* `Session.deriveMessages()` replays every synthetic prompt input as a
|
|
95
|
+
* user-role message: runtime context, agent instructions, the skill catalogue,
|
|
96
|
+
* plan/PTC mode notices, time context, and subagent relays all carry their own
|
|
97
|
+
* `source.kind`. Only `kind: 'user'` (or a hand-built message with no source,
|
|
98
|
+
* which is what tests use) is the user's own voice.
|
|
99
|
+
*/
|
|
100
|
+
export function isUserAuthored(message) {
|
|
101
|
+
if (!message || message.role !== 'user') return false
|
|
102
|
+
const source = message.source
|
|
103
|
+
if (source === undefined || source === null) return true
|
|
104
|
+
return source.kind === 'user'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Keep only the messages a prediction should read, oldest first. */
|
|
108
|
+
function conversationMessages(messages) {
|
|
109
|
+
const rows = []
|
|
110
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
111
|
+
const role = message && message.role
|
|
112
|
+
if (role !== 'user' && role !== 'assistant') continue
|
|
113
|
+
if (role === 'user' && !isUserAuthored(message)) continue
|
|
114
|
+
const text = textOf(message)
|
|
115
|
+
if (!text) continue
|
|
116
|
+
rows.push({ role, text })
|
|
117
|
+
}
|
|
118
|
+
return rows
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One message clipped to at most `limit` characters.
|
|
123
|
+
*
|
|
124
|
+
* A long assistant reply carries its conclusion at the END ("fixed", "tests
|
|
125
|
+
* pass", "press Cmd+R"). Head-only clipping used to hide exactly that part and
|
|
126
|
+
* leave the model mid-debugging, so it kept predicting another bug report, so
|
|
127
|
+
* assistant text keeps both ends. User requests keep their head. The result
|
|
128
|
+
* never exceeds `limit`: the ellipsis is counted inside the budget.
|
|
129
|
+
*/
|
|
130
|
+
function clipMessage(text, limit, role) {
|
|
131
|
+
const max = Math.floor(Math.max(0, Number(limit)))
|
|
132
|
+
if (max <= 0) return ''
|
|
133
|
+
if (text.length <= max) return text
|
|
134
|
+
if (role !== 'assistant') {
|
|
135
|
+
return max <= 1 ? text.slice(0, max) : text.slice(0, max - 1) + '…'
|
|
136
|
+
}
|
|
137
|
+
const sep = ' … '
|
|
138
|
+
if (max <= sep.length) return text.slice(0, max)
|
|
139
|
+
const head = Math.max(0, Math.round(max * 0.35))
|
|
140
|
+
const tail = max - head - sep.length
|
|
141
|
+
return tail > 0
|
|
142
|
+
? text.slice(0, head) + sep + text.slice(text.length - tail)
|
|
143
|
+
: text.slice(0, max)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render the model-visible history as a plain transcript, newest last.
|
|
148
|
+
*
|
|
149
|
+
* The current turn — the last user request and the assistant reply that just
|
|
150
|
+
* answered it — is mandatory and budgeted first: a prediction without the
|
|
151
|
+
* request that earned the reply, or without the reply's conclusion, is noise.
|
|
152
|
+
* Older context only uses leftover budget. The newest assistant reply gets a
|
|
153
|
+
* larger per-message budget than the rest.
|
|
154
|
+
*/
|
|
155
|
+
export function buildTranscript(messages, options = {}) {
|
|
156
|
+
const limit = options.historyMessages ?? DEFAULTS.historyMessages
|
|
157
|
+
const perMessage = options.perMessageChars ?? DEFAULTS.perMessageChars
|
|
158
|
+
const total = options.totalChars ?? DEFAULTS.totalChars
|
|
159
|
+
const rows = conversationMessages(messages)
|
|
160
|
+
if (rows.length === 0) return ''
|
|
161
|
+
|
|
162
|
+
const label = (role) => (role === 'user' ? 'User: ' : 'Assistant: ')
|
|
163
|
+
const line = (role, body) => label(role) + body
|
|
164
|
+
|
|
165
|
+
// Locate the current turn: it begins at the last user-authored message.
|
|
166
|
+
let lastUser = -1
|
|
167
|
+
for (let index = rows.length - 1; index >= 0 && lastUser === -1; index -= 1) {
|
|
168
|
+
if (rows[index].role === 'user') lastUser = index
|
|
169
|
+
}
|
|
170
|
+
const newestAssistant = rows[rows.length - 1].role === 'assistant'
|
|
171
|
+
const paired = newestAssistant && lastUser >= 0 && lastUser < rows.length - 1
|
|
172
|
+
|
|
173
|
+
// --- paired path: reserve budget for [last user, newest assistant] ---
|
|
174
|
+
if (paired) {
|
|
175
|
+
const userRow = rows[lastUser]
|
|
176
|
+
const assistantRow = rows[rows.length - 1]
|
|
177
|
+
const overhead = label('user').length + label('assistant').length + 2 // '\n\n'
|
|
178
|
+
let assistantBudget = Math.round(perMessage * 1.5)
|
|
179
|
+
let userBudget = perMessage
|
|
180
|
+
const available = total - overhead
|
|
181
|
+
// Shrink both bodies until they fit, keeping them proportional.
|
|
182
|
+
const want = assistantBudget + userBudget
|
|
183
|
+
if (want > available) {
|
|
184
|
+
const factor = available / want
|
|
185
|
+
assistantBudget = Math.max(0, Math.floor(assistantBudget * factor))
|
|
186
|
+
userBudget = Math.max(0, Math.floor(userBudget * factor))
|
|
187
|
+
}
|
|
188
|
+
const pairText = line('user', clipMessage(userRow.text, userBudget, 'user')) +
|
|
189
|
+
'\n\n' +
|
|
190
|
+
line('assistant', clipMessage(assistantRow.text, assistantBudget, 'assistant'))
|
|
191
|
+
|
|
192
|
+
// Older context, newest first, within the leftover budget after the pair.
|
|
193
|
+
const olderPieces = []
|
|
194
|
+
let olderUsed = 2 // the '\n\n' that would join older text to the pair
|
|
195
|
+
for (let index = lastUser - 1; index >= 0; index -= 1) {
|
|
196
|
+
const row = rows[index]
|
|
197
|
+
const room = total - pairText.length - olderUsed - label(row.role).length
|
|
198
|
+
if (room < 1) break
|
|
199
|
+
olderPieces.push(line(row.role, clipMessage(row.text, Math.min(room, perMessage), row.role)))
|
|
200
|
+
olderUsed += olderPieces[olderPieces.length - 1].length + 2
|
|
201
|
+
if (olderPieces.length >= Math.max(0, limit - 2)) break
|
|
202
|
+
}
|
|
203
|
+
olderPieces.reverse()
|
|
204
|
+
const olderText = olderPieces.join('\n\n')
|
|
205
|
+
return olderText ? olderText + '\n\n' + pairText : pairText
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- uniform path: no user/assistant pairing to preserve ---
|
|
209
|
+
const selected = rows.slice(-Math.max(1, limit))
|
|
210
|
+
const pieces = []
|
|
211
|
+
let used = 0
|
|
212
|
+
for (let index = selected.length - 1; index >= 0; index -= 1) {
|
|
213
|
+
const row = selected[index]
|
|
214
|
+
const room = total - used - (pieces.length ? 2 : 0) - label(row.role).length
|
|
215
|
+
if (room < 1 && pieces.length) break
|
|
216
|
+
pieces.push(line(row.role, clipMessage(row.text, Math.min(Math.max(0, room), perMessage), row.role)))
|
|
217
|
+
used += pieces[pieces.length - 1].length + (pieces.length > 1 ? 2 : 0)
|
|
218
|
+
if (used >= total) break
|
|
219
|
+
}
|
|
220
|
+
pieces.reverse()
|
|
221
|
+
return pieces.join('\n\n')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The role of the newest message a human would recognise. Used to refuse a
|
|
226
|
+
* prediction when the turn did not actually end on an assistant reply (an
|
|
227
|
+
* aborted turn, a queued message, or a failed request).
|
|
228
|
+
*/
|
|
229
|
+
export function lastConversationRole(messages) {
|
|
230
|
+
const rows = conversationMessages(messages)
|
|
231
|
+
return rows.length > 0 ? rows[rows.length - 1].role : undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One display line, or '' when the model declined (SKIP) or said nothing usable. */
|
|
235
|
+
export function normalizePrediction(raw, maxLength = DEFAULTS.maxPredictionChars) {
|
|
236
|
+
const limit = Number.isFinite(Number(maxLength)) ? Math.max(40, Math.round(Number(maxLength))) : DEFAULTS.maxPredictionChars
|
|
237
|
+
let text = String(raw == null ? '' : raw).replace(/\r/g, '').trim()
|
|
238
|
+
if (!text || /^skip\b/i.test(text)) return ''
|
|
239
|
+
// A small model occasionally wraps the line in a code fence or a label.
|
|
240
|
+
text = text.replace(/^```[a-z]*\s*/i, '').replace(/```\s*$/, '').trim()
|
|
241
|
+
const firstLine = text.split('\n').map((line) => line.trim()).find((line) => line !== '') || ''
|
|
242
|
+
const line = firstLine
|
|
243
|
+
.replace(/^[-*•]\s+/, '')
|
|
244
|
+
.replace(/^(?:user|用户)\s*[::]\s*/i, '')
|
|
245
|
+
.replace(/^(?:suggestion|prediction|next message|建议|预测)\s*[::]\s*/i, '')
|
|
246
|
+
.replace(/^[`"'“”「」『』]+/, '')
|
|
247
|
+
.replace(/[`"'“”「」『』]+$/, '')
|
|
248
|
+
.trim()
|
|
249
|
+
if (!line || /^skip\b/i.test(line)) return ''
|
|
250
|
+
// The model sometimes answers as the assistant ("Sure, I've updated…") instead
|
|
251
|
+
// of writing the user's own message. Borrowed from the sibling npm plugin's
|
|
252
|
+
// output guard: reject English assistant openers, keep Chinese ones (a user
|
|
253
|
+
// legitimately starts with "好的/已…").
|
|
254
|
+
if (/^(?:sure|certainly|of course|absolutely|done|here(?:'s| is| are)|i(?:'ve| have)? (?:updated|fixed|added|changed|created|removed|refactored))\b/i.test(line)) {
|
|
255
|
+
return ''
|
|
256
|
+
}
|
|
257
|
+
if (line.length <= limit) return line
|
|
258
|
+
return line.slice(0, limit).replace(/[\s,,、;;::.。]+$/, '')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The model the user already selected for this app, when that service is
|
|
263
|
+
* mounted (`ctx.get` is the optional-service convention: absent service, no
|
|
264
|
+
* throw). A prediction should sound like the agent the user is talking to.
|
|
265
|
+
*/
|
|
266
|
+
export function defaultSelection(ctx) {
|
|
267
|
+
const service = ctx && typeof ctx.get === 'function' ? ctx.get('agentDefaultModel') : undefined
|
|
268
|
+
if (!service || typeof service.currentSelection !== 'function') return undefined
|
|
269
|
+
try {
|
|
270
|
+
const selection = service.currentSelection()
|
|
271
|
+
if (!selection || !selection.provider || !selection.model) return undefined
|
|
272
|
+
return { provider: String(selection.provider), model: String(selection.model) }
|
|
273
|
+
} catch {
|
|
274
|
+
return undefined
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Pick the model route. Explicit config wins. Otherwise stay on the user's
|
|
280
|
+
* provider but prefer a cheap model there: a prediction is a latency-sensitive
|
|
281
|
+
* side call, not a coding turn. The user's own model is kept when it is already
|
|
282
|
+
* a cheap one, and is the fallback when discovery names no cheap sibling.
|
|
283
|
+
*/
|
|
284
|
+
export async function resolveRoute(llm, config = {}, signal) {
|
|
285
|
+
const providers = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
286
|
+
const offered = (Array.isArray(providers) ? providers : [])
|
|
287
|
+
.map((provider) => (provider && provider.id ? String(provider.id) : ''))
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
const provider = config.provider ? String(config.provider) : offered[0]
|
|
290
|
+
if (!provider) throw new Error('no LLM provider is registered')
|
|
291
|
+
if (config.model) return { provider, model: String(config.model) }
|
|
292
|
+
|
|
293
|
+
const preferred = config.preferredModel ? String(config.preferredModel) : ''
|
|
294
|
+
let advertised = []
|
|
295
|
+
try {
|
|
296
|
+
const models = await llm.listModels(provider, signal)
|
|
297
|
+
advertised = (Array.isArray(models) ? models : [])
|
|
298
|
+
.map((model) => (model && model.id != null ? String(model.id) : ''))
|
|
299
|
+
.filter(Boolean)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// Per-request discovery can fail on providers that only list models at
|
|
302
|
+
// configuration time; the user's own selection is still a valid route.
|
|
303
|
+
if (preferred) return { provider, model: preferred }
|
|
304
|
+
throw error
|
|
305
|
+
}
|
|
306
|
+
const named = advertised.includes(preferred) ? preferred : undefined
|
|
307
|
+
const model =
|
|
308
|
+
(named && CHEAP_MODEL.test(named) ? named : undefined) ||
|
|
309
|
+
advertised.find((id) => CHEAP_MODEL.test(id)) ||
|
|
310
|
+
named ||
|
|
311
|
+
advertised[0]
|
|
312
|
+
if (!model) throw new Error(`provider "${provider}" advertises no models`)
|
|
313
|
+
return { provider, model }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The cheapest reasoning effort the adapter supports, or undefined when the
|
|
318
|
+
* model does not reason (or cannot be inspected). Explicit config always wins.
|
|
319
|
+
*/
|
|
320
|
+
export async function resolveEffort(llm, route, signal) {
|
|
321
|
+
if (!llm || typeof llm.resolveModelInfo !== 'function') return undefined
|
|
322
|
+
let info
|
|
323
|
+
try {
|
|
324
|
+
info = await llm.resolveModelInfo(route.provider, route.model, signal)
|
|
325
|
+
} catch {
|
|
326
|
+
return undefined
|
|
327
|
+
}
|
|
328
|
+
const efforts = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : []
|
|
329
|
+
const ids = efforts
|
|
330
|
+
.map((effort) => (effort && effort.id != null ? String(effort.id) : ''))
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
for (const candidate of EFFORT_PREFERENCE) {
|
|
333
|
+
if (ids.includes(candidate)) return candidate
|
|
334
|
+
}
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Row config is user-edited YAML: clamp it instead of trusting it. */
|
|
339
|
+
export function normalizeConfig(input) {
|
|
340
|
+
const raw = input && typeof input === 'object' && !Array.isArray(input) ? input : {}
|
|
341
|
+
const integer = (value, fallback, min, max) => {
|
|
342
|
+
const number = Number(value)
|
|
343
|
+
if (!Number.isFinite(number)) return fallback
|
|
344
|
+
return Math.min(max, Math.max(min, Math.round(number)))
|
|
345
|
+
}
|
|
346
|
+
const string = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined)
|
|
347
|
+
return {
|
|
348
|
+
enabled: raw.enabled === undefined ? DEFAULTS.enabled : raw.enabled !== false,
|
|
349
|
+
provider: string(raw.provider),
|
|
350
|
+
model: string(raw.model),
|
|
351
|
+
reasoningEffort: string(raw.reasoningEffort),
|
|
352
|
+
maxTokens: integer(raw.maxTokens, DEFAULTS.maxTokens, 64, 4_096),
|
|
353
|
+
historyMessages: integer(raw.historyMessages, DEFAULTS.historyMessages, 2, 50),
|
|
354
|
+
timeoutMs: integer(raw.timeoutMs, DEFAULTS.timeoutMs, 1_000, 120_000),
|
|
355
|
+
perMessageChars: integer(raw.perMessageChars, DEFAULTS.perMessageChars, 200, 8_000),
|
|
356
|
+
totalChars: integer(raw.totalChars, DEFAULTS.totalChars, 1_000, 40_000),
|
|
357
|
+
maxPredictionChars: integer(raw.maxPredictionChars, DEFAULTS.maxPredictionChars, 40, 500),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A stable fingerprint of the message surface. `session.seq` cannot serve:
|
|
363
|
+
* persistence receipts (for example `session-log-deepseek/delivery-accepted`) are appended to the
|
|
364
|
+
* same log after every flush and bump it continuously. Surface nodes change
|
|
365
|
+
* only when a message is actually appended or replaced.
|
|
366
|
+
*/
|
|
367
|
+
export function sessionTail(session) {
|
|
368
|
+
const surface = session && session.surface
|
|
369
|
+
const nodes = surface && Array.isArray(surface.nodes) ? surface.nodes : []
|
|
370
|
+
return nodes.length > 0 ? Number(nodes[nodes.length - 1]) : 0
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Read a cached suggestion only when it still matches the session's input. */
|
|
374
|
+
function cachedPrediction(session, fingerprint) {
|
|
375
|
+
const key = String(session.id)
|
|
376
|
+
const entry = predictionCache.get(key)
|
|
377
|
+
if (!entry) return undefined
|
|
378
|
+
if (entry.fingerprint !== fingerprint || Date.now() - entry.at > CACHE_TTL_MS) {
|
|
379
|
+
predictionCache.delete(key)
|
|
380
|
+
return undefined
|
|
381
|
+
}
|
|
382
|
+
return entry
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function rememberPrediction(session, fingerprint, payload) {
|
|
386
|
+
const key = String(session.id)
|
|
387
|
+
predictionCache.delete(key)
|
|
388
|
+
predictionCache.set(key, { fingerprint, payload, at: Date.now() })
|
|
389
|
+
while (predictionCache.size > CACHE_LIMIT) {
|
|
390
|
+
const oldest = predictionCache.keys().next().value
|
|
391
|
+
predictionCache.delete(oldest)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Deterministic fingerprint of everything that changes the generated line: the
|
|
397
|
+
* transcript itself, the generator version, and the config knobs that affect
|
|
398
|
+
* output. Reusing a suggestion across a changed transcript, model, or budget
|
|
399
|
+
* would show a stale line, so the cache key cannot be the session's seq alone.
|
|
400
|
+
*/
|
|
401
|
+
function predictionFingerprint(config, transcript) {
|
|
402
|
+
const effectors = {
|
|
403
|
+
provider: config.provider ?? '',
|
|
404
|
+
model: config.model ?? '',
|
|
405
|
+
reasoningEffort: config.reasoningEffort ?? '',
|
|
406
|
+
maxTokens: config.maxTokens,
|
|
407
|
+
maxPredictionChars: config.maxPredictionChars,
|
|
408
|
+
historyMessages: config.historyMessages,
|
|
409
|
+
perMessageChars: config.perMessageChars,
|
|
410
|
+
totalChars: config.totalChars,
|
|
411
|
+
}
|
|
412
|
+
return hashString(GENERATOR_VERSION + '|' + transcript + '|' + JSON.stringify(effectors))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function hashString(input) {
|
|
416
|
+
let hash = 0x811c9dc5
|
|
417
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
418
|
+
hash ^= input.charCodeAt(index)
|
|
419
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
420
|
+
}
|
|
421
|
+
return hash.toString(36)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Test seam: the cache must never leak across cases. */
|
|
425
|
+
export function clearPredictionCache() {
|
|
426
|
+
predictionCache.clear()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Declared schema so the Loader validates the row config and the settings UI
|
|
431
|
+
* can edit it as a form. Every knob is optional: absent fields fall back to
|
|
432
|
+
* the defaults above (and the host's cheapest-model pick when provider/model
|
|
433
|
+
* are unset).
|
|
434
|
+
*/
|
|
435
|
+
export const Config = z.object({
|
|
436
|
+
enabled: z.boolean().default(DEFAULTS.enabled).description('总开关:关闭后不再请求或显示预测。'),
|
|
437
|
+
provider: z.string().role('text').description('模型 provider;默认用用户当前选中的 provider。'),
|
|
438
|
+
model: z.string().role('text').description('模型 id;默认用同 provider 下最便宜的型号,没有便宜型号则退回当前选中模型。'),
|
|
439
|
+
reasoningEffort: z.string().role('text').description('推理档;默认自动选 off > none > minimal > low。'),
|
|
440
|
+
maxTokens: z.number().role('slider').min(64).max(4096).step(64).default(DEFAULTS.maxTokens).description('一次预测的输出上限;推理模型会把预算先花在思考上,太小会返回空。'),
|
|
441
|
+
historyMessages: z.number().role('slider').min(2).max(50).step(1).default(DEFAULTS.historyMessages).description('喂给模型的历史消息条数(只含真人 user + assistant 文本)。'),
|
|
442
|
+
timeoutMs: z.number().role('slider').min(1000).max(120000).step(1000).default(DEFAULTS.timeoutMs).description('单次预测的最长等待时间。'),
|
|
443
|
+
perMessageChars: z.number().role('slider').min(200).max(8000).step(100).default(DEFAULTS.perMessageChars).description('单条消息截断上限(最新一条 assistant 用 1.5 倍)。'),
|
|
444
|
+
totalChars: z.number().role('slider').min(1000).max(40000).step(500).default(DEFAULTS.totalChars).description('整个转录的字符预算。'),
|
|
445
|
+
maxPredictionChars: z.number().role('slider').min(40).max(500).step(20).default(DEFAULTS.maxPredictionChars).description('建议的最大显示/接受长度。'),
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The reason of the newest `turn/end`, read by walking back from the session
|
|
450
|
+
* tail. `eventAt` is the cheap public accessor; `snapshotEvents` is the
|
|
451
|
+
* fallback for hosts that expose only the bulk reader.
|
|
452
|
+
*/
|
|
453
|
+
export function lastTurnOutcome(session) {
|
|
454
|
+
if (!session) return undefined
|
|
455
|
+
const seq = Number(session.seq)
|
|
456
|
+
if (!Number.isFinite(seq)) return undefined
|
|
457
|
+
if (typeof session.eventAt === 'function') {
|
|
458
|
+
for (let offset = 1; offset <= 64 && seq - offset >= 0; offset += 1) {
|
|
459
|
+
let event
|
|
460
|
+
try {
|
|
461
|
+
event = session.eventAt(seq - offset)
|
|
462
|
+
} catch {
|
|
463
|
+
return undefined
|
|
464
|
+
}
|
|
465
|
+
if (event && event.type === 'turn/end') return event.data && event.data.reason
|
|
466
|
+
}
|
|
467
|
+
return undefined
|
|
468
|
+
}
|
|
469
|
+
if (typeof session.snapshotEvents !== 'function') return undefined
|
|
470
|
+
let events
|
|
471
|
+
try {
|
|
472
|
+
events = session.snapshotEvents(Math.max(0, seq - 64))
|
|
473
|
+
} catch {
|
|
474
|
+
return undefined
|
|
475
|
+
}
|
|
476
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
477
|
+
const event = events[index]
|
|
478
|
+
if (event && event.type === 'turn/end') return event.data && event.data.reason
|
|
479
|
+
}
|
|
480
|
+
return undefined
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Codex only suggests a continuation after a SUCCESSFUL turn. The newest
|
|
485
|
+
* assistant text alone cannot tell an abort from a completion (an aborted turn
|
|
486
|
+
* can still leave partial text), so read the last `turn/end` reason. Unknown
|
|
487
|
+
* shapes allow the prediction rather than silently disabling the feature on an
|
|
488
|
+
* event-format change.
|
|
489
|
+
*/
|
|
490
|
+
export function lastTurnCompleted(session) {
|
|
491
|
+
const reason = lastTurnOutcome(session)
|
|
492
|
+
if (!reason || !reason.kind) return true
|
|
493
|
+
return reason.kind === 'completed' || reason.kind === 'max-tokens'
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* One prediction: transcript in, one line out. Split from the HTTP handler so
|
|
498
|
+
* the prompt, route selection, and stream handling stay testable.
|
|
499
|
+
*/
|
|
500
|
+
export async function predict(ctx, config, sessionId, signal) {
|
|
501
|
+
if (config && config.enabled === false) return { text: '', reason: 'disabled' }
|
|
502
|
+
const session =
|
|
503
|
+
ctx && ctx.sessions && typeof ctx.sessions.get === 'function' &&
|
|
504
|
+
(typeof sessionId === 'string' || typeof sessionId === 'number')
|
|
505
|
+
? ctx.sessions.get(sessionId)
|
|
506
|
+
: undefined
|
|
507
|
+
if (!session || typeof session.deriveMessages !== 'function') {
|
|
508
|
+
throw Object.assign(new Error('unknown session'), { status: 404 })
|
|
509
|
+
}
|
|
510
|
+
if (signal && signal.aborted) throw Object.assign(new Error('aborted'), { status: 499 })
|
|
511
|
+
|
|
512
|
+
// Eligibility is checked before anything is cached: a suggestion belongs to
|
|
513
|
+
// a successful, settled turn, so a cache entry must never survive the turn
|
|
514
|
+
// becoming aborted or the assistant reply vanishing.
|
|
515
|
+
if (!lastTurnCompleted(session)) return { text: '', reason: 'turn-not-completed' }
|
|
516
|
+
|
|
517
|
+
const messages = session.deriveMessages()
|
|
518
|
+
// The turn must have ended on the assistant's reply. A queued submission or
|
|
519
|
+
// an aborted turn leaves a user message last, and there is nothing sensible
|
|
520
|
+
// to predict in that state.
|
|
521
|
+
if (lastConversationRole(messages) !== 'assistant') return { text: '', reason: 'last-message-not-assistant' }
|
|
522
|
+
const transcript = buildTranscript(messages, config)
|
|
523
|
+
if (!transcript) return { text: '', reason: 'empty-transcript' }
|
|
524
|
+
|
|
525
|
+
// Reuse a suggestion only when this exact transcript, generator version, and
|
|
526
|
+
// output-affecting config generated it. Switching back to the session is
|
|
527
|
+
// therefore a cache hit, while any message or config change regenerates.
|
|
528
|
+
const fingerprint = predictionFingerprint(config, transcript)
|
|
529
|
+
const cached = cachedPrediction(session, fingerprint)
|
|
530
|
+
if (cached) return { ...cached.payload, cached: true }
|
|
531
|
+
|
|
532
|
+
const generationTail = sessionTail(session)
|
|
533
|
+
|
|
534
|
+
const selection = defaultSelection(ctx) || {}
|
|
535
|
+
const route = await resolveRoute(
|
|
536
|
+
ctx.llm,
|
|
537
|
+
{
|
|
538
|
+
provider: config.provider || selection.provider,
|
|
539
|
+
model: config.model,
|
|
540
|
+
preferredModel: selection.model,
|
|
541
|
+
},
|
|
542
|
+
signal,
|
|
543
|
+
)
|
|
544
|
+
const reasoningEffort = config.reasoningEffort || (await resolveEffort(ctx.llm, route, signal))
|
|
545
|
+
|
|
546
|
+
let raw = ''
|
|
547
|
+
let finish
|
|
548
|
+
for await (const chunk of ctx.llm.stream({
|
|
549
|
+
...route,
|
|
550
|
+
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
551
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: PROMPT_HEADER + transcript + PROMPT_FOOTER }] }],
|
|
552
|
+
system: SYSTEM_PROMPT,
|
|
553
|
+
maxTokens: config.maxTokens,
|
|
554
|
+
sessionId: session.id ?? sessionId,
|
|
555
|
+
signal,
|
|
556
|
+
})) {
|
|
557
|
+
if (!chunk) continue
|
|
558
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') raw += chunk.text
|
|
559
|
+
else if (chunk.type === 'finish') finish = chunk.reason
|
|
560
|
+
}
|
|
561
|
+
if (finish && (finish.kind === 'error' || finish.kind === 'aborted')) {
|
|
562
|
+
const failure = finish.failure || {}
|
|
563
|
+
throw new Error(`model call failed: ${failure.code || failure.message || finish.kind}`)
|
|
564
|
+
}
|
|
565
|
+
// The session may have moved on while the model was thinking (a queued
|
|
566
|
+
// submission, a new turn). A prediction for the old tail must not be shown.
|
|
567
|
+
if (sessionTail(session) !== generationTail) {
|
|
568
|
+
return { text: '', reason: 'session-moved' }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const text = normalizePrediction(raw, config.maxPredictionChars)
|
|
572
|
+
// A declined suggestion (SKIP) is remembered too, so switching back and forth
|
|
573
|
+
// does not re-run the model on the same unchanged tail.
|
|
574
|
+
const payload = {
|
|
575
|
+
text,
|
|
576
|
+
model: route.model,
|
|
577
|
+
provider: route.provider,
|
|
578
|
+
...(reasoningEffort ? { effort: reasoningEffort } : {}),
|
|
579
|
+
...(text ? {} : { reason: 'model-empty' }),
|
|
580
|
+
debug: {
|
|
581
|
+
tail: generationTail,
|
|
582
|
+
transcriptChars: transcript.length,
|
|
583
|
+
rawChars: raw.length,
|
|
584
|
+
finish: finish && finish.kind ? finish.kind : null,
|
|
585
|
+
},
|
|
586
|
+
}
|
|
587
|
+
rememberPrediction(session, fingerprint, payload)
|
|
588
|
+
return payload
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function readBody(request) {
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
const chunks = []
|
|
594
|
+
let size = 0
|
|
595
|
+
let settled = false
|
|
596
|
+
const fail = (error) => {
|
|
597
|
+
if (settled) return
|
|
598
|
+
settled = true
|
|
599
|
+
reject(error)
|
|
600
|
+
}
|
|
601
|
+
request.on('data', (chunk) => {
|
|
602
|
+
if (settled) return
|
|
603
|
+
size += chunk.length
|
|
604
|
+
if (size > BODY_LIMIT_BYTES) {
|
|
605
|
+
fail(Object.assign(new Error('request body too large'), { status: 413 }))
|
|
606
|
+
// Drain instead of destroying: the handler still owns a JSON reply.
|
|
607
|
+
request.resume()
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
chunks.push(chunk)
|
|
611
|
+
})
|
|
612
|
+
request.on('end', () => {
|
|
613
|
+
if (settled) return
|
|
614
|
+
settled = true
|
|
615
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
616
|
+
})
|
|
617
|
+
request.on('error', fail)
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function send(response, status, payload) {
|
|
622
|
+
if (response.writableEnded || response.destroyed) return
|
|
623
|
+
const body = JSON.stringify(payload)
|
|
624
|
+
response.writeHead(status, {
|
|
625
|
+
'content-type': 'application/json; charset=utf-8',
|
|
626
|
+
'cache-control': 'no-store',
|
|
627
|
+
'x-content-type-options': 'nosniff',
|
|
628
|
+
'content-length': Buffer.byteLength(body),
|
|
629
|
+
})
|
|
630
|
+
response.end(body)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* A browser page is the only intended caller. Browsers always send `Origin`
|
|
635
|
+
* for a cross-origin POST (and a preflight for JSON), so rejecting a foreign
|
|
636
|
+
* origin and a non-JSON body is enough to keep a random tab from spending the
|
|
637
|
+
* user's model credentials.
|
|
638
|
+
*/
|
|
639
|
+
function requestAllowed(request) {
|
|
640
|
+
const origin = request.headers.origin
|
|
641
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
642
|
+
if (origin === 'null') return false
|
|
643
|
+
try {
|
|
644
|
+
if (new URL(origin).host !== request.headers.host) return false
|
|
645
|
+
} catch {
|
|
646
|
+
return false
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
650
|
+
const contentType = request.headers['content-type']
|
|
651
|
+
if (typeof contentType === 'string' && contentType !== '' && !/^application\/json\b/i.test(contentType)) {
|
|
652
|
+
return false
|
|
653
|
+
}
|
|
654
|
+
return true
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Build the HTTP handler; exported so tests can drive a real server socket. */
|
|
658
|
+
export function createPredictHandler(ctx, config = {}) {
|
|
659
|
+
// `config` is either a plain object (tests) or a getter owned by apply, so a
|
|
660
|
+
// settings save can update the live values without re-registering the route.
|
|
661
|
+
const getOptions = typeof config === 'function' ? config : () => normalizeConfig(config)
|
|
662
|
+
return async function handle(request, response) {
|
|
663
|
+
if (request.method !== 'POST') {
|
|
664
|
+
response.writeHead(405, { allow: 'POST' }).end()
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
if (!requestAllowed(request)) {
|
|
668
|
+
send(response, 403, { error: 'forbidden' })
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const options = getOptions()
|
|
673
|
+
const controller = new AbortController()
|
|
674
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs)
|
|
675
|
+
// A page that navigates away (or a cancelled fetch) must not keep a model
|
|
676
|
+
// call alive. A normally finished response is already writableEnded.
|
|
677
|
+
response.on('close', () => {
|
|
678
|
+
if (!response.writableEnded) controller.abort()
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const raw = await readBody(request)
|
|
683
|
+
let body
|
|
684
|
+
try {
|
|
685
|
+
body = JSON.parse(raw || '{}')
|
|
686
|
+
} catch (error) {
|
|
687
|
+
throw Object.assign(new Error('invalid JSON body'), { status: 400, cause: error })
|
|
688
|
+
}
|
|
689
|
+
const prediction = await predict(ctx, options, body && body.sessionId, controller.signal)
|
|
690
|
+
send(response, 200, prediction)
|
|
691
|
+
} catch (error) {
|
|
692
|
+
const status = error && error.status
|
|
693
|
+
? error.status
|
|
694
|
+
: controller.signal.aborted
|
|
695
|
+
? 504
|
|
696
|
+
: 500
|
|
697
|
+
const message = error && error.message ? error.message : String(error)
|
|
698
|
+
if (status >= 500 && ctx && ctx.logger && typeof ctx.logger.warn === 'function') {
|
|
699
|
+
ctx.logger.warn('[composer-predictions] predict failed: %s', message)
|
|
700
|
+
}
|
|
701
|
+
send(response, status, { error: message })
|
|
702
|
+
} finally {
|
|
703
|
+
clearTimeout(timer)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** The public shape the settings page reads and writes. */
|
|
709
|
+
export function settingsView(options) {
|
|
710
|
+
const config = options || {}
|
|
711
|
+
return {
|
|
712
|
+
enabled: config.enabled !== false,
|
|
713
|
+
provider: config.provider ?? '',
|
|
714
|
+
model: config.model ?? '',
|
|
715
|
+
reasoningEffort: config.reasoningEffort ?? '',
|
|
716
|
+
maxTokens: config.maxTokens,
|
|
717
|
+
historyMessages: config.historyMessages,
|
|
718
|
+
timeoutMs: config.timeoutMs,
|
|
719
|
+
perMessageChars: config.perMessageChars,
|
|
720
|
+
totalChars: config.totalChars,
|
|
721
|
+
maxPredictionChars: config.maxPredictionChars,
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Untrusted page input: keep known keys and primitive types; clamp later. */
|
|
726
|
+
export function sanitizeSettings(input) {
|
|
727
|
+
const raw = input && typeof input === 'object' && !Array.isArray(input) ? input : {}
|
|
728
|
+
const out = {}
|
|
729
|
+
if (typeof raw.enabled === 'boolean') out.enabled = raw.enabled
|
|
730
|
+
if (typeof raw.provider === 'string') out.provider = raw.provider.trim().slice(0, 120)
|
|
731
|
+
if (typeof raw.model === 'string') out.model = raw.model.trim().slice(0, 120)
|
|
732
|
+
if (typeof raw.reasoningEffort === 'string') out.reasoningEffort = raw.reasoningEffort.trim().slice(0, 40)
|
|
733
|
+
for (const key of ['maxTokens', 'historyMessages', 'timeoutMs', 'perMessageChars', 'totalChars', 'maxPredictionChars']) {
|
|
734
|
+
if (raw[key] === undefined) continue
|
|
735
|
+
const number = Number(raw[key])
|
|
736
|
+
if (Number.isFinite(number)) out[key] = Math.round(number)
|
|
737
|
+
}
|
|
738
|
+
return out
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** A provider that cannot enumerate models must not hang the settings page. */
|
|
742
|
+
function withTimeout(promise, ms) {
|
|
743
|
+
return new Promise((resolve, reject) => {
|
|
744
|
+
const timer = setTimeout(() => reject(new Error('timeout')), ms)
|
|
745
|
+
Promise.resolve(promise).then(
|
|
746
|
+
(value) => {
|
|
747
|
+
clearTimeout(timer)
|
|
748
|
+
resolve(value)
|
|
749
|
+
},
|
|
750
|
+
(error) => {
|
|
751
|
+
clearTimeout(timer)
|
|
752
|
+
reject(error)
|
|
753
|
+
},
|
|
754
|
+
)
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* The provider/model catalog the settings page offers. Kept off the predict
|
|
760
|
+
* path: the dock asks for a suggestion on every mount and must stay fast.
|
|
761
|
+
*/
|
|
762
|
+
export async function collectOptions(ctx) {
|
|
763
|
+
// No cache: a provider added in Settings → Models must appear on the next
|
|
764
|
+
// open of this page, not after a TTL.
|
|
765
|
+
const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
|
|
766
|
+
const listed = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
767
|
+
const providers = (Array.isArray(listed) ? listed : [])
|
|
768
|
+
.filter((provider) => provider && typeof provider.id === 'string')
|
|
769
|
+
.map((provider) => ({ id: provider.id, name: typeof provider.name === 'string' ? provider.name : provider.id }))
|
|
770
|
+
const models = []
|
|
771
|
+
if (llm && typeof llm.listModels === 'function') {
|
|
772
|
+
await Promise.all(
|
|
773
|
+
providers.map(async (provider) => {
|
|
774
|
+
try {
|
|
775
|
+
const list = await withTimeout(llm.listModels(provider.id), 3_000)
|
|
776
|
+
for (const model of Array.isArray(list) ? list : []) {
|
|
777
|
+
if (model && typeof model.id === 'string') {
|
|
778
|
+
models.push({ provider: provider.id, id: model.id, name: typeof model.name === 'string' ? model.name : model.id })
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch {
|
|
782
|
+
// A provider with no reachable catalog simply contributes no suggestions.
|
|
783
|
+
}
|
|
784
|
+
}),
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
const selection = defaultSelection(ctx)
|
|
788
|
+
const value = {
|
|
789
|
+
providers,
|
|
790
|
+
models,
|
|
791
|
+
autoProvider: selection && typeof selection.provider === 'string' ? selection.provider : '',
|
|
792
|
+
}
|
|
793
|
+
return value
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function createOptionsHandler(ctx) {
|
|
797
|
+
return async function handle(request, response) {
|
|
798
|
+
if (request.method !== 'GET') {
|
|
799
|
+
response.writeHead(405, { allow: 'GET' }).end()
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
if (!requestAllowed(request)) {
|
|
803
|
+
send(response, 403, { error: 'forbidden' })
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
send(response, 200, await collectOptions(ctx))
|
|
808
|
+
} catch (error) {
|
|
809
|
+
send(response, 500, { error: error && error.message ? error.message : String(error) })
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Settings endpoint. Writes go through the platform's configEditor, which
|
|
816
|
+
* validates against the Config schema and persists the override in the
|
|
817
|
+
* profile's patch — one source of truth, no plugin-owned JSON file.
|
|
818
|
+
*/
|
|
819
|
+
export function createConfigHandler(ctx, getOptions, setOptions) {
|
|
820
|
+
return async function handle(request, response) {
|
|
821
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
822
|
+
response.writeHead(405, { allow: 'GET, POST' }).end()
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
if (!requestAllowed(request)) {
|
|
826
|
+
send(response, 403, { error: 'forbidden' })
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
if (request.method === 'GET') {
|
|
830
|
+
send(response, 200, { config: settingsView(getOptions()) })
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
const raw = await readBody(request)
|
|
835
|
+
let body
|
|
836
|
+
try {
|
|
837
|
+
body = JSON.parse(raw || '{}')
|
|
838
|
+
} catch (error) {
|
|
839
|
+
throw Object.assign(new Error('invalid JSON body'), { status: 400, cause: error })
|
|
840
|
+
}
|
|
841
|
+
const patch = sanitizeSettings(body && body.config !== undefined ? body.config : body)
|
|
842
|
+
const editor = ctx && typeof ctx.get === 'function' ? ctx.get('configEditor') : undefined
|
|
843
|
+
if (!editor || typeof editor.edit !== 'function' || typeof editor.entries !== 'function') {
|
|
844
|
+
throw Object.assign(new Error('config editor is unavailable in this runtime'), { status: 501 })
|
|
845
|
+
}
|
|
846
|
+
const entry = editor.entries().find(
|
|
847
|
+
(candidate) =>
|
|
848
|
+
candidate &&
|
|
849
|
+
candidate.options &&
|
|
850
|
+
(candidate.options.id === 'composer-predictions' || candidate.options.name === '@lsfcy/dsh-composer-predictions'),
|
|
851
|
+
)
|
|
852
|
+
if (!entry) throw Object.assign(new Error('composer-predictions entry not found'), { status: 404 })
|
|
853
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
854
|
+
const next = normalizeConfig({ ...getOptions(), ...patch })
|
|
855
|
+
setOptions(next)
|
|
856
|
+
send(response, 200, { config: settingsView(next) })
|
|
857
|
+
} catch (error) {
|
|
858
|
+
const status = error && error.status ? error.status : 500
|
|
859
|
+
const message = error && error.message ? error.message : String(error)
|
|
860
|
+
if (status >= 500 && ctx && ctx.logger && typeof ctx.logger.warn === 'function') {
|
|
861
|
+
ctx.logger.warn('[composer-predictions] config failed: %s', message)
|
|
862
|
+
}
|
|
863
|
+
send(response, status, { error: message })
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export function apply(ctx, config) {
|
|
869
|
+
const state = { options: normalizeConfig(config) }
|
|
870
|
+
const predictHandler = createPredictHandler(ctx, () => state.options)
|
|
871
|
+
const configHandler = createConfigHandler(
|
|
872
|
+
ctx,
|
|
873
|
+
() => state.options,
|
|
874
|
+
(next) => {
|
|
875
|
+
state.options = next
|
|
876
|
+
},
|
|
877
|
+
)
|
|
878
|
+
const optionsHandler = createOptionsHandler(ctx)
|
|
879
|
+
ctx.effect(
|
|
880
|
+
() => ctx.webServer.register({ kind: 'exact', path: PREDICT_PATH, handler: predictHandler }),
|
|
881
|
+
'composer-predictions: predict route',
|
|
882
|
+
)
|
|
883
|
+
ctx.effect(
|
|
884
|
+
() => ctx.webServer.register({ kind: 'exact', path: CONFIG_PATH, handler: configHandler }),
|
|
885
|
+
'composer-predictions: config route',
|
|
886
|
+
)
|
|
887
|
+
ctx.effect(
|
|
888
|
+
() => ctx.webServer.register({ kind: 'exact', path: OPTIONS_PATH, handler: optionsHandler }),
|
|
889
|
+
'composer-predictions: options route',
|
|
890
|
+
)
|
|
891
|
+
}
|