dsh-mobile-plugin 0.5.21 → 0.5.22
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 +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/local-images.js +10 -5
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +10 -10
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +65 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +266 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/package.json +10 -1
|
@@ -244,11 +244,11 @@ function sanitizeLedger(raw) {
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
const billed = sanitizeUsage(value.billed);
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
247
|
+
// **不再凭累计值编一份按天明细**(2026-11-06):以前老账本缺这一栏时会把整段历史量记在
|
|
248
|
+
// 它最后一次活动那天,而按天账 `days` 与模型桶都没跟着记 —— 于是那张卡在覆盖到那天的
|
|
249
|
+
// 范围里比整页总量还大、`untrackedBilled` 却是 0,页面「卡片加起来能对上总量」这条
|
|
250
|
+
// 自证就断了。整段累计量本来就没有「哪天花了多少」的信息,凭空造一天只是造一个错数。
|
|
251
|
+
// 现在这一批会话只出现在 `all` 那档(直接用累计值),按范围看的差额归 `untrackedBilled`。
|
|
252
252
|
ledger.sessions[sessionId] = {
|
|
253
253
|
title: typeof value.title === 'string' ? value.title : '',
|
|
254
254
|
workspaceName: typeof value.workspaceName === 'string' ? value.workspaceName : '',
|
|
@@ -456,6 +456,44 @@ function applyModelUsage(ledger, entry) {
|
|
|
456
456
|
return ledger;
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
/**
|
|
460
|
+
* 把一条会话**已经记过**的累计量补进模型桶(老账本回填那一步)。
|
|
461
|
+
*
|
|
462
|
+
* 与 `applyModelUsage` 只差一件事:按天明细**跟着这条会话自己那份 `daily` 摊开**,不是把
|
|
463
|
+
* 整段累计压到最后活动那一天。压一天会把三张表的口径弄散:`bucket.billed` 是累计(`all`
|
|
464
|
+
* 那档用它,本来就该等于会话的累计),可 `bucket.daily` 一旦把几年历史记在一天上,
|
|
465
|
+
* `7d` / `30d` 里这一行就比整页 KPI 还大,金额跟着高一个量级,`unpricedBilled` 还会被
|
|
466
|
+
* `Math.max(0, …)` 夹成 0 —— 页面连「这一截没有价」都说不出来。
|
|
467
|
+
*
|
|
468
|
+
* 会话自己的 `daily` 与按天账、卡片本来就是同一份近似(首采回填那笔也是三处同一天),
|
|
469
|
+
* 照抄它,三张表在每一档范围里才对得上。记录已经没有明细(`trim` 瘦身过 / 老账本没这一栏)
|
|
470
|
+
* 时就只补累计:那一档范围里卡片同样是空的,两边一致。
|
|
471
|
+
*/
|
|
472
|
+
function applyModelBackfill(ledger, entry) {
|
|
473
|
+
const selection = sanitizeModelSelection(entry && entry.model);
|
|
474
|
+
if (!selection) return ledger;
|
|
475
|
+
const key = modelKeyOf(selection.provider, selection.model);
|
|
476
|
+
if (!key) return ledger;
|
|
477
|
+
if (!ledger.models || typeof ledger.models !== 'object') ledger.models = {};
|
|
478
|
+
const bucket = ledger.models[key] || (ledger.models[key] = emptyModelBucket(selection));
|
|
479
|
+
// 名字以最新的为准;空名不覆盖(与 `applyModelUsage` 同一条)。
|
|
480
|
+
if (selection.providerName) bucket.providerName = selection.providerName;
|
|
481
|
+
if (selection.modelName) bucket.modelName = selection.modelName;
|
|
482
|
+
const usage = sanitizeUsage(entry.usage);
|
|
483
|
+
for (const usageKey of USAGE_KEYS) bucket.billed[usageKey] += usage[usageKey];
|
|
484
|
+
const daily = entry.daily && typeof entry.daily === 'object' ? entry.daily : {};
|
|
485
|
+
let lastAt = num(entry.at);
|
|
486
|
+
for (const [day, value] of Object.entries(daily)) {
|
|
487
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
|
|
488
|
+
const dayUsage = sanitizeUsage(value);
|
|
489
|
+
const target = bucket.daily[day] || (bucket.daily[day] = emptyUsage());
|
|
490
|
+
for (const usageKey of USAGE_KEYS) target[usageKey] += dayUsage[usageKey];
|
|
491
|
+
lastAt = Math.max(lastAt, dayStartAt(day));
|
|
492
|
+
}
|
|
493
|
+
bucket.lastAt = Math.max(bucket.lastAt, lastAt);
|
|
494
|
+
return ledger;
|
|
495
|
+
}
|
|
496
|
+
|
|
459
497
|
/**
|
|
460
498
|
* 把一次采集到的增量记进账本(纯函数,原地改并返回它)。
|
|
461
499
|
*
|
|
@@ -740,6 +778,7 @@ module.exports = {
|
|
|
740
778
|
sanitizeLedger,
|
|
741
779
|
applyDelta,
|
|
742
780
|
applyModelUsage,
|
|
781
|
+
applyModelBackfill,
|
|
743
782
|
summarize,
|
|
744
783
|
trim,
|
|
745
784
|
dayKeyOf,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 给模型的两个工具:读一篇公众号文章、写一篇。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么要有工具,而不是让它自己往目录里写文件
|
|
7
|
+
*
|
|
8
|
+
* 文章得有一个稳定的编号,用户会照着这个编号跟它说话(「wxa1b2c3d4 这篇改一下」);还得
|
|
9
|
+
* 有标题、时间这些能列出来的东西。让模型自己拼目录、自己起文件名,这些就全靠它记着,
|
|
10
|
+
* 换个说法就散了。工具负责的正是这几件它记不住的事:编号、放哪儿、怎么写回去。
|
|
11
|
+
*
|
|
12
|
+
* ## 为什么先写标题、后写正文是同一个工具
|
|
13
|
+
*
|
|
14
|
+
* 用户的工作方式是分阶段的:题目定下来就先进列表,内容之后再一段一段补。所以「写」这个
|
|
15
|
+
* 动作本来就是「有就给、没有就不动」——第一次只给标题也成立,之后每次给什么改什么。
|
|
16
|
+
* 没有状态、没有草稿 / 完成之分(用户 2026-11-05:完成了就是完成,没完成就继续改)。
|
|
17
|
+
*
|
|
18
|
+
* ## 能力面的边界
|
|
19
|
+
*
|
|
20
|
+
* 两个工具都只碰这一个库:编号形状卡死、正文大小有上限、图片名不含斜杠。模型给不了任意
|
|
21
|
+
* 路径,也没有「删」——删文章是人的动作,不该由模型顺手做掉。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { defineTool } = require('@deepseek-ai/dsh-tools');
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
writeArticle,
|
|
28
|
+
readArticle,
|
|
29
|
+
listArticles,
|
|
30
|
+
plainTextOf,
|
|
31
|
+
ID_RE,
|
|
32
|
+
} = require('./wechat.js');
|
|
33
|
+
|
|
34
|
+
/** 工具结果里给模型看的一行话:说清编号,它下一轮才引得上。 */
|
|
35
|
+
function summaryLine(article, isNew) {
|
|
36
|
+
const parts = [isNew ? '新建' : '更新'];
|
|
37
|
+
parts.push(`《${article.title}》`);
|
|
38
|
+
parts.push(`编号 ${article.id}`);
|
|
39
|
+
parts.push(article.hasBody ? `${article.chars} 字` : '还没有正文');
|
|
40
|
+
return parts.join(',');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 注册这两个工具。
|
|
45
|
+
*
|
|
46
|
+
* 必须走 `ctx.inject(['tools'], …)`:宿主里 `tools` 是子代理作用域提供的服务,插件这一层
|
|
47
|
+
* 直接读 `ctx.tools` 拿到的是 undefined(见 `phone/tools.js` 那一整段教训)。缺失时静默
|
|
48
|
+
* 跳过,别把整块装配掀掉。
|
|
49
|
+
*/
|
|
50
|
+
function installWechatTools(ctx) {
|
|
51
|
+
if (typeof ctx.inject !== 'function') return;
|
|
52
|
+
ctx.inject(['tools'], (scoped) => {
|
|
53
|
+
if (!scoped || !scoped.tools || typeof scoped.tools.register !== 'function') return;
|
|
54
|
+
|
|
55
|
+
scoped.tools.register(defineTool({
|
|
56
|
+
name: 'wechat_article_read',
|
|
57
|
+
description: 'Read the WeChat official-account articles the user keeps on this computer. Without an id it lists them (title, id, time, character count); with an id it returns that article\'s full HTML source, which is what you edit when the user asks for a revision.',
|
|
58
|
+
parameters: {
|
|
59
|
+
id: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'Article id (looks like wxa1b2c3d4). Omit to list all articles.',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
output: {
|
|
65
|
+
schema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
items: {
|
|
70
|
+
type: 'array',
|
|
71
|
+
items: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
properties: {
|
|
75
|
+
id: { type: 'string' },
|
|
76
|
+
title: { type: 'string' },
|
|
77
|
+
updatedAt: { type: 'number' },
|
|
78
|
+
chars: { type: 'number' },
|
|
79
|
+
hasBody: { type: 'boolean' },
|
|
80
|
+
},
|
|
81
|
+
required: ['id', 'title'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
article: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
properties: {
|
|
88
|
+
id: { type: 'string' },
|
|
89
|
+
title: { type: 'string' },
|
|
90
|
+
html: { type: 'string' },
|
|
91
|
+
chars: { type: 'number' },
|
|
92
|
+
},
|
|
93
|
+
required: ['id', 'title', 'html'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
render: (_args, value) => {
|
|
98
|
+
if (value.article) {
|
|
99
|
+
return [{ type: 'text', text: `《${value.article.title}》(${value.article.id}),${value.article.chars} 字,正文源码在下面的 html 字段里。` }];
|
|
100
|
+
}
|
|
101
|
+
const items = Array.isArray(value.items) ? value.items : [];
|
|
102
|
+
return [{
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: items.length === 0
|
|
105
|
+
? '这个库里还没有文章。'
|
|
106
|
+
: `${items.length} 篇:\n${items.map((row) => `· ${row.id} 《${row.title}》${row.hasBody ? '' : '(还没有正文)'}`).join('\n')}`,
|
|
107
|
+
}];
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
execute: async (args) => {
|
|
111
|
+
const id = typeof args.id === 'string' ? args.id.trim() : '';
|
|
112
|
+
if (id) {
|
|
113
|
+
if (!ID_RE.test(id)) throw new Error(`"${id}" 不像一个文章编号(形如 wxa1b2c3d4)。`);
|
|
114
|
+
const article = await readArticle(id);
|
|
115
|
+
if (!article) throw new Error(`没有找到编号为 ${id} 的文章,先用不带 id 的调用列一遍。`);
|
|
116
|
+
return {
|
|
117
|
+
article: {
|
|
118
|
+
id: article.id,
|
|
119
|
+
title: article.title,
|
|
120
|
+
html: article.html,
|
|
121
|
+
chars: article.chars,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const rows = await listArticles();
|
|
126
|
+
return {
|
|
127
|
+
items: rows.map((row) => ({
|
|
128
|
+
id: row.id,
|
|
129
|
+
title: row.title,
|
|
130
|
+
updatedAt: row.updatedAt,
|
|
131
|
+
chars: row.chars,
|
|
132
|
+
hasBody: row.hasBody,
|
|
133
|
+
})),
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
scoped.tools.register(defineTool({
|
|
139
|
+
name: 'wechat_article_write',
|
|
140
|
+
description: 'Create or update a WeChat official-account article in the library the user browses from their phone. Without an id it creates a new article (a title alone is fine — the user will see it in the list right away and you can fill in the body later). With an id it updates that article: pass only the fields you want to change. The body is finished HTML, not Markdown, and it is rendered as-is — write the styling you want inline. Pictures go in images[] and must be referenced from the HTML as media/<name>; give each one the PATH of the picture file on this computer (not its bytes) and the host copies it into the article folder, so never inline base64 into the HTML.',
|
|
141
|
+
parameters: {
|
|
142
|
+
id: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description: 'Article id to update. Omit to create a new article.',
|
|
145
|
+
},
|
|
146
|
+
title: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
description: 'Article title. Shown in the list; give it as soon as it is decided.',
|
|
149
|
+
},
|
|
150
|
+
html: {
|
|
151
|
+
type: 'string',
|
|
152
|
+
description: 'The finished article body as HTML (no <html>/<head> wrapper, no Markdown). It is rendered exactly as given. Reference uploaded pictures as media/<name>.',
|
|
153
|
+
},
|
|
154
|
+
images: {
|
|
155
|
+
type: 'array',
|
|
156
|
+
description: 'Pictures used by this body. Each one is copied into the article folder and then reachable in the HTML as media/<name>.',
|
|
157
|
+
items: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
properties: {
|
|
161
|
+
name: { type: 'string', description: 'File name inside the article folder, e.g. cover.png. Letters, digits, dot, dash, underscore only. Keep the extension in step with the real format.' },
|
|
162
|
+
path: { type: 'string', description: 'Absolute path of the picture file on this computer. This is the normal way: generate or find the picture, then hand over its path.' },
|
|
163
|
+
base64: { type: 'string', description: 'The image bytes, base64 encoded. Only when you already hold the bytes and have no file to point at.' },
|
|
164
|
+
},
|
|
165
|
+
required: ['name'],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
sessionId: {
|
|
169
|
+
type: 'string',
|
|
170
|
+
description: 'The session this article came from. Filled in automatically; leave it out.',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
output: {
|
|
174
|
+
schema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
additionalProperties: false,
|
|
177
|
+
properties: {
|
|
178
|
+
id: { type: 'string' },
|
|
179
|
+
title: { type: 'string' },
|
|
180
|
+
created: { type: 'boolean' },
|
|
181
|
+
chars: { type: 'number' },
|
|
182
|
+
hasBody: { type: 'boolean' },
|
|
183
|
+
images: { type: 'array', items: { type: 'string' } },
|
|
184
|
+
note: { type: 'string' },
|
|
185
|
+
},
|
|
186
|
+
required: ['id', 'title'],
|
|
187
|
+
},
|
|
188
|
+
render: (_args, value) => {
|
|
189
|
+
const lines = [summaryLine({ ...value, chars: value.chars || 0 }, value.created)];
|
|
190
|
+
if (Array.isArray(value.images) && value.images.length > 0) {
|
|
191
|
+
lines.push(`图片已存:${value.images.join('、')}(正文里按 media/<名字> 引用)。`);
|
|
192
|
+
}
|
|
193
|
+
if (value.note) lines.push(value.note);
|
|
194
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
execute: async (args, exec) => {
|
|
198
|
+
// 会话 id 从执行上下文取,不从参数取:模型不该有机会把文章写到别的对话名下。
|
|
199
|
+
const sessionId = (exec && exec.agent && typeof exec.agent.id === 'string') ? exec.agent.id : '';
|
|
200
|
+
const before = typeof args.id === 'string' && args.id.trim() ? await readArticle(args.id.trim()) : null;
|
|
201
|
+
let result;
|
|
202
|
+
try {
|
|
203
|
+
result = await writeArticle({
|
|
204
|
+
id: args.id,
|
|
205
|
+
title: args.title,
|
|
206
|
+
html: args.html,
|
|
207
|
+
images: args.images,
|
|
208
|
+
sessionId,
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = error && error.message ? error.message : '没能写进去';
|
|
212
|
+
// 正文里引用了没上传的图:这是真会发生的(模型先写正文、再补图),说清楚让它补一发。
|
|
213
|
+
throw new Error(message);
|
|
214
|
+
}
|
|
215
|
+
const { article, images, rejected } = result;
|
|
216
|
+
const missing = missingMedia(article.html, images);
|
|
217
|
+
const notes = [];
|
|
218
|
+
if (missing.length > 0) {
|
|
219
|
+
notes.push(`正文里引用了还没有的图:${missing.join('、')}。把它们的路径放进 images[] 再调用一次,否则手机上这几张是空的。`);
|
|
220
|
+
}
|
|
221
|
+
if (rejected.length > 0) {
|
|
222
|
+
notes.push(`这几张没能收进来:${rejected.join('、')}(读不到、不是图片,或者名字不合规矩)。`);
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
id: article.id,
|
|
226
|
+
title: article.title,
|
|
227
|
+
created: !before,
|
|
228
|
+
chars: article.chars,
|
|
229
|
+
hasBody: article.hasBody,
|
|
230
|
+
images,
|
|
231
|
+
...(notes.length > 0 ? { note: notes.join('\n') } : {}),
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
}));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 正文里 `media/<名字>` 引用了、但这次没有上传的图。
|
|
240
|
+
*
|
|
241
|
+
* 只用来提醒:这句提示能省掉一轮「手机上是空图、模型不知道为什么」的往返。图本来就可以
|
|
242
|
+
* 分两次传(先写正文、再补图),所以这不是错误。
|
|
243
|
+
*/
|
|
244
|
+
function missingMedia(html, uploaded) {
|
|
245
|
+
const have = new Set(uploaded);
|
|
246
|
+
const out = [];
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
for (const match of String(html || '').matchAll(/media\/([A-Za-z0-9][A-Za-z0-9._-]{0,80})/g)) {
|
|
249
|
+
const name = match[1];
|
|
250
|
+
if (have.has(name) || seen.has(name)) continue;
|
|
251
|
+
seen.add(name);
|
|
252
|
+
out.push(name);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { installWechatTools, missingMedia, plainTextOf };
|