dsh-mini-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/client/client.js +387 -0
- package/client/verify.mjs +514 -0
- package/cordis.patch.yml +5 -0
- package/lib/art/work-1-ready.webp +0 -0
- package/lib/art/work-2-reading.webp +0 -0
- package/lib/art/work-3-typing.webp +0 -0
- package/lib/art/work-4-checking.webp +0 -0
- package/lib/art/work-5-thinking.webp +0 -0
- package/lib/art/work-6-running.webp +0 -0
- package/lib/art/work-7-waiting.webp +0 -0
- package/lib/art/work-8-sprinting.webp +0 -0
- package/lib/build.js +59 -0
- package/lib/cloudflared.js +165 -0
- package/lib/events.js +165 -0
- package/lib/index.js +1206 -0
- package/lib/net.js +104 -0
- package/lib/notify.js +82 -0
- package/lib/page.html +1891 -0
- package/lib/page.js +46 -0
- package/lib/pairing.js +172 -0
- package/lib/server.js +590 -0
- package/lib/store.js +334 -0
- package/lib/tree.js +185 -0
- package/lib/tunnel.js +144 -0
- package/package.json +74 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 找到(或下载)cloudflared 可执行文件。
|
|
3
|
+
*
|
|
4
|
+
* cloudflared 是 Cloudflare 的隧道客户端。它从你的电脑主动连出去,Cloudflare
|
|
5
|
+
* 那头给你一个公网网址,转发回本机的手机服务。好处是电脑不用有公网 IP、不用
|
|
6
|
+
* 在路由器上开端口;代价是那个网址任何人都访问得到——密码和限速是唯一的门。
|
|
7
|
+
*
|
|
8
|
+
* 找的顺序是「能用现成的就不下载」:配置里指定的 → 系统 PATH 里的 → dsh-pocket
|
|
9
|
+
* 已经下过的那份 → 自己下(下过一次就缓存下来)。
|
|
10
|
+
*/
|
|
11
|
+
import { chmod, mkdir, rename, rm, stat, access } from 'node:fs/promises'
|
|
12
|
+
import { createWriteStream } from 'node:fs'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import { join, dirname } from 'node:path'
|
|
15
|
+
import { Readable } from 'node:stream'
|
|
16
|
+
import { pipeline } from 'node:stream/promises'
|
|
17
|
+
|
|
18
|
+
const BIN_NAME = process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 各系统对应的发布产物名。
|
|
22
|
+
* 注意 linux 是**裸二进制**(没有 .tgz)——dsh-pocket 的注释里记着,早期给
|
|
23
|
+
* linux 拼 `cloudflared-linux-amd64.tgz` 是错的,那个文件根本不存在。这里把
|
|
24
|
+
* .tgz 留在候选里只作回退,万一上游改回打包方式也不会直接躺平。
|
|
25
|
+
*/
|
|
26
|
+
function assetNames() {
|
|
27
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'
|
|
28
|
+
if (process.platform === 'win32') return ['cloudflared-windows-amd64.exe']
|
|
29
|
+
if (process.platform === 'darwin') return [`cloudflared-darwin-${arch}.tgz`]
|
|
30
|
+
return [`cloudflared-linux-${arch}`, `cloudflared-linux-${arch}.tgz`]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 下载源,按顺序试。前三个是 GitHub 加速代理——国内直连 GitHub 的 release
|
|
35
|
+
* 经常超时。(npmmirror 没有 cloudflared 镜像,实测 404,所以不在列表里。)
|
|
36
|
+
*
|
|
37
|
+
* 这三个加速源和 dsh-pocket(shaobeichen,GPL-2.0)用的是同一批。这类镜像地址是
|
|
38
|
+
* 国内开发者的公共常识,不是谁的独创;这里记一笔,是为了不假装没见过它。
|
|
39
|
+
* 代码本身(这个数组的形状、下载与校验流程)是我们自己写的。
|
|
40
|
+
*/
|
|
41
|
+
const MIRRORS = [
|
|
42
|
+
(asset) => `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
|
|
43
|
+
(asset) => `https://ghproxy.net/https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
|
|
44
|
+
(asset) => `https://gh.ddlc.top/https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
|
|
45
|
+
(asset) => `https://gh-proxy.com/https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
async function exists(file) {
|
|
49
|
+
try {
|
|
50
|
+
await access(file)
|
|
51
|
+
return true
|
|
52
|
+
} catch {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 跑一次 `cloudflared --version` 看能不能用。
|
|
59
|
+
* 比自己在 PATH 里拼路径可靠——PATH 之外还有别名、软链、包管理器装的 shim。
|
|
60
|
+
*/
|
|
61
|
+
function probe(command) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
let child
|
|
64
|
+
try {
|
|
65
|
+
child = spawn(command, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
66
|
+
} catch {
|
|
67
|
+
resolve(false)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
let out = ''
|
|
71
|
+
const finish = (ok) => {
|
|
72
|
+
clearTimeout(timer)
|
|
73
|
+
try { child.kill() } catch { /* 已经退了 */ }
|
|
74
|
+
resolve(ok)
|
|
75
|
+
}
|
|
76
|
+
const timer = setTimeout(() => finish(false), 5000)
|
|
77
|
+
timer.unref?.()
|
|
78
|
+
child.stdout?.on('data', (chunk) => { out += chunk })
|
|
79
|
+
child.stderr?.on('data', (chunk) => { out += chunk })
|
|
80
|
+
child.on('error', () => finish(false))
|
|
81
|
+
child.on('close', (code) => finish(code === 0 && /cloudflared/i.test(out)))
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchTo(url, file) {
|
|
86
|
+
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(180_000) })
|
|
87
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
88
|
+
if (!res.body) throw new Error('响应没有正文')
|
|
89
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(file))
|
|
90
|
+
const { size } = await stat(file)
|
|
91
|
+
// 代理挂掉时经常返回一个 HTML 错误页而不是报错,用体积把它挡掉
|
|
92
|
+
if (size < 1_000_000) throw new Error(`文件只有 ${size} 字节,多半下到的是错误页`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractTgz(archive, dir) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const child = spawn('tar', ['-xzf', archive, '-C', dir], { stdio: 'ignore' })
|
|
98
|
+
child.on('error', reject)
|
|
99
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`tar 退出码 ${code}`))))
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function download(dataDir, log) {
|
|
104
|
+
const dir = join(dataDir, 'bin')
|
|
105
|
+
await mkdir(dir, { recursive: true })
|
|
106
|
+
const target = join(dir, BIN_NAME)
|
|
107
|
+
const tmp = `${target}.part`
|
|
108
|
+
const failures = []
|
|
109
|
+
|
|
110
|
+
for (const asset of assetNames()) {
|
|
111
|
+
for (const mirror of MIRRORS) {
|
|
112
|
+
const url = mirror(asset)
|
|
113
|
+
try {
|
|
114
|
+
log?.info?.('正在下载 cloudflared(约 50MB,国内可能比较慢,只需一次)…')
|
|
115
|
+
await fetchTo(url, tmp)
|
|
116
|
+
if (asset.endsWith('.tgz')) {
|
|
117
|
+
await extractTgz(tmp, dir)
|
|
118
|
+
await rm(tmp, { force: true })
|
|
119
|
+
} else {
|
|
120
|
+
await rename(tmp, target)
|
|
121
|
+
}
|
|
122
|
+
if (process.platform !== 'win32') await chmod(target, 0o755)
|
|
123
|
+
log?.info?.(`cloudflared 已下载到 ${target}`)
|
|
124
|
+
return target
|
|
125
|
+
} catch (err) {
|
|
126
|
+
failures.push(`${new URL(url).host} — ${err?.message ?? err}`)
|
|
127
|
+
await rm(tmp, { force: true })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new Error(`所有下载源都没成功:\n ${failures.join('\n ')}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {object} opts
|
|
137
|
+
* @param {string} [opts.configuredPath] settings.json 里的 cloudflaredPath
|
|
138
|
+
* @param {string} opts.dataDir 插件数据目录(下载物落在它的 bin/ 下)
|
|
139
|
+
* @returns {Promise<{path: string|null, source?: string, error?: string}>}
|
|
140
|
+
*/
|
|
141
|
+
export async function ensureCloudflared({ configuredPath, dataDir, log }) {
|
|
142
|
+
if (configuredPath) {
|
|
143
|
+
if (await exists(configuredPath)) return { path: configuredPath, source: 'configured' }
|
|
144
|
+
return { path: null, error: `settings.json 里的 cloudflaredPath 指向的文件不存在:${configuredPath}` }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (await probe('cloudflared')) return { path: 'cloudflared', source: 'path' }
|
|
148
|
+
|
|
149
|
+
// dsh-pocket 很可能已经下过一份,直接复用,省掉 50MB 下载
|
|
150
|
+
const shared = join(dirname(dataDir), 'dsh-pocket', 'bin', BIN_NAME)
|
|
151
|
+
if (await exists(shared)) return { path: shared, source: 'dsh-pocket' }
|
|
152
|
+
|
|
153
|
+
const cached = join(dataDir, 'bin', BIN_NAME)
|
|
154
|
+
if (await exists(cached)) return { path: cached, source: 'cached' }
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
return { path: await download(dataDir, log), source: 'downloaded' }
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return {
|
|
160
|
+
path: null,
|
|
161
|
+
error: `${err?.message ?? err}\n也可以自己装一个(apt/dnf install cloudflared),`
|
|
162
|
+
+ '或者在 settings.json 里用 cloudflaredPath 直接指向它。',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
package/lib/events.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 DSH 会话事件流里,只捞出两样东西:
|
|
3
|
+
* 1. 用户真正发出的指令(不是插件注入的合成上下文)
|
|
4
|
+
* 2. Agent 一轮任务的最终回复(不是中间的工具调用和思考)
|
|
5
|
+
*
|
|
6
|
+
* 依据 DSH 0.1.5-rc.2 实测的事件契约:
|
|
7
|
+
* SessionEvent = { type, seq, time, data }
|
|
8
|
+
* - turn/start data: { turn }
|
|
9
|
+
* - user/message data: UserMessage (data.source.kind === 'user' 才是人发的)
|
|
10
|
+
* - assistant/message data: { turn, step, message: AssistantMessage, stream, usage? }
|
|
11
|
+
* - turn/end data: { turn, reason: { kind } }
|
|
12
|
+
* reason.kind ∈ completed | aborted | blocked | error | max-tokens | interrupted
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 只取真正面向用户可见的 text 块,丢掉 reasoning / tool-call / image。 */
|
|
16
|
+
export function visibleText(content) {
|
|
17
|
+
if (!Array.isArray(content)) return ''
|
|
18
|
+
const parts = []
|
|
19
|
+
for (const block of content) {
|
|
20
|
+
if (block && block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
|
|
21
|
+
parts.push(block.text)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return parts.join('\n\n').trim()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 这条助手消息是不是「中间步骤」。
|
|
29
|
+
*
|
|
30
|
+
* 判据:内容里有没有工具调用块。**有就是中间步骤**——模型先写一句「我先看看…」,
|
|
31
|
+
* 再发起工具调用,那句话说到底只是它自己的旁白,不是给你的回答。
|
|
32
|
+
*
|
|
33
|
+
* 依据 dsh-llm 的类型定义(lib/types/types.d.ts):
|
|
34
|
+
* TextBlock { type: 'text', text } 面向用户的可见文字
|
|
35
|
+
* ReasoningBlock { type: 'reasoning', text } 思考内容,与可见文字明确区分
|
|
36
|
+
* ToolCallBlock { type: 'tool-call', id, name, arguments }
|
|
37
|
+
*
|
|
38
|
+
* 用户 2026-09-21 实机报的「手机端我看回答过来的是思考过程」,就是这里漏了:
|
|
39
|
+
* 原来只要一条消息有文字就记下来,于是一轮里最后那条旁白被当成了回答。
|
|
40
|
+
*/
|
|
41
|
+
export function isIntermediateStep(content) {
|
|
42
|
+
if (!Array.isArray(content)) return false
|
|
43
|
+
return content.some((block) => block && block.type === 'tool-call')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 这段文字里是不是夹着工具调用的原始标记。
|
|
48
|
+
*
|
|
49
|
+
* 实机见过:一条助手消息的 text 块里既有旁白,又有没被解析掉的
|
|
50
|
+
* `<parameter name="edit">…`。那属于协议,不是给人读的话——
|
|
51
|
+
* 用户在手机上看到的就是这个(2026-09-21 反馈里贴出来的原文)。
|
|
52
|
+
*
|
|
53
|
+
* 判据故意写窄:只认工具调用那几种标记的开头,不碰别的尖括号内容。
|
|
54
|
+
* 写宽了会误伤——用户完全可能正当地跟你讨论一段 XML 或 HTML。
|
|
55
|
+
*/
|
|
56
|
+
const TOOL_MARKUP = /<parameter\s+name\s*=|<\s*function_calls\s*>|<invoke\s+name\s*=/
|
|
57
|
+
|
|
58
|
+
export function looksLikeToolMarkup(text) {
|
|
59
|
+
return typeof text === 'string' && TOOL_MARKUP.test(text)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 一轮任务的累计器。
|
|
64
|
+
*
|
|
65
|
+
* 一个 turn 可能包含多个 step(模型多次调用工具),每个 step 都会落一条
|
|
66
|
+
* assistant/message。
|
|
67
|
+
*
|
|
68
|
+
* **只有「不含工具调用」的那一条才是最终回答。** 带工具调用的那些是中间步骤,
|
|
69
|
+
* 里面的文字是模型的旁白(「编辑没生效,我重做一下」这种),不是给你的回答——
|
|
70
|
+
* 原来把它们也记下来,于是用户看到的是「思考过程」。
|
|
71
|
+
*
|
|
72
|
+
* 一轮跑完却始终没出现这样一条(被中断、或卡在工具里出不来),那就**没有回答可推**,
|
|
73
|
+
* 推空。宁可什么都不显示,也不能把旁白冒充成回答。
|
|
74
|
+
*/
|
|
75
|
+
export function createTurnTracker() {
|
|
76
|
+
/** @type {Map<string, { turn: number|null, lastText: string, userText: string, interrupted: boolean }>} */
|
|
77
|
+
const bySession = new Map()
|
|
78
|
+
|
|
79
|
+
function slot(sessionId) {
|
|
80
|
+
let s = bySession.get(sessionId)
|
|
81
|
+
if (!s) {
|
|
82
|
+
s = { turn: null, lastText: '', userText: '', interrupted: false }
|
|
83
|
+
bySession.set(sessionId, s)
|
|
84
|
+
}
|
|
85
|
+
return s
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
/**
|
|
90
|
+
* 喂入一条会话事件。
|
|
91
|
+
* @returns {{kind:'user', text:string}|{kind:'reply', text:string, reason:string}|null}
|
|
92
|
+
* 需要推送到手机时返回动作,否则返回 null。
|
|
93
|
+
*/
|
|
94
|
+
feed(sessionId, event) {
|
|
95
|
+
if (!event || typeof event.type !== 'string') return null
|
|
96
|
+
const s = slot(sessionId)
|
|
97
|
+
|
|
98
|
+
switch (event.type) {
|
|
99
|
+
case 'turn/start':
|
|
100
|
+
s.turn = event.data?.turn ?? s.turn
|
|
101
|
+
s.lastText = ''
|
|
102
|
+
s.interrupted = false
|
|
103
|
+
return null
|
|
104
|
+
|
|
105
|
+
case 'user/message': {
|
|
106
|
+
// 只认人发的;agent.inject() 的合成上下文(文件变更通知、skill 内容等)
|
|
107
|
+
// 的 source.kind 是 'plugin',必须排除,否则手机里会冒出莫名其妙的消息。
|
|
108
|
+
if (event.data?.source?.kind !== 'user') return null
|
|
109
|
+
const text = visibleText(event.data.content)
|
|
110
|
+
if (!text) return null
|
|
111
|
+
s.userText = text
|
|
112
|
+
// 带上 id:手机自己发出去的指令,注入后也会以这条事件回来,
|
|
113
|
+
// 调用方要靠 id 把它认出来、不要记第二笔(见 index.js 的 injectedIds)。
|
|
114
|
+
return { kind: 'user', text, id: event.data?.id }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'assistant/message': {
|
|
118
|
+
const content = event.data?.message?.content
|
|
119
|
+
// 中间步骤的文字是旁白,不是回答——跳过。见上面 isIntermediateStep 的说明。
|
|
120
|
+
if (isIntermediateStep(content)) return null
|
|
121
|
+
const text = visibleText(content)
|
|
122
|
+
if (!text) return null
|
|
123
|
+
// 夹着工具调用原始标记的文字不是给人读的,同样不能推。
|
|
124
|
+
if (looksLikeToolMarkup(text)) return null
|
|
125
|
+
s.lastText = text
|
|
126
|
+
/**
|
|
127
|
+
* 被中止的那一轮,会把「已经吐出来的那一段」作为一个 assistant/message
|
|
128
|
+
* 落下来,带 interrupted: true(见 dsh-session 的类型注释:
|
|
129
|
+
* "A turn cancelled mid-stream finalizes its delivered text/reasoning
|
|
130
|
+
* prefix as this event with `interrupted: true`; undispatched tool calls
|
|
131
|
+
* are absent.")。
|
|
132
|
+
*
|
|
133
|
+
* **那个「没派发的工具调用不在里面」是这里的关键**:中止的那条消息通常
|
|
134
|
+
* 一个 tool-call 块都没有,于是 isIntermediateStep 放它过去、被当成最终
|
|
135
|
+
* 回答。手机上就会莫名其妙出现半句话——看着像模型答崩了。
|
|
136
|
+
* 所以必须单独认这个标记,让手机知道「这是你按停的那半句」。
|
|
137
|
+
*/
|
|
138
|
+
s.interrupted = event.data?.interrupted === true
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case 'turn/end': {
|
|
143
|
+
const reason = event.data?.reason?.kind ?? 'completed'
|
|
144
|
+
const text = s.lastText
|
|
145
|
+
const userText = s.userText
|
|
146
|
+
const interrupted = s.interrupted
|
|
147
|
+
s.lastText = ''
|
|
148
|
+
s.userText = ''
|
|
149
|
+
s.interrupted = false
|
|
150
|
+
// 被取消/出错的轮次,以及卡在工具里没跑出回答的轮次,都没有什么可推的。
|
|
151
|
+
if (!text) return null
|
|
152
|
+
return { kind: 'reply', text, reason, userText, interrupted }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
default:
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/** 会话销毁时清掉累计器,避免长期运行下 Map 无限增长。 */
|
|
161
|
+
forget(sessionId) {
|
|
162
|
+
bySession.delete(sessionId)
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|