dsh-her 0.2.0 → 0.3.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/README.md +1 -1
- package/lib/body-release.json +19 -0
- package/lib/dsh/body-install.js +68 -10
- package/lib/dsh/body-launcher.js +1 -1
- package/lib/dsh/config.js +2 -0
- package/lib/dsh/plugin.js +5 -3
- package/lib/net.js +113 -0
- package/lib/platform.js +12 -0
- package/lib/types/dsh/body-install.d.ts +25 -1
- package/lib/types/dsh/config.d.ts +2 -0
- package/lib/types/net.d.ts +34 -0
- package/lib/types/platform.d.ts +9 -0
- package/lib/types/voice/models.d.ts +2 -0
- package/lib/voice/ming.js +2 -1
- package/lib/voice/models.js +14 -3
- package/lib/voice/uv.js +4 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
| M3 外形包与姿势图 | ✅ mac 通过([记录](docs/M3记录.md));用合成测试包验证,"想看 JK" 全流程跑通;待真实素材校准 |
|
|
15
15
|
| M4 内置记忆 | ✅ mac 通过([记录](docs/M4记录.md));换全新会话仍记得,删掉的事不再提 |
|
|
16
16
|
| M5 向导与管理页 | ✅ mac 通过([记录](docs/M5记录.md));dsh 侧栏"她":7 步向导 + 管理页。身体的下载安装挪到 M6 |
|
|
17
|
-
| M6 打磨与发布 | 进行中([记录](docs/M6记录.md)):mac 身体打包、一键下载安装和自动更新、Edge 声音不再依赖系统 Python
|
|
17
|
+
| M6 打磨与发布 | 进行中([记录](docs/M6记录.md)):mac 身体打包、一键下载安装和自动更新、Edge 声音不再依赖系统 Python;Windows 身体打包和安装已写好、等实测([Windows适配](docs/Windows适配.md));签名公证、省电待做 |
|
|
18
18
|
|
|
19
19
|
## 组成
|
|
20
20
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"base": "https://github.com/AIcivilization/dsh-her/releases/download/v0.3.1/",
|
|
3
|
+
"manifest": {
|
|
4
|
+
"format": "dsh-her-body@1",
|
|
5
|
+
"version": "0.3.0",
|
|
6
|
+
"assets": {
|
|
7
|
+
"darwin-arm64": {
|
|
8
|
+
"file": "dsh-her-body-0.3.0-darwin-arm64.zip",
|
|
9
|
+
"size": 148276980,
|
|
10
|
+
"sha256": "e648aa5f4d4776e0c591bf31f617ffec23084fb6fafd918e4d5a0552484ffceb"
|
|
11
|
+
},
|
|
12
|
+
"win32-x64": {
|
|
13
|
+
"file": "dsh-her-body-0.3.0-win32-x64.zip",
|
|
14
|
+
"size": 213762814,
|
|
15
|
+
"sha256": "f29c9be0e75efee1d76ed020c981398d883d0c3d59dd8362c8d49556ec58d3d3"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
package/lib/dsh/body-install.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* { "format": "dsh-her-body@1", "version": "0.1.0",
|
|
7
7
|
* "assets": { "darwin-arm64": { "file": "dsh-her-body-0.1.0-darwin-arm64.zip", "size": …, "sha256": "…" } } }
|
|
8
8
|
*
|
|
9
|
+
* The app is `dsh-her.app` on macOS and a `dsh-her` folder with
|
|
10
|
+
* `dsh-her.exe` on Windows (body/scripts/package.mjs makes both).
|
|
9
11
|
* `<home>/body/current.json` says which version is installed. A download
|
|
10
12
|
* that does not match its SHA-256 is thrown away; the previous version stays
|
|
11
13
|
* until the new one is in place, and one older version is kept to go back to.
|
|
@@ -17,9 +19,15 @@ import { join } from 'node:path';
|
|
|
17
19
|
import { Readable } from 'node:stream';
|
|
18
20
|
import { pipeline } from 'node:stream/promises';
|
|
19
21
|
import { promisify } from 'node:util';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { fetchChecked, httpFetch, NetError } from "../net.js";
|
|
24
|
+
import { systemTar } from "../platform.js";
|
|
20
25
|
const run = promisify(execFile);
|
|
21
26
|
export const MANIFEST = 'body-latest.json';
|
|
22
|
-
|
|
27
|
+
/** The app inside a release zip (and in `<home>/body/<version>/`). */
|
|
28
|
+
export const appName = (platform = process.platform) => (platform === 'win32' ? 'dsh-her' : 'dsh-her.app');
|
|
29
|
+
/** Platforms with a body in the release. */
|
|
30
|
+
const PACKAGED = new Set(['darwin', 'win32']);
|
|
23
31
|
/** This computer's asset key in the manifest. */
|
|
24
32
|
export const platformKey = (platform = process.platform, arch = process.arch) => `${platform}-${arch}`;
|
|
25
33
|
/** -1, 0, 1: "0.1.0-dev.0" < "0.1.0" < "0.1.1" < "0.2.0". */
|
|
@@ -72,23 +80,63 @@ export function installedPlan(home) {
|
|
|
72
80
|
return undefined;
|
|
73
81
|
if (process.platform === 'darwin')
|
|
74
82
|
return { binary: join(installed.app, 'Contents', 'MacOS', 'Electron'), args: [], cwd: join(installed.app, '..') };
|
|
83
|
+
if (process.platform === 'win32')
|
|
84
|
+
return { binary: join(installed.app, 'dsh-her.exe'), args: [], cwd: installed.app };
|
|
75
85
|
return undefined;
|
|
76
86
|
}
|
|
77
87
|
/** `base` + file: the release's download address ends with a slash, or the file is appended after one. */
|
|
78
88
|
const at = (base, file) => `${base.replace(/\/?$/, '/')}${file}`;
|
|
89
|
+
/**
|
|
90
|
+
* The release's manifest, from GitHub itself (through the system proxy when
|
|
91
|
+
* there is one), never from a mirror: it carries the checksums that make a
|
|
92
|
+
* mirror's zip safe to use.
|
|
93
|
+
*/
|
|
79
94
|
export async function fetchManifest(base) {
|
|
80
|
-
|
|
95
|
+
let res;
|
|
96
|
+
try {
|
|
97
|
+
res = await httpFetch(at(base, MANIFEST), { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw new NetError(at(base, MANIFEST), error);
|
|
101
|
+
}
|
|
81
102
|
if (!res.ok)
|
|
82
103
|
throw new Error(`读不到发布信息(${res.status}):${at(base, MANIFEST)}`);
|
|
83
104
|
return validateManifest(await res.json());
|
|
84
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The manifest of this plugin's own release, put into the npm package when it
|
|
108
|
+
* was published (scripts/embed-body-release.mjs). The npm registry (and its
|
|
109
|
+
* mirrors) is reachable where GitHub is not, so a first install can still
|
|
110
|
+
* know what to download and how to check it.
|
|
111
|
+
*/
|
|
112
|
+
export function bundledRelease(file = fileURLToPath(new URL('../body-release.json', import.meta.url))) {
|
|
113
|
+
try {
|
|
114
|
+
const { base, manifest } = JSON.parse(readFileSync(file, 'utf8'));
|
|
115
|
+
return typeof base === 'string' && base.startsWith('https://github.com/') ? { base, manifest: validateManifest(manifest) } : undefined;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** What to install: the newest release on GitHub, or, when GitHub cannot be reached, this plugin's own. */
|
|
122
|
+
export async function latestRelease(base) {
|
|
123
|
+
try {
|
|
124
|
+
return { manifest: await fetchManifest(base), base };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const bundled = bundledRelease();
|
|
128
|
+
if (error instanceof NetError && bundled !== undefined)
|
|
129
|
+
return bundled;
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
85
133
|
/**
|
|
86
134
|
* Download this computer's asset of `manifest`, check it, unpack it next to
|
|
87
135
|
* the installed version and make it current. Progress in bytes.
|
|
88
136
|
*/
|
|
89
137
|
export async function install(home, base, manifest, onProgress) {
|
|
90
|
-
if (process.platform
|
|
91
|
-
throw new Error('
|
|
138
|
+
if (!PACKAGED.has(process.platform))
|
|
139
|
+
throw new Error('这个系统的身体程序还没有发布(现在有 macOS 和 Windows 版)');
|
|
92
140
|
const asset = manifest.assets[platformKey()];
|
|
93
141
|
if (asset === undefined)
|
|
94
142
|
throw new Error(`发布里没有这台电脑(${platformKey()})的身体程序`);
|
|
@@ -96,7 +144,8 @@ export async function install(home, base, manifest, onProgress) {
|
|
|
96
144
|
const downloads = join(root, 'download');
|
|
97
145
|
mkdirSync(downloads, { recursive: true });
|
|
98
146
|
const zip = join(downloads, asset.file);
|
|
99
|
-
|
|
147
|
+
// From GitHub, or a mirror when GitHub cannot be reached: the size and SHA-256 below decide either way.
|
|
148
|
+
const res = await fetchChecked(at(base, asset.file));
|
|
100
149
|
if (!res.ok || res.body === null)
|
|
101
150
|
throw new Error(`下载失败(${res.status})`);
|
|
102
151
|
const hash = createHash('sha256');
|
|
@@ -116,22 +165,31 @@ export async function install(home, base, manifest, onProgress) {
|
|
|
116
165
|
const unpacking = `${target}.unpacking`;
|
|
117
166
|
rmSync(unpacking, { recursive: true, force: true });
|
|
118
167
|
mkdirSync(unpacking, { recursive: true });
|
|
119
|
-
// ditto keeps the app's symlinks, permissions and signature.
|
|
120
|
-
|
|
121
|
-
|
|
168
|
+
// macOS: ditto keeps the app's symlinks, permissions and signature. Windows: the system tar reads zip.
|
|
169
|
+
if (process.platform === 'darwin')
|
|
170
|
+
await run('ditto', ['-x', '-k', zip, unpacking], { timeout: 300_000 });
|
|
171
|
+
else
|
|
172
|
+
await run(systemTar(), ['-xf', zip, '-C', unpacking], { timeout: 300_000 });
|
|
173
|
+
if (!existsSync(join(unpacking, appName())))
|
|
122
174
|
throw new Error('下载的包里没有身体程序');
|
|
123
175
|
rmSync(target, { recursive: true, force: true });
|
|
124
176
|
renameSync(unpacking, target);
|
|
125
177
|
rmSync(zip, { force: true });
|
|
126
178
|
const previous = installedBody(home);
|
|
127
|
-
const installed = { version: manifest.version, app: join(target,
|
|
179
|
+
const installed = { version: manifest.version, app: join(target, appName()), installedAt: new Date().toISOString() };
|
|
128
180
|
writeFileSync(join(root, 'current.json.tmp'), `${JSON.stringify(installed, null, 2)}\n`);
|
|
129
181
|
renameSync(join(root, 'current.json.tmp'), join(root, 'current.json'));
|
|
130
182
|
// Keep this version and the one before it; older ones go.
|
|
131
183
|
for (const entry of readdirSync(root)) {
|
|
132
184
|
const keep = entry === manifest.version || entry === previous?.version || entry === 'download' || entry.endsWith('.json');
|
|
133
|
-
if (
|
|
185
|
+
if (keep)
|
|
186
|
+
continue;
|
|
187
|
+
try {
|
|
134
188
|
rmSync(join(root, entry), { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Windows keeps a running program's files locked: that old version goes next time.
|
|
192
|
+
}
|
|
135
193
|
}
|
|
136
194
|
return installed;
|
|
137
195
|
}
|
package/lib/dsh/body-launcher.js
CHANGED
|
@@ -42,7 +42,7 @@ export function launchBody(plan, env) {
|
|
|
42
42
|
const childEnv = { ...process.env, DSH_HER_URL: env.url, DSH_HER_TOKEN: env.token, DSH_HER_HOME: env.home };
|
|
43
43
|
delete childEnv.ELECTRON_RUN_AS_NODE;
|
|
44
44
|
const args = env.pack === '' ? plan.args : [...plan.args, '--pack', env.pack];
|
|
45
|
-
const child = spawn(plan.binary, args, { cwd: plan.cwd, env: childEnv, detached: true, stdio: 'ignore' });
|
|
45
|
+
const child = spawn(plan.binary, args, { cwd: plan.cwd, env: childEnv, detached: true, stdio: 'ignore', windowsHide: true });
|
|
46
46
|
child.unref();
|
|
47
47
|
return child.pid;
|
|
48
48
|
}
|
package/lib/dsh/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Schema from '@deepseek-ai/schemastery';
|
|
2
|
+
import { DEFAULT_MIRRORS } from "../net.js";
|
|
2
3
|
import { DEFAULT_VOICE, SAMPLE_PERSONA } from "../core/index.js";
|
|
3
4
|
export const Config = Schema.object({
|
|
4
5
|
home: Schema.string().default('~/.dsh-her').description('dsh-her home (characters, packs, voice models)'),
|
|
@@ -16,6 +17,7 @@ export const Config = Schema.object({
|
|
|
16
17
|
pack: Schema.string().default('').description('Appearance pack directory (empty = body default)'),
|
|
17
18
|
releases: Schema.string().default('https://github.com/AIcivilization/dsh-her/releases/latest/download/').description('Where the body app is downloaded from (the folder with body-latest.json)'),
|
|
18
19
|
useDev: Schema.boolean().default(true).description('Use the development body in the repository when there is one (off: like a release install)'),
|
|
20
|
+
mirrors: Schema.array(String).default([...DEFAULT_MIRRORS]).description('GitHub mirrors tried when github.com cannot be reached (body, voice models, Ming); files are checked by size and SHA-256. Empty: GitHub only'),
|
|
19
21
|
}).default({}),
|
|
20
22
|
voice: Schema.object({
|
|
21
23
|
enabled: Schema.boolean().default(true).description('Her voice; off = she only writes'),
|
package/lib/dsh/plugin.js
CHANGED
|
@@ -39,13 +39,14 @@ import { PROTOCOL_VERSION } from "../protocol/index.js";
|
|
|
39
39
|
import { BrainServer } from "../server/brain-server.js";
|
|
40
40
|
import { API_BASE, apiHandlers, handle } from "./api.js";
|
|
41
41
|
import { bodyRunning, findDevBody, launchBody } from "./body-launcher.js";
|
|
42
|
-
import { compareVersions,
|
|
42
|
+
import { compareVersions, install, installedBody, installedPlan, latestRelease } from "./body-install.js";
|
|
43
43
|
import { IDLE_IMPORT } from "./import-status.js";
|
|
44
44
|
import { DESK_DIR, newProject, sceneDir } from "../core/workshop.js";
|
|
45
45
|
import { exampleFile, examples, exportProject, findProject, importProject, listProjects, prepareImport, projectStatus, saveProject } from "./workshop.js";
|
|
46
46
|
import { HerSession } from "./her-session.js";
|
|
47
47
|
import { heardText, SpeechQueue } from "../voice/speech-queue.js";
|
|
48
48
|
import { Voice } from "../voice/voice.js";
|
|
49
|
+
import { setMirrors } from "../net.js";
|
|
49
50
|
import { defaultIdleMinutes, MingVoice } from "../voice/ming.js";
|
|
50
51
|
import { MING_STEPS, OwnVoices, prepareRecording } from "../voice/ming-voices.js";
|
|
51
52
|
const SERVICES = ['agents', 'agentDefaultModel', 'workspaceRegistry', 'sessionTitle', 'sessionQuery', 'llm'];
|
|
@@ -95,6 +96,7 @@ const UPDATE_CHECK_MS = 6 * 60 * 60_000;
|
|
|
95
96
|
const STUCK_AFTER_MS = 20_000;
|
|
96
97
|
export function apply(ctx, config) {
|
|
97
98
|
const paths = herPaths(config.home);
|
|
99
|
+
setMirrors(config.body.mirrors);
|
|
98
100
|
const cid = config.character.id;
|
|
99
101
|
const dir = join(paths.characters, cid);
|
|
100
102
|
let services;
|
|
@@ -284,14 +286,14 @@ export function apply(ctx, config) {
|
|
|
284
286
|
return;
|
|
285
287
|
installState = { ...installState, state: 'checking' };
|
|
286
288
|
try {
|
|
287
|
-
const manifest = await
|
|
289
|
+
const { manifest, base } = await latestRelease(config.body.releases);
|
|
288
290
|
installState = { ...installState, latest: manifest.version };
|
|
289
291
|
if (before !== undefined && compareVersions(manifest.version, before.version) <= 0) {
|
|
290
292
|
installState = { ...installState, state: 'idle' };
|
|
291
293
|
return;
|
|
292
294
|
}
|
|
293
295
|
installState = { ...installState, state: 'downloading', done: 0, total: 0 };
|
|
294
|
-
const installed = await install(paths.root,
|
|
296
|
+
const installed = await install(paths.root, base, manifest, (done, total) => {
|
|
295
297
|
installState = { ...installState, state: done < total ? 'downloading' : 'installing', done, total };
|
|
296
298
|
});
|
|
297
299
|
installState = { ...installState, state: 'idle', installed: installed.version, restart: before !== undefined && (server?.bodies.length ?? 0) > 0 };
|
package/lib/net.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads that work on the networks her users are on.
|
|
3
|
+
*
|
|
4
|
+
* - Inside the DSH desktop app, requests go through Electron's `net.fetch`:
|
|
5
|
+
* Chromium's network stack, which follows the system proxy settings the
|
|
6
|
+
* way a browser does (Node's own fetch ignores them, so a computer that
|
|
7
|
+
* reaches GitHub only through Clash or the like got "fetch failed").
|
|
8
|
+
* Elsewhere (the dsh command line) it is Node's fetch.
|
|
9
|
+
* - A GitHub download that cannot connect is tried again through mirrors
|
|
10
|
+
* (`body.mirrors`, prefixes such as `https://ghfast.top/`). Mirrors are
|
|
11
|
+
* third parties: only files whose size and SHA-256 the caller knows in
|
|
12
|
+
* advance should come through them, and every caller here checks both.
|
|
13
|
+
* - A failure says what went wrong in plain words (no bare "fetch failed").
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
/** Mirror prefixes tried, in order, after github.com itself. */
|
|
17
|
+
export const DEFAULT_MIRRORS = ['https://ghfast.top/', 'https://gh-proxy.com/'];
|
|
18
|
+
let mirrors = DEFAULT_MIRRORS;
|
|
19
|
+
let systemFetch;
|
|
20
|
+
/** The plugin's `body.mirrors` setting (an empty list: GitHub only). */
|
|
21
|
+
export function setMirrors(list) {
|
|
22
|
+
mirrors = list.map(m => m.trim()).filter(m => /^https:\/\/\S+\/$/.test(m));
|
|
23
|
+
}
|
|
24
|
+
/** Electron's net.fetch when this runs inside the DSH desktop app. */
|
|
25
|
+
function viaSystem() {
|
|
26
|
+
if (systemFetch === undefined) {
|
|
27
|
+
try {
|
|
28
|
+
const electron = createRequire(import.meta.url)('electron');
|
|
29
|
+
systemFetch = typeof electron === 'object' && typeof electron.net?.fetch === 'function' ? electron.net.fetch.bind(electron.net) : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
systemFetch = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return systemFetch ?? undefined;
|
|
36
|
+
}
|
|
37
|
+
/** fetch, through the system proxy when it can. */
|
|
38
|
+
export async function httpFetch(url, init = {}) {
|
|
39
|
+
const system = viaSystem();
|
|
40
|
+
if (system !== undefined) {
|
|
41
|
+
try {
|
|
42
|
+
return await system(url, init);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError')
|
|
46
|
+
throw error;
|
|
47
|
+
// Electron's stack could not; Node's may (a proxy that only Node sees, through its own settings).
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return fetch(url, init);
|
|
51
|
+
}
|
|
52
|
+
/** Where to try `url`: itself, then each mirror when it is a GitHub download. */
|
|
53
|
+
export function candidates(url, list = mirrors) {
|
|
54
|
+
return [url, ...(isGithub(url) ? list.map(m => `${m}${url}`) : [])];
|
|
55
|
+
}
|
|
56
|
+
const isGithub = (url) => /^https:\/\/(github\.com|objects\.githubusercontent\.com|release-assets\.githubusercontent\.com)\//.test(url);
|
|
57
|
+
/**
|
|
58
|
+
* The response for `url`, from GitHub or, if that cannot connect, a mirror.
|
|
59
|
+
* Only for files the caller checks (size and SHA-256) before using them.
|
|
60
|
+
* Throws a NetError explaining the first failure.
|
|
61
|
+
*/
|
|
62
|
+
export async function fetchChecked(url, init = {}) {
|
|
63
|
+
let first;
|
|
64
|
+
for (const candidate of candidates(url)) {
|
|
65
|
+
try {
|
|
66
|
+
const res = await httpFetch(candidate, { redirect: 'follow', ...init });
|
|
67
|
+
// A mirror that answers with an error page is no better than one that does not answer.
|
|
68
|
+
if (res.ok || candidate === url && res.status === 404)
|
|
69
|
+
return res;
|
|
70
|
+
first ??= new Error(`HTTP ${res.status}`);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.name === 'AbortError')
|
|
74
|
+
throw error;
|
|
75
|
+
first ??= error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new NetError(url, first);
|
|
79
|
+
}
|
|
80
|
+
/** A download that failed, with the reason in Chinese for the page. */
|
|
81
|
+
export class NetError extends Error {
|
|
82
|
+
constructor(url, cause) {
|
|
83
|
+
super(explain(url, cause), { cause });
|
|
84
|
+
this.name = 'NetError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** "连不上 github.com:…" from fetch's error (its `cause` carries the system's code). */
|
|
88
|
+
export function explain(url, error) {
|
|
89
|
+
const host = (() => {
|
|
90
|
+
try {
|
|
91
|
+
return new URL(url).host;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return url;
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
const e = error;
|
|
98
|
+
const code = e?.cause?.code ?? e?.code ?? '';
|
|
99
|
+
const detail = `${code}${e?.cause?.message ?? e?.message ?? ''}`;
|
|
100
|
+
const github = /github/.test(host);
|
|
101
|
+
const tip = github ? '如果你平时通过代理上 GitHub,打开代理软件的"系统代理"再试;没有代理也会自动换镜像下载,都不行时可以在插件设置 body.mirrors 里换一个镜像。' : '检查一下网络再试。';
|
|
102
|
+
if (/HTTP \d+/.test(detail))
|
|
103
|
+
return `${host} 返回了错误(${detail.match(/HTTP \d+/)[0]})。${tip}`;
|
|
104
|
+
if (/ENOTFOUND|EAI_AGAIN|NAME_NOT_RESOLVED/.test(detail))
|
|
105
|
+
return `找不到 ${host}(域名解析失败)。${tip}`;
|
|
106
|
+
if (/ECONNRESET|ECONNREFUSED|CONNECTION_RESET|CONNECTION_REFUSED|CONNECTION_CLOSED/.test(detail))
|
|
107
|
+
return `连不上 ${host}(连接被拒绝或被重置)。${tip}`;
|
|
108
|
+
if (/TIMEOUT|ETIMEDOUT|TIMED_OUT|Timeout/i.test(detail))
|
|
109
|
+
return `连接 ${host} 超时。${tip}`;
|
|
110
|
+
if (/CERT|SSL|TLS|SIGNATURE|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(detail))
|
|
111
|
+
return `和 ${host} 的加密连接出错(证书不对,可能被网络或代理拦截了)。${tip}`;
|
|
112
|
+
return `连不上 ${host}(${detail || '网络错误'})。${tip}`;
|
|
113
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small differences between operating systems the plugin runs on.
|
|
3
|
+
*/
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* The system's own bsdtar (macOS, Windows 10+). On Windows it is named by its
|
|
7
|
+
* full path: a `tar` earlier on PATH (Git for Windows ships GNU tar) reads
|
|
8
|
+
* `C:\…` as a remote host and fails.
|
|
9
|
+
*/
|
|
10
|
+
export function systemTar(platform = process.platform) {
|
|
11
|
+
return platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
|
12
|
+
}
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
* { "format": "dsh-her-body@1", "version": "0.1.0",
|
|
7
7
|
* "assets": { "darwin-arm64": { "file": "dsh-her-body-0.1.0-darwin-arm64.zip", "size": …, "sha256": "…" } } }
|
|
8
8
|
*
|
|
9
|
+
* The app is `dsh-her.app` on macOS and a `dsh-her` folder with
|
|
10
|
+
* `dsh-her.exe` on Windows (body/scripts/package.mjs makes both).
|
|
9
11
|
* `<home>/body/current.json` says which version is installed. A download
|
|
10
12
|
* that does not match its SHA-256 is thrown away; the previous version stays
|
|
11
13
|
* until the new one is in place, and one older version is kept to go back to.
|
|
12
14
|
*/
|
|
13
15
|
import type { LaunchPlan } from './body-launcher.ts';
|
|
14
16
|
export declare const MANIFEST = "body-latest.json";
|
|
17
|
+
/** The app inside a release zip (and in `<home>/body/<version>/`). */
|
|
18
|
+
export declare const appName: (platform?: string) => "dsh-her" | "dsh-her.app";
|
|
15
19
|
export interface BodyAsset {
|
|
16
20
|
file: string;
|
|
17
21
|
size: number;
|
|
@@ -24,7 +28,7 @@ export interface BodyManifest {
|
|
|
24
28
|
}
|
|
25
29
|
export interface Installed {
|
|
26
30
|
version: string;
|
|
27
|
-
/** The app (macOS: dsh-her.app) inside `<home>/body/<version>/`. */
|
|
31
|
+
/** The app (macOS: dsh-her.app; Windows: the dsh-her folder) inside `<home>/body/<version>/`. */
|
|
28
32
|
app: string;
|
|
29
33
|
installedAt: string;
|
|
30
34
|
}
|
|
@@ -50,7 +54,27 @@ export declare function validateManifest(value: unknown): BodyManifest;
|
|
|
50
54
|
export declare function installedBody(home: string): Installed | undefined;
|
|
51
55
|
/** How to start the installed body. */
|
|
52
56
|
export declare function installedPlan(home: string): LaunchPlan | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* The release's manifest, from GitHub itself (through the system proxy when
|
|
59
|
+
* there is one), never from a mirror: it carries the checksums that make a
|
|
60
|
+
* mirror's zip safe to use.
|
|
61
|
+
*/
|
|
53
62
|
export declare function fetchManifest(base: string): Promise<BodyManifest>;
|
|
63
|
+
/**
|
|
64
|
+
* The manifest of this plugin's own release, put into the npm package when it
|
|
65
|
+
* was published (scripts/embed-body-release.mjs). The npm registry (and its
|
|
66
|
+
* mirrors) is reachable where GitHub is not, so a first install can still
|
|
67
|
+
* know what to download and how to check it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function bundledRelease(file?: string): {
|
|
70
|
+
manifest: BodyManifest;
|
|
71
|
+
base: string;
|
|
72
|
+
} | undefined;
|
|
73
|
+
/** What to install: the newest release on GitHub, or, when GitHub cannot be reached, this plugin's own. */
|
|
74
|
+
export declare function latestRelease(base: string): Promise<{
|
|
75
|
+
manifest: BodyManifest;
|
|
76
|
+
base: string;
|
|
77
|
+
}>;
|
|
54
78
|
/**
|
|
55
79
|
* Download this computer's asset of `manifest`, check it, unpack it next to
|
|
56
80
|
* the installed version and make it current. Progress in bytes.
|
|
@@ -25,6 +25,8 @@ export interface HerConfig {
|
|
|
25
25
|
releases: string;
|
|
26
26
|
/** Start the development body in the repository when there is one (off: behave like a release install). */
|
|
27
27
|
useDev: boolean;
|
|
28
|
+
/** Prefixes put before a GitHub address when GitHub cannot be reached (net.ts); every file through them is checked. */
|
|
29
|
+
mirrors: string[];
|
|
28
30
|
};
|
|
29
31
|
voice: VoiceConfig & {
|
|
30
32
|
/** How the body listens: one utterance per shortcut press, or all the time. */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads that work on the networks her users are on.
|
|
3
|
+
*
|
|
4
|
+
* - Inside the DSH desktop app, requests go through Electron's `net.fetch`:
|
|
5
|
+
* Chromium's network stack, which follows the system proxy settings the
|
|
6
|
+
* way a browser does (Node's own fetch ignores them, so a computer that
|
|
7
|
+
* reaches GitHub only through Clash or the like got "fetch failed").
|
|
8
|
+
* Elsewhere (the dsh command line) it is Node's fetch.
|
|
9
|
+
* - A GitHub download that cannot connect is tried again through mirrors
|
|
10
|
+
* (`body.mirrors`, prefixes such as `https://ghfast.top/`). Mirrors are
|
|
11
|
+
* third parties: only files whose size and SHA-256 the caller knows in
|
|
12
|
+
* advance should come through them, and every caller here checks both.
|
|
13
|
+
* - A failure says what went wrong in plain words (no bare "fetch failed").
|
|
14
|
+
*/
|
|
15
|
+
/** Mirror prefixes tried, in order, after github.com itself. */
|
|
16
|
+
export declare const DEFAULT_MIRRORS: readonly string[];
|
|
17
|
+
/** The plugin's `body.mirrors` setting (an empty list: GitHub only). */
|
|
18
|
+
export declare function setMirrors(list: readonly string[]): void;
|
|
19
|
+
/** fetch, through the system proxy when it can. */
|
|
20
|
+
export declare function httpFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
21
|
+
/** Where to try `url`: itself, then each mirror when it is a GitHub download. */
|
|
22
|
+
export declare function candidates(url: string, list?: readonly string[]): string[];
|
|
23
|
+
/**
|
|
24
|
+
* The response for `url`, from GitHub or, if that cannot connect, a mirror.
|
|
25
|
+
* Only for files the caller checks (size and SHA-256) before using them.
|
|
26
|
+
* Throws a NetError explaining the first failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fetchChecked(url: string, init?: RequestInit): Promise<Response>;
|
|
29
|
+
/** A download that failed, with the reason in Chinese for the page. */
|
|
30
|
+
export declare class NetError extends Error {
|
|
31
|
+
constructor(url: string, cause: unknown);
|
|
32
|
+
}
|
|
33
|
+
/** "连不上 github.com:…" from fetch's error (its `cause` carries the system's code). */
|
|
34
|
+
export declare function explain(url: string, error: unknown): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small differences between operating systems the plugin runs on.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* The system's own bsdtar (macOS, Windows 10+). On Windows it is named by its
|
|
6
|
+
* full path: a `tar` earlier on PATH (Git for Windows ships GNU tar) reads
|
|
7
|
+
* `C:\…` as a remote host and fails.
|
|
8
|
+
*/
|
|
9
|
+
export declare function systemTar(platform?: string): string;
|
package/lib/voice/ming.js
CHANGED
|
@@ -25,6 +25,7 @@ import { Readable, Transform } from 'node:stream';
|
|
|
25
25
|
import { pipeline } from 'node:stream/promises';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { wavFromPcm16 } from "../core/index.js";
|
|
28
|
+
import { fetchChecked } from "../net.js";
|
|
28
29
|
import { MING_MODEL, MING_MODEL_BYTES } from "./ming-model.js";
|
|
29
30
|
import { installUv, run, uvEnv } from "./uv.js";
|
|
30
31
|
const PYTHON_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'python');
|
|
@@ -312,7 +313,7 @@ export class MingVoice {
|
|
|
312
313
|
}
|
|
313
314
|
/** One file from `url` into `target`, checked against its size and SHA-256 before it takes the file's place. */
|
|
314
315
|
async function download(url, target, file, onProgress) {
|
|
315
|
-
const res = await
|
|
316
|
+
const res = await fetchChecked(url, { signal: AbortSignal.timeout(3_600_000) });
|
|
316
317
|
if (!res.ok || res.body === null)
|
|
317
318
|
throw new Error(`HTTP ${res.status}`);
|
|
318
319
|
const hash = createHash('sha256');
|
package/lib/voice/models.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* the cloud (cosyvoice.ts). Design: docs/语音方案.md.
|
|
5
5
|
*/
|
|
6
6
|
import { execFile } from 'node:child_process';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { fetchChecked } from "../net.js";
|
|
9
|
+
import { systemTar } from "../platform.js";
|
|
7
10
|
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
8
11
|
import { join } from 'node:path';
|
|
9
12
|
import { Readable } from 'node:stream';
|
|
@@ -16,6 +19,7 @@ export const MODELS = [
|
|
|
16
19
|
name: 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09',
|
|
17
20
|
url: `${RELEASES}/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2`,
|
|
18
21
|
bytes: 165_783_878,
|
|
22
|
+
sha256: '7305f7905bfcf77fa0b39388a313f3da35c68d971661a65475b56fb2162c8e63',
|
|
19
23
|
archive: true,
|
|
20
24
|
},
|
|
21
25
|
{
|
|
@@ -23,6 +27,7 @@ export const MODELS = [
|
|
|
23
27
|
name: 'silero_vad.onnx',
|
|
24
28
|
url: `${RELEASES}/asr-models/silero_vad.onnx`,
|
|
25
29
|
bytes: 643_854,
|
|
30
|
+
sha256: '9e2449e1087496d8d4caba907f23e0bd3f78d91fa552479bb9c23ac09cbb1fd6',
|
|
26
31
|
archive: false,
|
|
27
32
|
},
|
|
28
33
|
];
|
|
@@ -38,18 +43,24 @@ export function missingModels(modelsDir) {
|
|
|
38
43
|
*/
|
|
39
44
|
export async function fetchModel(modelsDir, spec, onProgress) {
|
|
40
45
|
mkdirSync(modelsDir, { recursive: true });
|
|
41
|
-
const response = await
|
|
46
|
+
const response = await fetchChecked(spec.url);
|
|
42
47
|
if (!response.ok || response.body === null)
|
|
43
48
|
throw new Error(`下载失败 ${spec.url}(HTTP ${response.status})`);
|
|
44
|
-
const total =
|
|
49
|
+
const total = spec.bytes;
|
|
45
50
|
const partial = join(modelsDir, `${spec.name}.download`);
|
|
46
51
|
let received = 0;
|
|
52
|
+
const hash = createHash('sha256');
|
|
47
53
|
const body = Readable.fromWeb(response.body);
|
|
48
54
|
body.on('data', (chunk) => {
|
|
49
55
|
received += chunk.byteLength;
|
|
56
|
+
hash.update(chunk);
|
|
50
57
|
onProgress?.(received, total);
|
|
51
58
|
});
|
|
52
59
|
await pipeline(body, createWriteStream(partial));
|
|
60
|
+
if (received !== spec.bytes || hash.digest('hex') !== spec.sha256) {
|
|
61
|
+
rmSync(partial, { force: true });
|
|
62
|
+
throw new Error(`下载的 ${spec.name} 校验不对(可能没下完或被改过),已经删掉,下次再试`);
|
|
63
|
+
}
|
|
53
64
|
if (!spec.archive) {
|
|
54
65
|
renameSync(partial, modelPath(modelsDir, spec));
|
|
55
66
|
return;
|
|
@@ -59,7 +70,7 @@ export async function fetchModel(modelsDir, spec, onProgress) {
|
|
|
59
70
|
mkdirSync(staging, { recursive: true });
|
|
60
71
|
try {
|
|
61
72
|
// bsdtar ships with macOS and Windows 10+, and reads bzip2 itself.
|
|
62
|
-
await promisify(execFile)(
|
|
73
|
+
await promisify(execFile)(systemTar(), ['-xjf', partial, '-C', staging]);
|
|
63
74
|
renameSync(join(staging, spec.name), modelPath(modelsDir, spec));
|
|
64
75
|
}
|
|
65
76
|
finally {
|
package/lib/voice/uv.js
CHANGED
|
@@ -10,6 +10,8 @@ import { createHash } from 'node:crypto';
|
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
11
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { fetchChecked } from "../net.js";
|
|
14
|
+
import { systemTar } from "../platform.js";
|
|
13
15
|
export function run(cmd, args, timeout, env) {
|
|
14
16
|
return new Promise((resolve, reject) => {
|
|
15
17
|
execFile(cmd, args, { timeout, maxBuffer: 8 << 20, ...(env === undefined ? {} : { env: { ...process.env, ...env } }) }, (error, stdout, stderr) => {
|
|
@@ -41,7 +43,7 @@ export async function installUv(uvHome) {
|
|
|
41
43
|
const found = [join(dir, 'uv'), join(dir, 'uv.exe'), join(dir, asset.file.replace(/\.(tar\.gz|zip)$/, ''), 'uv')].find(p => existsSync(p));
|
|
42
44
|
if (found !== undefined)
|
|
43
45
|
return found;
|
|
44
|
-
const res = await
|
|
46
|
+
const res = await fetchChecked(`https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${asset.file}`, { signal: AbortSignal.timeout(300_000) });
|
|
45
47
|
if (!res.ok)
|
|
46
48
|
throw new Error(`下载 uv 失败(${res.status})`);
|
|
47
49
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
@@ -51,7 +53,7 @@ export async function installUv(uvHome) {
|
|
|
51
53
|
const archive = join(dir, asset.file);
|
|
52
54
|
await writeFile(archive, bytes);
|
|
53
55
|
// tar reads both the .tar.gz and (Windows 10+) the .zip.
|
|
54
|
-
await run(
|
|
56
|
+
await run(systemTar(), ['-xf', archive, '-C', dir], 120_000);
|
|
55
57
|
await rm(archive, { force: true });
|
|
56
58
|
const uv = [join(dir, 'uv'), join(dir, 'uv.exe'), join(dir, asset.file.replace(/\.(tar\.gz|zip)$/, ''), 'uv')].find(p => existsSync(p));
|
|
57
59
|
if (uv === undefined)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-her",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A desktop companion for DeepSeek Harness (dsh): she lives on your real desktop (on top of your windows or inside the wallpaper), talks with you by voice and remembers you. · DSH 桌面女友:住在你真正的桌面上,能语音聊天、记得你。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test": "node --test --disable-warning=ExperimentalWarning \"tests/*.test.ts\"",
|
|
35
35
|
"voice:models": "node --disable-warning=ExperimentalWarning scripts/fetch-voice-models.ts",
|
|
36
36
|
"voice:bench": "node --disable-warning=ExperimentalWarning scripts/voice-bench.ts",
|
|
37
|
-
"prepack": "npm run build"
|
|
37
|
+
"prepack": "npm run build && node scripts/embed-body-release.mjs"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@deepseek-ai/cordis": "^4.0.1",
|