@followbin/dsh-selection 0.0.0-stage → 0.2.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 +151 -2
- package/README.zh-CN.md +152 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/client.js +1070 -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 +334 -0
- package/src/host/settings.mjs +282 -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 +66 -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 +274 -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 +266 -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 +162 -0
- package/ui/toolbar.html +54 -0
- package/ui/toolbar.js +552 -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
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none"><path d="M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5" stroke="url(#dsh-selection-frame)" stroke-width="2.4" stroke-linecap="round"/><path d="M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z" fill="url(#dsh-selection-spark)" stroke="none"/><defs><linearGradient id="dsh-selection-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient><linearGradient id="dsh-selection-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient></defs></svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// DSH plugin entry (Host side). Starts the selection service and mounts its HTTP API.
|
|
2
|
+
// The settings page lives in client.js; the toolbar runs in a worker process (src/worker).
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { createHttpHandler, ROUTE } from './src/host/http.mjs'
|
|
5
|
+
import { createLogger } from './src/host/log.mjs'
|
|
6
|
+
import { createSelectionService } from './src/host/service.mjs'
|
|
7
|
+
import { dataDirectory } from './src/shared/paths.mjs'
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-selection'
|
|
10
|
+
export const inject = ['webServer', 'connection']
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {any} ctx Cordis Host context.
|
|
14
|
+
* @param {{ promptForPermission?: boolean, force?: boolean }} [config] Row config from cordis.patch.yml.
|
|
15
|
+
* `force` starts the toolbar outside DSH Desktop (only for a web Host on the user's own machine).
|
|
16
|
+
*/
|
|
17
|
+
export function apply(ctx, config = {}) {
|
|
18
|
+
const dataDir = dataDirectory()
|
|
19
|
+
const log = createLogger(join(dataDir, 'host.log'))
|
|
20
|
+
// The toolbar must appear on the user's screen, so it only runs where the Host shares their desktop.
|
|
21
|
+
const desktop = Boolean(process.versions.electron) || config.force === true
|
|
22
|
+
const service = createSelectionService({
|
|
23
|
+
// Looked up per request: the llm service may load after this plugin, or be swapped at runtime.
|
|
24
|
+
getLlm: () => ctx.get('llm'),
|
|
25
|
+
log,
|
|
26
|
+
dataDir,
|
|
27
|
+
desktop,
|
|
28
|
+
promptForPermission: config.promptForPermission !== false,
|
|
29
|
+
})
|
|
30
|
+
log('info', 'plugin started', { platform: process.platform, desktop })
|
|
31
|
+
ctx.effect(() => () => service.dispose(), 'dsh-selection: service')
|
|
32
|
+
ctx.on('llm/adapters-updated', () => service.invalidateModels())
|
|
33
|
+
ctx.effect(
|
|
34
|
+
() => ctx.webServer.register({
|
|
35
|
+
kind: 'prefix',
|
|
36
|
+
path: ROUTE,
|
|
37
|
+
handler: createHttpHandler(service, { log, connection: ctx.connection }),
|
|
38
|
+
}),
|
|
39
|
+
'dsh-selection: http routes',
|
|
40
|
+
)
|
|
41
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@followbin/dsh-selection",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Select text in any app and let DSH translate, summarize, explain, answer or read it aloud.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"selection",
|
|
10
|
+
"translate",
|
|
11
|
+
"popclip"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "followbin",
|
|
15
|
+
"homepage": "https://github.com/followbin/dsh-selection#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/followbin/dsh-selection.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/followbin/dsh-selection/issues"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./index.js",
|
|
28
|
+
"icon": "./icon.svg",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./index.js",
|
|
31
|
+
"./client": "./client.js",
|
|
32
|
+
"./locale/*.json": "./locale/*.json",
|
|
33
|
+
"./icon": "./icon.svg",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.js",
|
|
38
|
+
"client.js",
|
|
39
|
+
"cordis.patch.yml",
|
|
40
|
+
"icon.svg",
|
|
41
|
+
"locale/*.json",
|
|
42
|
+
"src",
|
|
43
|
+
"ui",
|
|
44
|
+
"vendor",
|
|
45
|
+
"README.md",
|
|
46
|
+
"README.zh-CN.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"THIRD_PARTY_NOTICES.md"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test test/*.test.mjs",
|
|
52
|
+
"selftest": "ELECTRON_RUN_AS_NODE=1 \"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness\" scripts/selftest.mjs",
|
|
53
|
+
"dev": "ELECTRON_RUN_AS_NODE=1 \"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness\" scripts/dev-host.mjs",
|
|
54
|
+
"vendor:selection-hook": "node scripts/vendor-selection-hook.mjs",
|
|
55
|
+
"screenshots": "ELECTRON_RUN_AS_NODE=1 \"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness\" scripts/screenshots.mjs"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@webviewjs/webview": "0.6.0"
|
|
59
|
+
},
|
|
60
|
+
"optionalDependencies": {
|
|
61
|
+
"@koromix/koffi-darwin-arm64": "3.3.6",
|
|
62
|
+
"@koromix/koffi-darwin-x64": "3.3.6",
|
|
63
|
+
"@koromix/koffi-win32-arm64": "3.3.6",
|
|
64
|
+
"@koromix/koffi-win32-x64": "3.3.6"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=24"
|
|
68
|
+
},
|
|
69
|
+
"dsh": {
|
|
70
|
+
"bundle": {
|
|
71
|
+
"patch": "./cordis.patch.yml",
|
|
72
|
+
"platforms": [
|
|
73
|
+
"darwin",
|
|
74
|
+
"win32"
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
"client": {
|
|
78
|
+
"platform": "web",
|
|
79
|
+
"immediately": true,
|
|
80
|
+
"inject": [
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
82
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
83
|
+
"@deepseek-ai/dsh-client-locale"
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"devDependencies": {
|
|
88
|
+
"jsdom": "26.1.0",
|
|
89
|
+
"react": "19.1.10",
|
|
90
|
+
"react-dom": "19.1.10"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Writes a file so that readers see either the old or the new content, never half of it.
|
|
2
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export function writeFileAtomic(file, text) {
|
|
6
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
7
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
8
|
+
writeFileSync(temporary, text, { mode: 0o600 })
|
|
9
|
+
try {
|
|
10
|
+
replaceFile(temporary, file)
|
|
11
|
+
} catch (error) {
|
|
12
|
+
try { unlinkSync(temporary) } catch { /* already gone */ }
|
|
13
|
+
throw error
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Windows refuses to replace a file another process has open (antivirus, indexer, sync clients),
|
|
18
|
+
// usually for a few milliseconds.
|
|
19
|
+
function replaceFile(source, target) {
|
|
20
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
21
|
+
try {
|
|
22
|
+
renameSync(source, target)
|
|
23
|
+
return
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (process.platform !== 'win32' || attempt === 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) throw error
|
|
26
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// HTTP API for the settings page, mounted on DSH's web server under /dsh-selection.
|
|
2
|
+
// DSH's `connection` service is the gate: it fences Host/Origin (cross-site calls, DNS rebinding) and
|
|
3
|
+
// requires the signed-in browser session, the same check DSH's own routes run. Writes must be JSON,
|
|
4
|
+
// which keeps simple cross-site form posts out even before that check.
|
|
5
|
+
import { BadRequest } from './service.mjs'
|
|
6
|
+
|
|
7
|
+
export const ROUTE = '/dsh-selection'
|
|
8
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
9
|
+
const KEEPALIVE_MS = 25_000
|
|
10
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]'])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {ReturnType<import('./service.mjs').createSelectionService>} service
|
|
14
|
+
* @param {{
|
|
15
|
+
* log: (level: string, message: string, fields?: object) => void,
|
|
16
|
+
* connection?: { requestRejection: (req: import('node:http').IncomingMessage) => number | undefined },
|
|
17
|
+
* }} options Without `connection` (tests, the dev runner) only loopback same-origin requests pass.
|
|
18
|
+
*/
|
|
19
|
+
export function createHttpHandler(service, { log, connection }) {
|
|
20
|
+
return async function handle(req, res) {
|
|
21
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
22
|
+
const path = url.pathname.slice(ROUTE.length) || '/'
|
|
23
|
+
const status = connection ? connection.requestRejection(req) : localRejection(req)
|
|
24
|
+
if (status !== undefined) return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
|
|
25
|
+
if (browserRejection(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
26
|
+
const writes = req.method === 'POST' || req.method === 'PUT'
|
|
27
|
+
if (writes && !isJson(req)) return sendJson(res, 415, { error: 'expected application/json' })
|
|
28
|
+
try {
|
|
29
|
+
switch (`${req.method} ${path}`) {
|
|
30
|
+
case 'GET /api/state':
|
|
31
|
+
return sendJson(res, 200, service.getState())
|
|
32
|
+
case 'GET /api/models':
|
|
33
|
+
return sendJson(res, 200, await service.listModels())
|
|
34
|
+
case 'GET /api/events':
|
|
35
|
+
return openEventStream(req, res, service)
|
|
36
|
+
case 'PUT /api/settings':
|
|
37
|
+
return sendJson(res, 200, { settings: service.saveSettings(await readJson(req)) })
|
|
38
|
+
case 'POST /api/settings/reset':
|
|
39
|
+
return sendJson(res, 200, { settings: service.resetSettings() })
|
|
40
|
+
case 'POST /api/models/test':
|
|
41
|
+
return sendJson(res, 200, await service.testModel())
|
|
42
|
+
case 'POST /api/skills/test':
|
|
43
|
+
return sendJson(res, 200, await service.testSkill(await readJson(req)))
|
|
44
|
+
case 'GET /api/usage':
|
|
45
|
+
return sendJson(res, 200, service.getUsage())
|
|
46
|
+
case 'POST /api/usage/reset':
|
|
47
|
+
return sendJson(res, 200, service.resetUsage())
|
|
48
|
+
case 'POST /api/worker/restart':
|
|
49
|
+
service.restartWorker()
|
|
50
|
+
return sendJson(res, 200, { ok: true })
|
|
51
|
+
case 'POST /api/permission/open':
|
|
52
|
+
service.openPermissionSettings()
|
|
53
|
+
return sendJson(res, 200, { ok: true })
|
|
54
|
+
default:
|
|
55
|
+
return sendJson(res, 404, { error: 'not found' })
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof BadRequest) return sendJson(res, 400, { error: error.message })
|
|
59
|
+
log('error', 'request failed', { method: req.method, path, error: String(error?.message ?? error) })
|
|
60
|
+
return sendJson(res, 500, { error: 'internal error' })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fetch metadata and Origin say the request comes from another site. Cheap, and true for any Host. */
|
|
66
|
+
export function browserRejection(req) {
|
|
67
|
+
const site = req.headers['sec-fetch-site']
|
|
68
|
+
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return true
|
|
69
|
+
const origin = req.headers.origin
|
|
70
|
+
if (typeof origin !== 'string' || origin === 'null') return false
|
|
71
|
+
try {
|
|
72
|
+
return new URL(origin).host !== req.headers.host
|
|
73
|
+
} catch {
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fallback gate when DSH's connection service is absent: loopback only. */
|
|
79
|
+
export function localRejection(req) {
|
|
80
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host : ''
|
|
81
|
+
const hostname = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0].toLowerCase()
|
|
82
|
+
return LOOPBACK_HOSTS.has(hostname) ? undefined : 403
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isJson(req) {
|
|
86
|
+
return String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readJson(req) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const chunks = []
|
|
92
|
+
let size = 0
|
|
93
|
+
req.on('data', (chunk) => {
|
|
94
|
+
size += chunk.length
|
|
95
|
+
if (size > MAX_BODY_BYTES) {
|
|
96
|
+
reject(new BadRequest('request body too large'))
|
|
97
|
+
req.destroy()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
chunks.push(chunk)
|
|
101
|
+
})
|
|
102
|
+
req.on('end', () => {
|
|
103
|
+
try {
|
|
104
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null'))
|
|
105
|
+
} catch {
|
|
106
|
+
reject(new BadRequest('invalid JSON'))
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
req.on('error', reject)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sendJson(res, status, value) {
|
|
114
|
+
const body = JSON.stringify(value)
|
|
115
|
+
res.writeHead(status, {
|
|
116
|
+
'content-type': 'application/json; charset=utf-8',
|
|
117
|
+
'cache-control': 'no-store',
|
|
118
|
+
'content-length': Buffer.byteLength(body),
|
|
119
|
+
})
|
|
120
|
+
res.end(body)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Server-sent events: `status`, `settings` and `navigate` (open the settings page). Every connection
|
|
125
|
+
* starts with the current status and settings, so a page that reconnects never edits stale values.
|
|
126
|
+
*/
|
|
127
|
+
function openEventStream(req, res, service) {
|
|
128
|
+
res.writeHead(200, {
|
|
129
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
130
|
+
'cache-control': 'no-store',
|
|
131
|
+
connection: 'keep-alive',
|
|
132
|
+
})
|
|
133
|
+
let unsubscribe = () => {}
|
|
134
|
+
const keepalive = setInterval(() => res.write(': keepalive\n\n'), KEEPALIVE_MS)
|
|
135
|
+
const close = () => {
|
|
136
|
+
clearInterval(keepalive)
|
|
137
|
+
unsubscribe()
|
|
138
|
+
}
|
|
139
|
+
const write = (event, data) => {
|
|
140
|
+
if (event === 'closing') {
|
|
141
|
+
close()
|
|
142
|
+
res.end()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
146
|
+
}
|
|
147
|
+
const { status, settings } = service.getState()
|
|
148
|
+
write('status', status)
|
|
149
|
+
write('settings', settings)
|
|
150
|
+
unsubscribe = service.subscribe(write)
|
|
151
|
+
req.on('close', close)
|
|
152
|
+
res.on('error', close)
|
|
153
|
+
}
|
package/src/host/llm.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Calls models through DSH's own `llm` service, so requests use whatever account or API key the user
|
|
2
|
+
// already configured in DSH. Each skill click is one request with no tools and thinking off; Ask DSH
|
|
3
|
+
// carries the earlier turns of its panel in that request.
|
|
4
|
+
|
|
5
|
+
const PREFERRED_ROUTES = [
|
|
6
|
+
['deepseek-account', 'deepseek-flash'],
|
|
7
|
+
['deepseek-official', 'deepseek-flash'],
|
|
8
|
+
]
|
|
9
|
+
const MAX_OUTPUT_TOKENS = 4096
|
|
10
|
+
// Silence limits rather than a total limit: a long, healthy answer keeps streaming as long as it needs.
|
|
11
|
+
const FIRST_TOKEN_TIMEOUT_MS = 60_000
|
|
12
|
+
const IDLE_TIMEOUT_MS = 45_000
|
|
13
|
+
const ROUTE_CACHE_MS = 5 * 60_000
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ provider: string, providerName: string, model: string, modelName: string }} Route */
|
|
16
|
+
|
|
17
|
+
export class ModelError extends Error {
|
|
18
|
+
/**
|
|
19
|
+
* @param {'NO_LLM' | 'NO_MODEL' | 'MODEL_FAILED' | 'EMPTY' | 'TIMEOUT'} code Stable code the UI translates.
|
|
20
|
+
* @param {string} [detail] Provider message, shown as a secondary line.
|
|
21
|
+
*/
|
|
22
|
+
constructor(code, detail = '') {
|
|
23
|
+
super(detail || code)
|
|
24
|
+
this.code = code
|
|
25
|
+
this.detail = detail
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{ getLlm: () => any, log?: (level: string, message: string, fields?: object) => void }} options
|
|
31
|
+
*/
|
|
32
|
+
export function createModelClient({ getLlm, log }) {
|
|
33
|
+
let cache = null
|
|
34
|
+
let listing = null
|
|
35
|
+
// Bumped by invalidate(), so a listing already under way when DSH's providers change is not cached.
|
|
36
|
+
let generation = 0
|
|
37
|
+
// Routes that reject an "off" reasoning level; without this every click would prepare the call twice.
|
|
38
|
+
const withoutOff = new Set()
|
|
39
|
+
|
|
40
|
+
function llmService() {
|
|
41
|
+
const llm = getLlm()
|
|
42
|
+
if (!llm) throw new ModelError('NO_LLM')
|
|
43
|
+
return llm
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every provider/model pair DSH can currently call. Concurrent callers share one listing. */
|
|
47
|
+
function listRoutes({ fresh = false } = {}) {
|
|
48
|
+
if (!fresh && cache && Date.now() - cache.at < ROUTE_CACHE_MS) return Promise.resolve(cache.routes)
|
|
49
|
+
if (!fresh && listing) return listing
|
|
50
|
+
const current = readRoutes(generation).finally(() => {
|
|
51
|
+
if (listing === current) listing = null
|
|
52
|
+
})
|
|
53
|
+
listing = current
|
|
54
|
+
return current
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readRoutes(started) {
|
|
58
|
+
const llm = llmService()
|
|
59
|
+
const providers = llm.listProviders()
|
|
60
|
+
// Each provider looks up its credentials and may fetch its model list, so ask them all at once.
|
|
61
|
+
const results = await Promise.allSettled(providers.map((provider) => llm.listModels(provider.id)))
|
|
62
|
+
const routes = []
|
|
63
|
+
let complete = true
|
|
64
|
+
results.forEach((result, index) => {
|
|
65
|
+
const provider = providers[index]
|
|
66
|
+
if (result.status === 'rejected') {
|
|
67
|
+
complete = false
|
|
68
|
+
log?.('warn', 'listing models failed', { provider: provider.id, error: String(result.reason?.message ?? result.reason) })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
for (const model of result.value ?? []) {
|
|
72
|
+
routes.push({
|
|
73
|
+
provider: provider.id,
|
|
74
|
+
providerName: provider.displayName ?? provider.name ?? provider.label ?? provider.id,
|
|
75
|
+
model: model.id,
|
|
76
|
+
modelName: model.displayName ?? model.name ?? model.id,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
// An empty or partial list is often minutes from being fixed (a sign-in, a provider hiccup);
|
|
81
|
+
// caching it would keep answering NO_MODEL, or a fallback route, after that.
|
|
82
|
+
if (started === generation) cache = complete && routes.length ? { at: Date.now(), routes } : null
|
|
83
|
+
return routes
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The configured route if it still exists, otherwise DeepSeek Flash, otherwise the first route. */
|
|
87
|
+
async function resolveRoute(settings) {
|
|
88
|
+
const routes = await listRoutes()
|
|
89
|
+
if (routes.length === 0) throw new ModelError('NO_MODEL')
|
|
90
|
+
const { provider, model } = settings.model
|
|
91
|
+
const pick = (p, m) => routes.find((route) => route.provider === p && route.model === m)
|
|
92
|
+
if (provider && model && pick(provider, model)) return pick(provider, model)
|
|
93
|
+
for (const [p, m] of PREFERRED_ROUTES) if (pick(p, m)) return pick(p, m)
|
|
94
|
+
return routes[0]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Streams the reply text.
|
|
99
|
+
* @param {{
|
|
100
|
+
* settings: import('./settings.mjs').Settings,
|
|
101
|
+
* prompt: (route: Route) => { system: string, user: string },
|
|
102
|
+
* signal: AbortSignal,
|
|
103
|
+
* onUsage?: (usage: object) => void,
|
|
104
|
+
* }} request `prompt` runs once the route is known, so the request can name the model answering it.
|
|
105
|
+
* `onUsage` receives DSH's token counts for the request, also when it fails or is cancelled.
|
|
106
|
+
* @returns {AsyncGenerator<string>}
|
|
107
|
+
*/
|
|
108
|
+
async function* stream({ settings, prompt, signal, onUsage }) {
|
|
109
|
+
const llm = llmService()
|
|
110
|
+
const silence = new AbortController()
|
|
111
|
+
let timer = setTimeout(() => silence.abort(), FIRST_TOKEN_TIMEOUT_MS)
|
|
112
|
+
const combined = AbortSignal.any([signal, silence.signal])
|
|
113
|
+
const failed = (error) => {
|
|
114
|
+
if (signal.aborted) return signal.reason ?? new DOMException('Aborted', 'AbortError')
|
|
115
|
+
if (silence.signal.aborted) return new ModelError('TIMEOUT')
|
|
116
|
+
return error instanceof ModelError ? error : new ModelError('MODEL_FAILED', String(error?.message ?? error))
|
|
117
|
+
}
|
|
118
|
+
let usage = null
|
|
119
|
+
try {
|
|
120
|
+
const route = await abortable(resolveRoute(settings), combined)
|
|
121
|
+
const { system, user } = prompt(route)
|
|
122
|
+
const prepare = (effort) => llm.prepareCall({
|
|
123
|
+
provider: route.provider,
|
|
124
|
+
model: route.model,
|
|
125
|
+
maxTokens: MAX_OUTPUT_TOKENS,
|
|
126
|
+
...effort ? { reasoningEffort: effort } : {},
|
|
127
|
+
}, combined)
|
|
128
|
+
const key = `${route.provider}\u0000${route.model}`
|
|
129
|
+
let prepared
|
|
130
|
+
if (withoutOff.has(key)) prepared = await prepare(null)
|
|
131
|
+
else {
|
|
132
|
+
try {
|
|
133
|
+
prepared = await prepare('off')
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (combined.aborted) throw error
|
|
136
|
+
// Routes without an "off" reasoning level reject it; their own default is the next best choice.
|
|
137
|
+
prepared = await prepare(null)
|
|
138
|
+
if (error?.code === 'UNSUPPORTED_REASONING_EFFORT') withoutOff.add(key)
|
|
139
|
+
log?.('info', 'reasoning effort off unavailable, using route default', { provider: route.provider, model: route.model, error: String(error?.message ?? error) })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const messages = [{ role: 'user', content: [{ type: 'text', text: user }] }]
|
|
143
|
+
let produced = false
|
|
144
|
+
for await (const chunk of prepared.stream({ ...prepared.config, system, messages, signal: combined })) {
|
|
145
|
+
clearTimeout(timer)
|
|
146
|
+
timer = setTimeout(() => silence.abort(), IDLE_TIMEOUT_MS)
|
|
147
|
+
if (chunk.type === 'text-delta' && chunk.text) {
|
|
148
|
+
produced = true
|
|
149
|
+
yield chunk.text
|
|
150
|
+
} else if (chunk.type === 'usage') {
|
|
151
|
+
usage = chunk.usage
|
|
152
|
+
} else if (chunk.type === 'finish') {
|
|
153
|
+
if (chunk.reason?.kind === 'error') throw new ModelError('MODEL_FAILED', chunk.reason.failure?.message ?? '')
|
|
154
|
+
if (chunk.reason?.kind === 'aborted') throw new DOMException('Aborted', 'AbortError')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!produced) throw new ModelError('EMPTY')
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw failed(error)
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
if (usage) {
|
|
163
|
+
try {
|
|
164
|
+
onUsage?.(usage)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
log?.('warn', 'recording usage failed', { error: String(error?.message ?? error) })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Forgets what was learned about the routes, after DSH's providers or the chosen model change. */
|
|
173
|
+
function invalidate() {
|
|
174
|
+
generation += 1
|
|
175
|
+
cache = null
|
|
176
|
+
listing = null
|
|
177
|
+
withoutOff.clear()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { listRoutes, resolveRoute, stream, invalidate }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Rejects as soon as the signal aborts, even if the work itself cannot be cancelled. */
|
|
184
|
+
function abortable(promise, signal) {
|
|
185
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const onAbort = () => reject(signal.reason)
|
|
188
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
189
|
+
promise.then(
|
|
190
|
+
(value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
|
|
191
|
+
(error) => { signal.removeEventListener('abort', onAbort); reject(error) },
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
}
|
package/src/host/log.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Small JSON-lines log under the plugin's data directory. It never records selected text or model
|
|
2
|
+
// output, only events, counts and errors, so it is safe to attach to bug reports.
|
|
3
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const MAX_BYTES = 1024 * 1024
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} file
|
|
10
|
+
* @param {{ echo?: boolean }} [options] Also print to stderr (useful when running outside DSH).
|
|
11
|
+
* @returns {(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: object) => void}
|
|
12
|
+
*/
|
|
13
|
+
export function createLogger(file, { echo = false } = {}) {
|
|
14
|
+
let ready = false
|
|
15
|
+
return (level, message, fields = {}) => {
|
|
16
|
+
const record = { time: new Date().toISOString(), level, message }
|
|
17
|
+
// A field never replaces the record's own time, level or message.
|
|
18
|
+
for (const [key, value] of Object.entries(fields)) record[key in record ? `_${key}` : key] = value
|
|
19
|
+
const line = JSON.stringify(record)
|
|
20
|
+
if (echo) console.error(`[dsh-selection] ${line}`)
|
|
21
|
+
try {
|
|
22
|
+
if (!ready) {
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
24
|
+
ready = true
|
|
25
|
+
}
|
|
26
|
+
if (statSize(file) > MAX_BYTES) renameSync(file, `${file}.1`)
|
|
27
|
+
appendFileSync(file, `${line}\n`, { mode: 0o600 })
|
|
28
|
+
} catch {
|
|
29
|
+
// Logging must never take the plugin down.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statSize(file) {
|
|
35
|
+
try {
|
|
36
|
+
return statSync(file).size
|
|
37
|
+
} catch {
|
|
38
|
+
return 0
|
|
39
|
+
}
|
|
40
|
+
}
|