@followbin/dsh-selection 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/README.zh-CN.md +141 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/client.js +1055 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +1 -0
- package/index.js +41 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +90 -4
- package/src/host/files.mjs +29 -0
- package/src/host/http.mjs +153 -0
- package/src/host/llm.mjs +194 -0
- package/src/host/log.mjs +40 -0
- package/src/host/service.mjs +324 -0
- package/src/host/settings.mjs +279 -0
- package/src/host/skills.mjs +88 -0
- package/src/host/supervisor.mjs +126 -0
- package/src/host/usage.mjs +128 -0
- package/src/shared/icons.mjs +43 -0
- package/src/shared/open-with-system.mjs +15 -0
- package/src/shared/paths.mjs +7 -0
- package/src/shared/protocol.mjs +4 -0
- package/src/worker/controller.mjs +261 -0
- package/src/worker/main.mjs +147 -0
- package/src/worker/native-window.mjs +319 -0
- package/src/worker/open-url.mjs +20 -0
- package/src/worker/overlay.mjs +263 -0
- package/src/worker/page.mjs +14 -0
- package/src/worker/placement.mjs +58 -0
- package/src/worker/speech.mjs +136 -0
- package/ui/markdown.js +122 -0
- package/ui/toolbar.css +152 -0
- package/ui/toolbar.html +52 -0
- package/ui/toolbar.js +492 -0
- package/vendor/selection-hook/LICENSE +21 -0
- package/vendor/selection-hook/index.d.ts +517 -0
- package/vendor/selection-hook/index.js +662 -0
- package/vendor/selection-hook/package.json +10 -0
- package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/win32-x64/selection-hook.node +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// The Host half: owns settings, the worker process and model calls, and answers the settings page.
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { CUSTOM_SKILL_ICONS, ICONS } from '../shared/icons.mjs'
|
|
5
|
+
import { openWithSystem } from '../shared/open-with-system.mjs'
|
|
6
|
+
import { dataDirectory } from '../shared/paths.mjs'
|
|
7
|
+
import { ModelError, createModelClient } from './llm.mjs'
|
|
8
|
+
import {
|
|
9
|
+
BUILTIN_SKILLS, LANGUAGES, LIMITS, SEARCH_ENGINES,
|
|
10
|
+
createSettingsStore, defaultSettings, enabledSkills, findSkill, searchTemplate,
|
|
11
|
+
} from './settings.mjs'
|
|
12
|
+
import { buildRequest } from './skills.mjs'
|
|
13
|
+
import { startWorker } from './supervisor.mjs'
|
|
14
|
+
import { createUsageStore } from './usage.mjs'
|
|
15
|
+
|
|
16
|
+
const WORKER_PATH = fileURLToPath(new URL('../worker/main.mjs', import.meta.url))
|
|
17
|
+
const SUPPORTED_PLATFORMS = new Set(['darwin', 'win32'])
|
|
18
|
+
const RECENT_APPS = 12
|
|
19
|
+
const ACCESSIBILITY_SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
|
|
20
|
+
|
|
21
|
+
export class BadRequest extends Error {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* getLlm: () => any,
|
|
26
|
+
* log: (level: string, message: string, fields?: object) => void,
|
|
27
|
+
* dataDir?: string,
|
|
28
|
+
* desktop: boolean,
|
|
29
|
+
* promptForPermission?: boolean,
|
|
30
|
+
* platform?: string,
|
|
31
|
+
* startWorker?: typeof startWorker,
|
|
32
|
+
* openExternal?: (url: string) => void,
|
|
33
|
+
* }} options `desktop` is false when the Host is not on the user's machine (e.g. a remote web Host).
|
|
34
|
+
* Tests replace `platform`, `startWorker` and `openExternal`.
|
|
35
|
+
*/
|
|
36
|
+
export function createSelectionService({
|
|
37
|
+
getLlm, log, dataDir = dataDirectory(), desktop, promptForPermission = true,
|
|
38
|
+
platform = process.platform, startWorker: launchWorker = startWorker, openExternal = (url) => openWithSystem(url, log),
|
|
39
|
+
}) {
|
|
40
|
+
const settings = createSettingsStore({ file: join(dataDir, 'settings.json'), log })
|
|
41
|
+
const model = createModelClient({ getLlm, log })
|
|
42
|
+
const usage = createUsageStore({ file: join(dataDir, 'usage.json'), log })
|
|
43
|
+
const subscribers = new Set()
|
|
44
|
+
const runs = new Map()
|
|
45
|
+
const recentApps = []
|
|
46
|
+
const status = {
|
|
47
|
+
platform,
|
|
48
|
+
supported: desktop && SUPPORTED_PLATFORMS.has(platform),
|
|
49
|
+
worker: 'stopped',
|
|
50
|
+
permission: platform === 'darwin' ? 'unknown' : 'granted',
|
|
51
|
+
hook: false,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const worker = status.supported
|
|
55
|
+
? launchWorker({
|
|
56
|
+
workerPath: WORKER_PATH,
|
|
57
|
+
args: promptForPermission ? [] : ['--no-prompt'],
|
|
58
|
+
log,
|
|
59
|
+
onMessage: handleWorkerMessage,
|
|
60
|
+
onState: ({ state }) => {
|
|
61
|
+
// Answers in flight belong to the panel of the process that just went away.
|
|
62
|
+
if (state !== 'starting' && state !== 'running') abortRuns()
|
|
63
|
+
setStatus({ worker: state })
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
: null
|
|
67
|
+
if (!status.supported) log('info', 'selection toolbar not started', { platform, desktop })
|
|
68
|
+
|
|
69
|
+
let modelChoice = JSON.stringify(settings.get().model)
|
|
70
|
+
const unsubscribe = settings.onChange((next) => {
|
|
71
|
+
worker?.send({ type: 'config', config: workerConfig(next) })
|
|
72
|
+
if (JSON.stringify(next.model) !== modelChoice) {
|
|
73
|
+
modelChoice = JSON.stringify(next.model)
|
|
74
|
+
model.invalidate()
|
|
75
|
+
}
|
|
76
|
+
emit('settings', next)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
function abortRuns() {
|
|
80
|
+
for (const controller of runs.values()) controller.abort()
|
|
81
|
+
runs.clear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function recordUsage(skill, route, counts) {
|
|
85
|
+
usage.record({ skill, route, usage: counts })
|
|
86
|
+
emit('usage', usage.summary())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function emit(event, data) {
|
|
90
|
+
for (const subscriber of subscribers) subscriber(event, data)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setStatus(patch) {
|
|
94
|
+
Object.assign(status, patch)
|
|
95
|
+
emit('status', status)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What the worker needs: no prompts or model details, only what to draw and how to behave. */
|
|
99
|
+
function workerConfig(current) {
|
|
100
|
+
return {
|
|
101
|
+
enabled: current.enabled,
|
|
102
|
+
trigger: current.trigger,
|
|
103
|
+
toolbarSize: current.toolbarSize,
|
|
104
|
+
skills: enabledSkills(current),
|
|
105
|
+
disabledApps: current.disabledApps,
|
|
106
|
+
clipboardFallback: current.clipboardFallback,
|
|
107
|
+
maxTextLength: current.maxTextLength,
|
|
108
|
+
searchTemplate: searchTemplate(current),
|
|
109
|
+
translateTarget: current.translateTarget,
|
|
110
|
+
languages: LANGUAGES.map(({ code, name }) => ({ code, name })),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handleWorkerMessage(message) {
|
|
115
|
+
switch (message?.type) {
|
|
116
|
+
case 'hello':
|
|
117
|
+
worker?.send({ type: 'config', config: workerConfig(settings.get()) })
|
|
118
|
+
break
|
|
119
|
+
case 'ready':
|
|
120
|
+
setStatus({ hook: Boolean(message.hookStarted) })
|
|
121
|
+
break
|
|
122
|
+
case 'permission':
|
|
123
|
+
setStatus({ permission: message.trusted ? 'granted' : 'denied' })
|
|
124
|
+
break
|
|
125
|
+
case 'selection':
|
|
126
|
+
rememberApp(message.app)
|
|
127
|
+
break
|
|
128
|
+
case 'run':
|
|
129
|
+
void runSkill(message)
|
|
130
|
+
break
|
|
131
|
+
case 'cancel':
|
|
132
|
+
runs.get(message.requestId)?.abort()
|
|
133
|
+
break
|
|
134
|
+
case 'disable-app':
|
|
135
|
+
if (typeof message.app === 'string' && message.app) {
|
|
136
|
+
try {
|
|
137
|
+
const saved = settings.update((current) => ({ ...current, disabledApps: [...current.disabledApps, message.app] }))
|
|
138
|
+
if (saved.disabledApps.includes(message.app)) log('info', 'app disabled from toolbar', { app: message.app })
|
|
139
|
+
else log('warn', 'app not disabled: the list is full', { app: message.app, limit: LIMITS.disabledApps })
|
|
140
|
+
} catch (error) {
|
|
141
|
+
log('error', 'saving settings failed', { error: String(error?.message ?? error) })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
break
|
|
145
|
+
case 'open-settings':
|
|
146
|
+
openSettingsPage()
|
|
147
|
+
break
|
|
148
|
+
case 'log':
|
|
149
|
+
log(message.level ?? 'info', `worker: ${message.message}`, message.fields ?? {})
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function rememberApp(app) {
|
|
155
|
+
if (typeof app !== 'string' || !app) return
|
|
156
|
+
const index = recentApps.indexOf(app)
|
|
157
|
+
if (index !== -1) recentApps.splice(index, 1)
|
|
158
|
+
recentApps.unshift(app)
|
|
159
|
+
recentApps.length = Math.min(recentApps.length, RECENT_APPS)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function runSkill({ requestId, skillId, text, language, question, history }) {
|
|
163
|
+
const current = settings.get()
|
|
164
|
+
const controller = new AbortController()
|
|
165
|
+
runs.set(requestId, controller)
|
|
166
|
+
const started = Date.now()
|
|
167
|
+
let outputChars = 0
|
|
168
|
+
let route = null
|
|
169
|
+
try {
|
|
170
|
+
const skill = findSkill(current, skillId)
|
|
171
|
+
if (!skill || (skill.kind !== 'prompt' && skill.kind !== 'ask')) throw new ModelError('MODEL_FAILED', `unknown skill: ${skillId}`)
|
|
172
|
+
const input = {
|
|
173
|
+
text: String(text ?? '').slice(0, current.maxTextLength),
|
|
174
|
+
language: typeof language === 'string' ? language : undefined,
|
|
175
|
+
question: typeof question === 'string' ? question.slice(0, 2000) : undefined,
|
|
176
|
+
history: sanitizeHistory(history),
|
|
177
|
+
}
|
|
178
|
+
const prompt = (resolved) => {
|
|
179
|
+
route = resolved
|
|
180
|
+
return buildRequest(skill, current, { ...input, model: resolved.modelName })
|
|
181
|
+
}
|
|
182
|
+
const onUsage = (counts) => recordUsage(skillId, route, counts)
|
|
183
|
+
for await (const chunk of model.stream({ settings: current, prompt, signal: controller.signal, onUsage })) {
|
|
184
|
+
outputChars += chunk.length
|
|
185
|
+
worker?.send({ type: 'result', requestId, chunk })
|
|
186
|
+
}
|
|
187
|
+
worker?.send({ type: 'result', requestId, done: true })
|
|
188
|
+
log('info', 'skill finished', { skill: skillId, ...routeFields(route), ms: Date.now() - started, inputChars: String(text ?? '').length, outputChars })
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (controller.signal.aborted && !(error instanceof ModelError)) {
|
|
191
|
+
log('info', 'skill cancelled', { skill: skillId, ms: Date.now() - started })
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const failure = toFailure(error)
|
|
195
|
+
worker?.send({ type: 'result', requestId, error: failure, done: true })
|
|
196
|
+
log('warn', 'skill failed', { skill: skillId, ...routeFields(route), ...failure, ms: Date.now() - started })
|
|
197
|
+
} finally {
|
|
198
|
+
if (runs.get(requestId) === controller) runs.delete(requestId)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Runs one request to the end for the settings page's test buttons; the stream's own silence limits apply. */
|
|
203
|
+
async function collect(skill, build, current) {
|
|
204
|
+
const controller = new AbortController()
|
|
205
|
+
const started = Date.now()
|
|
206
|
+
let route = null
|
|
207
|
+
const prompt = (resolved) => {
|
|
208
|
+
route = resolved
|
|
209
|
+
return build(resolved)
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
let output = ''
|
|
213
|
+
const onUsage = (counts) => recordUsage(skill, route, counts)
|
|
214
|
+
for await (const chunk of model.stream({ settings: current, prompt, signal: controller.signal, onUsage })) output += chunk
|
|
215
|
+
return { ok: true, output, ms: Date.now() - started, route }
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return { ok: false, error: toFailure(error), ms: Date.now() - started }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function openSettingsPage() {
|
|
222
|
+
emit('navigate', { panel: 'dsh-selection' })
|
|
223
|
+
// The page is open inside DSH; bring the window forward so the user sees it.
|
|
224
|
+
openExternal('dsh://open')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
getState() {
|
|
229
|
+
return {
|
|
230
|
+
settings: settings.get(),
|
|
231
|
+
defaults: defaultSettings(),
|
|
232
|
+
status,
|
|
233
|
+
recentApps: [...recentApps],
|
|
234
|
+
catalog: {
|
|
235
|
+
builtins: BUILTIN_SKILLS,
|
|
236
|
+
languages: LANGUAGES,
|
|
237
|
+
searchEngines: SEARCH_ENGINES,
|
|
238
|
+
icons: ICONS,
|
|
239
|
+
customIcons: CUSTOM_SKILL_ICONS,
|
|
240
|
+
limits: LIMITS,
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
/** Applies the changed fields only, so edits made meanwhile from the toolbar are not overwritten. */
|
|
245
|
+
saveSettings(patch) {
|
|
246
|
+
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) throw new BadRequest('settings must be an object')
|
|
247
|
+
return settings.update((current) => ({ ...current, ...patch }))
|
|
248
|
+
},
|
|
249
|
+
resetSettings: () => settings.reset(),
|
|
250
|
+
getUsage: () => usage.summary(),
|
|
251
|
+
resetUsage() {
|
|
252
|
+
usage.reset()
|
|
253
|
+
const summary = usage.summary()
|
|
254
|
+
emit('usage', summary)
|
|
255
|
+
return summary
|
|
256
|
+
},
|
|
257
|
+
/** DSH's model providers changed (sign-in, sign-out, a new API key). */
|
|
258
|
+
invalidateModels: () => model.invalidate(),
|
|
259
|
+
async listModels() {
|
|
260
|
+
try {
|
|
261
|
+
const routes = await model.listRoutes({ fresh: true })
|
|
262
|
+
const selected = routes.length ? await model.resolveRoute(settings.get()) : null
|
|
263
|
+
return { routes, selected }
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return { routes: [], selected: null, error: toFailure(error) }
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
/** Sends one short translation through the selected route; the settings page shows the outcome. */
|
|
269
|
+
testModel() {
|
|
270
|
+
const current = settings.get()
|
|
271
|
+
return collect('model-test', () => buildRequest(findSkill(current, 'translate'), { ...current, translateTarget: 'en' }, { text: '你好,世界' }), current)
|
|
272
|
+
},
|
|
273
|
+
/** Runs a draft custom skill against sample text without saving it. */
|
|
274
|
+
testSkill(input) {
|
|
275
|
+
const prompt = typeof input?.prompt === 'string' ? input.prompt.trim().slice(0, LIMITS.customPrompt) : ''
|
|
276
|
+
const text = typeof input?.text === 'string' ? input.text.trim().slice(0, 2000) : ''
|
|
277
|
+
if (!prompt || !text) throw new BadRequest('prompt and text are required')
|
|
278
|
+
const current = settings.get()
|
|
279
|
+
const skill = { id: 'custom-test', kind: 'prompt', custom: true, prompt }
|
|
280
|
+
return collect('skill-test', (route) => buildRequest(skill, current, { text, model: route.modelName }), current)
|
|
281
|
+
},
|
|
282
|
+
restartWorker() {
|
|
283
|
+
if (!worker) throw new BadRequest('the selection toolbar is not supported on this Host')
|
|
284
|
+
worker.restart()
|
|
285
|
+
},
|
|
286
|
+
openPermissionSettings() {
|
|
287
|
+
if (platform !== 'darwin') throw new BadRequest('only macOS needs an accessibility grant')
|
|
288
|
+
openExternal(ACCESSIBILITY_SETTINGS_URL)
|
|
289
|
+
},
|
|
290
|
+
subscribe(listener) {
|
|
291
|
+
subscribers.add(listener)
|
|
292
|
+
return () => subscribers.delete(listener)
|
|
293
|
+
},
|
|
294
|
+
async dispose() {
|
|
295
|
+
unsubscribe()
|
|
296
|
+
abortRuns()
|
|
297
|
+
usage.flush()
|
|
298
|
+
emit('closing', {})
|
|
299
|
+
subscribers.clear()
|
|
300
|
+
await worker?.stop()
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function sanitizeHistory(history) {
|
|
306
|
+
if (!Array.isArray(history)) return []
|
|
307
|
+
return history.slice(-6)
|
|
308
|
+
.filter((turn) => typeof turn?.answer === 'string' && (typeof turn.question === 'string' || typeof turn.skillId === 'string'))
|
|
309
|
+
.map((turn) => ({
|
|
310
|
+
...(typeof turn.question === 'string' ? { question: turn.question.slice(0, 2000) } : { skillId: turn.skillId.slice(0, 64) }),
|
|
311
|
+
...(typeof turn.language === 'string' ? { language: turn.language.slice(0, 16) } : {}),
|
|
312
|
+
answer: turn.answer.slice(0, 6000),
|
|
313
|
+
}))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Which route answered, for the log: ids only. */
|
|
317
|
+
function routeFields(route) {
|
|
318
|
+
return route ? { provider: route.provider, model: route.model } : {}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function toFailure(error) {
|
|
322
|
+
if (error instanceof ModelError) return { code: error.code, detail: error.detail.slice(0, 300) }
|
|
323
|
+
return { code: 'MODEL_FAILED', detail: String(error?.message ?? error).slice(0, 300) }
|
|
324
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// User settings: the defaults, a normalizer that turns any input into a valid value,
|
|
2
|
+
// and a file-backed store. Everything the settings page sends goes through normalizeSettings.
|
|
3
|
+
import { readFileSync, renameSync } from 'node:fs'
|
|
4
|
+
import { writeFileAtomic } from './files.mjs'
|
|
5
|
+
import { CUSTOM_SKILL_ICONS, ICONS } from '../shared/icons.mjs'
|
|
6
|
+
|
|
7
|
+
/** Built-in skills in their default order. `kind` decides who handles a click. */
|
|
8
|
+
export const BUILTIN_SKILLS = Object.freeze([
|
|
9
|
+
{ id: 'translate', kind: 'prompt', icon: 'translate', name: { zh: '翻译', en: 'Translate' } },
|
|
10
|
+
{ id: 'summarize', kind: 'prompt', icon: 'summarize', name: { zh: '总结', en: 'Summarize' } },
|
|
11
|
+
{ id: 'explain', kind: 'prompt', icon: 'explain', name: { zh: '解释', en: 'Explain' } },
|
|
12
|
+
{ id: 'ask', kind: 'ask', icon: 'ask', name: { zh: '问问 DSH', en: 'Ask DSH' } },
|
|
13
|
+
{ id: 'speak', kind: 'speak', icon: 'speak', name: { zh: '朗读', en: 'Read aloud' } },
|
|
14
|
+
{ id: 'copy', kind: 'copy', icon: 'copy', name: { zh: '复制', en: 'Copy' } },
|
|
15
|
+
{ id: 'polish', kind: 'prompt', icon: 'polish', name: { zh: '润色', en: 'Polish' } },
|
|
16
|
+
{ id: 'search', kind: 'search', icon: 'search', name: { zh: '搜索', en: 'Search' } },
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
export const LANGUAGES = Object.freeze([
|
|
20
|
+
{ code: 'zh-CN', name: '简体中文', english: 'Simplified Chinese' },
|
|
21
|
+
{ code: 'zh-TW', name: '繁體中文', english: 'Traditional Chinese' },
|
|
22
|
+
{ code: 'en', name: 'English', english: 'English' },
|
|
23
|
+
{ code: 'ja', name: '日本語', english: 'Japanese' },
|
|
24
|
+
{ code: 'ko', name: '한국어', english: 'Korean' },
|
|
25
|
+
{ code: 'fr', name: 'Français', english: 'French' },
|
|
26
|
+
{ code: 'de', name: 'Deutsch', english: 'German' },
|
|
27
|
+
{ code: 'es', name: 'Español', english: 'Spanish' },
|
|
28
|
+
{ code: 'ru', name: 'Русский', english: 'Russian' },
|
|
29
|
+
{ code: 'pt', name: 'Português', english: 'Portuguese' },
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
export const SEARCH_ENGINES = Object.freeze({
|
|
33
|
+
google: 'https://www.google.com/search?q=%s',
|
|
34
|
+
bing: 'https://www.bing.com/search?q=%s',
|
|
35
|
+
baidu: 'https://www.baidu.com/s?wd=%s',
|
|
36
|
+
duckduckgo: 'https://duckduckgo.com/?q=%s',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const LIMITS = Object.freeze({
|
|
40
|
+
toolbarSize: { min: 1, max: 8 },
|
|
41
|
+
maxTextLength: { min: 200, max: 20000 },
|
|
42
|
+
customSkills: 30,
|
|
43
|
+
customName: 16,
|
|
44
|
+
customPrompt: 4000,
|
|
45
|
+
disabledApps: 200,
|
|
46
|
+
appName: 200,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const LANGUAGE_CODES = new Set(LANGUAGES.map((language) => language.code))
|
|
50
|
+
const BUILTIN_IDS = new Set(BUILTIN_SKILLS.map((skill) => skill.id))
|
|
51
|
+
const CUSTOM_ID = /^custom-[a-z0-9]{4,24}$/
|
|
52
|
+
|
|
53
|
+
/** @returns {Settings} */
|
|
54
|
+
export function defaultSettings() {
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
enabled: true,
|
|
58
|
+
trigger: 'auto',
|
|
59
|
+
toolbarSize: 5,
|
|
60
|
+
skills: BUILTIN_SKILLS.map((skill) => ({ id: skill.id, enabled: true })),
|
|
61
|
+
disabledApps: [],
|
|
62
|
+
nativeLanguage: 'zh-CN',
|
|
63
|
+
translateTarget: 'auto',
|
|
64
|
+
answerLanguage: 'native',
|
|
65
|
+
model: { provider: '', model: '' },
|
|
66
|
+
maxTextLength: 4000,
|
|
67
|
+
clipboardFallback: true,
|
|
68
|
+
searchEngine: 'google',
|
|
69
|
+
customSearchUrl: '',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Coerces any value into valid settings. Unknown fields are dropped, bad values fall back to defaults.
|
|
75
|
+
* @param {unknown} input
|
|
76
|
+
* @returns {Settings}
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeSettings(input) {
|
|
79
|
+
const defaults = defaultSettings()
|
|
80
|
+
const value = isObject(input) ? input : {}
|
|
81
|
+
const settings = {
|
|
82
|
+
version: 1,
|
|
83
|
+
enabled: typeof value.enabled === 'boolean' ? value.enabled : defaults.enabled,
|
|
84
|
+
trigger: value.trigger === 'modifier' ? 'modifier' : 'auto',
|
|
85
|
+
toolbarSize: clampInt(value.toolbarSize, LIMITS.toolbarSize, defaults.toolbarSize),
|
|
86
|
+
skills: normalizeSkills(value.skills),
|
|
87
|
+
disabledApps: normalizeApps(value.disabledApps),
|
|
88
|
+
nativeLanguage: LANGUAGE_CODES.has(value.nativeLanguage) ? value.nativeLanguage : defaults.nativeLanguage,
|
|
89
|
+
translateTarget: value.translateTarget === 'auto' || LANGUAGE_CODES.has(value.translateTarget) ? value.translateTarget : 'auto',
|
|
90
|
+
answerLanguage: ['native', 'source'].includes(value.answerLanguage) || LANGUAGE_CODES.has(value.answerLanguage) ? value.answerLanguage : 'native',
|
|
91
|
+
model: {
|
|
92
|
+
provider: shortString(value.model?.provider, 100),
|
|
93
|
+
model: shortString(value.model?.model, 200),
|
|
94
|
+
},
|
|
95
|
+
maxTextLength: clampInt(value.maxTextLength, LIMITS.maxTextLength, defaults.maxTextLength),
|
|
96
|
+
clipboardFallback: typeof value.clipboardFallback === 'boolean' ? value.clipboardFallback : defaults.clipboardFallback,
|
|
97
|
+
searchEngine: value.searchEngine === 'custom' || Object.hasOwn(SEARCH_ENGINES, value.searchEngine) ? value.searchEngine : 'google',
|
|
98
|
+
customSearchUrl: isSearchTemplate(value.customSearchUrl) ? value.customSearchUrl.trim() : '',
|
|
99
|
+
}
|
|
100
|
+
if (settings.searchEngine === 'custom' && !settings.customSearchUrl) settings.searchEngine = 'google'
|
|
101
|
+
return settings
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** URL template for the search skill; `%s` is replaced by the encoded selection. */
|
|
105
|
+
export function searchTemplate(settings) {
|
|
106
|
+
return settings.searchEngine === 'custom' ? settings.customSearchUrl : SEARCH_ENGINES[settings.searchEngine]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Enabled skills in toolbar order, with what the toolbar needs to draw them. */
|
|
110
|
+
export function enabledSkills(settings) {
|
|
111
|
+
const builtins = new Map(BUILTIN_SKILLS.map((skill) => [skill.id, skill]))
|
|
112
|
+
return settings.skills.filter((entry) => entry.enabled).map((entry) => {
|
|
113
|
+
if (entry.custom) return { id: entry.id, kind: 'prompt', icon: entry.icon, name: entry.name, custom: true }
|
|
114
|
+
const builtin = builtins.get(entry.id)
|
|
115
|
+
return { id: builtin.id, kind: builtin.kind, icon: builtin.icon, name: builtin.name, custom: false }
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Looks a skill up by id, including disabled ones (a click may race a settings change). */
|
|
120
|
+
export function findSkill(settings, id) {
|
|
121
|
+
const entry = settings.skills.find((skill) => skill.id === id)
|
|
122
|
+
if (!entry) return null
|
|
123
|
+
if (entry.custom) return { ...entry, kind: 'prompt' }
|
|
124
|
+
return { ...BUILTIN_SKILLS.find((skill) => skill.id === id), enabled: entry.enabled }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const UNREAD_RETRY_MS = 2000
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* File-backed settings. Writes are atomic (temp file + rename). A file that is not valid JSON is moved
|
|
131
|
+
* aside and replaced by defaults instead of blocking startup.
|
|
132
|
+
* @param {{ file: string, log?: (level: string, message: string, fields?: object) => void }} options
|
|
133
|
+
*/
|
|
134
|
+
export function createSettingsStore({ file, log }) {
|
|
135
|
+
const listeners = new Set()
|
|
136
|
+
// The file exists but could not be read (locked, no permission, out of descriptors). Until a read
|
|
137
|
+
// succeeds the defaults stand in, nothing is written over the file, and it is read again now and
|
|
138
|
+
// then; when that works, every listener (the worker among them) gets the real settings.
|
|
139
|
+
let unread = false
|
|
140
|
+
let retry = null
|
|
141
|
+
let current = load()
|
|
142
|
+
|
|
143
|
+
function load() {
|
|
144
|
+
let text
|
|
145
|
+
try {
|
|
146
|
+
text = readFileSync(file, 'utf8')
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error?.code === 'ENOENT') return defaultSettings()
|
|
149
|
+
if (!unread) log?.('warn', 'settings file could not be read, using defaults for now', { error: String(error?.message ?? error) })
|
|
150
|
+
unread = true
|
|
151
|
+
retry ??= setTimeout(() => {
|
|
152
|
+
retry = null
|
|
153
|
+
latest()
|
|
154
|
+
}, UNREAD_RETRY_MS)
|
|
155
|
+
retry.unref?.()
|
|
156
|
+
return defaultSettings()
|
|
157
|
+
}
|
|
158
|
+
unread = false
|
|
159
|
+
try {
|
|
160
|
+
return normalizeSettings(JSON.parse(text))
|
|
161
|
+
} catch (error) {
|
|
162
|
+
log?.('warn', 'settings file is not valid JSON, moved aside', { error: String(error?.message ?? error) })
|
|
163
|
+
try { renameSync(file, `${file}.broken-${Date.now()}`) } catch { /* nothing left to preserve */ }
|
|
164
|
+
return defaultSettings()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function latest() {
|
|
169
|
+
if (!unread) return current
|
|
170
|
+
const next = load()
|
|
171
|
+
if (unread) return current
|
|
172
|
+
current = next
|
|
173
|
+
for (const listener of listeners) listener(current)
|
|
174
|
+
return current
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function save(next) {
|
|
178
|
+
latest()
|
|
179
|
+
if (unread) throw new Error('the settings file cannot be read right now, so it was left unchanged')
|
|
180
|
+
const normalized = normalizeSettings(next)
|
|
181
|
+
// A change that normalization undid, or no change at all: nothing to write or announce.
|
|
182
|
+
if (JSON.stringify(normalized) === JSON.stringify(current)) return current
|
|
183
|
+
writeFileAtomic(file, `${JSON.stringify(normalized, null, 2)}\n`)
|
|
184
|
+
current = normalized
|
|
185
|
+
for (const listener of listeners) listener(current)
|
|
186
|
+
return current
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
get: latest,
|
|
191
|
+
save,
|
|
192
|
+
update: (change) => save(change(structuredClone(latest()))),
|
|
193
|
+
reset: () => save(defaultSettings()),
|
|
194
|
+
onChange(listener) {
|
|
195
|
+
listeners.add(listener)
|
|
196
|
+
return () => listeners.delete(listener)
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeSkills(input) {
|
|
202
|
+
const skills = []
|
|
203
|
+
const seen = new Set()
|
|
204
|
+
let customCount = 0
|
|
205
|
+
for (const entry of Array.isArray(input) ? input : []) {
|
|
206
|
+
if (!isObject(entry) || typeof entry.id !== 'string' || seen.has(entry.id)) continue
|
|
207
|
+
const enabled = typeof entry.enabled === 'boolean' ? entry.enabled : true
|
|
208
|
+
if (BUILTIN_IDS.has(entry.id)) {
|
|
209
|
+
skills.push({ id: entry.id, enabled })
|
|
210
|
+
seen.add(entry.id)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (!CUSTOM_ID.test(entry.id) || customCount >= LIMITS.customSkills) continue
|
|
214
|
+
const name = shortString(entry.name, LIMITS.customName)
|
|
215
|
+
const prompt = shortString(entry.prompt, LIMITS.customPrompt)
|
|
216
|
+
if (!name || !prompt) continue
|
|
217
|
+
const icon = CUSTOM_SKILL_ICONS.includes(entry.icon) && Object.hasOwn(ICONS, entry.icon) ? entry.icon : 'sparkles'
|
|
218
|
+
skills.push({ id: entry.id, custom: true, enabled, name, icon, prompt })
|
|
219
|
+
seen.add(entry.id)
|
|
220
|
+
customCount += 1
|
|
221
|
+
}
|
|
222
|
+
// Skills added in a newer release show up enabled at the end of an existing list.
|
|
223
|
+
for (const builtin of BUILTIN_SKILLS) {
|
|
224
|
+
if (!seen.has(builtin.id)) skills.push({ id: builtin.id, enabled: true })
|
|
225
|
+
}
|
|
226
|
+
return skills
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeApps(input) {
|
|
230
|
+
const apps = new Set()
|
|
231
|
+
for (const entry of Array.isArray(input) ? input : []) {
|
|
232
|
+
const app = shortString(entry, LIMITS.appName)
|
|
233
|
+
if (app) apps.add(app)
|
|
234
|
+
if (apps.size >= LIMITS.disabledApps) break
|
|
235
|
+
}
|
|
236
|
+
return [...apps]
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isSearchTemplate(value) {
|
|
240
|
+
if (typeof value !== 'string' || value.length > 500 || !value.includes('%s')) return false
|
|
241
|
+
try {
|
|
242
|
+
const url = new URL(value.trim().replace('%s', 'x'))
|
|
243
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
244
|
+
} catch {
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function clampInt(value, { min, max }, fallback) {
|
|
250
|
+
const number = Number(value)
|
|
251
|
+
if (!Number.isFinite(number)) return fallback
|
|
252
|
+
return Math.min(max, Math.max(min, Math.round(number)))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function shortString(value, max) {
|
|
256
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : ''
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isObject(value) {
|
|
260
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @typedef {object} Settings
|
|
265
|
+
* @property {1} version
|
|
266
|
+
* @property {boolean} enabled Show the toolbar when text is selected.
|
|
267
|
+
* @property {'auto' | 'modifier'} trigger `modifier` shows it only while ⌥ / Alt is held.
|
|
268
|
+
* @property {number} toolbarSize How many enabled skills sit on the toolbar; the rest go to the ⋯ menu.
|
|
269
|
+
* @property {Array<{ id: string, enabled: boolean, custom?: true, name?: string, icon?: string, prompt?: string }>} skills
|
|
270
|
+
* @property {string[]} disabledApps Program names (as reported by the OS) where the toolbar never shows.
|
|
271
|
+
* @property {string} nativeLanguage Language the user reads best; auto translation flips around it.
|
|
272
|
+
* @property {string} translateTarget `auto` or a language code.
|
|
273
|
+
* @property {string} answerLanguage `native`, `source` or a language code, for summaries, explanations and answers.
|
|
274
|
+
* @property {{ provider: string, model: string }} model Empty strings pick a route automatically.
|
|
275
|
+
* @property {number} maxTextLength Longer selections are cut before being sent anywhere.
|
|
276
|
+
* @property {boolean} clipboardFallback Simulate a copy when accessibility APIs cannot read the selection.
|
|
277
|
+
* @property {string} searchEngine A key of SEARCH_ENGINES or `custom`.
|
|
278
|
+
* @property {string} customSearchUrl http(s) URL containing `%s`.
|
|
279
|
+
*/
|