@morehao/dsh-skills-console 0.5.0 → 0.5.2
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 +27 -3
- package/client.js +688 -300
- package/lib/core/intro-store.js +156 -0
- package/lib/core/intro.js +430 -0
- package/lib/core/paths.js +2 -0
- package/lib/core/remote.js +103 -1
- package/lib/core/service.js +477 -1
- package/lib/index.js +16 -0
- package/lib/routes.js +50 -2
- package/package.json +1 -1
package/lib/core/remote.js
CHANGED
|
@@ -190,7 +190,14 @@ export async function snapshotFromTarball({
|
|
|
190
190
|
} catch (error) {
|
|
191
191
|
throw new RemoteError('scan-failed', `计算 ${skill.name} tree 失败: ${error.message}`, { cause: error });
|
|
192
192
|
}
|
|
193
|
-
entries.push({
|
|
193
|
+
entries.push({
|
|
194
|
+
name: skill.name,
|
|
195
|
+
skillPath: skill.skillPath,
|
|
196
|
+
treeSha,
|
|
197
|
+
// 订阅条目在界面上还要用一行原文描述帮用户决定装不装:只是 frontmatter 里作者自己的话,
|
|
198
|
+
// 不调模型、也不进提示词;截断避免 state.json 被超长描述撑大。
|
|
199
|
+
description: typeof skill.description === 'string' ? skill.description.slice(0, 600) : '',
|
|
200
|
+
});
|
|
194
201
|
const dir = skillDirOf(skill.skillPath);
|
|
195
202
|
if (dir && !seenDirs.has(dir)) {
|
|
196
203
|
seenDirs.add(dir);
|
|
@@ -332,3 +339,98 @@ export async function downloadTarball({
|
|
|
332
339
|
if (timer) clearTimeout(timer);
|
|
333
340
|
}
|
|
334
341
|
}
|
|
342
|
+
|
|
343
|
+
// ---- 订阅候选的 SKILL.md(AI 介绍用) -------------------------------------
|
|
344
|
+
// 只读单个文件、只允许 raw.githubusercontent.com;按 commit 读取,避免分支移动导致内容漂移。
|
|
345
|
+
|
|
346
|
+
export const REMOTE_SKILL_MD_MAX_BYTES = 512 * 1024;
|
|
347
|
+
export const REMOTE_SKILL_MD_TIMEOUT_MS = 20_000;
|
|
348
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
|
|
349
|
+
|
|
350
|
+
/** 技能文件路径校验:快照里的 skillPath 也不盲信,逐段拒绝越界与特殊字符。 */
|
|
351
|
+
function assertSkillFilePath(skillPath) {
|
|
352
|
+
if (typeof skillPath !== 'string' || skillPath.length === 0 || skillPath.length > 400) {
|
|
353
|
+
throw new RemoteError('intro-source-failed', '技能路径非法');
|
|
354
|
+
}
|
|
355
|
+
if (skillPath.startsWith('/') || skillPath.includes('\\') || /^[A-Za-z]:/.test(skillPath)) {
|
|
356
|
+
throw new RemoteError('intro-source-failed', `技能路径不是仓库内相对路径: ${skillPath}`);
|
|
357
|
+
}
|
|
358
|
+
// eslint-disable-next-line no-control-regex
|
|
359
|
+
if (/[\u0000-\u001f]/.test(skillPath)) {
|
|
360
|
+
throw new RemoteError('intro-source-failed', '技能路径含控制字符');
|
|
361
|
+
}
|
|
362
|
+
const segments = skillPath.split('/');
|
|
363
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
364
|
+
throw new RemoteError('intro-source-failed', `技能路径含非法段: ${skillPath}`);
|
|
365
|
+
}
|
|
366
|
+
return segments.map(encodeURIComponent).join('/');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 读订阅快照里某个技能的 `SKILL.md`(用于按需生成能力介绍)。
|
|
371
|
+
*
|
|
372
|
+
* - `ref` 必须是快照里的 40 位 commit:订阅页的「介绍」要可复现,不能读会移动的分支;
|
|
373
|
+
* - 只允许 GitHub 的 raw host,路径由服务端从快照反查后传入,不接受客户端拼路径;
|
|
374
|
+
* - 大小 / 超时都有上限,超限抛 RemoteError,由 Service 转成 4xx/5xx。
|
|
375
|
+
*
|
|
376
|
+
* @returns {Promise<{ text: string, bytes: number, source: string, ref: string, skillPath: string }>}
|
|
377
|
+
*/
|
|
378
|
+
export async function fetchRemoteSkillMd({
|
|
379
|
+
source,
|
|
380
|
+
ref,
|
|
381
|
+
skillPath,
|
|
382
|
+
token = null,
|
|
383
|
+
fetchImpl = fetch,
|
|
384
|
+
maxBytes = REMOTE_SKILL_MD_MAX_BYTES,
|
|
385
|
+
timeoutMs = REMOTE_SKILL_MD_TIMEOUT_MS,
|
|
386
|
+
}) {
|
|
387
|
+
const parsed = parseSource(source);
|
|
388
|
+
if (!parsed) throw new RemoteError('invalid-source', `不是 GitHub 来源: ${source}`);
|
|
389
|
+
if (typeof ref !== 'string' || !COMMIT_SHA.test(ref)) {
|
|
390
|
+
throw new RemoteError('invalid-ref', '订阅介绍必须按 40 位 commit 读取');
|
|
391
|
+
}
|
|
392
|
+
const repo = `${parsed.owner}/${parsed.repo}`;
|
|
393
|
+
const relPath = assertSkillFilePath(skillPath);
|
|
394
|
+
const url = `https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/${ref.toLowerCase()}/${relPath}`;
|
|
395
|
+
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
398
|
+
try {
|
|
399
|
+
const response = await fetchImpl(url, {
|
|
400
|
+
headers: {
|
|
401
|
+
'user-agent': 'dsh-skills-console',
|
|
402
|
+
accept: 'text/plain, text/markdown;q=0.9, */*;q=0.1',
|
|
403
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
404
|
+
},
|
|
405
|
+
signal: controller.signal,
|
|
406
|
+
});
|
|
407
|
+
if (!response?.ok) {
|
|
408
|
+
throw new RemoteError(
|
|
409
|
+
'intro-source-failed',
|
|
410
|
+
`读不到 ${repo}@${ref.slice(0, 7)} 的 ${skillPath}(HTTP ${response?.status ?? '?'})`,
|
|
411
|
+
{ status: response?.status ?? null },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
const text = await response.text();
|
|
415
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
416
|
+
if (bytes > maxBytes) {
|
|
417
|
+
throw new RemoteError(
|
|
418
|
+
'intro-source-too-large',
|
|
419
|
+
`${repo}@${ref.slice(0, 7)} 的 ${skillPath} 太大(${bytes} 字节),无法作为介绍素材`,
|
|
420
|
+
{ bytes, maxBytes },
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if (!text.trim()) throw new RemoteError('intro-source-failed', `${repo} 的 ${skillPath} 是空文件`);
|
|
424
|
+
return { text, bytes, source: repo, ref: ref.toLowerCase(), skillPath };
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error instanceof RemoteError) throw error;
|
|
427
|
+
if (controller.signal.aborted) {
|
|
428
|
+
throw new RemoteError('intro-source-timeout', `读取 ${repo} 的 SKILL.md 超时(${timeoutMs}ms)`, { cause: error });
|
|
429
|
+
}
|
|
430
|
+
throw new RemoteError('intro-source-failed', `读取 ${repo} 的 SKILL.md 失败:${error?.message || '网络错误'}`, {
|
|
431
|
+
cause: error,
|
|
432
|
+
});
|
|
433
|
+
} finally {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
}
|
|
436
|
+
}
|
package/lib/core/service.js
CHANGED
|
@@ -39,7 +39,17 @@ import {
|
|
|
39
39
|
skillTreeSha,
|
|
40
40
|
snapshotId,
|
|
41
41
|
} from './updates.js';
|
|
42
|
-
import { fetchAtomCommit, snapshotFromTarball } from './remote.js';
|
|
42
|
+
import { fetchAtomCommit, fetchRemoteSkillMd, snapshotFromTarball } from './remote.js';
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_INTRO_CONFIG,
|
|
45
|
+
contentFingerprint,
|
|
46
|
+
buildIntroPrompt,
|
|
47
|
+
normalizeIntroConfig,
|
|
48
|
+
runIntroCall,
|
|
49
|
+
introLang,
|
|
50
|
+
IntroError,
|
|
51
|
+
introEntry,
|
|
52
|
+
} from './intro.js';
|
|
43
53
|
|
|
44
54
|
const QUEUE_LIMIT = 8;
|
|
45
55
|
|
|
@@ -260,6 +270,14 @@ export function createService({
|
|
|
260
270
|
invalidatePolicies = async () => {},
|
|
261
271
|
/** `{ available, liveAgents, registered, errors[] }`;没有 provider 时启停必须 409。 */
|
|
262
272
|
policyStatus = () => ({ available: false, liveAgents: 0, registered: 0, errors: [] }),
|
|
273
|
+
/** 技能介绍缓存(可丢);缺省时介绍接口返回 501,其余功能不受影响。 */
|
|
274
|
+
introStore = null,
|
|
275
|
+
/** 归一化后的介绍配置(provider/model 可空,空则用宿主默认模型)。 */
|
|
276
|
+
introOptions = normalizeIntroConfig(DEFAULT_INTRO_CONFIG),
|
|
277
|
+
/** 宿主 `llm` 服务(`ctx.get('llm')`);按需取,不能要求它在插件加载时已就绪。 */
|
|
278
|
+
getLlm = () => null,
|
|
279
|
+
/** 宿主 `agentDefaultModel.currentSelection()`;返回 `{ provider, model }` 或 null。 */
|
|
280
|
+
getDefaultModel = () => null,
|
|
263
281
|
}) {
|
|
264
282
|
const enqueue = createQueue(logger);
|
|
265
283
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
@@ -302,6 +320,203 @@ export function createService({
|
|
|
302
320
|
return capabilities;
|
|
303
321
|
}
|
|
304
322
|
|
|
323
|
+
// ---- 技能介绍(模型生成) -------------------------------------------------
|
|
324
|
+
// 全局只允许一个生成在跑:辅助调用不进 agent 的重试/限流体系,并发越高越容易撞 429。
|
|
325
|
+
// 在第一个 await 之前占位,避免两个请求同时通过检查。
|
|
326
|
+
let introInFlight = null;
|
|
327
|
+
|
|
328
|
+
/** `/state` 下发的介绍能力:前端据此决定按钮显隐与提示。 */
|
|
329
|
+
function introCapability() {
|
|
330
|
+
return {
|
|
331
|
+
enabled: Boolean(introOptions.enabled),
|
|
332
|
+
available: Boolean(introStore) && Boolean(getLlm()),
|
|
333
|
+
langs: ['zh', 'en'],
|
|
334
|
+
timeoutMs: introOptions.timeoutMs,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 介绍用的模型路由:显式配置 > 宿主默认模型;解析不出来就是「没有可用模型」。 */
|
|
339
|
+
function resolveIntroRoute() {
|
|
340
|
+
if (introOptions.provider && introOptions.model) {
|
|
341
|
+
return { provider: introOptions.provider, model: introOptions.model };
|
|
342
|
+
}
|
|
343
|
+
let selection = null;
|
|
344
|
+
try {
|
|
345
|
+
selection = getDefaultModel();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logger('intro.default-model.failed', { message: error?.message });
|
|
348
|
+
selection = null;
|
|
349
|
+
}
|
|
350
|
+
if (selection?.provider && selection?.model) {
|
|
351
|
+
return { provider: selection.provider, model: selection.model };
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 读 SKILL.md(只读;根反查与路径校验沿用查看器那一层)。 */
|
|
357
|
+
async function readIntroSource(name) {
|
|
358
|
+
assertSafeSkillName(name);
|
|
359
|
+
try {
|
|
360
|
+
const root = await resolveSkillRoot(paths, name);
|
|
361
|
+
const file = await readSkillFile({ root: root.root, kind: root.kind });
|
|
362
|
+
return { root, file };
|
|
363
|
+
} catch (error) {
|
|
364
|
+
throw skillFileFailure(error);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 只列文件名(不发内容):让模型知道技能还带哪些资料,不扩大数据外发面。 */
|
|
369
|
+
async function introFileNames(root, bodyRelPath) {
|
|
370
|
+
const names = new Set(bodyRelPath ? [bodyRelPath] : ['SKILL.md']);
|
|
371
|
+
try {
|
|
372
|
+
const tree = await readSkillTree({ root: root.root, kind: root.kind });
|
|
373
|
+
for (const item of tree.files) {
|
|
374
|
+
if (item.kind === 'file' && item.path) names.add(item.path);
|
|
375
|
+
}
|
|
376
|
+
} catch (error) {
|
|
377
|
+
logger('intro.files.failed', { message: error?.message });
|
|
378
|
+
}
|
|
379
|
+
return [...names].slice(0, 30);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 缓存条目 → 接口形状;`stale` 只表示 SKILL.md 的内容哈希对不上。 */
|
|
383
|
+
function publicIntro(entry, hash) {
|
|
384
|
+
return {
|
|
385
|
+
text: entry.text,
|
|
386
|
+
stale: Boolean(hash) && entry.hash !== hash,
|
|
387
|
+
generatedAt: entry.generatedAt,
|
|
388
|
+
provider: entry.provider,
|
|
389
|
+
model: entry.model,
|
|
390
|
+
truncated: Boolean(entry.truncated),
|
|
391
|
+
usage: entry.usage || null,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 订阅候选:按订阅 id + 条目名反查快照,`skillPath` 只从服务端状态取。
|
|
397
|
+
* 客户端只给 id 与名字,永远不能自带路径。
|
|
398
|
+
*/
|
|
399
|
+
function remoteIntroTarget(subscriptionId, name) {
|
|
400
|
+
if (typeof subscriptionId !== 'string' || !subscriptionId || typeof name !== 'string' || !name) {
|
|
401
|
+
throw new ServiceError('error.skills.bad-request', '缺少订阅 id 或技能名', null, 400);
|
|
402
|
+
}
|
|
403
|
+
const subscriptions = Array.isArray(store.state?.subscriptions) ? store.state.subscriptions : [];
|
|
404
|
+
const subscription = subscriptions.find((item) => item.id === subscriptionId) || null;
|
|
405
|
+
if (!subscription) {
|
|
406
|
+
throw new ServiceError(
|
|
407
|
+
'error.skills.subscription-not-found',
|
|
408
|
+
'订阅不存在,可能已被取消',
|
|
409
|
+
{ subscriptionId },
|
|
410
|
+
404,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const snapshot = subscription.snapshot || null;
|
|
414
|
+
const entry = (Array.isArray(snapshot?.entries) ? snapshot.entries : []).find((item) => item.name === name) || null;
|
|
415
|
+
if (!entry?.skillPath || !snapshot?.commit) {
|
|
416
|
+
throw new ServiceError(
|
|
417
|
+
'error.skills.intro-no-source',
|
|
418
|
+
'订阅快照里没有这个技能,请先点「检查更新」刷新快照',
|
|
419
|
+
{ name },
|
|
420
|
+
409,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
subscription,
|
|
425
|
+
entry,
|
|
426
|
+
source: subscription.source,
|
|
427
|
+
commit: snapshot.commit,
|
|
428
|
+
skillPath: entry.skillPath,
|
|
429
|
+
description: typeof entry.description === 'string' ? entry.description : '',
|
|
430
|
+
cacheKey: `remote:${subscription.source}@${snapshot.commit}:${entry.skillPath}`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 远端介绍没有「过期」概念:缓存 key 已经带 commit,换 commit 就是另一条。 */
|
|
435
|
+
function publicRemoteIntro(entry) {
|
|
436
|
+
return {
|
|
437
|
+
text: entry.text,
|
|
438
|
+
stale: false,
|
|
439
|
+
generatedAt: entry.generatedAt,
|
|
440
|
+
provider: entry.provider,
|
|
441
|
+
model: entry.model,
|
|
442
|
+
truncated: Boolean(entry.truncated),
|
|
443
|
+
usage: entry.usage || null,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function remoteIntroSourceFailure(error) {
|
|
448
|
+
const detail = { code: error?.code || null, status: error?.status ?? null, bytes: error?.bytes ?? null };
|
|
449
|
+
if (error?.code === 'intro-source-timeout') {
|
|
450
|
+
return new ServiceError('error.skills.intro-source-timeout', error.message, detail, 504);
|
|
451
|
+
}
|
|
452
|
+
if (error?.code === 'intro-source-too-large') {
|
|
453
|
+
return new ServiceError('error.skills.intro-source-too-large', error.message, detail, 413);
|
|
454
|
+
}
|
|
455
|
+
return new ServiceError('error.skills.intro-source', error?.message || '读不到远端的 SKILL.md', detail, 502);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 一次模型调用的统一超时:到点 abort,映射成 504(与用户取消区分)。 */
|
|
459
|
+
async function callIntroModel({ llm, route, prompt, signal }) {
|
|
460
|
+
let timedOut = false;
|
|
461
|
+
const controller = new AbortController();
|
|
462
|
+
const timer = setTimeout(() => {
|
|
463
|
+
timedOut = true;
|
|
464
|
+
controller.abort();
|
|
465
|
+
}, introOptions.timeoutMs);
|
|
466
|
+
const combined = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
467
|
+
try {
|
|
468
|
+
return await runIntroCall({
|
|
469
|
+
llm,
|
|
470
|
+
route,
|
|
471
|
+
prompt,
|
|
472
|
+
maxOutputTokens: introOptions.maxOutputTokens,
|
|
473
|
+
signal: combined,
|
|
474
|
+
});
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (timedOut) {
|
|
477
|
+
throw new ServiceError(
|
|
478
|
+
'error.skills.intro-timeout',
|
|
479
|
+
`生成超时(${Math.round(introOptions.timeoutMs / 1000)} 秒),请重试`,
|
|
480
|
+
{ timeoutMs: introOptions.timeoutMs },
|
|
481
|
+
504,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
throw error;
|
|
485
|
+
} finally {
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** 全局单飞:辅助调用不进 agent 的重试/限流体系,并发越高越容易撞 429。 */
|
|
491
|
+
async function withIntroInFlight(meta, task) {
|
|
492
|
+
if (introInFlight) {
|
|
493
|
+
throw new ServiceError(
|
|
494
|
+
'error.skills.intro-busy',
|
|
495
|
+
'正在生成另一个技能介绍,请稍后再试',
|
|
496
|
+
introInFlight,
|
|
497
|
+
409,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const startedAt = Date.now();
|
|
501
|
+
introInFlight = { ...meta, startedAt };
|
|
502
|
+
try {
|
|
503
|
+
return await task(startedAt);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (error instanceof IntroError) throw introFailure(error);
|
|
506
|
+
throw error;
|
|
507
|
+
} finally {
|
|
508
|
+
introInFlight = null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 介绍层的异常统一转成路由能识别的 ServiceError(保留 429/413/504 语义)。 */
|
|
513
|
+
function introFailure(error) {
|
|
514
|
+
if (error instanceof IntroError) {
|
|
515
|
+
return new ServiceError(error.code, error.message, error.detail, error.status);
|
|
516
|
+
}
|
|
517
|
+
return error;
|
|
518
|
+
}
|
|
519
|
+
|
|
305
520
|
|
|
306
521
|
|
|
307
522
|
function detectedTargets() {
|
|
@@ -956,6 +1171,8 @@ export function createService({
|
|
|
956
1171
|
support: INVOCATION_SUPPORT,
|
|
957
1172
|
modes: ['auto', 'manual', 'hidden'],
|
|
958
1173
|
},
|
|
1174
|
+
// 技能介绍(模型生成):前端据此决定按钮显隐;可用 ≠ 已登录,失败会在生成时如实报错
|
|
1175
|
+
intro: introCapability(),
|
|
959
1176
|
agents: { all: registry.all(), detected: detectedTargets() },
|
|
960
1177
|
subscriptions: state().subscriptions,
|
|
961
1178
|
workspaces: await workspaceList(),
|
|
@@ -1148,6 +1365,265 @@ export function createService({
|
|
|
1148
1365
|
}
|
|
1149
1366
|
},
|
|
1150
1367
|
|
|
1368
|
+
/**
|
|
1369
|
+
* 读缓存的技能介绍(只读,**不触发模型调用**)。
|
|
1370
|
+
* `stale` 表示 SKILL.md 已被改动过;文本仍然返回,让用户先看到东西。
|
|
1371
|
+
*/
|
|
1372
|
+
async getSkillIntro({ name, lang } = {}) {
|
|
1373
|
+
assertSafeSkillName(name);
|
|
1374
|
+
const language = introLang(lang);
|
|
1375
|
+
const capability = introCapability();
|
|
1376
|
+
if (!introStore) return { ok: true, name, lang: language, intro: null, capability };
|
|
1377
|
+
const { file } = await readIntroSource(name);
|
|
1378
|
+
const entry = introStore.get(name, language);
|
|
1379
|
+
return {
|
|
1380
|
+
ok: true,
|
|
1381
|
+
name,
|
|
1382
|
+
lang: language,
|
|
1383
|
+
intro: entry ? publicIntro(entry, contentFingerprint(file.content)) : null,
|
|
1384
|
+
capability,
|
|
1385
|
+
};
|
|
1386
|
+
},
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* 生成技能介绍:显式操作,一次一个模型调用。
|
|
1390
|
+
* 命中缓存且内容未变时直接返回(`cached: true`),不烧额度。
|
|
1391
|
+
*/
|
|
1392
|
+
async generateSkillIntro({ name, lang, force = false, signal = null } = {}) {
|
|
1393
|
+
assertSafeSkillName(name);
|
|
1394
|
+
const language = introLang(lang);
|
|
1395
|
+
if (!introOptions.enabled) {
|
|
1396
|
+
throw new ServiceError('error.skills.intro-disabled', '技能介绍已关闭(config.intro.enabled = false)', null, 501);
|
|
1397
|
+
}
|
|
1398
|
+
if (!introStore) {
|
|
1399
|
+
throw new ServiceError('error.skills.intro-unavailable', '介绍缓存不可用', null, 501);
|
|
1400
|
+
}
|
|
1401
|
+
const llm = getLlm();
|
|
1402
|
+
if (!llm) {
|
|
1403
|
+
throw new ServiceError(
|
|
1404
|
+
'error.skills.intro-unavailable',
|
|
1405
|
+
'当前宿主没有模型服务(llm),无法生成介绍',
|
|
1406
|
+
null,
|
|
1407
|
+
501,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return withIntroInFlight({ name, lang: language, kind: 'local' }, async (startedAt) => {
|
|
1412
|
+
try {
|
|
1413
|
+
const { root, file } = await readIntroSource(name);
|
|
1414
|
+
const hash = contentFingerprint(file.content);
|
|
1415
|
+
const cached = introStore.get(name, language);
|
|
1416
|
+
if (cached && !force) {
|
|
1417
|
+
const intro = publicIntro(cached, hash);
|
|
1418
|
+
if (!intro.stale) {
|
|
1419
|
+
return { ok: true, name, lang: language, cached: true, intro, usage: null };
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const route = resolveIntroRoute();
|
|
1424
|
+
if (!route) {
|
|
1425
|
+
throw new ServiceError(
|
|
1426
|
+
'error.skills.intro-no-model',
|
|
1427
|
+
'还没有可用的默认模型,请先在 DSH 里选择模型',
|
|
1428
|
+
null,
|
|
1429
|
+
409,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const prompt = buildIntroPrompt({
|
|
1434
|
+
name,
|
|
1435
|
+
description: parseSkillFrontmatter(file.content).description || '',
|
|
1436
|
+
files: await introFileNames(root, file.relPath),
|
|
1437
|
+
content: file.content,
|
|
1438
|
+
maxInputBytes: introOptions.maxInputBytes,
|
|
1439
|
+
lang: language,
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
const result = await callIntroModel({ llm, route, prompt, signal });
|
|
1443
|
+
const entry = introEntry({
|
|
1444
|
+
text: result.text,
|
|
1445
|
+
hash,
|
|
1446
|
+
provider: result.provider,
|
|
1447
|
+
model: result.model,
|
|
1448
|
+
usage: result.usage,
|
|
1449
|
+
inputBytes: prompt.inputBytes,
|
|
1450
|
+
truncated: prompt.truncated,
|
|
1451
|
+
sourceBytes: prompt.sourceBytes,
|
|
1452
|
+
});
|
|
1453
|
+
introStore.put(name, language, entry);
|
|
1454
|
+
logger('intro.generate', {
|
|
1455
|
+
name,
|
|
1456
|
+
lang: language,
|
|
1457
|
+
provider: result.provider,
|
|
1458
|
+
model: result.model,
|
|
1459
|
+
inputBytes: prompt.inputBytes,
|
|
1460
|
+
truncated: prompt.truncated,
|
|
1461
|
+
durationMs: Date.now() - startedAt,
|
|
1462
|
+
usage: result.usage || null,
|
|
1463
|
+
});
|
|
1464
|
+
return {
|
|
1465
|
+
ok: true,
|
|
1466
|
+
name,
|
|
1467
|
+
lang: language,
|
|
1468
|
+
cached: false,
|
|
1469
|
+
intro: publicIntro(entry, hash),
|
|
1470
|
+
usage: result.usage || null,
|
|
1471
|
+
};
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
if (!(error instanceof ServiceError)) {
|
|
1474
|
+
logger('intro.generate.failed', { name, message: error?.message, code: error?.code });
|
|
1475
|
+
}
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
},
|
|
1480
|
+
|
|
1481
|
+
/** 读订阅候选的介绍缓存(只读,不联网、不调模型)。 */
|
|
1482
|
+
async getRemoteIntro({ subscriptionId, name, lang } = {}) {
|
|
1483
|
+
const language = introLang(lang);
|
|
1484
|
+
const capability = introCapability();
|
|
1485
|
+
const target = remoteIntroTarget(subscriptionId, name);
|
|
1486
|
+
const entry = introStore ? introStore.get(target.cacheKey, language) : null;
|
|
1487
|
+
return {
|
|
1488
|
+
ok: true,
|
|
1489
|
+
subscriptionId,
|
|
1490
|
+
name,
|
|
1491
|
+
lang: language,
|
|
1492
|
+
source: target.source,
|
|
1493
|
+
commit: target.commit,
|
|
1494
|
+
skillPath: target.skillPath,
|
|
1495
|
+
description: target.description,
|
|
1496
|
+
intro: entry ? publicRemoteIntro(entry) : null,
|
|
1497
|
+
capability,
|
|
1498
|
+
};
|
|
1499
|
+
},
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* 生成订阅候选的介绍:先按快照 commit 拉远端 SKILL.md,再走同一套提示词与模型调用。
|
|
1503
|
+
* 拉不到 / 超限只影响这次介绍,不影响安装按钮。
|
|
1504
|
+
*/
|
|
1505
|
+
async generateRemoteIntro({ subscriptionId, name, lang, force = false, signal = null } = {}) {
|
|
1506
|
+
const language = introLang(lang);
|
|
1507
|
+
if (!introOptions.enabled) {
|
|
1508
|
+
throw new ServiceError('error.skills.intro-disabled', '技能介绍已关闭(config.intro.enabled = false)', null, 501);
|
|
1509
|
+
}
|
|
1510
|
+
if (!introStore) {
|
|
1511
|
+
throw new ServiceError('error.skills.intro-unavailable', '介绍缓存不可用', null, 501);
|
|
1512
|
+
}
|
|
1513
|
+
const llm = getLlm();
|
|
1514
|
+
if (!llm) {
|
|
1515
|
+
throw new ServiceError(
|
|
1516
|
+
'error.skills.intro-unavailable',
|
|
1517
|
+
'当前宿主没有模型服务(llm),无法生成介绍',
|
|
1518
|
+
null,
|
|
1519
|
+
501,
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const target = remoteIntroTarget(subscriptionId, name);
|
|
1524
|
+
const cached = introStore.get(target.cacheKey, language);
|
|
1525
|
+
if (cached && !force) {
|
|
1526
|
+
return {
|
|
1527
|
+
ok: true,
|
|
1528
|
+
subscriptionId,
|
|
1529
|
+
name,
|
|
1530
|
+
lang: language,
|
|
1531
|
+
cached: true,
|
|
1532
|
+
source: target.source,
|
|
1533
|
+
commit: target.commit,
|
|
1534
|
+
skillPath: target.skillPath,
|
|
1535
|
+
intro: publicRemoteIntro(cached),
|
|
1536
|
+
usage: null,
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
return withIntroInFlight(
|
|
1541
|
+
{ name, lang: language, kind: 'remote', source: target.source },
|
|
1542
|
+
async (startedAt) => {
|
|
1543
|
+
try {
|
|
1544
|
+
const route = resolveIntroRoute();
|
|
1545
|
+
if (!route) {
|
|
1546
|
+
throw new ServiceError(
|
|
1547
|
+
'error.skills.intro-no-model',
|
|
1548
|
+
'还没有可用的默认模型,请先在 DSH 里选择模型',
|
|
1549
|
+
null,
|
|
1550
|
+
409,
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
let file;
|
|
1555
|
+
try {
|
|
1556
|
+
file = await fetchRemoteSkillMd({
|
|
1557
|
+
source: target.source,
|
|
1558
|
+
ref: target.commit,
|
|
1559
|
+
skillPath: target.skillPath,
|
|
1560
|
+
token: tokenOf,
|
|
1561
|
+
fetchImpl,
|
|
1562
|
+
});
|
|
1563
|
+
} catch (error) {
|
|
1564
|
+
throw remoteIntroSourceFailure(error);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const prompt = buildIntroPrompt({
|
|
1568
|
+
name,
|
|
1569
|
+
description: target.description,
|
|
1570
|
+
files: [],
|
|
1571
|
+
content: file.text,
|
|
1572
|
+
maxInputBytes: introOptions.maxInputBytes,
|
|
1573
|
+
lang: language,
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
const result = await callIntroModel({ llm, route, prompt, signal });
|
|
1577
|
+
const entry = introEntry({
|
|
1578
|
+
text: result.text,
|
|
1579
|
+
hash: target.commit,
|
|
1580
|
+
provider: result.provider,
|
|
1581
|
+
model: result.model,
|
|
1582
|
+
usage: result.usage,
|
|
1583
|
+
inputBytes: prompt.inputBytes,
|
|
1584
|
+
truncated: prompt.truncated,
|
|
1585
|
+
sourceBytes: prompt.sourceBytes,
|
|
1586
|
+
});
|
|
1587
|
+
introStore.put(target.cacheKey, language, entry);
|
|
1588
|
+
logger('intro.generate.remote', {
|
|
1589
|
+
source: target.source,
|
|
1590
|
+
name,
|
|
1591
|
+
lang: language,
|
|
1592
|
+
commit: target.commit,
|
|
1593
|
+
provider: result.provider,
|
|
1594
|
+
model: result.model,
|
|
1595
|
+
inputBytes: prompt.inputBytes,
|
|
1596
|
+
truncated: prompt.truncated,
|
|
1597
|
+
durationMs: Date.now() - startedAt,
|
|
1598
|
+
usage: result.usage || null,
|
|
1599
|
+
});
|
|
1600
|
+
return {
|
|
1601
|
+
ok: true,
|
|
1602
|
+
subscriptionId,
|
|
1603
|
+
name,
|
|
1604
|
+
lang: language,
|
|
1605
|
+
cached: false,
|
|
1606
|
+
source: target.source,
|
|
1607
|
+
commit: target.commit,
|
|
1608
|
+
skillPath: target.skillPath,
|
|
1609
|
+
intro: publicRemoteIntro(entry),
|
|
1610
|
+
usage: result.usage || null,
|
|
1611
|
+
};
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
if (!(error instanceof ServiceError)) {
|
|
1614
|
+
logger('intro.generate.remote.failed', {
|
|
1615
|
+
source: target.source,
|
|
1616
|
+
name,
|
|
1617
|
+
message: error?.message,
|
|
1618
|
+
code: error?.code,
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
throw error;
|
|
1622
|
+
}
|
|
1623
|
+
},
|
|
1624
|
+
);
|
|
1625
|
+
},
|
|
1626
|
+
|
|
1151
1627
|
/** 列技能目录的文件树(只读,不跟随符号链接)。 */
|
|
1152
1628
|
async listSkillFiles({ name }) {
|
|
1153
1629
|
assertSafeSkillName(name);
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { createPaths } from './core/paths.js';
|
|
|
4
4
|
import { createRegistry } from './core/agents.js';
|
|
5
5
|
import { createPolicyProvider, installPolicyProviders } from './core/policy.js';
|
|
6
6
|
import { createService } from './core/service.js';
|
|
7
|
+
import { normalizeIntroConfig } from './core/intro.js';
|
|
8
|
+
import { createIntroStore } from './core/intro-store.js';
|
|
7
9
|
import { StateStore } from './core/state.js';
|
|
8
10
|
import { createRoutes, ROUTE_PREFIX } from './routes.js';
|
|
9
11
|
|
|
@@ -86,6 +88,20 @@ export function apply(ctx, config = {}) {
|
|
|
86
88
|
workspacesProvider,
|
|
87
89
|
invalidatePolicies: () => policies.invalidate(),
|
|
88
90
|
policyStatus: () => policies.status(),
|
|
91
|
+
// 技能介绍:走宿主已挂载的模型服务(可选服务,缺失时接口 501,其他功能不受影响)
|
|
92
|
+
introStore: createIntroStore({ paths, logger }),
|
|
93
|
+
introOptions: normalizeIntroConfig(config.intro || {}),
|
|
94
|
+
getLlm: () => ctx.get('llm'),
|
|
95
|
+
getDefaultModel: () => {
|
|
96
|
+
const defaults = ctx.get('agentDefaultModel');
|
|
97
|
+
if (!defaults?.currentSelection) return null;
|
|
98
|
+
try {
|
|
99
|
+
return defaults.currentSelection();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
logger('intro.default-model.failed', { message: error?.message });
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
89
105
|
});
|
|
90
106
|
|
|
91
107
|
// 迁移必须尽早跑:GUI 一打开就该是「停用 = 真不可见」,而不是等用户点到某一行才发现。
|