dsh-armory 2.3.0 → 2.3.1
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.en.md +21 -15
- package/README.md +24 -14
- package/docs/installation.md +0 -2
- package/lib/client.js +2323 -629
- package/lib/community-meta.js +222 -0
- package/lib/expert-market.js +119 -23
- package/lib/index.js +218 -91
- package/lib/loadout.js +178 -0
- package/lib/mcp-config.js +0 -171
- package/lib/mcp-oauth.js +2 -4
- package/lib/npm-downloads.js +262 -0
- package/lib/suites-registry.js +10 -4
- package/lib/typert.js +13 -10
- package/lib/wb-awesome-market.js +706 -214
- package/lib/wb-install-state.js +10 -4
- package/lib/wb-market.js +99 -27
- package/lib/wb-registry.js +32 -31
- package/lib/whats-new-en.js +21 -0
- package/lib/whats-new.js +24 -0
- package/package.json +2 -2
- package/docs/mcp-market-design.md +0 -617
- package/lib/mcp-catalog-en.js +0 -244
- package/lib/mcp-catalog.js +0 -806
- package/lib/wb-plugin-market.js +0 -291
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// 社区元信息(只读):仓库的「人味」信号——维护者、最近提交、issue 数、Star。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一层:
|
|
4
|
+
// - 市场目录里的条目只带「上次核验」「发布方」这类静态字段,看不出这个项目
|
|
5
|
+
// **现在还活着吗**。GitHub 的 pushed_at / open_issues / archived 是最直接的证据。
|
|
6
|
+
// - 这些请求只应在用户真正打开某条详情时按需发生(每页 12 张卡片全量拉取会迅速
|
|
7
|
+
// 耗尽未认证 GitHub 的 60 次/小时配额),所以缓存与限流处理必须在这里收口。
|
|
8
|
+
//
|
|
9
|
+
// 边界:
|
|
10
|
+
// - 只读公开接口,不接受 token,不写任何用户数据。
|
|
11
|
+
// - 单次最多 8 个仓库;命中 6 小时缓存不联网;有旧缓存时网络失败回落旧数据并标记 stale。
|
|
12
|
+
// - 缓存落在 ~/.dsh/armory/github-meta.json,staging + rename 原子写;测试可注入 rootDir/fetch。
|
|
13
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
export const COMMUNITY_META_VERSION = 1;
|
|
18
|
+
export const COMMUNITY_META_TTL_MS = 6 * 60 * 60 * 1000;
|
|
19
|
+
export const COMMUNITY_META_MAX_REPOS = 8;
|
|
20
|
+
|
|
21
|
+
const GITHUB_API = "https://api.github.com/repos/";
|
|
22
|
+
const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
|
|
23
|
+
|
|
24
|
+
/** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 install-state 同一解析序)。 */
|
|
25
|
+
function armoryRoot(options = {}) {
|
|
26
|
+
return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function repoMetaPath(options = {}) {
|
|
30
|
+
return join(armoryRoot(options), "github-meta.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 从各种仓库写法里提取 `owner/repo`:
|
|
35
|
+
* owner/repo、https://github.com/o/r、git+https://github.com/o/r.git、
|
|
36
|
+
* git@github.com:o/r.git、带 #readme / /tree/main 尾巴的。
|
|
37
|
+
* 不是 GitHub 或提取不到返回 ""。
|
|
38
|
+
*/
|
|
39
|
+
export function parseGithubRepo(raw) {
|
|
40
|
+
const text = String(raw || "").trim();
|
|
41
|
+
if (!text) return "";
|
|
42
|
+
const ssh = /^git@github\.com:([^/\s]+)\/([^/\s#]+?)(?:\.git)?$/i.exec(text);
|
|
43
|
+
if (ssh) return `${ssh[1]}/${ssh[2]}`;
|
|
44
|
+
const http = /^(?:git\+)?https?:\/\/(?:www\.)?github\.com\/([^/\s#?]+)\/([^/\s#?]+)/i.exec(text);
|
|
45
|
+
if (http) return `${http[1]}/${http[2].replace(/\.git$/i, "")}`;
|
|
46
|
+
const bare = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(text);
|
|
47
|
+
if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i, "")}`;
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 入参归一:字符串 / 数组 / {repo} 对象都收,去重、去空、最多 8 条。 */
|
|
52
|
+
export function normalizeRepoList(value) {
|
|
53
|
+
const rows = Array.isArray(value) ? value : [value];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const row of rows) {
|
|
57
|
+
const raw = row && typeof row === "object" ? row.repo || row.repository || "" : row;
|
|
58
|
+
const repo = parseGithubRepo(raw);
|
|
59
|
+
if (!repo) continue;
|
|
60
|
+
const key = repo.toLowerCase();
|
|
61
|
+
if (seen.has(key)) continue;
|
|
62
|
+
seen.add(key);
|
|
63
|
+
out.push(repo);
|
|
64
|
+
if (out.length >= COMMUNITY_META_MAX_REPOS) break;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stringOf(value) {
|
|
70
|
+
return typeof value === "string" ? value : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function numberOrZero(value) {
|
|
74
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** GitHub 原始响应 → 面板需要的窄投影(不落全量 JSON,缓存小且字段稳定)。 */
|
|
78
|
+
export function projectRepoMeta(repo, data) {
|
|
79
|
+
const owner = stringOf(data?.owner?.login) || String(repo).split("/")[0] || "";
|
|
80
|
+
const full = stringOf(data?.full_name) || repo;
|
|
81
|
+
const license = data?.license && typeof data.license === "object" ? stringOf(data.license.spdx_id) : "";
|
|
82
|
+
return {
|
|
83
|
+
repo: full,
|
|
84
|
+
owner,
|
|
85
|
+
ownerAvatar: stringOf(data?.owner?.avatar_url) || (owner ? `https://github.com/${owner}.png?size=64` : ""),
|
|
86
|
+
ownerUrl: stringOf(data?.owner?.html_url) || (owner ? `https://github.com/${owner}` : ""),
|
|
87
|
+
htmlUrl: stringOf(data?.html_url) || `https://github.com/${full}`,
|
|
88
|
+
description: stringOf(data?.description),
|
|
89
|
+
stars: numberOrZero(data?.stargazers_count),
|
|
90
|
+
forks: numberOrZero(data?.forks_count),
|
|
91
|
+
openIssues: numberOrZero(data?.open_issues_count),
|
|
92
|
+
watchers: numberOrZero(data?.subscribers_count),
|
|
93
|
+
pushedAt: stringOf(data?.pushed_at),
|
|
94
|
+
updatedAt: stringOf(data?.updated_at),
|
|
95
|
+
archived: data?.archived === true,
|
|
96
|
+
disabled: data?.disabled === true,
|
|
97
|
+
license,
|
|
98
|
+
language: stringOf(data?.language),
|
|
99
|
+
homepage: stringOf(data?.homepage),
|
|
100
|
+
defaultBranch: stringOf(data?.default_branch),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readCache(options = {}) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(await readFile(repoMetaPath(options), "utf8"));
|
|
107
|
+
const repos = raw && typeof raw.repos === "object" && raw.repos !== null && !Array.isArray(raw.repos) ? raw.repos : {};
|
|
108
|
+
return {
|
|
109
|
+
version: raw && typeof raw.version === "number" ? raw.version : COMMUNITY_META_VERSION,
|
|
110
|
+
repos,
|
|
111
|
+
rateLimitedUntil: typeof raw?.rateLimitedUntil === "number" ? raw.rateLimitedUntil : 0,
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
return { version: COMMUNITY_META_VERSION, repos: {}, rateLimitedUntil: 0 };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function dirnameOf(path) {
|
|
119
|
+
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
120
|
+
return cut > 0 ? path.slice(0, cut) : path;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function writeCache(cache, options = {}) {
|
|
124
|
+
const path = repoMetaPath(options);
|
|
125
|
+
await mkdir(dirnameOf(path), { recursive: true });
|
|
126
|
+
// 唯一临时名 + rename:详情卡是逐张 fire-and-forget 调进来的,固定 `.staging` 会让
|
|
127
|
+
// 两个并发写互相踩踏(交错 fd 写出撕坏的文件再被 rename 进正位)。
|
|
128
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
129
|
+
try {
|
|
130
|
+
await writeFile(tmp, JSON.stringify(cache, null, 2));
|
|
131
|
+
await rename(tmp, path);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function errorText(error) {
|
|
139
|
+
return String(error?.message || error || "读取失败").slice(0, 200);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function rateLimitError(resetSeconds) {
|
|
143
|
+
const minutes = Math.max(1, Math.ceil((Number(resetSeconds) * 1000 - Date.now()) / 60_000));
|
|
144
|
+
return new Error(`GitHub 接口已限流,约 ${minutes} 分钟后恢复`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 批量读取仓库元信息。
|
|
149
|
+
*
|
|
150
|
+
* 入参:`{ repos: string[] | {repo}[] | string, refresh?: boolean }`
|
|
151
|
+
* 返回:`{ repos: { "owner/repo": { ok, ...projectRepoMeta, cached?, stale?, error? } }, fetchedAt }`
|
|
152
|
+
*
|
|
153
|
+
* 单条失败不抛整体错误:UI 需要把「这一条查不到」与「整个接口挂了」分开显示。
|
|
154
|
+
* 有旧缓存时网络失败回落到旧数据(`stale: true`),永不因为刷新失败把界面打空。
|
|
155
|
+
*/
|
|
156
|
+
export async function wbRepoMeta(payload = {}, options = {}) {
|
|
157
|
+
const repos = normalizeRepoList(payload?.repos ?? payload?.repo);
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (!repos.length) return { repos: {}, fetchedAt: now };
|
|
160
|
+
const cache = await readCache(options);
|
|
161
|
+
const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : fetch;
|
|
162
|
+
const results = {};
|
|
163
|
+
|
|
164
|
+
for (const repo of repos) {
|
|
165
|
+
const key = repo.toLowerCase();
|
|
166
|
+
const entry = cache.repos[key];
|
|
167
|
+
const fresh = entry && entry.data && now - Number(entry.fetchedAt || 0) < COMMUNITY_META_TTL_MS;
|
|
168
|
+
if (fresh && payload?.refresh !== true) {
|
|
169
|
+
results[repo] = { ok: true, cached: true, ...entry.data };
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (cache.rateLimitedUntil && now < cache.rateLimitedUntil) {
|
|
173
|
+
results[repo] = entry?.data
|
|
174
|
+
? { ok: true, cached: true, stale: true, ...entry.data, error: "GitHub 接口已限流,显示的是缓存" }
|
|
175
|
+
: { ok: false, error: "GitHub 接口已限流,稍后再试" };
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const headers = { "user-agent": USER_AGENT, accept: "application/vnd.github+json" };
|
|
179
|
+
if (entry?.etag) headers["if-none-match"] = entry.etag;
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetchImpl(`${GITHUB_API}${repo}`, {
|
|
182
|
+
headers,
|
|
183
|
+
signal: AbortSignal.timeout(12_000),
|
|
184
|
+
});
|
|
185
|
+
const remaining = response.headers?.get?.("x-ratelimit-remaining");
|
|
186
|
+
if (response.status === 304 && entry?.data) {
|
|
187
|
+
cache.repos[key] = { ...entry, fetchedAt: now };
|
|
188
|
+
results[repo] = { ok: true, cached: true, ...entry.data };
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (response.status === 403 && remaining === "0") {
|
|
192
|
+
const reset = Number(response.headers?.get?.("x-ratelimit-reset") || 0);
|
|
193
|
+
cache.rateLimitedUntil = reset ? reset * 1000 : now + 10 * 60_000;
|
|
194
|
+
throw rateLimitError(reset);
|
|
195
|
+
}
|
|
196
|
+
if (response.status === 404) {
|
|
197
|
+
results[repo] = { ok: false, error: "仓库不存在或已删除" };
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!response.ok) throw new Error(`GitHub 返回 HTTP ${response.status}`);
|
|
201
|
+
const data = projectRepoMeta(repo, await response.json());
|
|
202
|
+
cache.repos[key] = {
|
|
203
|
+
fetchedAt: now,
|
|
204
|
+
etag: response.headers?.get?.("etag") || "",
|
|
205
|
+
data,
|
|
206
|
+
};
|
|
207
|
+
results[repo] = { ok: true, cached: false, ...data };
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const message = errorText(error);
|
|
210
|
+
results[repo] = entry?.data
|
|
211
|
+
? { ok: true, cached: true, stale: true, ...entry.data, error: message }
|
|
212
|
+
: { ok: false, error: message };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await writeCache(cache, options);
|
|
218
|
+
} catch {
|
|
219
|
+
// 缓存写失败不影响本次结果;下次只是多打一次网络。
|
|
220
|
+
}
|
|
221
|
+
return { repos: results, fetchedAt: now };
|
|
222
|
+
}
|
package/lib/expert-market.js
CHANGED
|
@@ -44,9 +44,17 @@ const EXPERT_ROW_PREFIX = "preset-wbexpert-";
|
|
|
44
44
|
const EXPERT_PRESET_PREFIX = "wbexpert-";
|
|
45
45
|
const EXPERT_SKILL_PREFIX = "wbexpert__";
|
|
46
46
|
const JS_TAG = "tag:yaml.org,2002:js";
|
|
47
|
+
// 召唤预设行 config.name 的固定前缀:宿主的会话头预设标签显示的就是整串
|
|
48
|
+
// 「WorkBuddy 专家 · 吴八哥」。写侧用它拼,读侧(readExpertPresets)剥它取名,
|
|
49
|
+
// 两处共用一个常量才不会拼/剥漂移。与 client.js 的 EXPERT_PRESET_LABEL_PREFIX
|
|
50
|
+
// 必须逐字相同(那份是匹配宿主标签用的,同样不参与翻译)。
|
|
51
|
+
const EXPERT_PRESET_NAME_PREFIX = "WorkBuddy 专家 · ";
|
|
47
52
|
|
|
48
53
|
function cacheDir() {
|
|
49
|
-
|
|
54
|
+
// 测试隔离口(惯例同 wb-registry 的 DSH_ARMORY_HOME):设置时整个 wb-market 缓存根
|
|
55
|
+
// 指到临时目录,本目录 = <根>/experts;不设置走真实 ~/.dsh。
|
|
56
|
+
const root = process.env.DSH_ARMORY_MARKET_HOME || join(homedir(), ".dsh", "wb-market");
|
|
57
|
+
return join(root, "experts");
|
|
50
58
|
}
|
|
51
59
|
function manifestPath() {
|
|
52
60
|
return join(cacheDir(), "expert_center.json");
|
|
@@ -103,6 +111,17 @@ async function fetchText(url) {
|
|
|
103
111
|
// ── 专家目录:下载 + ETag 缓存 ─────────────────────────────────────────
|
|
104
112
|
// 与连接器 ZIP 同一模式:meta.json 记 ETag,命中即用磁盘缓存;网络挂了继续用旧的。
|
|
105
113
|
let cachedManifest = null; // { manifest, loadedFrom: "memory" | "disk" | "network" }
|
|
114
|
+
/** 缓存正文用临时文件 + rename 落盘:写一半被杀只会留下无人读的 .tmp,不会留下截断的清单。 */
|
|
115
|
+
async function writeCacheFile(path, data) {
|
|
116
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
117
|
+
try {
|
|
118
|
+
await writeFile(tmp, data);
|
|
119
|
+
await rename(tmp, path);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
106
125
|
async function loadManifest() {
|
|
107
126
|
if (cachedManifest) return cachedManifest;
|
|
108
127
|
await mkdir(cacheDir(), { recursive: true });
|
|
@@ -122,35 +141,51 @@ async function loadManifest() {
|
|
|
122
141
|
const raw = await readFile(manifestPath(), "utf8");
|
|
123
142
|
const manifest = JSON.parse(raw);
|
|
124
143
|
if (!manifest || !Array.isArray(manifest.experts)) throw new Error("磁盘缓存的专家清单结构无效");
|
|
125
|
-
|
|
144
|
+
return manifest;
|
|
145
|
+
};
|
|
146
|
+
const persistNetwork = async (fresh) => {
|
|
147
|
+
// 结构校验先于一切赋值/写盘:CDN 或中间代理返回"可解析但不是清单"的 JSON 时,
|
|
148
|
+
// 不能让它覆盖掉磁盘上那份好的(校验失败按下载失败处理,走下面的回落)。
|
|
149
|
+
const manifest = JSON.parse(fresh.buffer.toString("utf8"));
|
|
150
|
+
if (!manifest || !Array.isArray(manifest.experts)) throw new Error("专家清单结构无效(缺 experts 数组)");
|
|
151
|
+
await writeCacheFile(manifestPath(), fresh.buffer);
|
|
152
|
+
await writeCacheFile(manifestMetaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
|
|
153
|
+
cachedManifest = { manifest, loadedFrom: "network" };
|
|
126
154
|
return cachedManifest;
|
|
127
155
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
// 磁盘缓存先读好备用:它是 304(内容未变)与断网两条路的共同回落——头注承诺的
|
|
157
|
+
// 「网络挂了继续用旧的」靠它兑现。读失败(损坏/缺失)只回落到全量下载,不抛出去。
|
|
158
|
+
let diskManifest = null;
|
|
159
|
+
try {
|
|
160
|
+
diskManifest = await useDisk();
|
|
161
|
+
} catch {
|
|
162
|
+
diskManifest = null;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
// 只在磁盘缓存有效时才带上 etag:磁盘副本损坏/缺失而 etag 命中 304 的话,
|
|
166
|
+
// 我们手上没有任何正文可用(旧实现此时会拿着 null 去取 buffer,崩成 TypeError)。
|
|
167
|
+
const fresh = await download(diskManifest && meta.etag ? meta.etag : "");
|
|
168
|
+
if (fresh) return await persistNetwork(fresh);
|
|
169
|
+
// 304 = 服务器内容与 etag 一致 = 磁盘缓存就是权威副本。
|
|
170
|
+
cachedManifest = { manifest: diskManifest, loadedFrom: "disk" };
|
|
171
|
+
return cachedManifest;
|
|
172
|
+
} catch {
|
|
173
|
+
// 网络失败 / 响应结构无效:有磁盘缓存就回落到它,绝不因为刷新失败把面板打空。
|
|
174
|
+
}
|
|
175
|
+
if (diskManifest) {
|
|
176
|
+
cachedManifest = { manifest: diskManifest, loadedFrom: "disk" };
|
|
177
|
+
return cachedManifest;
|
|
140
178
|
}
|
|
141
|
-
|
|
142
|
-
const manifest = JSON.parse(fresh.buffer.toString("utf8"));
|
|
143
|
-
if (!manifest || !Array.isArray(manifest.experts)) throw new Error("专家清单结构无效(缺 experts 数组)");
|
|
144
|
-
cachedManifest = { manifest, loadedFrom: "network" };
|
|
145
|
-
await writeFile(manifestPath(), fresh.buffer);
|
|
146
|
-
await writeFile(manifestMetaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
|
|
147
|
-
return cachedManifest;
|
|
179
|
+
throw new Error("专家目录不可用:下载失败且本地没有有效的缓存副本");
|
|
148
180
|
}
|
|
149
181
|
|
|
150
182
|
// ── 目录投影 ───────────────────────────────────────────────────────────
|
|
151
183
|
function bi(value) {
|
|
152
184
|
return value && typeof value === "object" ? String(value.zh || value.en || "") : String(value || "");
|
|
153
185
|
}
|
|
186
|
+
function biEn(value) {
|
|
187
|
+
return value && typeof value === "object" ? String(value.en || value.zh || "") : String(value || "");
|
|
188
|
+
}
|
|
154
189
|
function biMember(value) {
|
|
155
190
|
return value && typeof value === "object" ? String(value.zh || value.en || "") : String(value || "");
|
|
156
191
|
}
|
|
@@ -181,17 +216,58 @@ function projectExpert(entry, categoryById) {
|
|
|
181
216
|
name: bi(entry.displayName) || plugin,
|
|
182
217
|
profession: bi(entry.profession),
|
|
183
218
|
description: bi(entry.description),
|
|
219
|
+
// 清单给的人设路径是权威来源:wbExpertDetail 拿它当首选(plugin.json 拉不到时是唯一来源)。
|
|
220
|
+
promptFile: String(entry.promptFile || ""),
|
|
184
221
|
categoryId: String(entry.categoryId || ""),
|
|
185
222
|
categoryName: category ? bi(category.name) : String(entry.categoryId || ""),
|
|
186
223
|
tags: Array.isArray(entry.tags) ? entry.tags.slice(0, 3).map(bi).filter(Boolean) : [],
|
|
187
224
|
quickPrompts: Array.isArray(entry.quickPrompts) ? entry.quickPrompts.slice(0, 3).map(bi).filter(Boolean) : [],
|
|
188
225
|
defaultInitPrompt: bi(entry.defaultInitPrompt),
|
|
226
|
+
createdAt: String(entry.createdAt || ""),
|
|
189
227
|
updatedAt: String(entry.updatedAt || ""),
|
|
190
228
|
isOPC: !!entry.isOPC,
|
|
191
229
|
avatarUrl: entry.avatar ? EXPERT_ORIGIN + String(entry.avatar) : "",
|
|
192
230
|
};
|
|
193
231
|
}
|
|
194
232
|
|
|
233
|
+
/**
|
|
234
|
+
* plugin → 专家显示名(中英两套)+ 头像地址。只读本机目录缓存,不发网络请求:
|
|
235
|
+
* 输入框快捷菜单每次打开都要用,不能为了一次取名去拉 1.1MB 目录;而能召唤专家
|
|
236
|
+
* 的前提就是目录已经落过盘,缓存缺失只可能是手工清目录,返回空表由前端回落 plugin。
|
|
237
|
+
* avatarUrl 与 projectExpert 同一拼法(EXPERT_ORIGIN + entry.avatar),已召唤页的
|
|
238
|
+
* 卡片头像不用再等整份目录——只有名字缓存在场也能出图。
|
|
239
|
+
* 按 mtime+size 记忆:同一份目录只解析一次(wbExpertInstalled 会被面板多处并发调用);
|
|
240
|
+
* 目录刷新写盘后戳变化,下一次调用自动重读。
|
|
241
|
+
*/
|
|
242
|
+
let displayNamesCache = null; // { stamp, names }
|
|
243
|
+
export async function readExpertDisplayNames() {
|
|
244
|
+
const path = manifestPath();
|
|
245
|
+
let stamp = "";
|
|
246
|
+
try {
|
|
247
|
+
const info = await stat(path);
|
|
248
|
+
stamp = `${info.mtimeMs}:${info.size}`;
|
|
249
|
+
} catch {
|
|
250
|
+
return new Map();
|
|
251
|
+
}
|
|
252
|
+
if (displayNamesCache && displayNamesCache.stamp === stamp) return displayNamesCache.names;
|
|
253
|
+
const manifest = await readJson(path);
|
|
254
|
+
const names = new Map();
|
|
255
|
+
if (!manifest || !Array.isArray(manifest.experts)) return names;
|
|
256
|
+
for (const entry of manifest.experts) {
|
|
257
|
+
const plugin = String(entry?.plugin || "").trim();
|
|
258
|
+
if (!plugin) continue;
|
|
259
|
+
names.set(plugin, {
|
|
260
|
+
nameZh: bi(entry.displayName),
|
|
261
|
+
nameEn: biEn(entry.displayName),
|
|
262
|
+
professionZh: bi(entry.profession),
|
|
263
|
+
professionEn: biEn(entry.profession),
|
|
264
|
+
avatarUrl: entry.avatar ? EXPERT_ORIGIN + String(entry.avatar) : "",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
displayNamesCache = { stamp, names };
|
|
268
|
+
return names;
|
|
269
|
+
}
|
|
270
|
+
|
|
195
271
|
/** 专家目录 + 15 个行业分类。UI 列表唯一数据源。 */
|
|
196
272
|
export async function wbExpertCatalog() {
|
|
197
273
|
const { manifest, loadedFrom } = await loadManifest();
|
|
@@ -204,7 +280,17 @@ export async function wbExpertCatalog() {
|
|
|
204
280
|
name: bi(category.name),
|
|
205
281
|
description: bi(category.description),
|
|
206
282
|
})).filter((category) => category.id);
|
|
207
|
-
|
|
283
|
+
// 目录本身的来源信息:面板上用来说明「这些专家是谁维护的、多久更新一次」。
|
|
284
|
+
// 缺字段就给空串,不编造。
|
|
285
|
+
const author = manifest.author && typeof manifest.author === "object" ? manifest.author : {};
|
|
286
|
+
const meta = {
|
|
287
|
+
authorName: String(author.name || ""),
|
|
288
|
+
authorSource: String(author.source || ""),
|
|
289
|
+
license: String(author.license || ""),
|
|
290
|
+
version: String(manifest.version || ""),
|
|
291
|
+
lastUpdated: String(manifest.lastUpdated || ""),
|
|
292
|
+
};
|
|
293
|
+
return { loadedFrom, total: experts.length, experts, categories, meta };
|
|
208
294
|
}
|
|
209
295
|
|
|
210
296
|
// ── 本机 WorkBuddy 缓存:完整技能树的优先来源 ───────────────────────────
|
|
@@ -539,9 +625,19 @@ export function readExpertPresets(content) {
|
|
|
539
625
|
const prefix = personaConfig ? personaConfig.get("prefix", true) : null;
|
|
540
626
|
if (prefix && typeof prefix.value === "string") personaText = prefix.value;
|
|
541
627
|
}
|
|
628
|
+
// 召唤那一刻就随预设行落盘的专家显示名:剥掉 `WorkBuddy 专家 · ` 前缀即得。
|
|
629
|
+
// 目录缓存(readExpertDisplayNames)是首选来源——它有中英两套、且随目录刷新;
|
|
630
|
+
// 但缓存没落盘、或该专家已从市场下架时只剩这一份可用。没有它,输入框召唤菜单
|
|
631
|
+
// 只能回落到英文标识名(senior-developer 这种),对中文界面就是「没翻译」。
|
|
632
|
+
// 用户在宿主里手改过预设名的,原样尊重(剥不到前缀就不剥)。
|
|
633
|
+
const presetName = String(mapValue(config, "name") || "").trim();
|
|
634
|
+
const displayName = presetName.startsWith(EXPERT_PRESET_NAME_PREFIX)
|
|
635
|
+
? presetName.slice(EXPERT_PRESET_NAME_PREFIX.length).trim()
|
|
636
|
+
: presetName;
|
|
542
637
|
rows.push({
|
|
543
638
|
presetId: String(mapValue(entry, "id")),
|
|
544
639
|
plugin: presetId.slice(EXPERT_PRESET_PREFIX.length),
|
|
640
|
+
displayName,
|
|
545
641
|
personaText,
|
|
546
642
|
});
|
|
547
643
|
}
|
|
@@ -660,7 +756,7 @@ function buildExpertPresetRow(doc, spec, presetId, presetConfigId) {
|
|
|
660
756
|
entry.set("name", PRESET_PLUGIN);
|
|
661
757
|
const config = new YAMLMap();
|
|
662
758
|
config.set("id", presetConfigId);
|
|
663
|
-
config.set("name",
|
|
759
|
+
config.set("name", `${EXPERT_PRESET_NAME_PREFIX}${spec.displayName}`);
|
|
664
760
|
config.set("description", `${spec.displayName} · ${spec.profession} —— 来自 WorkBuddy 专家市场${spec.description ? `:${spec.description}` : ""}`);
|
|
665
761
|
config.set("order", 30);
|
|
666
762
|
config.set("plugins", toNode(expertPresetPlugins(spec)));
|