dsh-mobile-plugin 0.5.18 → 0.5.19
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/config/agent-presets/standard/agent.cordis.yml +9 -0
- package/host.js +26 -4
- package/lib/host-compat.cjs +51 -4
- package/lib/mobile/cli/config-prefs.js +4 -21
- package/lib/mobile/cli/mobile.js +36 -4
- package/lib/mobile/phone/tools.js +94 -0
- package/lib/mobile/questions.js +9 -3
- package/lib/mobile/stats-ledger.cjs +8 -23
- package/package.json +1 -1
|
@@ -110,14 +110,23 @@
|
|
|
110
110
|
config:
|
|
111
111
|
- id: tool-subagent-control
|
|
112
112
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
113
|
+
# 独立的 `list_agents` 入口只在 0.2.1 及更早存在;0.2.2 把它并进了主包,
|
|
114
|
+
# 子路径没了。这一行保留是为了老运行时,启动时宿主会按「这一代认不认这个
|
|
115
|
+
# 子路径」把副本里的这一行整行拿掉(见 lib/host-compat.cjs 的 dropRows)。
|
|
113
116
|
- id: tool-subagent-list-agents
|
|
114
117
|
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
118
|
+
# 子代理挑模型的开关,两代运行时键名不同:0.2.1 及更早读
|
|
119
|
+
# `modelSelectionSettings`,0.2.2 起改成 `modelSelection`(另加
|
|
120
|
+
# `modelDiscovery` 才让模型查得到可选模型)。两个键都写:各自的 schema 只认
|
|
121
|
+
# 自己那个、另一个原样忽略,所以同一份预设挂到哪一代都对。
|
|
115
122
|
- id: tool-subagent
|
|
116
123
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
117
124
|
config:
|
|
118
125
|
provider: spawn
|
|
119
126
|
toolName: subagent
|
|
120
127
|
modelSelectionSettings: true
|
|
128
|
+
modelSelection: true
|
|
129
|
+
modelDiscovery: true
|
|
121
130
|
backgroundMode: continuable
|
|
122
131
|
- id: tool-subagent-fork
|
|
123
132
|
name: '@deepseek-ai/dsh-tool-subagent'
|
package/host.js
CHANGED
|
@@ -20,7 +20,7 @@ import { installProcessGuard } from './lib/host-guard.cjs'
|
|
|
20
20
|
import { DIRECTORY_PICKER_ROW, TOOL_ROWS, pinInsertedRows, syncPresetDir } from './lib/host-compat.cjs'
|
|
21
21
|
import { dropBareNameSelfMounts } from './lib/host-patches.cjs'
|
|
22
22
|
import {
|
|
23
|
-
assertEntriesActivated,
|
|
23
|
+
auditStartupEntries as assertEntriesActivated,
|
|
24
24
|
boot,
|
|
25
25
|
evaluatePluginCompatibility,
|
|
26
26
|
getDshRuntimeVersion,
|
|
@@ -29,11 +29,19 @@ import {
|
|
|
29
29
|
loadLayeredEnv,
|
|
30
30
|
loadOverlayPatches,
|
|
31
31
|
loadProfile,
|
|
32
|
-
PROFILE_TEMPLATES,
|
|
33
32
|
readProfileCompatibility,
|
|
34
33
|
resolveProfileDir,
|
|
35
34
|
} from '@deepseek-ai/dsh-app-boot'
|
|
36
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 新建 profile 时的初始组合层。
|
|
38
|
+
*
|
|
39
|
+
* 不从 `dsh-app-boot` 取 `PROFILE_TEMPLATES`:0.2.2 把这份清单挪进了
|
|
40
|
+
* `@deepseek-ai/dsh-official-bundles`,旧位置不再导出,而静态导入一个不存在的
|
|
41
|
+
* 命名导出是让整个宿主起不来(不是降级)。这里本来就只要 web 那一档的两行。
|
|
42
|
+
*/
|
|
43
|
+
const WEB_PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
|
|
44
|
+
|
|
37
45
|
const BIN_NAME = 'dsh-mobile-plugin'
|
|
38
46
|
const PROFILE_NAME = process.env.DSH_MOBILE_PROFILE || 'mobile'
|
|
39
47
|
const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url))
|
|
@@ -58,7 +66,7 @@ try {
|
|
|
58
66
|
const home = process.env.DSH_HOME
|
|
59
67
|
const profileDir = resolveProfileDir(PROFILE_NAME, home)
|
|
60
68
|
// Idempotent: an already-initialized profile keeps its own patch layer.
|
|
61
|
-
initProfile(profileDir,
|
|
69
|
+
initProfile(profileDir, WEB_PROFILE_BUNDLES)
|
|
62
70
|
const profile = loadProfile(BIN_NAME, PROFILE_NAME, INSTALL_ANCHOR, home, { userLayer: true })
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -142,6 +150,19 @@ try {
|
|
|
142
150
|
)
|
|
143
151
|
}
|
|
144
152
|
|
|
153
|
+
// 预设里「列子代理」那一行挂的是 `dsh-tool-subagent-control` 的一个子入口。
|
|
154
|
+
// 0.2.2 把那个工具并进了主包,子路径没有了 —— 留着它,每建一个会话都会有一行
|
|
155
|
+
// 起不来,而能力本身还在。判据是**这一代运行时认不认这个子路径**,不按版本号猜。
|
|
156
|
+
const listAgentsEntry = '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
157
|
+
let listAgentsRowGone = false
|
|
158
|
+
try {
|
|
159
|
+
runtimeRequire?.resolve(listAgentsEntry)
|
|
160
|
+
} catch {
|
|
161
|
+
listAgentsRowGone = true
|
|
162
|
+
process.stderr.write(`${BIN_NAME}: 这一代运行时没有 ${listAgentsEntry}(该工具已并在主包里),预设副本里那一行不再挂\n`)
|
|
163
|
+
}
|
|
164
|
+
const dropPresetIds = listAgentsRowGone ? ['tool-subagent-list-agents'] : []
|
|
165
|
+
|
|
145
166
|
// 预设里的两行默认按裸名写:Loader 从它自己的位置解析,命中哪一份不由我们决定。
|
|
146
167
|
// 解析到了同代的那份就改写一份副本给它,路径写死成那一份的入口。
|
|
147
168
|
const presetRoots = {}
|
|
@@ -149,11 +170,12 @@ try {
|
|
|
149
170
|
const hit = pinnedRows.get(id)
|
|
150
171
|
if (hit) presetRoots[id] = hit.entry
|
|
151
172
|
}
|
|
152
|
-
const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined)
|
|
173
|
+
const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined) || dropPresetIds.length > 0
|
|
153
174
|
? syncPresetDir(
|
|
154
175
|
join(PACKAGE_DIR, 'config', 'agent-presets'),
|
|
155
176
|
join(profileDir, '.mobile-agent-presets'),
|
|
156
177
|
presetRoots,
|
|
178
|
+
{ dropIds: dropPresetIds },
|
|
157
179
|
)
|
|
158
180
|
: join(PACKAGE_DIR, 'config', 'agent-presets')
|
|
159
181
|
|
package/lib/host-compat.cjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 所以启动时先问运行时那棵树要同代的那一份,拿到就把行的 `name` 换成它的入口绝对路径,
|
|
9
9
|
* 绕开按裸名解析;拿不到就保持原样,让官方检查去禁用并打印原因。
|
|
10
10
|
*
|
|
11
|
+
* 还有另一种形状:入口本身没了(0.2.2 把 `list_agents` 并进主包,子路径随之下线)。
|
|
12
|
+
* 那一条换成哪一份都没用,只能整行从预设副本里拿掉 —— 见 `dropRows`。
|
|
13
|
+
*
|
|
11
14
|
* 这里只放纯函数:`host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
|
|
12
15
|
*/
|
|
13
16
|
|
|
@@ -76,21 +79,64 @@ function rewriteToolNames(source, targets) {
|
|
|
76
79
|
* @param {string} presetDir 包内 `config/agent-presets`
|
|
77
80
|
* @param {string} outDir 宿主可写的目标目录
|
|
78
81
|
* @param {Record<string, string>} targets 行 id → 入口绝对路径
|
|
82
|
+
* @param {{ dropIds?: string[] }} [options] 这一代运行时里不再有独立入口的行
|
|
79
83
|
* @returns {string} 真正该交给 `agent-presets` roots 的目录
|
|
80
84
|
*/
|
|
81
|
-
function syncPresetDir(presetDir, outDir, targets) {
|
|
85
|
+
function syncPresetDir(presetDir, outDir, targets, options = {}) {
|
|
82
86
|
fs.rmSync(outDir, { recursive: true, force: true })
|
|
83
87
|
fs.cpSync(presetDir, outDir, { recursive: true })
|
|
84
|
-
|
|
88
|
+
const dropIds = options.dropIds ?? []
|
|
89
|
+
if (Object.keys(targets).length === 0 && dropIds.length === 0) return outDir
|
|
85
90
|
for (const entry of fs.readdirSync(outDir, { recursive: true, withFileTypes: true })) {
|
|
86
91
|
if (!entry.isFile() || !entry.name.endsWith('.yml')) continue
|
|
87
92
|
const file = path.join(entry.parentPath ?? entry.path, entry.name)
|
|
88
|
-
const
|
|
89
|
-
|
|
93
|
+
const original = fs.readFileSync(file, 'utf8')
|
|
94
|
+
const trimmed = dropRows(original, dropIds)
|
|
95
|
+
const { text, changed } = rewriteToolNames(trimmed, targets)
|
|
96
|
+
if (changed.length > 0 || trimmed !== original) fs.writeFileSync(file, text)
|
|
90
97
|
}
|
|
91
98
|
return outDir
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/**
|
|
102
|
+
* 去掉预设里指定的整行(含它下面缩进更深的配置)。
|
|
103
|
+
*
|
|
104
|
+
* 给「这一代运行时里已经没有这个独立入口」的行用:留着它,Loader 会按裸名去 import
|
|
105
|
+
* 一个不存在的子路径,那一行起不来;能力本身可能已经并进主包,所以不是缺工具,
|
|
106
|
+
* 只是多了一条注定失败的行。
|
|
107
|
+
*
|
|
108
|
+
* @param {string} source 预设文件内容
|
|
109
|
+
* @param {string[]} ids 要整行删掉的入口 id
|
|
110
|
+
* @returns {string} 删完的文本(没命中就原样返回)
|
|
111
|
+
*/
|
|
112
|
+
function dropRows(source, ids) {
|
|
113
|
+
if (ids.length === 0) return source
|
|
114
|
+
const targets = new Set(ids)
|
|
115
|
+
const lines = source.split('\n')
|
|
116
|
+
const indentOf = (line) => line.length - line.trimStart().length
|
|
117
|
+
const kept = []
|
|
118
|
+
let dropping = false
|
|
119
|
+
let dropIndent = 0
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const id = ROW_ID.exec(line)
|
|
122
|
+
if (dropping) {
|
|
123
|
+
// 同级的下一行或更浅的一行就是这条的边界;更深的仍是它的配置。
|
|
124
|
+
if (id || (line.trim() !== '' && indentOf(line) <= dropIndent)) dropping = false
|
|
125
|
+
}
|
|
126
|
+
if (id && targets.has(id[2])) {
|
|
127
|
+
dropIndent = indentOf(line)
|
|
128
|
+
// 紧挨着这一行上方的注释块是在说它,跟着一起删;留下会说反话的说明。
|
|
129
|
+
while (kept.length > 0 && kept[kept.length - 1].trim().startsWith('#') && indentOf(kept[kept.length - 1]) >= dropIndent) {
|
|
130
|
+
kept.pop()
|
|
131
|
+
}
|
|
132
|
+
dropping = true
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (!dropping) kept.push(line)
|
|
136
|
+
}
|
|
137
|
+
return kept.join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
94
140
|
/**
|
|
95
141
|
* 把 patch 层里命中目标的 `name` 换成入口绝对路径。
|
|
96
142
|
*
|
|
@@ -115,6 +161,7 @@ module.exports = {
|
|
|
115
161
|
TOOL_ROWS,
|
|
116
162
|
DIRECTORY_PICKER_ROW,
|
|
117
163
|
rewriteToolNames,
|
|
164
|
+
dropRows,
|
|
118
165
|
syncPresetDir,
|
|
119
166
|
pinInsertedRows,
|
|
120
167
|
}
|
|
@@ -3,27 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 「这个 CLI 该按哪一套配置跑」—— 一台电脑每个 CLI 记一份。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 这一轮没结束就改不动配置 —— 用户说的「得等这一轮结束才能去调权限」正是这个。
|
|
11
|
-
*
|
|
12
|
-
* 所以这里做的是「**记住,然后开局就套上**」:用户在配置页里定的那一套(权限档 / 模型 /
|
|
13
|
-
* 思考档位 / 其它会话级下拉)存在这台电脑上,按 `agentId` 记;新建会话握手完、第一次发消息之前
|
|
14
|
-
* 逐项套过去。ACP 没有「一次设置管所有会话」的开关,但对用户来说效果就是全局的。
|
|
15
|
-
*
|
|
16
|
-
* 三条口径:
|
|
17
|
-
*
|
|
18
|
-
* 1. **只记用户显式选过的项**(`values`)。没选过的不记,新会话就让 CLI 用它自己的默认 ——
|
|
19
|
-
* 替用户猜一个档位,等于悄悄改了它的行为。
|
|
20
|
-
* 2. **选项表也记一份**(`options`)。配置页要在**没开会话**的时候就能画出「权限档有哪几个」,
|
|
21
|
-
* 而那是 CLI 握手之后才给的东西;不缓存就只能等开一条对话才看得见。缓存只用于显示,
|
|
22
|
-
* 真正写的时候仍然按新会话自己给的那张表校验(CLI 升级换了档位名这类情况就不会写错)。
|
|
23
|
-
* 3. **写不进去不算错**。新会话里那一项不存在(换了名字 / 这一版没这个选项)就跳过它,
|
|
24
|
-
* 停在 CLI 自己的默认上,别让一条过期的偏好把整条会话建不起来。
|
|
25
|
-
*
|
|
26
|
-
* 文件:`$DSH_HOME/storages/mobile-plugin/cli-config.json`。
|
|
6
|
+
* ACP 的配置是**会话级**的:`session/new` 之后才有 `configOptions`,`set_config_option` 只对那一条
|
|
7
|
+
* 会话生效,每开一条新对话都回到 CLI 默认档。所以这里把用户定的那套按 `agentId` 存下来,新会话
|
|
8
|
+
* 握手完、第一次发消息之前逐项套过去。只记用户显式选过的项、没选过的不猜;选项表缓存一份供没开
|
|
9
|
+
* 会话时画,但写入仍按新会话校验;写不进去不算错。文件:`$DSH_HOME/storages/mobile-plugin/cli-config.json`。
|
|
27
10
|
*/
|
|
28
11
|
|
|
29
12
|
const fs = require('node:fs');
|
package/lib/mobile/cli/mobile.js
CHANGED
|
@@ -488,6 +488,24 @@ function createCliMobile(deps) {
|
|
|
488
488
|
const states = new WeakMap()
|
|
489
489
|
/** rpcId → { session, requestId }:`/approval/respond` 靠它找回 ACP 的挂起项。 */
|
|
490
490
|
const approvals = new Map()
|
|
491
|
+
/**
|
|
492
|
+
* 投递去重账:`${sessionId}:${clientMessageId}` → 收下的时刻。
|
|
493
|
+
*
|
|
494
|
+
* CLI 这条发送没有宿主那本日志,`/session/delivery` 早先恒回 `delivered:false` —— 于是 App
|
|
495
|
+
* 在一发超时后会认为「没送到」再发一次;若这一轮刚好结束,就会真的把同一句喂给模型两遍。
|
|
496
|
+
* 这里记一笔:`/session/send` 见到同一个投递 id 直接按成功收(幂等),`/session/delivery`
|
|
497
|
+
* 照实回「早就收下了」。只留最近若干条,旧的自然淘汰。
|
|
498
|
+
*/
|
|
499
|
+
const deliveries = new Map()
|
|
500
|
+
const DELIVERIES_MAX = 300
|
|
501
|
+
function rememberDelivery(key) {
|
|
502
|
+
deliveries.set(key, Date.now())
|
|
503
|
+
while (deliveries.size > DELIVERIES_MAX) {
|
|
504
|
+
const oldest = deliveries.keys().next().value
|
|
505
|
+
if (oldest === undefined) break
|
|
506
|
+
deliveries.delete(oldest)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
491
509
|
|
|
492
510
|
function stateOf(session) {
|
|
493
511
|
let state = states.get(session)
|
|
@@ -556,8 +574,10 @@ function createCliMobile(deps) {
|
|
|
556
574
|
cwd: session.cwd,
|
|
557
575
|
// 侧栏按 workspaceId 分组:给不准就会把 CLI 会话扔进「未分组」。
|
|
558
576
|
workspaceId: session.workspaceId,
|
|
559
|
-
// 对话页「⋯ →
|
|
577
|
+
// 对话页「⋯ → 终端」靠这几格:哪个 CLI、展示名、在哪个目录。
|
|
560
578
|
cliAgent: session.agentId,
|
|
579
|
+
// 展示名(`Codex CLI` 之类):终端页 / 模型管理页标题用它,别把 id 直接摆给用户看。
|
|
580
|
+
...(session.label ? { cliAgentLabel: session.label } : {}),
|
|
561
581
|
updatedAt: session.updatedAt || 0,
|
|
562
582
|
// 置顶与宿主同一个字段:App 的行尾图钉读它(排序见 mergeList)。
|
|
563
583
|
...(state.pinned ? { pinned: true } : {}),
|
|
@@ -1005,9 +1025,13 @@ function createCliMobile(deps) {
|
|
|
1005
1025
|
case '/session/goal':
|
|
1006
1026
|
sendJsonQuiet(res, {})
|
|
1007
1027
|
return
|
|
1008
|
-
case '/session/delivery':
|
|
1009
|
-
|
|
1028
|
+
case '/session/delivery': {
|
|
1029
|
+
// App 的离线发件箱超时后回查这一发到底收下没有(见 useOutboxFlush):照实回答,
|
|
1030
|
+
// 免得它再发一遍。没有 clientMessageId 的老客户端只能回「不知道」。
|
|
1031
|
+
const cid = textOf(queryOf(req).get('clientMessageId'))
|
|
1032
|
+
sendJsonQuiet(res, { delivered: Boolean(cid && deliveries.has(`${session.id}:${cid}`)) })
|
|
1010
1033
|
return
|
|
1034
|
+
}
|
|
1011
1035
|
case '/session/rename': {
|
|
1012
1036
|
const title = textOf(body && body.title).trim()
|
|
1013
1037
|
if (title) setTitle(session, title)
|
|
@@ -1025,6 +1049,13 @@ function createCliMobile(deps) {
|
|
|
1025
1049
|
case '/session/send': {
|
|
1026
1050
|
const content = Array.isArray(body && body.content) ? body.content : []
|
|
1027
1051
|
const message = textOf(body && body.text) || textOfContent(content)
|
|
1052
|
+
// 同一个投递 id 再来一次就是重发:早就收下了,直接按成功回(幂等),别再喂模型一遍。
|
|
1053
|
+
const clientMessageId = textOf(body && body.clientMessageId)
|
|
1054
|
+
const deliveryKey = clientMessageId ? `${session.id}:${clientMessageId}` : ''
|
|
1055
|
+
if (deliveryKey && deliveries.has(deliveryKey)) {
|
|
1056
|
+
sendJson(res, 200, { ok: true, accepted: true })
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1028
1059
|
// 这一发带的图:先认下字节(手机发上来时已经落在收件箱里),块表里只记元数据。
|
|
1029
1060
|
const sent = await saveImages(content)
|
|
1030
1061
|
// 图的毛病先说清楚(上传过期 / 读不出来),比「这一发没有正文」有用得多。
|
|
@@ -1054,6 +1085,7 @@ function createCliMobile(deps) {
|
|
|
1054
1085
|
}
|
|
1055
1086
|
const result = runPrompt(session, message, sent.images)
|
|
1056
1087
|
if (!result.ok) return sendJson(res, 409, { ok: false, error: { code: 'cli-busy', message: result.error || '这一轮还没结束' } })
|
|
1088
|
+
if (deliveryKey) rememberDelivery(deliveryKey)
|
|
1057
1089
|
sendJson(res, 200, { ok: true, accepted: true })
|
|
1058
1090
|
return
|
|
1059
1091
|
}
|
|
@@ -1063,7 +1095,7 @@ function createCliMobile(deps) {
|
|
|
1063
1095
|
case '/subagents':
|
|
1064
1096
|
sendJsonQuiet(res, { entries: [], parentAvailable: true })
|
|
1065
1097
|
return
|
|
1066
|
-
case '/extension
|
|
1098
|
+
case '/extension/cards':
|
|
1067
1099
|
sendJsonQuiet(res, { cards: [] })
|
|
1068
1100
|
return
|
|
1069
1101
|
default:
|
|
@@ -106,6 +106,22 @@ function installPhoneTools(ctx, deps) {
|
|
|
106
106
|
const build = (scoped) => {
|
|
107
107
|
const register = (tool) => scoped.tools.register(defineTool(tool));
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* 宿主的附件库:截图要交给它存,编号才是宿主认得的那种(见 `phone_screenshot` 的说明)。
|
|
111
|
+
*
|
|
112
|
+
* 取用放在执行时、不在注册时:`attachments` 是宿主后装的,注册这一趟它可能还不在。先问
|
|
113
|
+
* 注入作用域、再问外层 —— 与 `schedule.js` 同一个口径(那边也是执行时 `ctx.get`)。
|
|
114
|
+
*/
|
|
115
|
+
const attachmentsOf = () => {
|
|
116
|
+
for (const source of [scoped, ctx]) {
|
|
117
|
+
if (!source || typeof source.get !== 'function') continue;
|
|
118
|
+
let store = null;
|
|
119
|
+
try { store = source.get('attachments'); } catch { store = null; }
|
|
120
|
+
if (store && typeof store.saveImage === 'function') return store;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
|
|
109
125
|
/**
|
|
110
126
|
* 所有工具都要的那一步:从执行上下文里认出这条会话。
|
|
111
127
|
*
|
|
@@ -182,6 +198,84 @@ function installPhoneTools(ctx, deps) {
|
|
|
182
198
|
},
|
|
183
199
|
});
|
|
184
200
|
|
|
201
|
+
/**
|
|
202
|
+
* 截一张后台屏的画面。
|
|
203
|
+
*
|
|
204
|
+
* **为什么要有它**:读屏拿到的是界面树(文字、控件、坐标),但视频、画布、游戏、部分网页
|
|
205
|
+
* 根本没有可读的节点 —— 那时候模型只能瞎猜。实测(2026-11-04):让它去抖音里看内容,
|
|
206
|
+
* 它连标题都读不到,因为整屏都是自绘的画面。截图这条能力一直在手机侧(读屏读不到时兜底
|
|
207
|
+
* 用的就是它),只是没交到模型手上。
|
|
208
|
+
*
|
|
209
|
+
* 图必须存进**宿主**的附件库:工具结果里的图片引用会随会话历史再发给模型,而宿主只认自己
|
|
210
|
+
* 发的那种带 `sha256:` 前缀的编号。2026-11-04 真机:图存进了插件自己那份库里、编号是裸的
|
|
211
|
+
* 哈希,下一轮请求整条挂掉(「附件引用无效」)。插件那份库是给手机端发图用的,两边不是一回事。
|
|
212
|
+
*/
|
|
213
|
+
register({
|
|
214
|
+
name: 'phone_screenshot',
|
|
215
|
+
description: 'Take a picture of the background screen the agent is working on. Use it when the accessibility tree cannot describe what is on screen: videos, canvas, games, image galleries. Returns the picture as an image.',
|
|
216
|
+
parameters: {
|
|
217
|
+
target: { type: 'string', description: 'What you are looking at, in a few words.' },
|
|
218
|
+
},
|
|
219
|
+
output: {
|
|
220
|
+
schema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
additionalProperties: false,
|
|
223
|
+
properties: {
|
|
224
|
+
deviceId: { type: 'string' },
|
|
225
|
+
width: { type: 'number' },
|
|
226
|
+
height: { type: 'number' },
|
|
227
|
+
note: { type: 'string' },
|
|
228
|
+
image: { type: 'object', additionalProperties: true },
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
render: (_args, value) => {
|
|
232
|
+
const blocks = [{ type: 'text', text: `后台屏截图(${value.width}×${value.height})` }];
|
|
233
|
+
if (value.image) blocks.push(value.image);
|
|
234
|
+
return blocks;
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
execute: async (args, exec) => {
|
|
238
|
+
const sessionId = sessionOf(exec);
|
|
239
|
+
const { deviceId, service } = targetOf(ctx, deps, sessionId);
|
|
240
|
+
const data = await run(service, deviceId, sessionId, 'screen.screenshot', {}, exec.signal);
|
|
241
|
+
const blob = data && typeof data.blob === 'string' ? data.blob : '';
|
|
242
|
+
if (!blob) throw new Error('没截到画面:手机那边没有回图片。');
|
|
243
|
+
const store = attachmentsOf();
|
|
244
|
+
if (!store) throw new Error('宿主没有附件库,这张图存不进去。');
|
|
245
|
+
let ref;
|
|
246
|
+
try {
|
|
247
|
+
ref = await store.saveImage({
|
|
248
|
+
data: Buffer.from(blob, 'base64'),
|
|
249
|
+
mediaType: 'image/png',
|
|
250
|
+
name: textOf(args.target, MAX_TEXT) || 'phone-screen',
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new Error(`截到的画面存不下来:${String((error && error.message) || error).slice(0, 200)}`);
|
|
254
|
+
}
|
|
255
|
+
// 有值才塞键:宿主不接受值为 undefined 的字段(见 A3-79 的坑)。
|
|
256
|
+
const value = {
|
|
257
|
+
deviceId,
|
|
258
|
+
width: Number(ref.width) || 0,
|
|
259
|
+
height: Number(ref.height) || 0,
|
|
260
|
+
note: mentionMismatch(sessionId, deviceId),
|
|
261
|
+
// 引用块按宿主自己那种形状写(`attachmentId` 带 `sha256:` 前缀、另有字节数与宽高),
|
|
262
|
+
// 这一块会被原样留在历史里,下一轮请求按它取图。
|
|
263
|
+
image: {
|
|
264
|
+
type: 'image',
|
|
265
|
+
attachment: {
|
|
266
|
+
attachmentId: ref.attachmentId,
|
|
267
|
+
mediaType: ref.mediaType,
|
|
268
|
+
bytes: ref.bytes,
|
|
269
|
+
width: ref.width,
|
|
270
|
+
height: ref.height,
|
|
271
|
+
...(ref.name ? { name: ref.name } : {}),
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
return value;
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
185
279
|
register({
|
|
186
280
|
name: 'phone_tap',
|
|
187
281
|
description: 'Tap an element on the phone screen by its ref (from phone_screen), or by viewport-relative percentage coordinates when no ref fits. One call taps once.',
|
package/lib/mobile/questions.js
CHANGED
|
@@ -255,14 +255,20 @@ function noAnswerer(error) {
|
|
|
255
255
|
* 撤回那一发,卡会一直悬着直到人手动点。那一发被撤时 `downstream` 会再落一次;上面的
|
|
256
256
|
* race 已经挂着它的处理函数,迟到的那次不会冒成未处理异常,`settle` 本身也是幂等的。
|
|
257
257
|
*
|
|
258
|
-
* `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate
|
|
259
|
-
*
|
|
258
|
+
* `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate 回来)**任何情况下都不是结论**,也跟
|
|
259
|
+
* 「手机此刻有没有在看这条会话」无关:手机这条路本来就是「人离开了再看」,通知与侧栏的
|
|
260
|
+
* 待处理标记就是把人带回来的那两根线。此前只在「有观众」时才据此继续等,于是手机停在
|
|
261
|
+
* 别的会话 / 在后台时,一条本来还能答的提问当场被判成没人能答,模型当作被跳过继续跑 ——
|
|
262
|
+
* 用户看到的就是「点通知进来,问题已经自己跳过了」。
|
|
263
|
+
*
|
|
264
|
+
* 收尾只有两条:手机作答(`entry.wait`),或者宿主的取消信号(停止这一轮 / 轮次被中止,
|
|
265
|
+
* 见 `bindRequest` 的 onAbort)。一直没人答也没有取消时,这条提问就停在原地等人回来。
|
|
260
266
|
*/
|
|
261
267
|
function holdForPhone(entry, next) {
|
|
262
268
|
const downstream = Promise.resolve().then(() => next()).then(
|
|
263
269
|
(answer) => { settle(entry, null); return answer; },
|
|
264
270
|
(error) => {
|
|
265
|
-
if (noAnswerer(error)
|
|
271
|
+
if (noAnswerer(error)) return new Promise(() => {});
|
|
266
272
|
settle(entry, null);
|
|
267
273
|
throw error;
|
|
268
274
|
},
|
|
@@ -3,24 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 统计账本:按天累计的用量 + 每条会话的累计与身份(`$DSH_HOME/mobile-stats.json`)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 不寄在会话日志上:用户会清理已归档的会话,一旦读到增量就落进这个文件,之后会话在不在都不影响
|
|
7
|
+
* 历史数字。口径与桌面 dsh-context 的每日账本一致:一天的账落在「观测到增量」的宿主本地日期上,
|
|
8
|
+
* 计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出。
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 每条会话一条记录,范围内有量的都上卡片 —— 卡片条数就是页面那个「会话」数字,所以
|
|
12
|
-
* **不按展示需要裁剪**(裁了那个数字就变成上限值了,不再是真的条数)。
|
|
13
|
-
*
|
|
14
|
-
* 第三张表是模型维度 `models`:按「渠道 + 模型」攒量,服务统计页那块「模型用量」。它账的是
|
|
15
|
-
* 同一个增量 —— 也就是说这里没有「按模型拆过一遍」的原始数据,模型名取自**采集那一刻**这条
|
|
16
|
-
* 会话正在用的那个选择(见 stats-collect.cjs 文件头),会话中途换模型的那一段整体归到换后的
|
|
17
|
-
* 那个名下。这是水位法本来就有的近似,不是这里另加的简化。
|
|
18
|
-
*
|
|
19
|
-
* 金额(`cost`)是**估算**,不落盘:折算时按模型桶的四个 token 桶乘单价(`model-prices.cjs`),
|
|
20
|
-
* 价格变了下次出数就跟着变,账本里不留一份会过期的钱。模型归属的近似照旧继承下来;查不到单价
|
|
21
|
-
* 的那一截 token 由 `unpricedBilled` 明说,不摊进合计。
|
|
22
|
-
*
|
|
23
|
-
* 只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
|
|
10
|
+
* 每条会话一条记录,范围内有量的都上卡片,**不按展示裁剪**(卡片条数就是页面那个「会话」数字)。
|
|
11
|
+
* 第三张表 `models` 按「渠道 + 模型」攒量,模型名取自采集那一刻这条会话的选择;金额是估算、不落盘,只做纯数据、不碰网络,采集在 stats-collect.cjs。
|
|
24
12
|
*/
|
|
25
13
|
|
|
26
14
|
const fs = require('node:fs');
|
|
@@ -120,12 +108,9 @@ function modelKeyOf(provider, model) {
|
|
|
120
108
|
/**
|
|
121
109
|
* 模型选择的清洗:provider / model 缺一个才算没有,展示名缺了留空串。
|
|
122
110
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* 模型桶里翻倍。
|
|
127
|
-
*
|
|
128
|
-
* 展示名按「非空才覆盖」的规矩用,空串不能把已经记下的名字擦掉。
|
|
111
|
+
* 展示名不算有效性判据:目录下架、渠道卸载只是「名字对不上」,这条用量记在哪个渠道哪个模型名下
|
|
112
|
+
* 仍是确定的。别拿「展示名齐不齐」当归属判据 —— 那样记录一重启读盘就变回「没有模型」,下一轮
|
|
113
|
+
* 又触发老账本回填,同一段累计量在模型桶里翻倍。展示名按「非空才覆盖」用,空串不能擦掉已知名。
|
|
129
114
|
*/
|
|
130
115
|
function sanitizeModelSelection(source) {
|
|
131
116
|
if (!source || typeof source !== 'object') return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.19",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"icon": "./icon.png",
|