dsh-chat-embed 0.0.0-stage → 0.6.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/index.js ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * dsh-chat-embed — Host half.
3
+ *
4
+ * The feature itself lives in the Client half (`client.js`): the desktop
5
+ * webview, the lease handshake, the panel UI. This half exists for two
6
+ * reasons, and nothing else:
7
+ *
8
+ * 1. the loader row named by `cordis.patch.yml` needs a Node-side export;
9
+ * 2. the optional shell patch needs a **Host-side** answer, because the
10
+ * patched archive lives on the Host's disk and only the Host can read
11
+ * and test it. The panel asks over the exposed channel, so a user can
12
+ * see why cross-restart login is off without being told to run a
13
+ * command and read its output.
14
+ *
15
+ * Keep this module inert beyond that probe. It must not import browser
16
+ * globals, and anything it registers must be cleaned up through `ctx.effect`
17
+ * so uninstalling the bundle leaves the Host process exactly as it was.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto'
21
+ import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { dirname, join } from 'node:path'
24
+ import { createRequire } from 'node:module'
25
+
26
+ export const name = 'dsh-chat-embed'
27
+
28
+ /** Route the panel fetches for the read-only shell probe. */
29
+ const PROBE_PATH = '/dsh-chat-embed/patch-check'
30
+
31
+ /** Strings that identify the patch in a rebuilt archive. */
32
+ const PATCH_MARK = 'persist:dsh-sidebar-'
33
+ const CACHE_MARK = '{ cache: false }'
34
+
35
+ /** Strings that identify an unpatched archive's patch sites. */
36
+ const PATCH_SITE_MARK = 'partition = `dsh-sidebar-browser-'
37
+ const CACHE_SITE_MARK = 'this.configureSession(session.fromPartition(partition))'
38
+
39
+ /**
40
+ * Candidate `resources` directories. `DSH_RESOURCES` wins so a custom install
41
+ * or a non-Windows layout can be pointed at explicitly.
42
+ *
43
+ * @returns {string[]} absolute candidates, most specific first
44
+ */
45
+ function resourcesCandidates() {
46
+ const candidates = []
47
+ // Electron hands the running app its own resources directory: the exact
48
+ // answer, and the only one that works for a non-standard install location.
49
+ if (typeof process.resourcesPath === 'string' && process.resourcesPath !== '') {
50
+ candidates.push(process.resourcesPath)
51
+ }
52
+ // A plain Node process started beside the app binary can derive it.
53
+ if (typeof process.execPath === 'string' && process.execPath !== '') {
54
+ candidates.push(join(dirname(process.execPath), 'resources'))
55
+ }
56
+ candidates.push(process.env.DSH_RESOURCES)
57
+ if (process.platform === 'win32') {
58
+ const localAppData = process.env.LOCALAPPDATA
59
+ if (localAppData !== undefined && localAppData !== '') {
60
+ candidates.push(join(localAppData, 'Programs', 'DeepSeek Harness', 'resources'))
61
+ }
62
+ candidates.push('C:\\Program Files\\DeepSeek Harness\\resources')
63
+ candidates.push('C:\\Program Files (x86)\\DeepSeek Harness\\resources')
64
+ } else if (process.platform === 'darwin') {
65
+ candidates.push('/Applications/DeepSeek Harness.app/Contents/Resources')
66
+ } else {
67
+ candidates.push('/opt/DeepSeek Harness/resources')
68
+ candidates.push(join(homedir(), 'DeepSeek Harness', 'resources'))
69
+ }
70
+ return candidates.filter((entry) => typeof entry === 'string' && entry !== '')
71
+ }
72
+
73
+ /** @returns {string | undefined} the directory that actually holds app.asar */
74
+ function detectResources() {
75
+ for (const candidate of resourcesCandidates()) {
76
+ if (existsSync(join(candidate, 'app.asar'))) return candidate
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /** @param {string} file @returns {boolean} */
82
+ function isWritable(file) {
83
+ try {
84
+ accessSync(file, constants.W_OK)
85
+ return true
86
+ } catch (error) {
87
+ void error
88
+ return false
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read a file with the real Node implementation.
94
+ *
95
+ * Inside Electron the `node:fs` binding is patched to treat a path ending in
96
+ * `.asar` as a directory, so `readFileSync('<install>/app.asar')` raises ENOENT
97
+ * even though the bytes are all there. `original-fs` is Electron's unpatched
98
+ * copy; outside Electron the ordinary binding is already literal and is used.
99
+ *
100
+ * @param {string} file
101
+ * @returns {Buffer}
102
+ */
103
+ function readRealFile(file) {
104
+ const fallback = () => readFileSync(file)
105
+ try {
106
+ const original = createRequire(import.meta.url)('original-fs')
107
+ if (original !== undefined && original !== null && typeof original.readFileSync === 'function') {
108
+ return original.readFileSync(file)
109
+ }
110
+ } catch (error) {
111
+ void error
112
+ }
113
+ return fallback()
114
+ }
115
+
116
+ /** @param {string} file @returns {number} the real byte length */
117
+ function statRealSize(file) {
118
+ try {
119
+ const original = createRequire(import.meta.url)('original-fs')
120
+ if (original !== undefined && original !== null && typeof original.statSync === 'function') {
121
+ return original.statSync(file).size
122
+ }
123
+ } catch (error) {
124
+ void error
125
+ }
126
+ return statSync(file).size
127
+ }
128
+
129
+ /**
130
+ * Read one entry out of an asar archive.
131
+ *
132
+ * The container is `u32 @0 = 4`, `u32 @4 = headerLen + 8`, `u32 @8 = headerLen + 4`,
133
+ * `u32 @12 = headerLen`, then the JSON header, padding, then the file data.
134
+ *
135
+ * @param {string} file
136
+ * @param {string} entry slash-separated path inside the archive
137
+ * @returns {string | undefined} the entry as UTF-8, or undefined when absent
138
+ */
139
+ function readAsarEntry(file, entry) {
140
+ const buffer = readRealFile(file)
141
+ const headerLength = buffer.readUInt32LE(12)
142
+ const header = JSON.parse(buffer.toString('utf8', 16, 16 + headerLength))
143
+ const padding = (4 - (headerLength % 4)) % 4
144
+ const dataStart = 16 + headerLength + padding
145
+ let node = header
146
+ for (const segment of entry.split('/')) {
147
+ if (node === undefined || node.files === undefined) return undefined
148
+ node = node.files[segment]
149
+ }
150
+ if (node === undefined || node.files !== undefined) return undefined
151
+ if (node.unpacked === true) return undefined
152
+ const offset = dataStart + Number(node.offset)
153
+ return buffer.toString('utf8', offset, offset + Number(node.size))
154
+ }
155
+
156
+ /**
157
+ * Inspect the desktop shell and the optional patch. Read-only: no file here is
158
+ * written, so this is safe to call from a diagnostics button.
159
+ *
160
+ * @param {string} [resources]
161
+ * @returns {object} a report the panel formats and copies
162
+ */
163
+ function inspectShell(resources) {
164
+ const explicit = typeof resources === 'string' && resources !== ''
165
+ const resolved = explicit ? resources : detectResources()
166
+ if (resolved === undefined) {
167
+ return {
168
+ found: false,
169
+ platform: process.platform,
170
+ node: process.version,
171
+ detail: '没有找到 DSH 安装目录;用 DSH_RESOURCES 或 --resources 指定',
172
+ }
173
+ }
174
+ const asar = join(resolved, 'app.asar')
175
+ if (!existsSync(asar)) {
176
+ return {
177
+ found: false,
178
+ platform: process.platform,
179
+ node: process.version,
180
+ resources: resolved,
181
+ asar,
182
+ detail: '该目录下没有 app.asar;路径不对,或桌面壳安装不完整',
183
+ }
184
+ }
185
+ const staged = join(resolved, 'app.asar.new')
186
+ const backup = join(resolved, 'app.asar.orig')
187
+ const report = {
188
+ found: true,
189
+ platform: process.platform,
190
+ node: process.version,
191
+ resources: resolved,
192
+ asar,
193
+ exists: true,
194
+ writable: existsSync(asar) ? isWritable(asar) : isWritable(resolved),
195
+ backup: existsSync(backup),
196
+ staged: existsSync(staged),
197
+ patched: undefined,
198
+ patchSite: undefined,
199
+ detail: '',
200
+ }
201
+ try {
202
+ report.asarBytes = statRealSize(asar)
203
+ report.asarHash = createHash('sha256').update(readRealFile(asar)).digest('hex').slice(0, 16)
204
+ } catch (error) {
205
+ report.detail = '读取 app.asar 失败:' + firstLine(error)
206
+ return report
207
+ }
208
+ try {
209
+ const main = readAsarEntry(asar, 'lib/main.js')
210
+ if (main === undefined) {
211
+ report.detail = 'app.asar 中找不到 lib/main.js;壳结构已变化'
212
+ return report
213
+ }
214
+ report.patched = main.includes(PATCH_MARK)
215
+ report.cachePatch = main.includes(CACHE_MARK)
216
+ report.patchSite = main.includes(PATCH_SITE_MARK) || main.includes(PATCH_MARK)
217
+ if (report.patched !== true && report.patchSite === false) {
218
+ report.detail = '补丁点不存在:当前桌面壳版本的分区写法与补丁预期不一致,需要更新补丁'
219
+ } else if (report.patched !== true && report.cachePatch !== true) {
220
+ report.detail = '尚未打补丁'
221
+ }
222
+ } catch (error) {
223
+ report.detail = '解析 app.asar 失败:' + firstLine(error)
224
+ }
225
+ return report
226
+ }
227
+
228
+ /** @param {unknown} error @returns {string} */
229
+ function firstLine(error) {
230
+ const text = error instanceof Error ? error.message : String(error)
231
+ return text.split('\n')[0].slice(0, 200)
232
+ }
233
+
234
+ /**
235
+ * Serve the read-only probe over the Web GUI's HTTP carrier, so the panel can
236
+ * read it with a plain relative `fetch` — no Remote RPC endpoint, no shell
237
+ * bridge, and the same code path in the desktop app and the Web GUI.
238
+ *
239
+ * The route is GET-only and writes nothing; it reports where the shell is and
240
+ * what the archive contains, which is what a user needs to file a useful bug
241
+ * report when cross-restart login stops working.
242
+ *
243
+ * @param {import('@deepseek-ai/cordis').Context} ctx
244
+ * @returns {(() => void) | undefined} a disposer, or undefined without a webserver
245
+ */
246
+ function installHttpProbe(ctx) {
247
+ const server = typeof ctx.get === 'function' ? ctx.get('webServer') : undefined
248
+ if (server === undefined || server === null || typeof server.register !== 'function') return undefined
249
+ const route = {
250
+ kind: 'exact',
251
+ path: PROBE_PATH,
252
+ resource(request) {
253
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
254
+ return new Response('method not allowed', { status: 405 })
255
+ }
256
+ let body
257
+ try {
258
+ body = JSON.stringify(inspectShell())
259
+ } catch (error) {
260
+ body = JSON.stringify({ found: false, detail: firstLine(error) })
261
+ }
262
+ return new Response(request.method === 'HEAD' ? null : body, {
263
+ status: 200,
264
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
265
+ })
266
+ },
267
+ }
268
+ try {
269
+ const dispose = server.register(route)
270
+ return typeof dispose === 'function' ? dispose : () => {}
271
+ } catch (error) {
272
+ // Another composition already claimed the path, or the carrier rejected the
273
+ // shape. The probe is optional: the panel still reports the local state.
274
+ void error
275
+ return undefined
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Register the patch checker as an agent tool, so the model can answer
281
+ * "why does my login not survive a restart?" by looking instead of asking the
282
+ * user to run a command and paste its output.
283
+ *
284
+ * The tool is read-only and needs no approval: it reports paths, byte counts
285
+ * and whether the patch marks are present, which the user could read off the
286
+ * disk themselves.
287
+ *
288
+ * @param {import('@deepseek-ai/cordis').Context} ctx
289
+ * @returns {(() => void) | undefined} a disposer, or undefined without a tool registry
290
+ */
291
+ function installTool(ctx) {
292
+ const tools = typeof ctx.get === 'function' ? ctx.get('tools') : undefined
293
+ if (tools === undefined || tools === null || typeof tools.register !== 'function') return undefined
294
+ const definition = {
295
+ name: 'dsh_chat_embed_patch_check',
296
+ description:
297
+ 'Report whether the DSH desktop shell has the optional patch that keeps embedded chat logins across restarts: the install directory, whether app.asar is writable, whether it is already patched, whether the patch can still be applied to this shell version, and whether a backup exists. Read-only.',
298
+ parameters: {
299
+ resources: {
300
+ type: 'string',
301
+ description:
302
+ 'Optional install directory that contains app.asar. Omit to let the plugin detect the running shell.',
303
+ },
304
+ },
305
+ output: {
306
+ schema: { type: 'object' },
307
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
308
+ },
309
+ async execute(args) {
310
+ const requested = args !== undefined && args !== null && typeof args.resources === 'string' ? args.resources : undefined
311
+ return inspectShell(requested)
312
+ },
313
+ }
314
+ try {
315
+ const dispose = tools.register(definition)
316
+ return typeof dispose === 'function' ? dispose : () => {}
317
+ } catch (error) {
318
+ void error
319
+ return undefined
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Register the Host-side patch probe and the agent tool.
325
+ *
326
+ * @param {import('@deepseek-ai/cordis').Context} ctx
327
+ */
328
+ export function apply(ctx) {
329
+ if (ctx === undefined || ctx === null || typeof ctx.effect !== 'function') return
330
+ ctx.effect(() => installHttpProbe(ctx), 'dsh-chat-embed: shell patch probe')
331
+ ctx.effect(() => installTool(ctx), 'dsh-chat-embed: shell patch tool')
332
+ }
333
+
334
+ /** Exposed for tests and for the patch checker to reuse without a Context. */
335
+ export const internals = { inspectShell, detectResources, readAsarEntry, readRealFile, resourcesCandidates, PROBE_PATH }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Chat embed",
4
+ "description": "Opens a multi-tab AI chat panel in the desktop app: DeepSeek, Doubao, ChatGPT, MiniMax, Kimi, Gemini and Grok, each signed in separately."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "内嵌聊天",
4
+ "description": "在桌面版打开多标签 AI 聊天面板:DeepSeek、豆包、ChatGPT、MiniMax、Kimi、Gemini、Grok,各自独立登录态。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "dsh-chat-embed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.0",
4
+ "author": "poluce <fishsummer@126.com>",
5
+ "description": "Multi-tab AI chat embed for the DeepSeek Harness desktop app: DeepSeek, 豆包, ChatGPT, MiniMax, Kimi, Gemini and Grok, each signed in separately.",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "client.js",
17
+ "cordis.patch.yml",
18
+ "locale/*.json",
19
+ "icon.svg",
20
+ "docs",
21
+ "tools/shell-patch",
22
+ "restore-shell-patch.cmd",
23
+ "清理聊天缓存.cmd",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web",
33
+ "immediately": true,
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-layout",
37
+ "@deepseek-ai/dsh-client-ui-sidebar"
38
+ ]
39
+ }
40
+ },
41
+ "scripts": {
42
+ "check": "node scripts/verify-bundle.mjs && npm test",
43
+ "test": "node --test \"test/**/*.test.mjs\"",
44
+ "prepublishOnly": "npm run check",
45
+ "pack:dist": "node scripts/pack-dist.mjs"
46
+ },
47
+ "keywords": [
48
+ "dsh",
49
+ "deepseek-harness",
50
+ "dsh-plugin",
51
+ "cordis",
52
+ "chat",
53
+ "webview",
54
+ "embed"
55
+ ],
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/poluce/dsh-chat-embed.git"
59
+ },
60
+ "homepage": "https://github.com/poluce/dsh-chat-embed#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/poluce/dsh-chat-embed/issues"
63
+ },
64
+ "license": "MIT",
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "engines": {
69
+ "node": ">=22",
70
+ "dsh": ">=0.1.0-rc.6"
71
+ }
72
+ }
@@ -0,0 +1,39 @@
1
+ @echo off
2
+ rem Restore the original DeepSeek Harness app.asar after the persistence patch.
3
+ rem
4
+ rem Usage: restore-shell-patch.cmd [resources-dir]
5
+ rem The directory may also come from DSH_RESOURCES, otherwise the usual install
6
+ rem locations are tried.
7
+ setlocal
8
+ if not "%~1"=="" set "RES=%~1"
9
+ if "%RES%"=="" set "RES=%DSH_RESOURCES%"
10
+ if "%RES%"=="" if exist "%LOCALAPPDATA%\Programs\DeepSeek Harness\resources\app.asar" set "RES=%LOCALAPPDATA%\Programs\DeepSeek Harness\resources"
11
+ if "%RES%"=="" if exist "C:\Program Files\DeepSeek Harness\resources\app.asar" set "RES=C:\Program Files\DeepSeek Harness\resources"
12
+ if "%RES%"=="" goto nores
13
+ if not exist "%RES%\app.asar.orig" goto nobackup
14
+
15
+ if exist "%RES%\app.asar" (
16
+ move /Y "%RES%\app.asar" "%RES%\app.asar.patched.bak" >nul
17
+ )
18
+ move /Y "%RES%\app.asar.orig" "%RES%\app.asar" >nul
19
+ echo.
20
+ echo [OK] 已恢复原版 app.asar
21
+ echo 补丁版备份在 app.asar.patched.bak
22
+ echo.
23
+ goto end
24
+
25
+ :nores
26
+ echo.
27
+ echo [!!] 找不到 DSH 安装目录:请传入参数,或设置 DSH_RESOURCES。
28
+ echo 例:restore-shell-patch.cmd "D:\DeepSeek Harness\resources"
29
+ echo.
30
+
31
+ goto end
32
+
33
+ :nobackup
34
+ echo.
35
+ echo [!!] 找不到 app.asar.orig,可能从未打过补丁,或已被还原过。
36
+ echo.
37
+
38
+ :end
39
+ pause
@@ -0,0 +1,76 @@
1
+ # clean-cache.ps1 - free the persistent sidebar-browser caches, keep the logins.
2
+ #
3
+ # The shell patch made `dsh-sidebar-browser-*` partitions persistent, so each
4
+ # workspace keeps its Chromium profile on disk. Most of that is disposable
5
+ # cache; the login itself lives in IndexedDB / Local Storage / Cookies, which
6
+ # this script never touches.
7
+ #
8
+ # The app must be closed: Chromium holds exclusive locks on the cache files.
9
+
10
+ param(
11
+ [string]$Partitions = $env:DSH_PARTITIONS
12
+ )
13
+
14
+ $ErrorActionPreference = 'Continue'
15
+
16
+ $app = 'DeepSeek Harness'
17
+ if (-not $Partitions) {
18
+ $candidates = @()
19
+ if ($env:APPDATA) { $candidates += (Join-Path $env:APPDATA '@deepseek-ai\dsh-desktop\Partitions') }
20
+ if ($env:APPDATA) { $candidates += (Join-Path $env:APPDATA 'DeepSeek Harness\Partitions') }
21
+ foreach ($candidate in $candidates) {
22
+ if (Test-Path $candidate) { $Partitions = $candidate; break }
23
+ }
24
+ }
25
+ $base = $Partitions
26
+ $cacheDirs = @(
27
+ 'Cache',
28
+ 'Code Cache',
29
+ 'GPUCache',
30
+ 'DawnGraphiteCache',
31
+ 'DawnWebGPUCache',
32
+ 'GrShaderCache',
33
+ 'ShaderCache',
34
+ 'Shared Dictionary'
35
+ )
36
+
37
+ if (-not $base) {
38
+ Write-Host ' 找不到 Partitions 目录:请用 -Partitions <dir> 指定,或设置 DSH_PARTITIONS。'
39
+ exit 1
40
+ }
41
+
42
+ $running = Get-Process -Name $app -ErrorAction SilentlyContinue
43
+ if ($running) {
44
+ Write-Host ''
45
+ Write-Host ' DSH 正在运行,缓存文件被占用,无法清理。' -ForegroundColor Yellow
46
+ Write-Host ' 请先完全退出 DSH(托盘/菜单「退出」),再运行本脚本。' -ForegroundColor Yellow
47
+ Write-Host ''
48
+ exit 1
49
+ }
50
+
51
+ if (-not (Test-Path $base)) {
52
+ Write-Host ' 没有找到 Partitions 目录,无需清理。'
53
+ exit 0
54
+ }
55
+
56
+ $before = (Get-ChildItem $base -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
57
+
58
+ foreach ($partition in (Get-ChildItem $base -Directory -ErrorAction SilentlyContinue)) {
59
+ $freed = 0
60
+ foreach ($name in $cacheDirs) {
61
+ $target = Join-Path $partition.FullName $name
62
+ if (-not (Test-Path $target)) { continue }
63
+ $size = (Get-ChildItem $target -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
64
+ Remove-Item -Recurse -Force $target -ErrorAction SilentlyContinue
65
+ if (-not (Test-Path $target)) { $freed += $size }
66
+ }
67
+ if ($freed -gt 0) {
68
+ Write-Host (" {0}: 释放 {1:N1} MB(登录数据保留)" -f $partition.Name, ($freed / 1MB))
69
+ }
70
+ }
71
+
72
+ $after = (Get-ChildItem $base -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
73
+ Write-Host ''
74
+ Write-Host (" 总计释放 {0:N1} MB,剩余 {1:N1} MB" -f (($before - $after) / 1MB), ($after / 1MB))
75
+ Write-Host ' 登录状态、Cookie、LocalStorage 均未改动。'
76
+ Write-Host ''