dsh-deepseek-web-login 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/CHANGELOG.md +3165 -0
- package/LICENSE +202 -0
- package/NOTICE +12 -0
- package/README.en.md +510 -0
- package/README.md +673 -0
- package/cordis.patch.yml +6 -0
- package/docs/assets/architecture.svg +264 -0
- package/docs/assets/banner-dsh-deepseek-web-login.jpg +0 -0
- package/docs/assets/hero.svg +53 -0
- package/docs/assets/login-flow.svg +307 -0
- package/docs/assets/panel-preview.svg +64 -0
- package/docs/assets/qq-group.jpg +0 -0
- package/docs/assets/screenshot-settings.png +0 -0
- package/docs/assets/screenshot-usage-stats.png +0 -0
- package/docs/assets/tool-bridge.svg +276 -0
- package/lib/client.js +2165 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +9585 -0
- package/lib/index.js.map +1 -0
- package/package.json +79 -0
- package/scripts/build.mjs +59 -0
- package/scripts/build.sh +6 -0
- package/scripts/make-dev-copy.mjs +49 -0
- package/scripts/prepare.mjs +25 -0
- package/tools/changelog-section.mjs +42 -0
- package/tools/inspect-session.mjs +223 -0
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-deepseek-web-login",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Unofficial DSH plugin: use chat.deepseek.com web models as a DSH LLM provider (browser login capture + PoW solving + SSE streaming + prompting-based tool calls), provider route deepseek-web",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"scripts/build.sh",
|
|
20
|
+
"scripts/build.mjs",
|
|
21
|
+
"scripts/prepare.mjs",
|
|
22
|
+
"scripts/make-dev-copy.mjs",
|
|
23
|
+
"README.md",
|
|
24
|
+
"README.en.md",
|
|
25
|
+
"docs/assets",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"tools"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/cv-superding/dsh-deepseek-web-login.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/cv-superding/dsh-deepseek-web-login#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/cv-superding/dsh-deepseek-web-login/issues"
|
|
38
|
+
},
|
|
39
|
+
"license": "Apache-2.0",
|
|
40
|
+
"author": "cv-superding (Ding Li)",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"dsh",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"cordis-plugin",
|
|
45
|
+
"deepseek",
|
|
46
|
+
"llm",
|
|
47
|
+
"provider"
|
|
48
|
+
],
|
|
49
|
+
"dsh": {
|
|
50
|
+
"bundle": {
|
|
51
|
+
"patch": "./cordis.patch.yml"
|
|
52
|
+
},
|
|
53
|
+
"client": {
|
|
54
|
+
"inject": [
|
|
55
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
57
|
+
],
|
|
58
|
+
"platform": "web"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "node scripts/build.mjs",
|
|
63
|
+
"test": "node scripts/test-offline.mjs",
|
|
64
|
+
"prepare": "node scripts/prepare.mjs"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": "^22.18.0 || >=24.11.0"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@types/node": "24.13.3",
|
|
71
|
+
"@types/react": "19.3.0",
|
|
72
|
+
"tsdown": "0.22.14",
|
|
73
|
+
"typescript": "5.9.3"
|
|
74
|
+
},
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"registry": "https://registry.npmjs.org",
|
|
77
|
+
"access": "public"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 跨平台构建入口(审计 F20)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不用原来的 `bash scripts/build.sh`:Windows 上只装了 Node/npm、没有 Bash 时
|
|
6
|
+
* `npm run build` 会直接失败(构建脚本本身没毛病,问题是它要求一个 Unix shell)。
|
|
7
|
+
* 这里用 Node 直接起本地 tsdown 的 CLI,全程不经过 shell。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不再用「npx 兜底」取工具:
|
|
10
|
+
* - 缺依赖时会**联网下载** → 断网就构建不了;
|
|
11
|
+
* - 版本写的是范围,同一份源码在不同时间可能解析到不同的依赖树(不可复现)。
|
|
12
|
+
* 现在只认本地已安装的 tsdown(`npm ci` 装的),缺了就明确报错并给出命令。
|
|
13
|
+
*/
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
21
|
+
|
|
22
|
+
/** 从已安装的 tsdown 包里找出 CLI 入口(不依赖 .bin 软链,Windows 上更稳)。 */
|
|
23
|
+
function findTsdownBin() {
|
|
24
|
+
const require = createRequire(import.meta.url)
|
|
25
|
+
const manifest = require.resolve('tsdown/package.json')
|
|
26
|
+
const pkg = JSON.parse(readFileSync(manifest, 'utf8'))
|
|
27
|
+
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.tsdown
|
|
28
|
+
if (!bin) throw new Error('tsdown 的 package.json 里找不到 CLI 入口(bin.tsdown)')
|
|
29
|
+
return { version: pkg.version, path: resolve(dirname(manifest), bin) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const libDir = join(ROOT, 'lib')
|
|
33
|
+
try {
|
|
34
|
+
const tsdown = findTsdownBin()
|
|
35
|
+
const result = spawnSync(process.execPath, [tsdown.path, '--config', 'tsdown.config.ts'], {
|
|
36
|
+
cwd: ROOT,
|
|
37
|
+
stdio: 'inherit',
|
|
38
|
+
// shell: false —— 不经过 cmd.exe / bash,路径里有空格也不会被拆开
|
|
39
|
+
shell: false,
|
|
40
|
+
})
|
|
41
|
+
if (result.error) throw result.error
|
|
42
|
+
if (result.status !== 0) {
|
|
43
|
+
process.exitCode = result.status ?? 1
|
|
44
|
+
} else {
|
|
45
|
+
console.log(`[build] tsdown ${tsdown.version} 构建完成,产物:`)
|
|
46
|
+
if (existsSync(libDir)) {
|
|
47
|
+
for (const name of readdirSync(libDir).sort()) {
|
|
48
|
+
if (!name.endsWith('.js')) continue
|
|
49
|
+
const size = statSync(join(libDir, name)).size
|
|
50
|
+
console.log(` ${name.padEnd(12)} ${(size / 1024).toFixed(1)} KB`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('[build] 构建失败:找不到可用的本地 tsdown。')
|
|
56
|
+
console.error('[build] 请先在包目录执行 npm ci 安装开发依赖(本脚本不会联网下载工具)。')
|
|
57
|
+
console.error(`[build] 原因:${error?.message ?? error}`)
|
|
58
|
+
process.exitCode = 1
|
|
59
|
+
}
|
package/scripts/build.sh
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 生成「开发副本」包:把当前 lib/ 复制成 ../<name>-dev<N>,并改写 package.json 的 name。
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要:当前 DSH 版本(0.1.2-rc.1)移除了 loader 热重载所需的 API,
|
|
6
|
+
* 且 Node ESM 模块缓存以「解析后的文件路径」为键 —— 同一路径重新注入仍会命中
|
|
7
|
+
* 旧模块实例(实测:改了 lib/index.js 再注入,运行时仍是旧对象)。
|
|
8
|
+
* 因此迭代时改用新包名/新路径注入,卸载即净;正式交付仍用本目录原名。
|
|
9
|
+
*
|
|
10
|
+
* 用法:node scripts/make-dev-copy.mjs [suffix] → 输出副本目录绝对路径
|
|
11
|
+
*/
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
|
|
13
|
+
import { dirname, join, resolve } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
|
+
const base = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
|
|
18
|
+
const suffix = process.argv[2] || `dev${Date.now().toString(36).slice(-4)}`
|
|
19
|
+
const name = `${base.name}-${suffix}`
|
|
20
|
+
const target = resolve(ROOT, '..', name)
|
|
21
|
+
|
|
22
|
+
// R9(0.2.0):先**校验全部源产物**再动旧目录 —— 旧写法"先删后校",
|
|
23
|
+
// 产物缺失时旧副本已经被删了才报错,留下"想回退也没得回退"的窗口。
|
|
24
|
+
for (const file of ['lib/index.js', 'lib/client.js']) {
|
|
25
|
+
if (!existsSync(join(ROOT, file))) {
|
|
26
|
+
console.error(`[dev-copy] 缺少构建产物 ${file} —— 先跑构建(旧副本未动)`)
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true })
|
|
32
|
+
mkdirSync(join(target, 'lib'), { recursive: true })
|
|
33
|
+
|
|
34
|
+
for (const file of ['lib/index.js', 'lib/client.js']) {
|
|
35
|
+
copyFileSync(join(ROOT, file), join(target, file))
|
|
36
|
+
}
|
|
37
|
+
copyFileSync(join(ROOT, 'cordis.patch.yml'), join(target, 'cordis.patch.yml'))
|
|
38
|
+
|
|
39
|
+
const pkg = { ...base, name }
|
|
40
|
+
delete pkg.scripts
|
|
41
|
+
delete pkg.devDependencies
|
|
42
|
+
pkg.description = `${base.description}(开发副本 ${suffix})`
|
|
43
|
+
writeFileSync(join(target, 'package.json'), JSON.stringify(pkg, null, 2))
|
|
44
|
+
writeFileSync(
|
|
45
|
+
join(target, 'README.md'),
|
|
46
|
+
`开发副本(${name})—— 由 scripts/make-dev-copy.mjs 生成,用于绕过当前 DSH 版本的模块缓存热迭代。\n正式交付目录:${ROOT}\n`,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
console.log(target)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// git/path 安装时的 prepare 兜底:lib/ 缺失则构建一次。
|
|
3
|
+
//
|
|
4
|
+
// 审计 F20:这里原来在缺依赖时走 npx 兜底(联网下载 + 版本范围),
|
|
5
|
+
// 改成调用同一个跨平台构建脚本 —— 只有一份实现,也只有在装好开发依赖时才会成功。
|
|
6
|
+
// 注意:真正的 npm 安装路径上 `prepare` 会被 npm 以 --ignore-scripts 跳过(CI 里就是),
|
|
7
|
+
// 这里主要服务"从 git/path 直接装"的场景。
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { spawnSync } from 'node:child_process'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
14
|
+
|
|
15
|
+
if (existsSync(join(ROOT, 'lib', 'index.js')) && existsSync(join(ROOT, 'lib', 'client.js'))) {
|
|
16
|
+
console.log('[prepare] lib/ already built — skipping')
|
|
17
|
+
process.exit(0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const result = spawnSync(process.execPath, [join(ROOT, 'scripts', 'build.mjs')], {
|
|
21
|
+
cwd: ROOT,
|
|
22
|
+
stdio: 'inherit',
|
|
23
|
+
shell: false,
|
|
24
|
+
})
|
|
25
|
+
process.exit(result.status ?? 1)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 从 CHANGELOG.md 里取出指定版本的段落(发布说明复用 CHANGELOG,避免两处维护)。
|
|
4
|
+
*
|
|
5
|
+
* node tools/changelog-section.mjs 0.1.3
|
|
6
|
+
*
|
|
7
|
+
* 找不到该版本时输出一行兜底说明并正常退出(不让发布流程挂掉)。
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const version = (process.argv[2] ?? '').replace(/^v/, '').trim()
|
|
14
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
|
+
|
|
16
|
+
if (!version) {
|
|
17
|
+
console.error('用法: node tools/changelog-section.mjs <版本号,如 0.1.3>')
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let changelog = ''
|
|
22
|
+
try {
|
|
23
|
+
changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8')
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.log(`CHANGELOG.md 读取失败:${error.message}`)
|
|
26
|
+
process.exit(0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = changelog.split('\n')
|
|
30
|
+
const start = lines.findIndex((line) => new RegExp(`^##\\s+${version.replace(/\./g, '\\.')}(\\s|$)`).test(line))
|
|
31
|
+
if (start === -1) {
|
|
32
|
+
console.log(`详见 CHANGELOG.md。安装:\`dsh plugin add ./dsh-deepseek-web-login-${version}.tgz\``)
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
let end = lines.length
|
|
36
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
37
|
+
if (/^##\s/.test(lines[i])) {
|
|
38
|
+
end = i
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
console.log(lines.slice(start + 1, end).join('\n').trim())
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DSH 会话日志诊断工具。
|
|
4
|
+
*
|
|
5
|
+
* 用途:当出现「回复异常」时(被截断、只剩碎片、工具调用泄漏成正文、莫名重试),
|
|
6
|
+
* 直接从 DSH 的会话日志里读出**每一轮的原始事实**:用了哪个模型、模型消息里有哪些
|
|
7
|
+
* 内容块(长度/首尾)、结束原因、以及流式分块的类型与字节数。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node tools/inspect-session.mjs # 列出所有会话(时间/大小/消息数)
|
|
11
|
+
* node tools/inspect-session.mjs <会话ID前缀> # 诊断该会话的每一轮
|
|
12
|
+
* node tools/inspect-session.mjs --search "关键词" # 按关键词找会话(可多个关键词,逗号分隔)
|
|
13
|
+
* node tools/inspect-session.mjs <ID前缀> --blocks # 额外打印内容块的真实事件(原始 JSON 片段)
|
|
14
|
+
*
|
|
15
|
+
* 会话目录:`${DSH_HOME:-~/.dsh}/sessions/<workspace>/session-<id>/session.jsonl.zstd`
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 实现要点(踩坑):该日志是**逐条追加的独立 zstd 帧**拼接而成(一个几 MB 的文件里可能有上万个帧头),
|
|
18
|
+
* `zlib.zstdDecompressSync` 与流式解压都只返回第一帧(约 200 字节的会话头)。
|
|
19
|
+
* 因此必须按帧头 magic (0x28 0xB5 0x2F 0xFD) 切分、逐帧解压后拼接 —— 见 inflate()。
|
|
20
|
+
*
|
|
21
|
+
* 隐私:本脚本只读本机会话日志,不联网、不上传任何内容。
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import zlib from 'node:zlib'
|
|
27
|
+
|
|
28
|
+
const SESSION_ROOT = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'sessions')
|
|
29
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
30
|
+
|
|
31
|
+
/** 解压「多帧拼接」的 zstd 日志。 */
|
|
32
|
+
function inflate(buffer) {
|
|
33
|
+
const offsets = []
|
|
34
|
+
let index = -1
|
|
35
|
+
while ((index = buffer.indexOf(ZSTD_MAGIC, index + 1)) !== -1) offsets.push(index)
|
|
36
|
+
if (offsets.length === 0) return zlib.zstdDecompressSync(buffer).toString('utf8')
|
|
37
|
+
const chunks = []
|
|
38
|
+
for (let i = 0; i < offsets.length; i++) {
|
|
39
|
+
const end = offsets[i + 1] ?? buffer.length
|
|
40
|
+
try {
|
|
41
|
+
chunks.push(zlib.zstdDecompressSync(buffer.subarray(offsets[i], end)))
|
|
42
|
+
} catch {
|
|
43
|
+
// magic 可能出现在压缩数据内部:与下一帧合并重试
|
|
44
|
+
const nextEnd = offsets[i + 2] ?? buffer.length
|
|
45
|
+
try {
|
|
46
|
+
chunks.push(zlib.zstdDecompressSync(buffer.subarray(offsets[i], nextEnd)))
|
|
47
|
+
i += 1
|
|
48
|
+
} catch {
|
|
49
|
+
/* 跳过该帧 */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function* sessionLogs(dir = SESSION_ROOT) {
|
|
57
|
+
let entries
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
60
|
+
} catch {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const full = join(dir, entry.name)
|
|
65
|
+
if (entry.isDirectory()) yield* sessionLogs(full)
|
|
66
|
+
else if (entry.name === 'session.jsonl.zstd' || entry.name === 'session.jsonl') yield full
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readEvents(file) {
|
|
71
|
+
const raw = readFileSync(file)
|
|
72
|
+
const text = file.endsWith('.zstd') ? inflate(raw) : raw.toString('utf8')
|
|
73
|
+
const events = []
|
|
74
|
+
for (const line of text.split('\n')) {
|
|
75
|
+
if (!line.trim()) continue
|
|
76
|
+
try {
|
|
77
|
+
events.push(JSON.parse(line))
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
return events
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const idOf = (file) => /session-([0-9a-f-]+)/.exec(file)?.[1] ?? file
|
|
84
|
+
const shortId = (file) => idOf(file).slice(0, 8)
|
|
85
|
+
const clock = (time) => new Date(time ?? 0).toISOString().slice(11, 19)
|
|
86
|
+
|
|
87
|
+
function blocksSummary(content, preview = 60) {
|
|
88
|
+
return content
|
|
89
|
+
.map((block) => {
|
|
90
|
+
if (!block || typeof block !== 'object') return String(block)
|
|
91
|
+
if (typeof block.text === 'string') {
|
|
92
|
+
return `${block.type}(len=${block.text.length}) head=${JSON.stringify(block.text.slice(0, preview))} tail=${JSON.stringify(block.text.slice(-preview))}`
|
|
93
|
+
}
|
|
94
|
+
if (block.type === 'tool-call') return `tool-call(${block.name}) args=${String(block.arguments).slice(0, 80)}`
|
|
95
|
+
if (block.type === 'tool-result') return `tool-result(${(block.content ?? []).length} blocks)`
|
|
96
|
+
return String(block.type)
|
|
97
|
+
})
|
|
98
|
+
.join('\n ')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function diagnose(file, showBlocks) {
|
|
102
|
+
const events = readEvents(file)
|
|
103
|
+
const stat = statSync(file)
|
|
104
|
+
console.log(`=== ${shortId(file)} ${stat.mtime.toISOString()} ${(stat.size / 1024).toFixed(1)}KB(zstd) 事件 ${events.length} ===\n`)
|
|
105
|
+
|
|
106
|
+
const chunkStats = new Map()
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
const { type, data, time } = event
|
|
109
|
+
if (!data) continue
|
|
110
|
+
const at = clock(time)
|
|
111
|
+
if (type === 'turn/start') console.log(`\n━━━ turn ${data.turn} ━━━`)
|
|
112
|
+
else if (type === 'request/context') console.log(`[${at}] 模型: ${data.provider}/${data.model} ctx=${data.contextWindow}`)
|
|
113
|
+
else if (type === 'request/header') {
|
|
114
|
+
const config = data.header?.config ?? {}
|
|
115
|
+
console.log(`[${at}] 请求配置: ${JSON.stringify(config)} adapterDefaults=${JSON.stringify(data.header?.adapterDefaults ?? {})}`)
|
|
116
|
+
} else if (type === 'user/message') {
|
|
117
|
+
const text = (data.content ?? []).map((b) => b.text ?? `<${b.type}>`).join('')
|
|
118
|
+
if (text.trim()) console.log(`[${at}] 用户: ${JSON.stringify(text.slice(0, 90))}`)
|
|
119
|
+
} else if (type === 'assistant/chunk') {
|
|
120
|
+
const chunk = data.chunk ?? {}
|
|
121
|
+
const key = `${data.turn}/${data.step}`
|
|
122
|
+
const entry = chunkStats.get(key) ?? { kinds: new Map(), textChars: 0 }
|
|
123
|
+
const kind = entry.kinds.get(chunk.type) ?? { count: 0, chars: 0 }
|
|
124
|
+
kind.count += 1
|
|
125
|
+
if (typeof chunk.text === 'string') kind.chars += chunk.text.length
|
|
126
|
+
if (typeof chunk.argumentsDelta === 'string') kind.chars += chunk.argumentsDelta.length
|
|
127
|
+
entry.kinds.set(chunk.type, kind)
|
|
128
|
+
entry.textChars += typeof chunk.text === 'string' ? chunk.text.length : 0
|
|
129
|
+
chunkStats.set(key, entry)
|
|
130
|
+
} else if (type === 'text-chunks') {
|
|
131
|
+
// DSH 会把这些增量合并记录:texts 是分块原文,dt 是与前一块的间隔(ms)
|
|
132
|
+
console.log(`[${at}] 分块原文(文本): ${JSON.stringify(data.texts ?? []).slice(0, 300)}`)
|
|
133
|
+
} else if (type === 'assistant/message') {
|
|
134
|
+
const content = data.message?.content ?? data.content ?? []
|
|
135
|
+
console.log(`[${at}] 助手消息(${content.length} 块):\n ${blocksSummary(content)}`)
|
|
136
|
+
if (showBlocks) console.log(` 原始: ${JSON.stringify(data).slice(0, 600)}`)
|
|
137
|
+
} else if (type === 'step/end') {
|
|
138
|
+
console.log(`[${at}] step 结束: ${JSON.stringify(data).slice(0, 200)}`)
|
|
139
|
+
} else if (type === 'turn/end') {
|
|
140
|
+
console.log(`[${at}] turn 结束: ${JSON.stringify(data).slice(0, 200)}`)
|
|
141
|
+
} else if (type === 'tool/call') {
|
|
142
|
+
console.log(`[${at}] 工具调用: ${data.name ?? data.call?.name} args=${String(JSON.stringify(data.arguments ?? data.call?.arguments ?? '')).slice(0, 90)}`)
|
|
143
|
+
} else if (type === 'tool/result') {
|
|
144
|
+
console.log(`[${at}] 工具结果: ${String(JSON.stringify(data)).slice(0, 120)}`)
|
|
145
|
+
} else if (type === 'error' || type === 'session/error') {
|
|
146
|
+
console.log(`[${at}] ❌ 错误: ${JSON.stringify(data).slice(0, 300)}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (chunkStats.size > 0) {
|
|
151
|
+
console.log('\n=== 流式分块统计(每 turn/step)===')
|
|
152
|
+
for (const [key, entry] of chunkStats) {
|
|
153
|
+
const kinds = [...entry.kinds.entries()].map(([k, v]) => `${k}×${v.count}(text ${v.chars}B)`).join(' ')
|
|
154
|
+
const warn = entry.kinds.has('usage') && entry.kinds.get('usage').count > 1 ? ' ⚠ usage 出现多次 → 大概率发生了重试' : ''
|
|
155
|
+
console.log(` ${key}: ${kinds}${warn}`)
|
|
156
|
+
}
|
|
157
|
+
console.log('\n提示:若「助手消息」的文本长度远小于分块字节数,或文本里出现 {"tool_calls":…} / <tool_calls>,')
|
|
158
|
+
console.log(' 说明问题出在适配器的流式解析层;若 usage/finish 出现多次,说明触发了重试。')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function search(keywords) {
|
|
163
|
+
for (const file of sessionLogs()) {
|
|
164
|
+
let events
|
|
165
|
+
try {
|
|
166
|
+
events = readEvents(file)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error(` ! 读取失败 ${file}: ${error.message}`)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
const text = JSON.stringify(events)
|
|
172
|
+
const hits = keywords.filter((keyword) => text.includes(keyword))
|
|
173
|
+
if (hits.length === 0) continue
|
|
174
|
+
const stat = statSync(file)
|
|
175
|
+
console.log(`${shortId(file)} ${stat.mtime.toISOString()} ${(stat.size / 1024).toFixed(1)}KB 命中: ${hits.join(', ')}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function list() {
|
|
180
|
+
const rows = []
|
|
181
|
+
for (const file of sessionLogs()) {
|
|
182
|
+
let events = []
|
|
183
|
+
try {
|
|
184
|
+
events = readEvents(file)
|
|
185
|
+
} catch {}
|
|
186
|
+
const titles = events.filter((e) => e.type === 'session/title').map((e) => e.data?.title).filter(Boolean)
|
|
187
|
+
rows.push({ id: shortId(file), mtime: statSync(file).mtime, size: statSync(file).size, count: events.length, title: titles.at(-1) ?? '' })
|
|
188
|
+
}
|
|
189
|
+
rows.sort((a, b) => b.mtime - a.mtime)
|
|
190
|
+
for (const row of rows.slice(0, 30)) {
|
|
191
|
+
console.log(`${row.id} ${row.mtime.toISOString()} ${(row.size / 1024).toFixed(1).padStart(8)}KB 事件 ${String(row.count).padStart(6)} ${row.title}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── CLI ──
|
|
196
|
+
const args = process.argv.slice(2)
|
|
197
|
+
if (!existsSync(SESSION_ROOT)) {
|
|
198
|
+
console.error(`找不到会话目录:${SESSION_ROOT}(可通过 DSH_HOME 指定 DSH 主目录)`)
|
|
199
|
+
process.exit(1)
|
|
200
|
+
}
|
|
201
|
+
const searchArg = args.indexOf('--search')
|
|
202
|
+
if (searchArg !== -1) {
|
|
203
|
+
const keywords = (args[searchArg + 1] ?? '').split(',').map((k) => k.trim()).filter(Boolean)
|
|
204
|
+
if (keywords.length === 0) {
|
|
205
|
+
console.error('用法: node tools/inspect-session.mjs --search "关键词1,关键词2"')
|
|
206
|
+
process.exit(1)
|
|
207
|
+
}
|
|
208
|
+
search(keywords)
|
|
209
|
+
} else if (args[0] && !args[0].startsWith('--')) {
|
|
210
|
+
const prefix = args[0]
|
|
211
|
+
let found = false
|
|
212
|
+
for (const file of sessionLogs()) {
|
|
213
|
+
if (!file.includes(prefix)) continue
|
|
214
|
+
found = true
|
|
215
|
+
diagnose(file, args.includes('--blocks'))
|
|
216
|
+
}
|
|
217
|
+
if (!found) {
|
|
218
|
+
console.error(`没有匹配 "${prefix}" 的会话。先不带参数运行以列出会话 ID。`)
|
|
219
|
+
process.exit(1)
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
list()
|
|
223
|
+
}
|