dsh-mobile-plugin 0.5.23 → 0.5.26
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 +6 -6
- package/knowledge/apps/alipay.md +30 -0
- package/knowledge/apps/android-settings.md +30 -0
- package/knowledge/apps/bilibili.md +30 -0
- package/knowledge/apps/boss-zhipin.md +32 -0
- package/knowledge/apps/douyin.md +32 -0
- package/knowledge/apps/taobao.md +33 -0
- package/knowledge/apps/wechat.md +33 -0
- package/knowledge/apps/weibo.md +31 -0
- package/knowledge/apps/xiaohongshu.md +29 -0
- package/knowledge/apps/zhihu.md +30 -0
- package/knowledge/sites/bilibili.com.md +27 -0
- package/knowledge/sites/github.com.md +27 -0
- package/knowledge/sites/x.com.md +28 -0
- package/knowledge/sites/xiaohongshu.com.md +27 -0
- package/knowledge/sites/youtube.com.md +28 -0
- package/knowledge/sites/zhihu.com.md +27 -0
- package/lib/canvas-app.html +73 -72
- package/lib/client.js +18 -19
- package/lib/impl.js +11 -2
- package/lib/mobile/cli/acp.js +3 -2
- package/lib/mobile/cli/mobile.js +4 -28
- package/lib/mobile/pairing.js +44 -4
- package/lib/mobile/phone/confirm.js +189 -0
- package/lib/mobile/phone/index.js +21 -1
- package/lib/mobile/phone/irreversible.js +146 -0
- package/lib/mobile/phone/pacing.js +173 -0
- package/lib/mobile/phone/route-hint.js +164 -0
- package/lib/mobile/phone/routes-memory.js +133 -0
- package/lib/mobile/phone/routes.js +84 -0
- package/lib/mobile/phone/service.js +163 -13
- package/lib/mobile/phone/tools.js +1190 -62
- package/lib/mobile/phone-knowledge.js +301 -0
- package/lib/mobile/promo-receiver.cjs +77 -0
- package/lib/mobile/schedule.js +64 -7
- package/lib/mobile/sessions.js +40 -5
- package/lib/mobile/sync-inbox-store.js +181 -0
- package/lib/mobile/sync.js +124 -0
- package/lib/mobile/transfer-tickets.js +107 -0
- package/lib/mobile/wechat.js +26 -1
- package/lib/p2p/rendezvous.cjs +106 -0
- package/lib/p2p/session.cjs +455 -0
- package/lib/p2p/wiring.cjs +93 -0
- package/lib/panel/index.html +15 -14
- package/lib/relay/connection-check.js +55 -0
- package/lib/relay/status.js +4 -2
- package/lib/routes/control-plane.js +28 -13
- package/lib/routes/mobile-api.js +55 -1
- package/lib/shared/e2ee-request.js +27 -7
- package/lib/tailcat/host-process.cjs +37 -0
- package/lib/tailcat/session.cjs +42 -5
- package/lib/tailcat/wiring.cjs +25 -4
- package/lib/update-install.cjs +4 -38
- package/package.json +4 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 不可逆动作判定:纯函数,只回答「这一下点下去是不是收不回来」,不做任何动作。
|
|
5
|
+
*
|
|
6
|
+
* 给 tools 层做单步提示、给 batch 做熔断用。判据刻意窄:只有 **tap** 且能读到「点的是哪个
|
|
7
|
+
* 按钮的文字」才算数。读不到文字(只有 ref、只有坐标而没 target)就判不了,宁可放过也不
|
|
8
|
+
* 误判 —— 把整屏文字当成这一下的目标,会把「聊天页里恰好有『删除』两个字」判成删除,
|
|
9
|
+
* 那比漏判更烦人(每次点都停下来问)。
|
|
10
|
+
*
|
|
11
|
+
* 词表繁简两边都认,加上英文的 send / submit / order / pay / buy / delete。分成五类,
|
|
12
|
+
* 但输出的 kind 只有 payment / submit / send / delete / none —— 下单、购买、付款这类收钱的
|
|
13
|
+
* 都归 payment(对熔断来说它们是一回事:都花钱)。
|
|
14
|
+
*
|
|
15
|
+
* 已知局限:这是词表兜底,不看语义。订单状态一类的文字(「待付款」「付款成功」「订单记录」)
|
|
16
|
+
* 里也有付款 / 订单词,会被当成对外动作 —— 方向是「宁可多停一次」,不会误执行。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 只有点屏幕才有「点的是哪个按钮」这回事。 */
|
|
20
|
+
const TAP_ACTIONS = new Set(['tap']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 词表按 kind 分组,顺序即优先级:先看样子最重的付款 / 下单,再提交、发送、删除。
|
|
24
|
+
* 中文按包含判;纯英文词按单词边界判,免得 order 在 reorder、delete 在 undelete 里误命中。
|
|
25
|
+
*/
|
|
26
|
+
const WORD_TABLE = [
|
|
27
|
+
{
|
|
28
|
+
kind: 'payment',
|
|
29
|
+
words: [
|
|
30
|
+
'支付', '付款', '买单', '买單', '下单', '下單', '购买', '購買',
|
|
31
|
+
'结算', '結算', '订单', '訂單', '收银台', '收銀台',
|
|
32
|
+
'pay', 'payment', 'order', 'buy', 'purchase', 'checkout',
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
kind: 'submit',
|
|
37
|
+
words: ['提交', '确认提交', '確認提交', '送出', 'submit'],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kind: 'send',
|
|
41
|
+
words: ['发送', '傳送', '传送', '发布', '發布', '发表', '發表', 'send', 'post'],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
kind: 'delete',
|
|
45
|
+
words: ['删除', '刪除', '清空', '移除', 'delete', 'remove', 'clear'],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** 这些字段可能装着「点的是哪个按钮」的文字;按顺序取,取到非空就用。 */
|
|
50
|
+
const LABEL_FIELDS = [
|
|
51
|
+
'text', 'target', 'label', 'name', 'description', 'desc',
|
|
52
|
+
'contentDescription', 'title', 'content', 'value',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function isEnglishWord(word) {
|
|
56
|
+
return /^[a-z]+$/.test(word);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 在一个文字里找第一个命中的词,返回 {kind, word} 或 null。 */
|
|
60
|
+
function matchLabel(label) {
|
|
61
|
+
const lower = label.toLowerCase();
|
|
62
|
+
for (const group of WORD_TABLE) {
|
|
63
|
+
for (const word of group.words) {
|
|
64
|
+
if (isEnglishWord(word)) {
|
|
65
|
+
// 英文按整词匹配,避免 order 命中 reorder / preorder。
|
|
66
|
+
if (new RegExp(`\\b${word}\\b`, 'i').test(lower)) return { kind: group.kind, word };
|
|
67
|
+
} else if (label.includes(word)) {
|
|
68
|
+
return { kind: group.kind, word };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 从参数里能读到的、像「被点元素文字」的那几条。 */
|
|
76
|
+
function labelsOf(args) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const key of LABEL_FIELDS) {
|
|
79
|
+
const value = args[key];
|
|
80
|
+
if (typeof value === 'string') {
|
|
81
|
+
const text = value.trim();
|
|
82
|
+
if (text) out.push(text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 一个孤零零的短串才可能是按钮文字;长段多行的是整屏,不能当目标。 */
|
|
89
|
+
function looksLikeLabel(text) {
|
|
90
|
+
const trimmed = text.trim();
|
|
91
|
+
if (!trimmed || trimmed.length > 32) return false;
|
|
92
|
+
return !/[\r\n]/.test(trimmed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function none(reason) {
|
|
96
|
+
return { irreversible: false, kind: 'none', reason };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* judge({ action, args, screenText }) → { irreversible, kind, reason }。
|
|
101
|
+
*
|
|
102
|
+
* `screenText` 只在「既没 ref / 坐标、也没有任何文字字段」且它本身像个按钮短串时才当目标文字;
|
|
103
|
+
* 有 ref 或坐标却没有文字时一律判不了 —— 这是「ref 点按不误判」那条要求的落点。
|
|
104
|
+
*/
|
|
105
|
+
function judge(input) {
|
|
106
|
+
const { action, args, screenText } = input || {};
|
|
107
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
108
|
+
|
|
109
|
+
if (typeof action !== 'string' || !TAP_ACTIONS.has(action)) {
|
|
110
|
+
return none('只判 tap:别的动作没有「点的是哪个按钮」这回事');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasRef = typeof a.ref === 'string' && a.ref.trim() !== '';
|
|
114
|
+
const hasPoint = Number.isFinite(a.x) && Number.isFinite(a.y);
|
|
115
|
+
let labels = labelsOf(a);
|
|
116
|
+
|
|
117
|
+
if (labels.length === 0) {
|
|
118
|
+
if (hasRef || hasPoint) {
|
|
119
|
+
return none('只有 ref / 坐标、没有说话文字,判定不了');
|
|
120
|
+
}
|
|
121
|
+
if (typeof screenText === 'string' && looksLikeLabel(screenText)) {
|
|
122
|
+
labels = [screenText.trim()];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (labels.length === 0) return none('没有可判的文字');
|
|
127
|
+
|
|
128
|
+
for (const label of labels) {
|
|
129
|
+
const hit = matchLabel(label);
|
|
130
|
+
if (hit) {
|
|
131
|
+
return {
|
|
132
|
+
irreversible: true,
|
|
133
|
+
kind: hit.kind,
|
|
134
|
+
reason: `「${label}」命中不可逆词「${hit.word}」`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return none('没有命中不可逆词表');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
judge,
|
|
143
|
+
// 下面几个给守卫直接用真值表,不重复实现匹配逻辑。
|
|
144
|
+
WORD_TABLE,
|
|
145
|
+
matchLabel,
|
|
146
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 操作节奏:决定「动作之间停多久」「对外动作之间至少隔多久」。
|
|
5
|
+
*
|
|
6
|
+
* 三档 fast / standard / human,默认 standard,存在插件设置里(与播报开关同一处:
|
|
7
|
+
* `<DSH_HOME>/storages/mobile-plugin/phone-pacing.json`)。这里只给数字,**自己不 sleep**:
|
|
8
|
+
* 真正等待的是执行动作的那一层,本模块一睡就把测试和宿主都拖住。
|
|
9
|
+
*
|
|
10
|
+
* 对外动作的间隔照 Mika 的 OutwardPacing:最近 30 分钟内做得越多,间隔越往上涨,
|
|
11
|
+
* 上下限各 +0.5s / +1s,封顶 30s。第一次不额外等 —— 一条消息本来就要花那几个动作,
|
|
12
|
+
* 从第一条起就硬等只会让人以为手机卡了。
|
|
13
|
+
*
|
|
14
|
+
* 落盘失败不抛:节奏是锦上添花,写不进去也要让这一轮按新节奏继续跑。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const { dshHome } = require('../../shared/paths');
|
|
21
|
+
|
|
22
|
+
/** 可选的三档。 */
|
|
23
|
+
const MODES = ['fast', 'standard', 'human'];
|
|
24
|
+
const DEFAULT_MODE = 'standard';
|
|
25
|
+
|
|
26
|
+
/** 对外动作计数的时间窗:只算最近半小时做过的。 */
|
|
27
|
+
const OUTWARD_WINDOW_MS = 30 * 60 * 1000;
|
|
28
|
+
/** 每多做一次对外动作,间隔下限 / 上限各加这么多毫秒。 */
|
|
29
|
+
const PER_ACTION_MS = [500, 1000];
|
|
30
|
+
/** 间隔封顶,再密也不等超过半分钟。 */
|
|
31
|
+
const MAX_OUTWARD_MS = 30 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 三档的具体数字。
|
|
35
|
+
*
|
|
36
|
+
* `betweenActionsMs`:同一轮里相邻两个动作之间的停留范围;
|
|
37
|
+
* `outwardMinMs`:两个对外动作(发送 / 提交 / 下单这类)之间的最小间隔范围,起点也会被
|
|
38
|
+
* noteOutward 按次数往上加;
|
|
39
|
+
* `typingChunkMs`:一段一段打字时每段之间停多久(fast 一次写全文,所以是 0)。
|
|
40
|
+
*/
|
|
41
|
+
const TABLES = {
|
|
42
|
+
fast: {
|
|
43
|
+
betweenActionsMs: [0, 0],
|
|
44
|
+
outwardMinMs: [0, 0],
|
|
45
|
+
typingChunkMs: 0,
|
|
46
|
+
},
|
|
47
|
+
standard: {
|
|
48
|
+
betweenActionsMs: [500, 1200],
|
|
49
|
+
outwardMinMs: [8000, 20000],
|
|
50
|
+
typingChunkMs: 40,
|
|
51
|
+
},
|
|
52
|
+
human: {
|
|
53
|
+
betweenActionsMs: [1000, 2000],
|
|
54
|
+
outwardMinMs: [15000, 30000],
|
|
55
|
+
typingChunkMs: 90,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function settingsFile() {
|
|
60
|
+
return path.join(dshHome(), 'storages', 'mobile-plugin', 'phone-pacing.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 把大小写 / 空白各异的输入收成一个合法档名;认不出返回 null。 */
|
|
64
|
+
function normalizeMode(mode) {
|
|
65
|
+
if (typeof mode !== 'string') return null;
|
|
66
|
+
const m = mode.trim().toLowerCase();
|
|
67
|
+
return MODES.includes(m) ? m : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 上一轮写盘失败时记住的档名:写不进去也不能让调用方读回旧值。 */
|
|
71
|
+
let memoryMode = null;
|
|
72
|
+
|
|
73
|
+
function readStoredMode() {
|
|
74
|
+
try {
|
|
75
|
+
const raw = JSON.parse(fs.readFileSync(settingsFile(), 'utf8'));
|
|
76
|
+
return normalizeMode(raw && raw.mode);
|
|
77
|
+
} catch {
|
|
78
|
+
// 文件不存在 / 读坏 / 字段不对都当没设过,走默认档。
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 当前档。读不到就是 standard(坏文件同样落在这里)。 */
|
|
84
|
+
function get() {
|
|
85
|
+
if (memoryMode) return memoryMode;
|
|
86
|
+
return readStoredMode() || DEFAULT_MODE;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 改成某一档并落盘。非法档名是调用方写错了,直接抛;写盘失败只记住内存值、不抛。
|
|
91
|
+
* 返回改后的当前档,方便调用方直接回给界面。
|
|
92
|
+
*/
|
|
93
|
+
function set(mode) {
|
|
94
|
+
const m = normalizeMode(mode);
|
|
95
|
+
if (!m) throw new RangeError(`未知的操作节奏:${String(mode)}(可选 ${MODES.join(' / ')})`);
|
|
96
|
+
try {
|
|
97
|
+
const file = settingsFile();
|
|
98
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
fs.writeFileSync(file, `${JSON.stringify({ mode: m }, null, 2)}\n`);
|
|
100
|
+
memoryMode = null;
|
|
101
|
+
} catch {
|
|
102
|
+
memoryMode = m;
|
|
103
|
+
}
|
|
104
|
+
return m;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 某一档的数字。认不出的档按 standard 给,避免调用方拿到 undefined 再炸开。 */
|
|
108
|
+
function paceFor(mode) {
|
|
109
|
+
const table = TABLES[normalizeMode(mode) || DEFAULT_MODE];
|
|
110
|
+
return {
|
|
111
|
+
betweenActionsMs: [...table.betweenActionsMs],
|
|
112
|
+
outwardMinMs: [...table.outwardMinMs],
|
|
113
|
+
typingChunkMs: table.typingChunkMs,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 最近做过的对外动作时间点(升序)。只有一台手机,不按包名分组。 */
|
|
118
|
+
let outwardAt = [];
|
|
119
|
+
|
|
120
|
+
function prune(now) {
|
|
121
|
+
while (outwardAt.length > 0 && now - outwardAt[0] > OUTWARD_WINDOW_MS) outwardAt.shift();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 记一次「现在要做对外动作」,返回这一发至少要等多少毫秒。
|
|
126
|
+
*
|
|
127
|
+
* 返回的是**最小**值(下限),不是随机值:调用方自己决定要不要在这个余量之外再多停。
|
|
128
|
+
* 第一次对外动作返回 0;之后按最近 30 分钟已做的次数把下限往上抬(每次 +0.5s),
|
|
129
|
+
* 封顶 30s;如果从上一次到现在已经隔够了,返回 0。
|
|
130
|
+
*/
|
|
131
|
+
function noteOutward(nowMs) {
|
|
132
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
133
|
+
const pace = paceFor(get());
|
|
134
|
+
prune(now);
|
|
135
|
+
|
|
136
|
+
let minWaitMs = 0;
|
|
137
|
+
const baseLowMs = pace.outwardMinMs[0];
|
|
138
|
+
// fast 档下限是 0:不计数、不加码,永远不等(与 Mika 的 Off 一个意思)。
|
|
139
|
+
if (outwardAt.length > 0 && baseLowMs > 0) {
|
|
140
|
+
const extra = outwardAt.length - 1;
|
|
141
|
+
const required = Math.min(baseLowMs + extra * PER_ACTION_MS[0], MAX_OUTWARD_MS);
|
|
142
|
+
const elapsed = now - outwardAt[outwardAt.length - 1];
|
|
143
|
+
minWaitMs = Math.max(0, required - elapsed);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
outwardAt.push(now);
|
|
147
|
+
return minWaitMs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 单测用:清掉对外动作的计数。 */
|
|
151
|
+
function resetOutwardForTest() {
|
|
152
|
+
outwardAt = [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 单测用:把内存里「写盘失败留下的档」也清掉。 */
|
|
156
|
+
function resetCacheForTest() {
|
|
157
|
+
memoryMode = null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
get,
|
|
162
|
+
set,
|
|
163
|
+
paceFor,
|
|
164
|
+
noteOutward,
|
|
165
|
+
resetOutwardForTest,
|
|
166
|
+
resetCacheForTest,
|
|
167
|
+
settingsFile,
|
|
168
|
+
MODES,
|
|
169
|
+
DEFAULT_MODE,
|
|
170
|
+
OUTWARD_WINDOW_MS,
|
|
171
|
+
PER_ACTION_MS,
|
|
172
|
+
MAX_OUTWARD_MS,
|
|
173
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `phone_route_hint`:只读地翻出「这个 App 以前成功过的路线」。
|
|
5
|
+
*
|
|
6
|
+
* 用法是打开一个 App 之后问一句「以前有没有做成过、怎么做的」。**只给参考,不自动执行**:
|
|
7
|
+
* 这里既不连手机、也不发任何动作,模型看过之后还得自己按当前屏幕重新判断。之所以只给摘要不给
|
|
8
|
+
* 原始步骤,是因为界面会变,照抄旧路线比没有更危险 —— 摘要让模型看懂大意,具体每一步仍要现读屏。
|
|
9
|
+
*
|
|
10
|
+
* 注册形状照 `phone/tools.js` 里的那套:走 `ctx.inject(['tools'], …)` 再 `defineTool`,
|
|
11
|
+
* 不能直接读 `ctx.tools`(宿主里那是个影子对象,`register` 一调就抛,被容错吞掉后一条工具都交不到
|
|
12
|
+
* 模型手上)。缺 tools 注入的 profile 静默跳过,别把整块装配掀翻。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const routesMemory = require('./routes-memory.js');
|
|
16
|
+
|
|
17
|
+
/** 一次最多回几条路线。 */
|
|
18
|
+
const MAX_ROUTES = 5;
|
|
19
|
+
/** 摘要里单段文字最长多少字。 */
|
|
20
|
+
const MAX_TEXT = 60;
|
|
21
|
+
|
|
22
|
+
/** 动作名换成一句人话动词。 */
|
|
23
|
+
const ACTION_VERBS = {
|
|
24
|
+
tap: '点击',
|
|
25
|
+
type: '输入',
|
|
26
|
+
key: '按键',
|
|
27
|
+
swipe: '滑动',
|
|
28
|
+
gesture: '手势',
|
|
29
|
+
'screen.wait': '等待',
|
|
30
|
+
'screen.read': '读屏',
|
|
31
|
+
'screen.screenshot': '截图',
|
|
32
|
+
'app.open': '打开',
|
|
33
|
+
'app.close': '关闭',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function clip(text) {
|
|
37
|
+
const value = typeof text === 'string' ? text.trim() : '';
|
|
38
|
+
if (!value) return '';
|
|
39
|
+
return value.length > MAX_TEXT ? `${value.slice(0, MAX_TEXT)}…` : value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把一步压成一句摘要。只取最有辨识度的一个参数,不要把小箭头变成一段代码。
|
|
44
|
+
*/
|
|
45
|
+
function summarizeStep(step) {
|
|
46
|
+
if (!step || typeof step !== 'object') return '';
|
|
47
|
+
const action = typeof step.action === 'string' ? step.action.trim() : '';
|
|
48
|
+
if (!action) return '';
|
|
49
|
+
const args = step.args && typeof step.args === 'object' ? step.args : {};
|
|
50
|
+
const verb = ACTION_VERBS[action] || action;
|
|
51
|
+
|
|
52
|
+
let object = '';
|
|
53
|
+
if (action === 'type') {
|
|
54
|
+
const text = clip(args.text);
|
|
55
|
+
if (text) object = `「${text}」`;
|
|
56
|
+
} else if (action === 'tap') {
|
|
57
|
+
const target = clip(args.target);
|
|
58
|
+
if (target) object = `「${target}」`;
|
|
59
|
+
else if (typeof args.ref === 'string' && args.ref.trim()) object = args.ref.trim();
|
|
60
|
+
} else if (action === 'key') {
|
|
61
|
+
object = typeof args.key === 'string' ? args.key : '';
|
|
62
|
+
} else if (action === 'app.open') {
|
|
63
|
+
object = clip(args.packageName);
|
|
64
|
+
} else if (action === 'swipe') {
|
|
65
|
+
object = typeof args.direction === 'string' ? args.direction : '';
|
|
66
|
+
} else {
|
|
67
|
+
object = clip(args.target) || clip(args.text) || clip(args.packageName) || (typeof args.ref === 'string' ? args.ref.trim() : '');
|
|
68
|
+
}
|
|
69
|
+
return object ? `${verb}${object}` : verb;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把一条路线压成可读的摘要对象:步骤数、时间、整条路线的一句话。
|
|
74
|
+
*/
|
|
75
|
+
function summarizeRoute(route) {
|
|
76
|
+
const steps = route && Array.isArray(route.steps) ? route.steps : [];
|
|
77
|
+
const parts = steps.map(summarizeStep).filter(Boolean);
|
|
78
|
+
return {
|
|
79
|
+
at: route && Number.isFinite(route.at) ? route.at : 0,
|
|
80
|
+
stepCount: steps.length,
|
|
81
|
+
summary: parts.join(' → '),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 取这个 App 最近成功的几条,压成摘要。没有记录回空数组。 */
|
|
86
|
+
function hintFor(packageName) {
|
|
87
|
+
const routes = routesMemory.list(packageName);
|
|
88
|
+
// 最近的先给:新近成功的路线更贴近现在这个版本的界面。
|
|
89
|
+
return routes
|
|
90
|
+
.slice()
|
|
91
|
+
.sort((a, b) => b.at - a.at)
|
|
92
|
+
.slice(0, MAX_ROUTES)
|
|
93
|
+
.map(summarizeRoute);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 注册 `phone_route_hint`。与 `installPhoneTools` 同一口径:拿不到 tools 注入就静静不装。
|
|
98
|
+
*/
|
|
99
|
+
function installRouteHint(ctx) {
|
|
100
|
+
if (!ctx || typeof ctx.inject !== 'function') return;
|
|
101
|
+
let defineTool;
|
|
102
|
+
try {
|
|
103
|
+
({ defineTool } = require('@deepseek-ai/dsh-tools'));
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
ctx.inject(['tools'], (scoped) => {
|
|
109
|
+
if (!scoped || !scoped.tools || typeof scoped.tools.register !== 'function') return;
|
|
110
|
+
scoped.tools.register(defineTool({
|
|
111
|
+
name: 'phone_route_hint',
|
|
112
|
+
description: 'Look up routes this app has succeeded with before, as short step summaries. This is reference only — nothing is executed. After opening an app, use it to recall a past successful path, then re-read the screen and decide each step yourself; the UI may have changed.',
|
|
113
|
+
parameters: {
|
|
114
|
+
packageName: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
required: true,
|
|
117
|
+
description: 'Android package name of the app, e.g. "com.tencent.mm".',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
output: {
|
|
121
|
+
schema: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
properties: {
|
|
125
|
+
ok: { type: 'boolean' },
|
|
126
|
+
packageName: { type: 'string' },
|
|
127
|
+
count: { type: 'number' },
|
|
128
|
+
// 一条路线是「步骤数 + 摘要」,形状固定,但为了紧凑允许附加字段。
|
|
129
|
+
routes: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
130
|
+
note: { type: 'string' },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
render: (_args, value) => {
|
|
134
|
+
const count = Array.isArray(value.routes) ? value.routes.length : 0;
|
|
135
|
+
const lines = [`这个 App 以前成功过 ${count} 条路线(仅供参考,不会自动执行):`];
|
|
136
|
+
for (const route of value.routes || []) lines.push(`- ${route.summary}`);
|
|
137
|
+
if (!count) lines.push('- 还没有记录。');
|
|
138
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
execute: async (args) => {
|
|
142
|
+
const pkg = typeof args.packageName === 'string' ? args.packageName.trim() : '';
|
|
143
|
+
if (!pkg) throw new Error('要给一个包名(例如 com.tencent.mm)才能查历史路线。');
|
|
144
|
+
const routes = hintFor(pkg);
|
|
145
|
+
const value = {
|
|
146
|
+
ok: true,
|
|
147
|
+
packageName: pkg,
|
|
148
|
+
count: routes.length,
|
|
149
|
+
routes,
|
|
150
|
+
};
|
|
151
|
+
if (routes.length === 0) value.note = '这个 App 还没有成功过的路线记录。';
|
|
152
|
+
return value;
|
|
153
|
+
},
|
|
154
|
+
}));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
installRouteHint,
|
|
160
|
+
summarizeStep,
|
|
161
|
+
summarizeRoute,
|
|
162
|
+
hintFor,
|
|
163
|
+
MAX_ROUTES,
|
|
164
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 成功路线记忆:把「这个 App 上一轮是怎么一步步做成的」记下来,下次打开同一个 App 时能翻出
|
|
5
|
+
* 以前成功过的路线给模型参考。
|
|
6
|
+
*
|
|
7
|
+
* 存一份 JSON 在 `<DSH_HOME>/storages/mobile-plugin/phone-routes.json`,按包名分桶:
|
|
8
|
+
*
|
|
9
|
+
* { "<包名>": [ { steps: [...], at: 1794…, hits: 0 } ] } // 每个包最多 20 条
|
|
10
|
+
*
|
|
11
|
+
* 三条边界:
|
|
12
|
+
* - **只在成功时记**:`record` 由调用方在结局判定为成功后调;失败、超时、取消一律不记。
|
|
13
|
+
* - **每个包封顶 20 条**:旧记录满了就挤掉最旧的,避免一个常用 App 把文件越攒越大。
|
|
14
|
+
* - **加载失败安静退化**:文件不存在、读坏、形状不对,都当「还没有记录」,绝不抛 —— 记忆是
|
|
15
|
+
* 锦上添花,不能因为它把一次操作或者整个插件装载掀翻。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const { dshHome } = require('../../shared/paths');
|
|
22
|
+
|
|
23
|
+
/** 每个包名最多留这么多条历史路线。 */
|
|
24
|
+
const MAX_PER_PACKAGE = 20;
|
|
25
|
+
|
|
26
|
+
/** 落盘文件:`<DSH_HOME>/storages/mobile-plugin/phone-routes.json`,调用时解析。 */
|
|
27
|
+
function routesFile() {
|
|
28
|
+
return path.join(dshHome(), 'storages', 'mobile-plugin', 'phone-routes.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 只保留能落盘、也有意义的字段,别把整个 args 原样塞进去(可能很大)。 */
|
|
32
|
+
function normalizeSteps(steps) {
|
|
33
|
+
if (!Array.isArray(steps)) return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const step of steps) {
|
|
36
|
+
if (!step || typeof step !== 'object') continue;
|
|
37
|
+
const entry = {};
|
|
38
|
+
if (typeof step.action === 'string' && step.action.trim()) entry.action = step.action.trim();
|
|
39
|
+
if (step.args && typeof step.args === 'object' && !Array.isArray(step.args)) entry.args = step.args;
|
|
40
|
+
if (step.until && typeof step.until === 'object' && !Array.isArray(step.until)) entry.until = step.until;
|
|
41
|
+
if (entry.action) out.push(entry);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 读整份账本。读不到 / 读坏 / 形状不对都当空。 */
|
|
47
|
+
function readStore() {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(routesFile(), 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 写整份账本。写不进去就安静放弃:这一轮照样算完成,只是这条没记住。 */
|
|
65
|
+
function writeStore(store) {
|
|
66
|
+
const file = routesFile();
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
fs.writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 归一化包名。 */
|
|
72
|
+
function normalizePackage(packageName) {
|
|
73
|
+
return typeof packageName === 'string' ? packageName.trim() : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 记一条成功路线。只在成功时由调用方调;包名为空或步骤为空时不记。
|
|
78
|
+
* 返回这个包当前的全部记录(方便调用方/测试看结果)。
|
|
79
|
+
*/
|
|
80
|
+
function record(packageName, steps) {
|
|
81
|
+
const pkg = normalizePackage(packageName);
|
|
82
|
+
const cleaned = normalizeSteps(steps);
|
|
83
|
+
if (!pkg || cleaned.length === 0) return [];
|
|
84
|
+
|
|
85
|
+
const store = readStore();
|
|
86
|
+
const bucket = Array.isArray(store[pkg]) ? store[pkg].filter((item) => item && Array.isArray(item.steps)) : [];
|
|
87
|
+
bucket.push({ steps: cleaned, at: Date.now(), hits: 0 });
|
|
88
|
+
// 只留最近的 MAX_PER_PACKAGE 条:新记录在尾部,超了从头挤掉最旧的。
|
|
89
|
+
store[pkg] = bucket.slice(-MAX_PER_PACKAGE);
|
|
90
|
+
try {
|
|
91
|
+
writeStore(store);
|
|
92
|
+
} catch {
|
|
93
|
+
// 写失败不抛,记忆丢一条不影响这一轮已经成功的结果。
|
|
94
|
+
}
|
|
95
|
+
return store[pkg].map(copyRecord);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function copyRecord(item) {
|
|
99
|
+
return {
|
|
100
|
+
steps: Array.isArray(item.steps) ? item.steps.map((step) => ({ ...step })) : [],
|
|
101
|
+
at: Number.isFinite(item.at) ? item.at : 0,
|
|
102
|
+
hits: Number.isFinite(item.hits) ? item.hits : 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 读回某个 App 以前成功过的路线(按记录先后,旧的在前)。包名为空或没记录都回空数组。
|
|
108
|
+
* 返回的是副本,调用方改不到账本上。
|
|
109
|
+
*/
|
|
110
|
+
function list(packageName) {
|
|
111
|
+
const pkg = normalizePackage(packageName);
|
|
112
|
+
if (!pkg) return [];
|
|
113
|
+
const store = readStore();
|
|
114
|
+
const bucket = Array.isArray(store[pkg]) ? store[pkg] : [];
|
|
115
|
+
return bucket.filter((item) => item && Array.isArray(item.steps)).map(copyRecord);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 单测用:删掉落盘文件,回到「还没有任何记录」。 */
|
|
119
|
+
function resetForTest() {
|
|
120
|
+
try {
|
|
121
|
+
fs.rmSync(routesFile(), { force: true });
|
|
122
|
+
} catch {
|
|
123
|
+
// 本来就没有。
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
routesFile,
|
|
129
|
+
record,
|
|
130
|
+
list,
|
|
131
|
+
resetForTest,
|
|
132
|
+
MAX_PER_PACKAGE,
|
|
133
|
+
};
|