dsh-agentone 0.5.17 → 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/README.md +7 -2
- package/lib/index.js +78 -0
- package/lib/page.js +50 -1
- package/lib/store.js +43 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,8 +13,10 @@ lark-cli / ccpg-cli 状态探测。
|
|
|
13
13
|
OpenAI 兼容代理(`/api/plugin/v1`),模型密钥不下发到本机;对话经平台
|
|
14
14
|
走 TokenHub 按用户计量。平台 403 的 `plan_required` 错误码原样透传,
|
|
15
15
|
前端据此引导申请套餐(不依赖中文文案匹配)。
|
|
16
|
-
- **SkillHub 技能(M3
|
|
17
|
-
|
|
16
|
+
- **SkillHub 技能(M3)**:套餐技能自动装齐(启动/登录时,且常驻期间
|
|
17
|
+
套餐生效、套餐新配技能时由前端触发器与手动「同步套餐技能」按钮补装);
|
|
18
|
+
技能市场搜索、手动安装/卸载,装/卸载后技能列表即时刷新(失效 45s
|
|
19
|
+
status 缓存);已装技能平台侧对比 latest 版本/指纹,有更新时展示
|
|
18
20
|
「v旧 → v新」并支持一键升级(覆盖式重装,原子替换旧目录);技能包
|
|
19
21
|
zip 走平台代理下载(穿越清洗 + 原子落盘)。
|
|
20
22
|
- **SkillHub 连接配置(M3+)**:登录后自动从平台拉取 registry 与用户级
|
|
@@ -61,6 +63,9 @@ dsh plugin --profile web add dsh-agentone
|
|
|
61
63
|
- `$DSH_HOME/.credentials.yaml` 写入 `refs.AGENTONE_API_KEY`(0600),内容
|
|
62
64
|
是 30 天插件令牌而非真实模型密钥——密钥不出平台,TokenHub 按用户计量;
|
|
63
65
|
- 只覆盖 `platform` 命名空间,用户自己配置的其它 provider 原样保留;
|
|
66
|
+
- 同步失败(如平台侧 AirRouter 瞬时不可达)不阻断登录/启动,原因记录在
|
|
67
|
+
状态页「同步状态」;状态页轮询发现残留错误时按指数退避(1min 起、
|
|
68
|
+
封顶 30min、最多 30 次)自动重试,平台恢复后无需重启 dsh 即自愈;
|
|
64
69
|
- 「退出登录」(页头按钮)会吊销平台令牌、移除凭据引用与 platform
|
|
65
70
|
provider(其它 provider 的默认模型设置不受影响);凭据文件损坏时也能
|
|
66
71
|
正常退出(坏文件移开为 `credentials.json.corrupt`)。
|
package/lib/index.js
CHANGED
|
@@ -194,6 +194,41 @@ function pluginProfileDir(config) {
|
|
|
194
194
|
return join(resolveDshHome(config), 'profiles', PLUGIN_PROFILE);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/** 模型同步失败自愈重试调度(模块级单例:apply 重入先清旧定时器)。
|
|
198
|
+
* 平台侧瞬时故障(如 AirRouter 短暂不可达)会让启动同步失败,错误一直
|
|
199
|
+
* 挂在状态页直到下次 dsh 重启——这里由 5s 状态轮询驱动:发现 model_error
|
|
200
|
+
* 就按指数退避后台重试,成功即清错误,无需用户干预。 */
|
|
201
|
+
export const MODEL_RETRY_BASE_MS = 60_000;
|
|
202
|
+
export const MODEL_RETRY_MAX_MS = 30 * 60_000;
|
|
203
|
+
export const MODEL_RETRY_MAX_ATTEMPTS = 30;
|
|
204
|
+
let modelRetryTimer = null;
|
|
205
|
+
let modelRetryAttempts = 0;
|
|
206
|
+
|
|
207
|
+
function clearModelRetry() {
|
|
208
|
+
if (modelRetryTimer !== null) {
|
|
209
|
+
clearTimeout(modelRetryTimer);
|
|
210
|
+
modelRetryTimer = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function scheduleModelRetry(config, log) {
|
|
215
|
+
if (modelRetryTimer !== null || modelRetryAttempts >= MODEL_RETRY_MAX_ATTEMPTS) return;
|
|
216
|
+
const delay = Math.min(MODEL_RETRY_BASE_MS * 2 ** modelRetryAttempts, MODEL_RETRY_MAX_MS);
|
|
217
|
+
modelRetryAttempts += 1;
|
|
218
|
+
modelRetryTimer = setTimeout(() => {
|
|
219
|
+
modelRetryTimer = null;
|
|
220
|
+
log.info(`[dsh-agentone] model sync self-heal retry #${modelRetryAttempts}`);
|
|
221
|
+
syncPlatformModel(config, log)
|
|
222
|
+
.then(() => {
|
|
223
|
+
modelRetryAttempts = 0;
|
|
224
|
+
})
|
|
225
|
+
.catch(() => {
|
|
226
|
+
scheduleModelRetry(config, log);
|
|
227
|
+
});
|
|
228
|
+
}, delay);
|
|
229
|
+
modelRetryTimer.unref?.();
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
/** 转发 dsh plugin 子命令:desktop 走受管 pnpm,普通 dsh 走官方 CLI。 */
|
|
198
233
|
async function runPluginCli(args) {
|
|
199
234
|
if (desktopPluginCli) {
|
|
@@ -593,6 +628,12 @@ async function syncPlanSkills(config, log, credentials) {
|
|
|
593
628
|
const STATUS_CACHE_TTL_MS = 45_000;
|
|
594
629
|
let statusCache = { at: 0, key: null, skills: null, displayNames: null };
|
|
595
630
|
|
|
631
|
+
/** 失效技能列表缓存:安装/卸载/同步改动台账后调用,让下一次 status 立即
|
|
632
|
+
* 拉到新列表(前端装完按钮的 refresh() 依赖这点,否则最长 45s 显示旧状态)。 */
|
|
633
|
+
function invalidateStatusCache() {
|
|
634
|
+
statusCache = { at: 0, key: statusCache.key, skills: null, displayNames: null };
|
|
635
|
+
}
|
|
636
|
+
|
|
596
637
|
async function cachedPlatformStatus(config, credentials) {
|
|
597
638
|
const capabilities = await fetchPlatformCapabilities(config.platformUrl, credentials.access_token);
|
|
598
639
|
const key = `${config.platformUrl}|${credentials.access_token}`;
|
|
@@ -718,6 +759,9 @@ export function apply(ctx, config) {
|
|
|
718
759
|
});
|
|
719
760
|
}
|
|
720
761
|
ctx.effect(() => {
|
|
762
|
+
// 模块级重试定时器跨 apply 存活:重入先清,避免旧定时器引用旧 config
|
|
763
|
+
clearModelRetry();
|
|
764
|
+
modelRetryAttempts = 0;
|
|
721
765
|
const register = (path, handler) => ctx.webServer.register({ kind: 'exact', path, handler });
|
|
722
766
|
|
|
723
767
|
register('/agentone/', (request, response) => {
|
|
@@ -760,6 +804,14 @@ export function apply(ctx, config) {
|
|
|
760
804
|
if (userEmail) {
|
|
761
805
|
({ capabilities, skills, displayNames } = await cachedPlatformStatus(config, credentials));
|
|
762
806
|
}
|
|
807
|
+
// 失败自愈:模型同步残留错误时由轮询驱动后台重试(成功即清,
|
|
808
|
+
// 下次轮询状态页恢复);错误已被登录/手动同步清除时取消待定
|
|
809
|
+
// 重试并复位退避计数。
|
|
810
|
+
if (credentials.model_error) scheduleModelRetry(config, log);
|
|
811
|
+
else {
|
|
812
|
+
clearModelRetry();
|
|
813
|
+
modelRetryAttempts = 0;
|
|
814
|
+
}
|
|
763
815
|
}
|
|
764
816
|
sendJson(response, 200, {
|
|
765
817
|
platform_url: config.platformUrl,
|
|
@@ -938,12 +990,35 @@ export function apply(ctx, config) {
|
|
|
938
990
|
slug,
|
|
939
991
|
{ config },
|
|
940
992
|
);
|
|
993
|
+
invalidateStatusCache();
|
|
941
994
|
sendJson(response, 200, { ok: true, ...result });
|
|
942
995
|
} catch (error) {
|
|
943
996
|
sendJson(response, 502, { error: error?.message || '技能安装失败' });
|
|
944
997
|
}
|
|
945
998
|
});
|
|
946
999
|
|
|
1000
|
+
// 套餐技能同步:把平台上 source=plan 且未安装的技能装齐。前端在套餐
|
|
1001
|
+
// 生效联动与自动补装触发器里调,也可手动触发;同步结果失效技能缓存。
|
|
1002
|
+
register('/agentone/api/skill/sync', async (request, response) => {
|
|
1003
|
+
if (request.method !== 'POST') {
|
|
1004
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1005
|
+
response.end();
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
try {
|
|
1009
|
+
const credentials = await ensureFreshToken(config, log);
|
|
1010
|
+
if (!credentials) {
|
|
1011
|
+
sendJson(response, 401, { error: '请先完成飞书登录' });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const result = await syncPlanSkills(config, log, credentials);
|
|
1015
|
+
invalidateStatusCache();
|
|
1016
|
+
sendJson(response, 200, { ok: true, installed: result?.installed ?? 0, failed: result?.failed ?? 0 });
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
sendJson(response, 502, { error: error?.message || '同步套餐技能失败' });
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
|
|
947
1022
|
register('/agentone/api/skill/catalog', async (request, response) => {
|
|
948
1023
|
if (request.method !== 'GET') {
|
|
949
1024
|
response.writeHead(405, { allow: 'GET' });
|
|
@@ -997,6 +1072,7 @@ export function apply(ctx, config) {
|
|
|
997
1072
|
return;
|
|
998
1073
|
}
|
|
999
1074
|
await removePlatformSkill(config.platformUrl, credentials.access_token, slug, { config });
|
|
1075
|
+
invalidateStatusCache();
|
|
1000
1076
|
sendJson(response, 200, { ok: true, canonical_slug: slug });
|
|
1001
1077
|
} catch (error) {
|
|
1002
1078
|
sendJson(response, 502, { error: error?.message || '技能卸载失败' });
|
|
@@ -1357,6 +1433,8 @@ export function apply(ctx, config) {
|
|
|
1357
1433
|
return () => {
|
|
1358
1434
|
pendingStates.clear();
|
|
1359
1435
|
pendingCliAuth.clear();
|
|
1436
|
+
clearModelRetry();
|
|
1437
|
+
modelRetryAttempts = 0;
|
|
1360
1438
|
log.info('[dsh-agentone] routes unmounted');
|
|
1361
1439
|
};
|
|
1362
1440
|
}, 'dsh-agentone: http routes');
|
package/lib/page.js
CHANGED
|
@@ -230,6 +230,33 @@ $('cli-recheck-btn').onclick = () => {
|
|
|
230
230
|
document.querySelectorAll('.tab').forEach((tab) => {
|
|
231
231
|
tab.onclick = () => activateTab(tab.dataset.tab);
|
|
232
232
|
});
|
|
233
|
+
// 套餐技能自动补装触发器:forced=true(套餐刚生效)直接触发;否则仅当
|
|
234
|
+
// status 列表里有未安装的套餐技能才触发。60s 节流 + in-flight 单飞,
|
|
235
|
+
// 失败靠下一次轮询自然重试(skills_error 由 status 带回展示)。
|
|
236
|
+
let planSkillSyncAt = 0;
|
|
237
|
+
let planSkillSyncing = false;
|
|
238
|
+
const maybeAutoSyncPlanSkills = (forced) => {
|
|
239
|
+
if (planSkillSyncing) return;
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
if (now - planSkillSyncAt < 60000) return;
|
|
242
|
+
const skills = lastStatus?.skills?.skills;
|
|
243
|
+
if (!forced) {
|
|
244
|
+
if (!Array.isArray(skills)) return;
|
|
245
|
+
if (!skills.some((skill) => skill.source === 'plan' && !skill.installed)) return;
|
|
246
|
+
}
|
|
247
|
+
planSkillSyncAt = now;
|
|
248
|
+
planSkillSyncing = true;
|
|
249
|
+
fetch('api/skill/sync', { method: 'POST' })
|
|
250
|
+
.catch(() => { planSkillSyncAt = 0; })
|
|
251
|
+
.finally(() => { planSkillSyncing = false; });
|
|
252
|
+
};
|
|
253
|
+
// 套餐技能同步:调后端 /skill/sync 装齐 source=plan 且未安装的技能。
|
|
254
|
+
// 三个调用方:套餐生效联动、自动补装触发器、技能页手动按钮。
|
|
255
|
+
const syncPlanSkills = (btn) => withLoading(
|
|
256
|
+
btn,
|
|
257
|
+
() => fetch('api/skill/sync', { method: 'POST' }),
|
|
258
|
+
null,
|
|
259
|
+
);
|
|
233
260
|
const skillAction = (slug, action, btn, successText) => withLoading(
|
|
234
261
|
btn,
|
|
235
262
|
() => fetch('api/skill/' + action, {
|
|
@@ -849,15 +876,20 @@ const refresh = async () => {
|
|
|
849
876
|
}
|
|
850
877
|
lastStatus = s;
|
|
851
878
|
const modelEnabled = s.capabilities?.channels?.model?.enabled !== false;
|
|
852
|
-
|
|
879
|
+
const skillhubEnabled = s.capabilities?.channels?.skillhub?.enabled !== false;
|
|
880
|
+
// 套餐审批通过检测:plan_state 由非 active 变为 active 时自动同步模型与技能
|
|
853
881
|
const planState = s.capabilities?.plan_state;
|
|
854
882
|
if (s.logged_in && modelEnabled) {
|
|
855
883
|
if (planState === 'active' && lastPlanState && lastPlanState !== 'active') {
|
|
856
884
|
notify('套餐已生效,正在同步模型…');
|
|
857
885
|
fetch('api/model/sync', { method: 'POST' }).catch(() => {});
|
|
886
|
+
if (skillhubEnabled) maybeAutoSyncPlanSkills(true);
|
|
858
887
|
}
|
|
859
888
|
lastPlanState = planState || null;
|
|
860
889
|
}
|
|
890
|
+
// 常驻期间自动补装:列表出现「套餐技能未安装」(管理员后配、启动时同步
|
|
891
|
+
// 瞬时失败)就触发一次同步;节流 + 单飞,装完列表变 installed 自然停
|
|
892
|
+
if (s.logged_in && skillhubEnabled) maybeAutoSyncPlanSkills(false);
|
|
861
893
|
statePill.className = 'pill ' + (s.logged_in ? 'ok' : 'warn');
|
|
862
894
|
statePill.textContent = s.logged_in ? '已登录' : '未登录';
|
|
863
895
|
// 首个状态到达:骨架占位让位给登录卡/主内容
|
|
@@ -944,6 +976,20 @@ $('skillhub-sync-btn').onclick = () => withLoading(
|
|
|
944
976
|
() => fetch('api/skillhub/sync', { method: 'POST' }),
|
|
945
977
|
'SkillHub 配置已同步',
|
|
946
978
|
);
|
|
979
|
+
// 手动同步套餐技能:文案按结果给(装了几个),失败由 withLoading 展示
|
|
980
|
+
$('skill-sync-btn').onclick = () => withLoading(
|
|
981
|
+
$('skill-sync-btn'),
|
|
982
|
+
async () => {
|
|
983
|
+
const resp = await fetch('api/skill/sync', { method: 'POST' });
|
|
984
|
+
const data = await resp.clone().json().catch(() => ({}));
|
|
985
|
+
if (resp.ok) {
|
|
986
|
+
const installed = Number(data.installed) || 0;
|
|
987
|
+
notify(installed > 0 ? '已同步,新装 ' + installed + ' 个套餐技能' : '套餐技能已是最新');
|
|
988
|
+
}
|
|
989
|
+
return resp;
|
|
990
|
+
},
|
|
991
|
+
null,
|
|
992
|
+
);
|
|
947
993
|
logoutBtn.onclick = () => withLoading(
|
|
948
994
|
logoutBtn,
|
|
949
995
|
() => fetch('api/logout', { method: 'POST' }),
|
|
@@ -1033,6 +1079,9 @@ export function statusPageHtml() {
|
|
|
1033
1079
|
<h3>平台技能 <span class="muted">套餐自动同步</span></h3>
|
|
1034
1080
|
<div id="skills-list"></div>
|
|
1035
1081
|
<div class="hint" id="skills-meta"></div>
|
|
1082
|
+
<div class="row" style="margin-top:8px">
|
|
1083
|
+
<button id="skill-sync-btn" class="btn ghost small" data-loading="同步中…">同步套餐技能</button>
|
|
1084
|
+
</div>
|
|
1036
1085
|
<div class="search" style="margin-top:12px">
|
|
1037
1086
|
<input id="search-input" placeholder="搜索技能市场,如:数据 / 报告 / lark" />
|
|
1038
1087
|
<button id="search-btn" class="btn ghost small">搜索</button>
|
package/lib/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 平台凭据持久化:$DSH_HOME/agentone/credentials.json(0600,仅本机插件可读)。
|
|
2
|
-
import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
|
|
@@ -15,6 +15,26 @@ function credentialsPath(config) {
|
|
|
15
15
|
return join(storeDir(config), 'credentials.json');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* 凭据更新互斥链:启动块、登录回调、手动同步、自愈重试可能在同一进程
|
|
20
|
+
* 内并发执行 read-merge-write(updateCredentials)。无锁时两个并发写会
|
|
21
|
+
* 互相丢补丁,且 writeFile 非原子——读到半截 JSON 会触发 loadCredentials
|
|
22
|
+
* 的容错把文件当损坏移开(登录态直接丢失)。这里把所有更新串行化,
|
|
23
|
+
* 写入改为 tmp + rename 原子替换,任一时刻磁盘上都是完整 JSON。
|
|
24
|
+
*/
|
|
25
|
+
let credentialsWriteChain = Promise.resolve();
|
|
26
|
+
|
|
27
|
+
function serializedWrite(config, writeFn) {
|
|
28
|
+
const next = credentialsWriteChain.then(writeFn, writeFn);
|
|
29
|
+
// 链上错误不阻断后续更新,也不进调用方的结果
|
|
30
|
+
credentialsWriteChain = next.catch(() => {});
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function tmpName(path) {
|
|
35
|
+
return `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
/**
|
|
19
39
|
* 读取凭据。文件损坏(截断/手改坏)不抛出:把坏文件移开为
|
|
20
40
|
* credentials.json.corrupt,返回 null 回到未登录态——否则 logout 也依赖
|
|
@@ -40,21 +60,32 @@ export async function loadCredentials(config) {
|
|
|
40
60
|
}
|
|
41
61
|
}
|
|
42
62
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
export function saveCredentials(config, credentials) {
|
|
64
|
+
return serializedWrite(config, async () => {
|
|
65
|
+
const dir = storeDir(config);
|
|
66
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
const path = credentialsPath(config);
|
|
68
|
+
const tmp = tmpName(path);
|
|
69
|
+
await writeFile(tmp, JSON.stringify(credentials, null, 2) + '\n', { mode: 0o600, flag: 'w' });
|
|
70
|
+
await rename(tmp, path);
|
|
49
71
|
});
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
/** 读取-合并-写回:模型同步结果等增量字段落盘。 */
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
export function updateCredentials(config, patch) {
|
|
76
|
+
return serializedWrite(config, async () => {
|
|
77
|
+
const current = (await loadCredentials(config)) || {};
|
|
78
|
+
const path = credentialsPath(config);
|
|
79
|
+
const merged = { ...current, ...patch };
|
|
80
|
+
const dir = storeDir(config);
|
|
81
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
82
|
+
const tmp = tmpName(path);
|
|
83
|
+
await writeFile(tmp, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600, flag: 'w' });
|
|
84
|
+
await rename(tmp, path);
|
|
85
|
+
return merged;
|
|
86
|
+
});
|
|
56
87
|
}
|
|
57
88
|
|
|
58
|
-
export
|
|
59
|
-
|
|
89
|
+
export function clearCredentials(config) {
|
|
90
|
+
return serializedWrite(config, () => rm(credentialsPath(config), { force: true }));
|
|
60
91
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agentone",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "AgentOne
|
|
3
|
+
"version": "0.5.19",
|
|
4
|
+
"description": "AgentOne 平台集成插件:飞书登录、平台模型(同步失败自愈重试)、SkillHub 技能(升级检测一键升级、装后即时刷新、套餐技能自动同步)、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错与并发安全写入、状态页 UX 增强)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|