@zhangheteng/skill-hub 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +32 -2
- package/apps/cli/dist/args.js +111 -0
- package/apps/cli/dist/commands/audit.js +38 -0
- package/apps/cli/dist/commands/backup.js +34 -0
- package/apps/cli/dist/commands/doctor.js +86 -0
- package/apps/cli/dist/commands/index-cmd.js +122 -0
- package/apps/cli/dist/commands/ingest.js +198 -0
- package/apps/cli/dist/commands/init.js +110 -0
- package/apps/cli/dist/commands/inspect.js +68 -0
- package/apps/cli/dist/commands/inventory.js +259 -0
- package/apps/cli/dist/commands/manage.js +149 -0
- package/apps/cli/dist/commands/manifest.js +127 -0
- package/apps/cli/dist/commands/restore.js +37 -0
- package/apps/cli/dist/commands/route.js +92 -0
- package/apps/cli/dist/commands/runtime.js +89 -0
- package/apps/cli/dist/commands/sync.js +195 -0
- package/apps/cli/dist/commands/takeover.js +45 -0
- package/apps/cli/dist/commands/usage.js +55 -0
- package/apps/cli/dist/commands/verify.js +48 -0
- package/apps/cli/dist/index.js +468 -0
- package/apps/cli/dist/output.js +7 -0
- package/apps/mcp-server/dist/http.js +265 -0
- package/apps/mcp-server/dist/index.js +42 -0
- package/apps/mcp-server/dist/server.js +541 -0
- package/apps/web/dist/index.js +355 -0
- package/apps/web/public/app.js +927 -0
- package/apps/web/public/index.html +109 -0
- package/apps/web/public/styles.css +1796 -0
- package/configs/agents/default.yaml +44 -0
- package/configs/models.yaml +55 -0
- package/configs/profiles/coding.yaml +29 -0
- package/configs/profiles/video.yaml +18 -0
- package/configs/taxonomy.yaml +24 -0
- package/package.json +42 -4
- package/packages/core/dist/agent-sources.js +38 -0
- package/packages/core/dist/audit.js +275 -0
- package/packages/core/dist/catalog.js +173 -0
- package/packages/core/dist/config.js +295 -0
- package/packages/core/dist/conflict.js +95 -0
- package/packages/core/dist/context-cost.js +19 -0
- package/packages/core/dist/frontmatter.js +118 -0
- package/packages/core/dist/hash.js +7 -0
- package/packages/core/dist/history.js +60 -0
- package/packages/core/dist/index.js +23 -0
- package/packages/core/dist/managed.js +1774 -0
- package/packages/core/dist/materialize.js +149 -0
- package/packages/core/dist/parse.js +74 -0
- package/packages/core/dist/scan.js +112 -0
- package/packages/core/dist/takeover.js +96 -0
- package/packages/core/dist/tree.js +61 -0
- package/packages/core/dist/types.js +4 -0
- package/packages/core/dist/usage.js +267 -0
- package/packages/router/dist/bm25.js +144 -0
- package/packages/router/dist/context-cost.js +82 -0
- package/packages/router/dist/external.js +107 -0
- package/packages/router/dist/factory.js +144 -0
- package/packages/router/dist/hub-service.js +589 -0
- package/packages/router/dist/index-store.js +153 -0
- package/packages/router/dist/index.js +22 -0
- package/packages/router/dist/llm/config.js +311 -0
- package/packages/router/dist/llm/http.js +102 -0
- package/packages/router/dist/llm/index.js +6 -0
- package/packages/router/dist/llm/ollama.js +68 -0
- package/packages/router/dist/llm/openai-compatible.js +66 -0
- package/packages/router/dist/llm/registry.js +103 -0
- package/packages/router/dist/llm/types.js +31 -0
- package/packages/router/dist/profile.js +124 -0
- package/packages/router/dist/runtime.js +270 -0
- package/packages/router/dist/scope.js +13 -0
- package/packages/router/dist/taxonomy.js +172 -0
- package/packages/router/dist/tokenize.js +38 -0
- package/packages/router/dist/types.js +1 -0
- package/packages/router/dist/typesafe.js +331 -0
- package/packages/router/dist/usage-overview.js +143 -0
- package/packages/router/dist/usage.js +76 -0
- package/packages/shared/dist/constants.js +14 -0
- package/packages/shared/dist/errors.js +90 -0
- package/packages/shared/dist/index.js +14 -0
- package/packages/shared/dist/logger.js +95 -0
- package/packages/shared/dist/paths.js +118 -0
- package/packages/shared/dist/types.js +12 -0
- package/packages/sync/dist/apply.js +227 -0
- package/packages/sync/dist/backup.js +193 -0
- package/packages/sync/dist/fs-utils.js +188 -0
- package/packages/sync/dist/index.js +18 -0
- package/packages/sync/dist/integrity.js +238 -0
- package/packages/sync/dist/plan.js +211 -0
- package/packages/sync/dist/restore.js +69 -0
- package/packages/sync/dist/scope.js +22 -0
- package/packages/sync/dist/types.js +1 -0
- package/packages/sync/dist/verify.js +189 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
/* skill-hub Web 只读视图 — 前端 SPA(无构建依赖,hash 路由) */
|
|
2
|
+
(() => {
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const app = document.getElementById("app");
|
|
6
|
+
const statsbar = document.getElementById("statsbar");
|
|
7
|
+
const searchInput = document.getElementById("global-search");
|
|
8
|
+
|
|
9
|
+
const PAGE_SIZE = 24;
|
|
10
|
+
const state = {
|
|
11
|
+
query: "",
|
|
12
|
+
offset: 0,
|
|
13
|
+
tag: null,
|
|
14
|
+
tagSummary: null,
|
|
15
|
+
usage: {
|
|
16
|
+
days: "30",
|
|
17
|
+
origin: "mcp",
|
|
18
|
+
query: "",
|
|
19
|
+
tag: "",
|
|
20
|
+
status: "all",
|
|
21
|
+
sort: "usage-desc",
|
|
22
|
+
offset: 0,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/* ── 工具 ─────────────────────────── */
|
|
27
|
+
const escapeHtml = (s) =>
|
|
28
|
+
String(s ?? "").replace(
|
|
29
|
+
/[&<>"']/g,
|
|
30
|
+
(c) =>
|
|
31
|
+
({
|
|
32
|
+
"&": "&",
|
|
33
|
+
"<": "<",
|
|
34
|
+
">": ">",
|
|
35
|
+
'"': """,
|
|
36
|
+
"'": "'",
|
|
37
|
+
})[c],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const ICONS = {
|
|
41
|
+
arrowLeft: '<path d="m15 18-6-6 6-6"/>',
|
|
42
|
+
arrowUpRight: '<path d="M7 17 17 7M7 7h10v10"/>',
|
|
43
|
+
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/>',
|
|
44
|
+
folder: '<path d="M3 7a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
|
45
|
+
history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
|
46
|
+
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
|
|
47
|
+
layers: '<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/>',
|
|
48
|
+
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/>',
|
|
49
|
+
x: '<path d="m6 6 12 12M18 6 6 18"/>',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const icon = (name) =>
|
|
53
|
+
`<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ""}</svg>`;
|
|
54
|
+
|
|
55
|
+
async function api(path) {
|
|
56
|
+
const res = await fetch(path);
|
|
57
|
+
let data = null;
|
|
58
|
+
try {
|
|
59
|
+
data = await res.json();
|
|
60
|
+
} catch {
|
|
61
|
+
/* 非 JSON */
|
|
62
|
+
}
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const err = new Error((data && data.message) || `HTTP ${res.status}`);
|
|
65
|
+
err.code = data && data.code;
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fmtTime = (iso) => {
|
|
72
|
+
if (!iso) return "—";
|
|
73
|
+
const d = new Date(iso);
|
|
74
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
75
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
76
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const fmtSize = (n) => {
|
|
80
|
+
if (n === undefined || n === null) return "";
|
|
81
|
+
if (n < 1024) return `${n} B`;
|
|
82
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
83
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const md = () =>
|
|
87
|
+
window.markdownit ? window.markdownit({ html: false, linkify: true, breaks: false }) : null;
|
|
88
|
+
|
|
89
|
+
function renderMarkdown(text) {
|
|
90
|
+
const engine = md();
|
|
91
|
+
if (engine) return engine.render(text);
|
|
92
|
+
return `<pre>${escapeHtml(text)}</pre>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stripFrontmatter(text) {
|
|
96
|
+
if (!text.startsWith("---")) return text;
|
|
97
|
+
const end = text.indexOf("\n---", 3);
|
|
98
|
+
if (end < 0) return text;
|
|
99
|
+
return text.slice(end + 4).replace(/^\s+/, "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const scopeBadge = (prov) => {
|
|
103
|
+
const scope = prov && prov.scope;
|
|
104
|
+
if (!scope) return "";
|
|
105
|
+
return `<span class="badge scope-${escapeHtml(scope)}">${escapeHtml(scope)}</span>`;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const errorBox = (err) => `
|
|
109
|
+
<div class="error-box">
|
|
110
|
+
<div><b>加载失败</b>${err.code ? ` <code>${escapeHtml(err.code)}</code>` : ""}</div>
|
|
111
|
+
<div>${escapeHtml(err.message)}</div>
|
|
112
|
+
</div>`;
|
|
113
|
+
|
|
114
|
+
const loading = () => `<div class="loading" role="status">正在读取本地数据</div>`;
|
|
115
|
+
|
|
116
|
+
/* ── 统计条 ────────────────────────── */
|
|
117
|
+
const LLM_STATE_LABEL = {
|
|
118
|
+
disabled: ["未配置(可选增强)", "dim"],
|
|
119
|
+
ready: ["已配置", "ok"],
|
|
120
|
+
unreachable: ["服务不可达", "warn"],
|
|
121
|
+
misconfigured: ["配置有误", "warn"],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
async function loadStats() {
|
|
125
|
+
try {
|
|
126
|
+
const s = await api("/api/stats");
|
|
127
|
+
let llmChip = "";
|
|
128
|
+
try {
|
|
129
|
+
const llm = await api("/api/llm/status");
|
|
130
|
+
const [label, tone] = LLM_STATE_LABEL[llm.state] || [llm.state, "dim"];
|
|
131
|
+
const detail = llm.provider
|
|
132
|
+
? `${llm.provider.type} · ${llm.provider.id}${llm.provider.model ? " · " + llm.provider.model : ""}`
|
|
133
|
+
: "models.yaml";
|
|
134
|
+
const tip = llm.error
|
|
135
|
+
? `${llm.error.message}${llm.error.hint ? "\n" + llm.error.hint : ""}`
|
|
136
|
+
: detail;
|
|
137
|
+
llmChip = `<span class="llm-chip ${tone}" title="${escapeHtml(tip)}">模型 <b>${escapeHtml(label)}</b><span class="llm-detail">${escapeHtml(detail)}</span></span>`;
|
|
138
|
+
} catch {
|
|
139
|
+
/* llm 状态不可得不影响统计条 */
|
|
140
|
+
}
|
|
141
|
+
statsbar.hidden = false;
|
|
142
|
+
statsbar.innerHTML = `
|
|
143
|
+
<span class="stat-item"><b>${s.skillCount}</b> SKILLS</span>
|
|
144
|
+
<span class="stat-item">ENGINE <b>${escapeHtml(s.engineDefault)}</b> · TOP-K <b>${s.topKDefault}</b></span>
|
|
145
|
+
<span class="stat-item stat-updated">UPDATED <b>${fmtTime(s.catalogUpdatedAt || s.catalogMtime)}</b></span>
|
|
146
|
+
<span class="stat-item stat-source" title="${escapeHtml(s.skillsDir)}">SOURCE <b>${escapeHtml(s.skillsDir.replace(/^\/Users\/[^/]+/, "~"))}</b></span>
|
|
147
|
+
${llmChip}`;
|
|
148
|
+
} catch {
|
|
149
|
+
statsbar.hidden = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── 视图:技能列表 / 搜索 ─────────── */
|
|
154
|
+
const tagBadges = (tags) =>
|
|
155
|
+
(tags || []).map((t) => `<span class="badge tag">${escapeHtml(t.label)}</span>`).join("");
|
|
156
|
+
|
|
157
|
+
const cardHtml = (s, index) => {
|
|
158
|
+
const number = String((state.query.trim() ? 0 : state.offset) + index + 1).padStart(2, "0");
|
|
159
|
+
return `
|
|
160
|
+
<a class="card" href="#/skill/${encodeURIComponent(s.name)}">
|
|
161
|
+
<div class="card-top">
|
|
162
|
+
<span class="card-index">SKILL / ${number}</span>
|
|
163
|
+
<span class="card-arrow">${icon("arrowUpRight")}</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div class="card-name">${escapeHtml(s.name)}</div>
|
|
166
|
+
<div class="card-desc">${escapeHtml(s.description || "这个 Skill 还没有描述。")}</div>
|
|
167
|
+
<div class="card-meta">
|
|
168
|
+
${tagBadges(s.tags)}
|
|
169
|
+
${scopeBadge(s.provenance)}
|
|
170
|
+
${s.score !== undefined ? `<span class="badge score">${Number(s.score).toFixed(2)}</span>` : ""}
|
|
171
|
+
</div>
|
|
172
|
+
</a>`;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
function renderTagSidebar(summary) {
|
|
176
|
+
const items = [{ id: "", label: "全部技能", count: summary.total }, ...summary.tags];
|
|
177
|
+
return `
|
|
178
|
+
<div class="tag-side-title">Browse by category</div>
|
|
179
|
+
<div class="tag-items">
|
|
180
|
+
${items
|
|
181
|
+
.map((t) => {
|
|
182
|
+
const active = (!state.tag && !t.id) || state.tag === t.id;
|
|
183
|
+
return `
|
|
184
|
+
<button type="button" class="tag-item ${active ? "active" : ""}" data-tag="${escapeHtml(t.id)}" aria-pressed="${active}">
|
|
185
|
+
<span class="tag-label">${escapeHtml(t.label)}</span>
|
|
186
|
+
<span class="tag-count">${t.count}</span>
|
|
187
|
+
</button>`;
|
|
188
|
+
})
|
|
189
|
+
.join("")}
|
|
190
|
+
</div>
|
|
191
|
+
<div class="tag-side-hint">规则自动派生<br />可在 ~/.skill-hub/taxonomy.yaml 自定义</div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function viewList() {
|
|
195
|
+
setNav("skills");
|
|
196
|
+
const searching = Boolean(state.query.trim());
|
|
197
|
+
document.title = searching
|
|
198
|
+
? `${state.query.trim()} · 搜索 · skill-hub`
|
|
199
|
+
: "skill-hub · 本地技能工作台";
|
|
200
|
+
searchInput.value = state.query;
|
|
201
|
+
app.innerHTML = loading();
|
|
202
|
+
try {
|
|
203
|
+
if (!state.tagSummary) state.tagSummary = await api("/api/tags");
|
|
204
|
+
const summary = state.tagSummary;
|
|
205
|
+
const tagQ = state.tag ? `&tag=${encodeURIComponent(state.tag)}` : "";
|
|
206
|
+
let contentHtml = "";
|
|
207
|
+
let bindContent = () => {};
|
|
208
|
+
|
|
209
|
+
if (searching) {
|
|
210
|
+
const data = await api(`/api/search?q=${encodeURIComponent(state.query)}&top_k=50${tagQ}`);
|
|
211
|
+
contentHtml = `
|
|
212
|
+
<div class="section-title">
|
|
213
|
+
<div>
|
|
214
|
+
<span class="section-kicker">Search / ${escapeHtml(data.engine)}</span>
|
|
215
|
+
<h2>“${escapeHtml(state.query.trim())}”</h2>
|
|
216
|
+
<p class="section-subtitle">命中 ${data.results.length} 项 · 候选 ${data.candidate_count} 项</p>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="section-actions">
|
|
219
|
+
${state.tag ? '<button class="btn small" id="clear-tag">清除标签筛选</button>' : ""}
|
|
220
|
+
${data.index_rebuild_recommended ? '<span class="badge warn">索引建议重建</span>' : ""}
|
|
221
|
+
${data.decision ? `<span class="badge decision" title="Jev 概率仅作升级建议,不代表执行授权">决策 ${escapeHtml(data.decision.selected || "none")} · ${(Number(data.decision.confidence) * 100).toFixed(1)}%${data.decision.escalation_recommended ? " · 建议升级" : ""}</span>` : ""}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
${
|
|
225
|
+
data.results.length
|
|
226
|
+
? `<div class="grid">${data.results.map(cardHtml).join("")}</div>`
|
|
227
|
+
: `<div class="empty">没有匹配的技能,换个关键词或清除标签筛选。</div>`
|
|
228
|
+
}`;
|
|
229
|
+
bindContent = bindClearTag;
|
|
230
|
+
} else {
|
|
231
|
+
const data = await api(`/api/skills?offset=${state.offset}&limit=${PAGE_SIZE}${tagQ}`);
|
|
232
|
+
const page = Math.floor(state.offset / PAGE_SIZE) + 1;
|
|
233
|
+
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
|
|
234
|
+
const active = state.tag ? summary.tags.find((t) => t.id === state.tag) : null;
|
|
235
|
+
contentHtml = `
|
|
236
|
+
<div class="section-title">
|
|
237
|
+
<div>
|
|
238
|
+
<span class="section-kicker">Library index</span>
|
|
239
|
+
<h2>${active ? escapeHtml(active.label) : "全部技能"}</h2>
|
|
240
|
+
<p class="section-subtitle">共 ${data.total} 项 · 第 ${page} / ${pages} 页</p>
|
|
241
|
+
</div>
|
|
242
|
+
${active ? '<div class="section-actions"><button class="btn small" id="clear-tag">清除筛选</button></div>' : ""}
|
|
243
|
+
</div>
|
|
244
|
+
${
|
|
245
|
+
data.skills.length
|
|
246
|
+
? `<div class="grid">${data.skills.map(cardHtml).join("")}</div>`
|
|
247
|
+
: `<div class="empty">该分类下暂无技能。</div>`
|
|
248
|
+
}
|
|
249
|
+
<div class="pager">
|
|
250
|
+
<button class="btn" id="pg-prev" ${state.offset === 0 ? "disabled" : ""}>${icon("arrowLeft")}上一页</button>
|
|
251
|
+
<span>${data.total ? state.offset + 1 : 0}–${state.offset + data.skills.length} / ${data.total}</span>
|
|
252
|
+
<button class="btn" id="pg-next" ${data.nextOffset === null ? "disabled" : ""}>下一页${icon("arrowUpRight")}</button>
|
|
253
|
+
</div>`;
|
|
254
|
+
bindContent = () => {
|
|
255
|
+
document.getElementById("pg-prev").onclick = () => {
|
|
256
|
+
state.offset = Math.max(0, state.offset - PAGE_SIZE);
|
|
257
|
+
viewList();
|
|
258
|
+
};
|
|
259
|
+
document.getElementById("pg-next").onclick = () => {
|
|
260
|
+
if (data.nextOffset !== null) {
|
|
261
|
+
state.offset = data.nextOffset;
|
|
262
|
+
viewList();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
bindClearTag();
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const heroHtml = `
|
|
270
|
+
<section class="list-hero" aria-labelledby="library-title">
|
|
271
|
+
<div class="hero-copy">
|
|
272
|
+
<div class="eyebrow">Local skill library</div>
|
|
273
|
+
<h1 class="hero-title" id="library-title">Agent 能力,<br /><em>井然有序。</em></h1>
|
|
274
|
+
<p class="hero-description">在一个本地工作台中检索、审阅并理解每一项 Skill。少一点寻找,多一点创造。</p>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="hero-index">
|
|
277
|
+
<div class="hero-index-top"><span>LIBRARY / 001</span><span class="hero-live"><i></i>LOCAL INDEX</span></div>
|
|
278
|
+
<div class="hero-count">${summary.total}</div>
|
|
279
|
+
<div class="hero-count-label">项技能,集中在一处。</div>
|
|
280
|
+
<div class="hero-metrics">
|
|
281
|
+
<div><strong>${summary.tags.length}</strong><span>分类标签</span></div>
|
|
282
|
+
<div><strong>100%</strong><span>本地只读</span></div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</section>`;
|
|
286
|
+
|
|
287
|
+
app.innerHTML = `
|
|
288
|
+
${searching ? "" : heroHtml}
|
|
289
|
+
<div class="list-layout">
|
|
290
|
+
<aside class="tag-side" aria-label="技能分类">${renderTagSidebar(summary)}</aside>
|
|
291
|
+
<div class="list-main">${contentHtml}</div>
|
|
292
|
+
</div>`;
|
|
293
|
+
app.querySelectorAll(".tag-item").forEach((el) => {
|
|
294
|
+
el.onclick = () => {
|
|
295
|
+
state.tag = el.dataset.tag || null;
|
|
296
|
+
state.offset = 0;
|
|
297
|
+
viewList();
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
bindContent();
|
|
301
|
+
} catch (err) {
|
|
302
|
+
app.innerHTML = errorBox(err);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function bindClearTag() {
|
|
307
|
+
const btn = document.getElementById("clear-tag");
|
|
308
|
+
if (btn) {
|
|
309
|
+
btn.onclick = () => {
|
|
310
|
+
state.tag = null;
|
|
311
|
+
state.offset = 0;
|
|
312
|
+
viewList();
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ── 使用率与预算:固定分母,筛选/排序先于分页 ── */
|
|
318
|
+
let usageRequest = 0;
|
|
319
|
+
const fmtCount = (value) =>
|
|
320
|
+
value === null || value === undefined ? "—" : Number(value).toLocaleString("zh-CN");
|
|
321
|
+
const fmtRate = (value) =>
|
|
322
|
+
value === null ? "—" : value > 0 && value < 0.0001 ? "<0.01%" : `${(value * 100).toFixed(2)}%`;
|
|
323
|
+
const selectOptions = (items, selected) =>
|
|
324
|
+
items
|
|
325
|
+
.map(
|
|
326
|
+
([value, label]) =>
|
|
327
|
+
`<option value="${escapeHtml(value)}"${value === selected ? " selected" : ""}>${escapeHtml(label)}</option>`,
|
|
328
|
+
)
|
|
329
|
+
.join("");
|
|
330
|
+
|
|
331
|
+
async function viewUsage(focusId) {
|
|
332
|
+
const request = ++usageRequest;
|
|
333
|
+
setNav("usage");
|
|
334
|
+
document.title = "使用率与上下文预算 · skill-hub";
|
|
335
|
+
app.innerHTML = loading();
|
|
336
|
+
const filters = state.usage;
|
|
337
|
+
const params = new window.URLSearchParams({
|
|
338
|
+
sort: filters.sort,
|
|
339
|
+
status: filters.status,
|
|
340
|
+
offset: String(filters.offset),
|
|
341
|
+
limit: String(PAGE_SIZE),
|
|
342
|
+
});
|
|
343
|
+
for (const key of ["days", "origin", "tag"]) if (filters[key]) params.set(key, filters[key]);
|
|
344
|
+
if (filters.query.trim()) params.set("q", filters.query.trim());
|
|
345
|
+
try {
|
|
346
|
+
const [report, tags] = await Promise.all([
|
|
347
|
+
api(`/api/usage-overview?${params}`),
|
|
348
|
+
api("/api/tags"),
|
|
349
|
+
]);
|
|
350
|
+
if (request !== usageRequest || location.hash !== "#/usage") return;
|
|
351
|
+
const sample = report.usage.totalInitialReads;
|
|
352
|
+
const warnings = [];
|
|
353
|
+
if (report.usage.exists === false)
|
|
354
|
+
warnings.push(
|
|
355
|
+
"未找到使用记录:历史使用情况未知。请先由维护者给需要记录的进程配置 SKILL_HUB_USAGE_LOG;本页不会自动开启记录。",
|
|
356
|
+
);
|
|
357
|
+
if (!report.usage.complete)
|
|
358
|
+
warnings.push(
|
|
359
|
+
`使用记录不完整${report.usage.errorCode ? `(${report.usage.errorCode})` : `(${report.usage.issueCount} 条异常记录)`},次数与占比仅代表可读记录。`,
|
|
360
|
+
);
|
|
361
|
+
if (report.usage.exists && !sample)
|
|
362
|
+
warnings.push("当前时间与来源没有正文首段样本,暂不计算使用率。可切换时间范围或来源。");
|
|
363
|
+
if (sample > 0 && sample < 20)
|
|
364
|
+
warnings.push(`当前只有 ${sample} 次正文首段样本,占比不宜用于判断长期使用偏好。`);
|
|
365
|
+
if (!report.context.complete)
|
|
366
|
+
warnings.push(
|
|
367
|
+
`正文预算读取不完整(可读 ${report.context.readable}/${report.catalogTotal} 项);失败项显示未知,不按零开销处理。`,
|
|
368
|
+
);
|
|
369
|
+
const metrics = [
|
|
370
|
+
[
|
|
371
|
+
report.usage.exists === false || report.usage.exists === null ? "—" : fmtCount(sample),
|
|
372
|
+
"正文首段样本",
|
|
373
|
+
"所选时间与来源 · 全库分母",
|
|
374
|
+
],
|
|
375
|
+
[
|
|
376
|
+
fmtCount(report.usage.observedSkillCount),
|
|
377
|
+
"有观测的 Skill",
|
|
378
|
+
`当前库 ${report.catalogTotal} 项 · 包含候选/附件事件`,
|
|
379
|
+
],
|
|
380
|
+
[
|
|
381
|
+
`≈ ${fmtCount(report.context.metadataTotalEstimatedTokens)}`,
|
|
382
|
+
"元数据 Token 粗估",
|
|
383
|
+
"全库名称与描述 · 不含工具封装",
|
|
384
|
+
],
|
|
385
|
+
[
|
|
386
|
+
`${report.context.readable} / ${report.catalogTotal}`,
|
|
387
|
+
"正文预算可读",
|
|
388
|
+
"全文与首段分开 · 不含附件",
|
|
389
|
+
],
|
|
390
|
+
];
|
|
391
|
+
app.innerHTML = `
|
|
392
|
+
<header class="section-title usage-head">
|
|
393
|
+
<div><span class="section-kicker">Usage / Context budget</span><h1>用得如何,占用多少。</h1><p class="section-subtitle">按可观测读取理解使用率,按需控制上下文。</p></div>
|
|
394
|
+
<button class="btn btn-dark" id="usage-refresh">刷新数据</button>
|
|
395
|
+
</header>
|
|
396
|
+
<section class="usage-summary" aria-label="使用与预算摘要">${metrics.map(([value, label, hint]) => `<div class="usage-metric"><span>${label}</span><strong>${value}</strong><small>${hint}</small></div>`).join("")}</section>
|
|
397
|
+
${warnings.length ? `<div class="usage-notice" role="status">${warnings.map((text) => `<p>${escapeHtml(text)}</p>`).join("")}</div>` : ""}
|
|
398
|
+
<form id="usage-filters" class="usage-filters" aria-label="使用率筛选与排序">
|
|
399
|
+
<label class="usage-query">技能或描述<input type="search" id="usage-query" value="${escapeHtml(filters.query)}" placeholder="筛选技能名称、描述…" /></label>
|
|
400
|
+
<label>时间范围<select id="usage-days">${selectOptions(
|
|
401
|
+
[
|
|
402
|
+
["7", "近 7 天"],
|
|
403
|
+
["30", "近 30 天"],
|
|
404
|
+
["", "当前日志全部"],
|
|
405
|
+
],
|
|
406
|
+
filters.days,
|
|
407
|
+
)}</select></label>
|
|
408
|
+
<label>读取来源<select id="usage-origin">${selectOptions(
|
|
409
|
+
[
|
|
410
|
+
["mcp", "MCP"],
|
|
411
|
+
["cli", "CLI"],
|
|
412
|
+
["web", "Web 浏览"],
|
|
413
|
+
["library", "库调用"],
|
|
414
|
+
["", "全部(含 Web)"],
|
|
415
|
+
],
|
|
416
|
+
filters.origin,
|
|
417
|
+
)}</select></label>
|
|
418
|
+
<label>分类<select id="usage-tag">${selectOptions([["", "全部分类"], ...tags.tags.map((tag) => [tag.id, tag.label])], filters.tag)}</select></label>
|
|
419
|
+
<label>观测状态<select id="usage-status">${selectOptions(
|
|
420
|
+
[
|
|
421
|
+
["all", "全部状态"],
|
|
422
|
+
["read", "有正文首段读取"],
|
|
423
|
+
["observed", "有观测记录"],
|
|
424
|
+
["unobserved", "未观测"],
|
|
425
|
+
],
|
|
426
|
+
filters.status,
|
|
427
|
+
)}</select></label>
|
|
428
|
+
<label>排序<select id="usage-sort">${selectOptions(
|
|
429
|
+
[
|
|
430
|
+
["usage-desc", "使用率:高 → 低"],
|
|
431
|
+
["usage-asc", "使用率:低 → 高"],
|
|
432
|
+
["recent", "最近读取优先"],
|
|
433
|
+
["tokens-desc", "正文 Token:多 → 少"],
|
|
434
|
+
["tokens-asc", "正文 Token:少 → 多"],
|
|
435
|
+
["name", "名称:A → Z"],
|
|
436
|
+
],
|
|
437
|
+
filters.sort,
|
|
438
|
+
)}</select></label>
|
|
439
|
+
<div class="usage-filter-actions"><button class="btn btn-dark" type="submit">应用筛选</button><button class="btn" type="button" id="usage-reset">重置</button></div>
|
|
440
|
+
</form>
|
|
441
|
+
<p class="usage-definition">使用率 = 该 Skill 正文首段次数 ÷ 所选时间与来源内全库正文首段总次数。分类、关键词和状态筛选不改变分母;续读和附件不计入分母。默认 MCP,不混入 Web 人工浏览。</p>
|
|
442
|
+
<div class="section-title usage-results"><div><h2>观测明细</h2><p class="section-subtitle" id="usage-result-count" role="status">筛选 ${report.total} / ${report.catalogTotal} 项 · 全库分母 ${fmtCount(sample)} 次</p></div><span class="badge">未知值始终排后</span></div>
|
|
443
|
+
<div class="table-wrap usage-table-wrap" role="region" aria-label="技能使用率与预算表,可横向滚动" tabindex="0"><table class="table usage-table">
|
|
444
|
+
<thead><tr><th scope="col">Skill</th><th scope="col">使用率 / 首段次数</th><th scope="col">续读 / 附件 / 候选</th><th scope="col">最近读取</th><th scope="col">元数据 / 全文 / 首段 Token 粗估</th><th scope="col">已返回文本 Token 粗估</th></tr></thead>
|
|
445
|
+
<tbody>${
|
|
446
|
+
report.items
|
|
447
|
+
.map((row) => {
|
|
448
|
+
const cost = row.cost;
|
|
449
|
+
return `<tr data-skill="${escapeHtml(row.name)}"><td><a class="usage-skill-link" href="#/skill/${encodeURIComponent(row.name)}">${escapeHtml(row.name)}</a><p class="usage-description">${escapeHtml(row.description)}</p>${tagBadges(row.tags)}${!row.observed ? '<span class="badge">未观测</span>' : ""}</td>
|
|
450
|
+
<td><strong class="usage-rate">${fmtRate(row.usageRate)}</strong><div class="usage-rate-track" aria-hidden="true"><span style="width:${row.usageRate === null ? 0 : Math.max(0, Math.min(100, row.usageRate * 100))}%"></span></div><small>首段 ${fmtCount(row.initialBodyReads)} 次${row.observed && row.usageRate === null ? " · 无首段分母" : ""}</small></td>
|
|
451
|
+
<td>${fmtCount(row.continuationBodyReads)} / ${fmtCount(row.attachmentReads)} / ${fmtCount(row.appearances)}<small>服务错误 ${fmtCount(row.errors)}</small></td>
|
|
452
|
+
<td class="usage-time">${escapeHtml(fmtTime(row.lastReadAt))}</td>
|
|
453
|
+
<td title="正文UTF-16字符 ${fmtCount(cost?.fullBody?.utf16Chars)};UTF-8字节 ${fmtCount(cost?.fullBody?.utf8Bytes)}">≈ ${fmtCount(cost?.metadata?.estimatedTokens)} / ${cost?.fullBody ? `≈ ${fmtCount(cost.fullBody.estimatedTokens)}` : "未知"} / ${cost?.initialPage ? `≈ ${fmtCount(cost.initialPage.estimatedTokens)}` : "未知"}<small>${cost?.fullBody ? `正文 ${fmtCount(cost.fullBody.utf16Chars)} 字符 · ${fmtSize(cost.fullBody.utf8Bytes)}` : "正文长度未知"}</small><small>首段 ${report.context.maxChars} 字符上限${cost?.errorCode ? ` · ${escapeHtml(cost.errorCode)}` : ""}</small></td>
|
|
454
|
+
<td>${row.estimatedReturnedTokens === null ? "—" : `≈ ${fmtCount(row.estimatedReturnedTokens)}`}<small>正文与附件逐次相加</small></td></tr>`;
|
|
455
|
+
})
|
|
456
|
+
.join("") ||
|
|
457
|
+
'<tr><td colspan="6"><div class="empty">没有符合筛选条件的 Skill。可重置筛选或扩大时间范围。</div></td></tr>'
|
|
458
|
+
}</tbody>
|
|
459
|
+
</table></div>
|
|
460
|
+
<div class="pager"><button class="btn" id="usage-prev" ${filters.offset === 0 ? "disabled" : ""}>上一页</button><span>${report.total ? filters.offset + 1 : 0}–${filters.offset + report.items.length} / ${report.total}</span><button class="btn" id="usage-next" ${report.nextOffset === null ? "disabled" : ""}>下一页</button></div>
|
|
461
|
+
<details class="usage-explanation"><summary>查看观测范围与估算口径</summary><p>仅统计 Hub search/read 服务调用,未观测不等于从未使用;首段次数不是唯一任务数,读取不证明实际采用或执行成功。MCP 不能识别具体客户端,原生磁盘读取不可见。Web 进程记录配置:${report.usage.recordingConfigured ? "已设置(不代表其他客户端已启用)" : "未设置"}。日志最早可见记录:${escapeHtml(fmtTime(report.usage.recordedSince))}。</p><p>Token 使用 ${escapeHtml(report.context.method)} 粗估:ASCII / 4 + 非 ASCII 码点 × 1.5,向上取整。不是模型 tokenizer 或账单,不保证误差界;不含工具定义、JSON 封装、历史与缓存。模型真实 Token 和费用未知,不假设全库正文会注入。</p></details>`;
|
|
462
|
+
const update = (focus) => {
|
|
463
|
+
for (const key of ["query", "days", "origin", "tag", "status", "sort"])
|
|
464
|
+
filters[key] = document.getElementById(`usage-${key}`).value;
|
|
465
|
+
filters.offset = 0;
|
|
466
|
+
viewUsage(focus);
|
|
467
|
+
};
|
|
468
|
+
document.getElementById("usage-filters").onsubmit = (event) => {
|
|
469
|
+
event.preventDefault();
|
|
470
|
+
update("usage-query");
|
|
471
|
+
};
|
|
472
|
+
for (const key of ["days", "origin", "tag", "status", "sort"])
|
|
473
|
+
document.getElementById(`usage-${key}`).onchange = () => update(`usage-${key}`);
|
|
474
|
+
document.getElementById("usage-refresh").onclick = () => update("usage-refresh");
|
|
475
|
+
document.getElementById("usage-reset").onclick = () => {
|
|
476
|
+
state.usage = {
|
|
477
|
+
days: "30",
|
|
478
|
+
origin: "mcp",
|
|
479
|
+
query: "",
|
|
480
|
+
tag: "",
|
|
481
|
+
status: "all",
|
|
482
|
+
sort: "usage-desc",
|
|
483
|
+
offset: 0,
|
|
484
|
+
};
|
|
485
|
+
viewUsage("usage-query");
|
|
486
|
+
};
|
|
487
|
+
document.getElementById("usage-prev").onclick = () => {
|
|
488
|
+
filters.offset = Math.max(0, filters.offset - PAGE_SIZE);
|
|
489
|
+
viewUsage("usage-prev");
|
|
490
|
+
};
|
|
491
|
+
document.getElementById("usage-next").onclick = () => {
|
|
492
|
+
if (report.nextOffset !== null) {
|
|
493
|
+
filters.offset = report.nextOffset;
|
|
494
|
+
viewUsage("usage-next");
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
if (focusId) document.getElementById(focusId)?.focus({ preventScroll: true });
|
|
498
|
+
} catch (err) {
|
|
499
|
+
if (request === usageRequest && location.hash === "#/usage")
|
|
500
|
+
app.innerHTML = `${errorBox(err)}<p>若服务仍运行旧版本,请由维护者重启 Web 进程后再打开使用与预算页。</p><button class="btn" id="usage-retry">重试</button>`;
|
|
501
|
+
const retry = document.getElementById("usage-retry");
|
|
502
|
+
if (retry) retry.onclick = () => viewUsage();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* ── 视图:技能详情 ────────────────── */
|
|
507
|
+
const STATUS_CHIP = {
|
|
508
|
+
ok: ["ok", "正常"],
|
|
509
|
+
missing: ["missing", "缺失"],
|
|
510
|
+
wrong_target: ["warn", "指向错误"],
|
|
511
|
+
broken_symlink: ["bad", "死链"],
|
|
512
|
+
content_mismatch: ["warn", "内容漂移"],
|
|
513
|
+
real_path: ["warn", "实体路径"],
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
async function viewDetail(name) {
|
|
517
|
+
setNav("skills");
|
|
518
|
+
document.title = `${name} · skill-hub`;
|
|
519
|
+
app.innerHTML = loading();
|
|
520
|
+
try {
|
|
521
|
+
const [doc, explain] = await Promise.all([
|
|
522
|
+
api(`/api/skills/${encodeURIComponent(name)}`),
|
|
523
|
+
api(`/api/skills/${encodeURIComponent(name)}/explain`),
|
|
524
|
+
]);
|
|
525
|
+
|
|
526
|
+
const agents = (explain.paths?.agents || [])
|
|
527
|
+
.map((a) => {
|
|
528
|
+
const [cls, label] = STATUS_CHIP[a.status] || ["missing", a.status];
|
|
529
|
+
return `<div class="agent-row">
|
|
530
|
+
<span class="aid">${escapeHtml(a.agentId)}</span>
|
|
531
|
+
<span class="chip ${cls}" title="${escapeHtml(a.detail || a.target)}">
|
|
532
|
+
<span class="dot"></span>${label}${a.enabled ? "" : "(停用)"}
|
|
533
|
+
</span>
|
|
534
|
+
</div>`;
|
|
535
|
+
})
|
|
536
|
+
.join("");
|
|
537
|
+
|
|
538
|
+
const variants = explain.variants?.length || 0;
|
|
539
|
+
const metaRows = [
|
|
540
|
+
["路径", `<span class="mono">${escapeHtml(doc.path)}</span>`, "路径"],
|
|
541
|
+
["scope", null, "scope"],
|
|
542
|
+
["来源", null, "source"],
|
|
543
|
+
["revision", null, "revision"],
|
|
544
|
+
["状态", null, "status"],
|
|
545
|
+
["hash", null, "hash"],
|
|
546
|
+
["变体", null, "variants"],
|
|
547
|
+
["胜出原因", null, "winner"],
|
|
548
|
+
]
|
|
549
|
+
.map(([label, raw, key]) => {
|
|
550
|
+
let value;
|
|
551
|
+
if (key === "scope") value = scopeBadge({ scope: explain.scope }) || "—";
|
|
552
|
+
else if (key === "source") value = escapeHtml(explain.source || "—");
|
|
553
|
+
else if (key === "revision") value = escapeHtml(explain.revision || "—");
|
|
554
|
+
else if (key === "status") value = escapeHtml(explain.status || "—");
|
|
555
|
+
else if (key === "hash")
|
|
556
|
+
value = `<span class="mono">${escapeHtml((doc.hash || "").slice(0, 12))}…</span>`;
|
|
557
|
+
else if (key === "variants") value = `${variants} 个`;
|
|
558
|
+
else if (key === "winner") value = escapeHtml(explain.winnerReason || "—");
|
|
559
|
+
else value = raw;
|
|
560
|
+
return `<dt>${label}</dt><dd${key === "hash" ? ' class="mono"' : ""}>${value}</dd>`;
|
|
561
|
+
})
|
|
562
|
+
.join("");
|
|
563
|
+
|
|
564
|
+
app.innerHTML = `
|
|
565
|
+
<a class="backlink" href="#/">${icon("arrowLeft")}返回技能库</a>
|
|
566
|
+
<header class="detail-head">
|
|
567
|
+
<div class="eyebrow">Skill / Detail</div>
|
|
568
|
+
<h1>${escapeHtml(doc.name)}</h1>
|
|
569
|
+
<p>${escapeHtml(doc.description || "")}</p>
|
|
570
|
+
<div class="detail-tags">${tagBadges(doc.tags)}${scopeBadge({ scope: explain.scope })}</div>
|
|
571
|
+
</header>
|
|
572
|
+
<div class="detail-layout">
|
|
573
|
+
<article class="doc" id="doc-body">
|
|
574
|
+
${renderMarkdown(stripFrontmatter(doc.body))}
|
|
575
|
+
${
|
|
576
|
+
doc.truncated
|
|
577
|
+
? `<div class="doc-pager">
|
|
578
|
+
<span>已显示 ${doc.body.length} / ${doc.totalChars} 字符</span>
|
|
579
|
+
<button class="btn small" id="doc-more">继续读取</button>
|
|
580
|
+
</div>`
|
|
581
|
+
: ""
|
|
582
|
+
}
|
|
583
|
+
</article>
|
|
584
|
+
<aside class="side" aria-label="技能诊断信息">
|
|
585
|
+
<section class="panel">
|
|
586
|
+
<h3>${icon("info")}元信息</h3>
|
|
587
|
+
<dl class="kv">${metaRows}</dl>
|
|
588
|
+
</section>
|
|
589
|
+
<section class="panel">
|
|
590
|
+
<h3>${icon("layers")}Agent 投影状态</h3>
|
|
591
|
+
${agents || '<div class="empty compact">无配置</div>'}
|
|
592
|
+
</section>
|
|
593
|
+
<section class="panel">
|
|
594
|
+
<h3>${icon("shield")}静态安全审计</h3>
|
|
595
|
+
<button class="btn btn-dark small" id="run-audit">运行静态审计</button>
|
|
596
|
+
<div id="audit-result">尚未扫描。审计只读,不执行脚本。</div>
|
|
597
|
+
</section>
|
|
598
|
+
<section class="panel">
|
|
599
|
+
<div class="tabs" role="tablist" aria-label="附件与历史">
|
|
600
|
+
<button role="tab" data-tab="files" class="active" aria-selected="true">${icon("folder")}文件</button>
|
|
601
|
+
<button role="tab" data-tab="history" aria-selected="false">${icon("history")}历史</button>
|
|
602
|
+
</div>
|
|
603
|
+
<div id="tab-body" class="tab-body"></div>
|
|
604
|
+
</section>
|
|
605
|
+
</aside>
|
|
606
|
+
</div>`;
|
|
607
|
+
|
|
608
|
+
const auditButton = document.getElementById("run-audit");
|
|
609
|
+
auditButton.onclick = async () => {
|
|
610
|
+
auditButton.disabled = true;
|
|
611
|
+
const target = document.getElementById("audit-result");
|
|
612
|
+
target.innerHTML = loading();
|
|
613
|
+
try {
|
|
614
|
+
const audit = await api(`/api/skills/${encodeURIComponent(name)}/audit`);
|
|
615
|
+
const result = audit.skills[0];
|
|
616
|
+
target.innerHTML = `<div class="audit-summary ${result.passed ? "ok" : "warn"}">${!result.complete ? "扫描不完整,需要复核" : result.passed ? "未发现高风险项" : "发现需要复核的高风险项"}</div>
|
|
617
|
+
<div>critical ${audit.summary.critical} · high ${audit.summary.high} · warning ${audit.summary.warning} · ${audit.summary.filesScanned} 个文本文件</div>
|
|
618
|
+
<div class="audit-findings">${result.findings.map((f) => `<div><b>${escapeHtml(f.severity)}</b> ${escapeHtml(f.file)}:${f.line} · ${escapeHtml(f.message)}</div>`).join("")}</div>`;
|
|
619
|
+
} catch (err) {
|
|
620
|
+
target.innerHTML = errorBox(err);
|
|
621
|
+
} finally {
|
|
622
|
+
auditButton.disabled = false;
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const moreBtn = document.getElementById("doc-more");
|
|
627
|
+
if (moreBtn) {
|
|
628
|
+
moreBtn.onclick = async () => {
|
|
629
|
+
moreBtn.disabled = true;
|
|
630
|
+
try {
|
|
631
|
+
const next = await api(
|
|
632
|
+
`/api/skills/${encodeURIComponent(name)}?offset=${doc.nextOffset}`,
|
|
633
|
+
);
|
|
634
|
+
const pager = document.querySelector(".doc-pager");
|
|
635
|
+
const div = document.createElement("div");
|
|
636
|
+
div.innerHTML = renderMarkdown(stripFrontmatter(next.body));
|
|
637
|
+
document.getElementById("doc-body").insertBefore(div, pager);
|
|
638
|
+
if (next.truncated) {
|
|
639
|
+
pager.querySelector("span").textContent =
|
|
640
|
+
`已显示 ${doc.offset + doc.body.length + next.body.length} / ${next.totalChars} 字符`;
|
|
641
|
+
moreBtn.disabled = false;
|
|
642
|
+
moreBtn.onclick = null;
|
|
643
|
+
// 递归续读
|
|
644
|
+
let cursor = next;
|
|
645
|
+
moreBtn.onclick = async () => {
|
|
646
|
+
moreBtn.disabled = true;
|
|
647
|
+
const n2 = await api(
|
|
648
|
+
`/api/skills/${encodeURIComponent(name)}?offset=${cursor.nextOffset}`,
|
|
649
|
+
);
|
|
650
|
+
const d2 = document.createElement("div");
|
|
651
|
+
d2.innerHTML = renderMarkdown(stripFrontmatter(n2.body));
|
|
652
|
+
document.getElementById("doc-body").insertBefore(d2, pager);
|
|
653
|
+
cursor = n2;
|
|
654
|
+
if (!n2.truncated) pager.remove();
|
|
655
|
+
else moreBtn.disabled = false;
|
|
656
|
+
};
|
|
657
|
+
} else {
|
|
658
|
+
pager.remove();
|
|
659
|
+
}
|
|
660
|
+
} catch (e) {
|
|
661
|
+
moreBtn.disabled = false;
|
|
662
|
+
alert(e.message);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const tabBody = document.getElementById("tab-body");
|
|
668
|
+
const tabButtons = document.querySelectorAll(".tabs button");
|
|
669
|
+
const activate = (tab) => {
|
|
670
|
+
tabButtons.forEach((b) => {
|
|
671
|
+
const active = b.dataset.tab === tab;
|
|
672
|
+
b.classList.toggle("active", active);
|
|
673
|
+
b.setAttribute("aria-selected", String(active));
|
|
674
|
+
});
|
|
675
|
+
if (tab === "files") renderFiles(tabBody, name, "");
|
|
676
|
+
else renderHistory(tabBody, name);
|
|
677
|
+
};
|
|
678
|
+
tabButtons.forEach((b) => (b.onclick = () => activate(b.dataset.tab)));
|
|
679
|
+
activate("files");
|
|
680
|
+
} catch (err) {
|
|
681
|
+
app.innerHTML =
|
|
682
|
+
`<a class="backlink" href="#/">${icon("arrowLeft")}返回技能库</a>` + errorBox(err);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/* ── 文件树 ────────────────────────── */
|
|
687
|
+
async function renderFiles(container, name, dirPath) {
|
|
688
|
+
container.innerHTML = loading();
|
|
689
|
+
try {
|
|
690
|
+
const data = await api(
|
|
691
|
+
`/api/skills/${encodeURIComponent(name)}/files?path=${encodeURIComponent(dirPath)}&limit=100`,
|
|
692
|
+
);
|
|
693
|
+
const parts = dirPath ? dirPath.split("/") : [];
|
|
694
|
+
const crumbs = [
|
|
695
|
+
`<a data-dir="">${escapeHtml(name)}</a>`,
|
|
696
|
+
...parts.map(
|
|
697
|
+
(p, i) =>
|
|
698
|
+
`<a data-dir="${escapeHtml(parts.slice(0, i + 1).join("/"))}">${escapeHtml(p)}</a>`,
|
|
699
|
+
),
|
|
700
|
+
].join(" / ");
|
|
701
|
+
|
|
702
|
+
const rows = data.files
|
|
703
|
+
.map((f) => {
|
|
704
|
+
const iconName = f.type === "directory" ? "folder" : f.type === "blocked" ? "x" : "file";
|
|
705
|
+
const full = f.path;
|
|
706
|
+
return `<div class="file-row" data-type="${f.type}" data-path="${escapeHtml(full)}">
|
|
707
|
+
<span class="file-icon">${icon(iconName)}</span>
|
|
708
|
+
<span class="fname">${escapeHtml(f.path)}</span>
|
|
709
|
+
<span class="fsize">${f.type === "blocked" ? escapeHtml(f.reason || "blocked") : fmtSize(f.size)}</span>
|
|
710
|
+
</div>`;
|
|
711
|
+
})
|
|
712
|
+
.join("");
|
|
713
|
+
|
|
714
|
+
container.innerHTML = `
|
|
715
|
+
<div class="crumbs">${crumbs}</div>
|
|
716
|
+
${rows || '<div class="empty compact">空目录</div>'}
|
|
717
|
+
<div id="file-viewer-slot"></div>`;
|
|
718
|
+
|
|
719
|
+
container.querySelectorAll(".crumbs a").forEach((a) => {
|
|
720
|
+
a.onclick = () => renderFiles(container, name, a.dataset.dir);
|
|
721
|
+
});
|
|
722
|
+
container.querySelectorAll(".file-row").forEach((row) => {
|
|
723
|
+
row.onclick = async () => {
|
|
724
|
+
const type = row.dataset.type;
|
|
725
|
+
const p = row.dataset.path;
|
|
726
|
+
if (type === "directory") {
|
|
727
|
+
renderFiles(container, name, p);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (type === "blocked") return;
|
|
731
|
+
const slot = document.getElementById("file-viewer-slot");
|
|
732
|
+
slot.innerHTML = `<div class="file-viewer"><pre>读取中…</pre></div>`;
|
|
733
|
+
try {
|
|
734
|
+
const file = await api(
|
|
735
|
+
`/api/skills/${encodeURIComponent(name)}/read?file=${encodeURIComponent(p)}&max_chars=50000`,
|
|
736
|
+
);
|
|
737
|
+
slot.innerHTML = `<div class="crumbs file-viewer-heading">${escapeHtml(p)}
|
|
738
|
+
<span class="file-meta">(${file.totalChars} 字符${file.truncated ? ",已截断" : ""})</span></div>
|
|
739
|
+
<div class="file-viewer"><pre>${escapeHtml(file.body)}</pre></div>`;
|
|
740
|
+
} catch (e) {
|
|
741
|
+
slot.innerHTML = errorBox(e);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
});
|
|
745
|
+
} catch (err) {
|
|
746
|
+
container.innerHTML = errorBox(err);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/* ── 历史 ──────────────────────────── */
|
|
751
|
+
async function renderHistory(container, name) {
|
|
752
|
+
container.innerHTML = loading();
|
|
753
|
+
try {
|
|
754
|
+
const data = await api(`/api/skills/${encodeURIComponent(name)}/history?limit=50`);
|
|
755
|
+
if (!data.entries.length) {
|
|
756
|
+
container.innerHTML = `<div class="empty compact">暂无历史事件</div>`;
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
container.innerHTML = `<ul class="timeline">${data.entries
|
|
760
|
+
.map(
|
|
761
|
+
(e) => `<li>
|
|
762
|
+
<div class="t-time">${fmtTime(e.at)}</div>
|
|
763
|
+
<div class="t-action">${escapeHtml(e.type)}${e.action ? ` · ${escapeHtml(e.action)}` : ""}</div>
|
|
764
|
+
${e.source ? `<div class="t-source">${escapeHtml(e.source)}</div>` : ""}
|
|
765
|
+
</li>`,
|
|
766
|
+
)
|
|
767
|
+
.join("")}</ul>`;
|
|
768
|
+
} catch (err) {
|
|
769
|
+
container.innerHTML = errorBox(err);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/* ── 视图:多端盘点 ────────────────── */
|
|
774
|
+
async function viewInventory() {
|
|
775
|
+
setNav("inventory");
|
|
776
|
+
document.title = "多端盘点 · skill-hub";
|
|
777
|
+
app.innerHTML = loading();
|
|
778
|
+
try {
|
|
779
|
+
const data = await api("/api/inventory");
|
|
780
|
+
const directories = [
|
|
781
|
+
...data.agents.map((a) => ({ ...a, purpose: "兼容同步目标" })),
|
|
782
|
+
...data.sources
|
|
783
|
+
.filter((s) => s.scope === "user")
|
|
784
|
+
.map((s) => ({ ...s, purpose: "只读来源" })),
|
|
785
|
+
];
|
|
786
|
+
const otherSources = data.sources.filter((s) => s.scope !== "user");
|
|
787
|
+
const agentRows = directories
|
|
788
|
+
.map(
|
|
789
|
+
(a) => `<tr>
|
|
790
|
+
<td class="mono">${escapeHtml(a.id)}</td>
|
|
791
|
+
<td class="mono">${escapeHtml(a.path)}</td>
|
|
792
|
+
<td>${a.exists ? '<span class="chip ok"><span class="dot"></span>目录存在</span>' : '<span class="chip missing" title="未找到此 Skill 目录;不代表 Agent 未安装,也可能使用共享目录或 Hub MCP。"><span class="dot"></span>目录未找到</span>'}</td>
|
|
793
|
+
<td>${a.count}</td>
|
|
794
|
+
<td>${escapeHtml(a.purpose)}</td>
|
|
795
|
+
</tr>`,
|
|
796
|
+
)
|
|
797
|
+
.join("");
|
|
798
|
+
const sourceRows = otherSources
|
|
799
|
+
.map(
|
|
800
|
+
(s) => `<tr>
|
|
801
|
+
<td class="mono">${escapeHtml(s.id)}</td>
|
|
802
|
+
<td>${scopeBadge({ scope: s.scope })}</td>
|
|
803
|
+
<td class="mono">${escapeHtml(s.path)}</td>
|
|
804
|
+
<td>${s.exists ? '<span class="chip ok"><span class="dot"></span>目录存在</span>' : '<span class="chip missing" title="未找到此 Skill 目录;不代表 Agent 未安装,也可能使用共享目录或 Hub MCP。"><span class="dot"></span>目录未找到</span>'}</td>
|
|
805
|
+
<td>${s.count}</td>
|
|
806
|
+
</tr>`,
|
|
807
|
+
)
|
|
808
|
+
.join("");
|
|
809
|
+
app.innerHTML = `
|
|
810
|
+
<section class="list-hero inventory-hero" aria-labelledby="inventory-title">
|
|
811
|
+
<div class="hero-copy">
|
|
812
|
+
<div class="eyebrow">Runtime inventory</div>
|
|
813
|
+
<h1 class="hero-title" id="inventory-title">多端状态,<br /><em>一屏尽览。</em></h1>
|
|
814
|
+
<p class="hero-description">对照 Agent 目标目录与技能来源,查看各路径的技能数量与目录状态。</p>
|
|
815
|
+
</div>
|
|
816
|
+
<div class="hero-index">
|
|
817
|
+
<div class="hero-index-top"><span>INVENTORY / 002</span><span class="hero-live"><i></i>READ ONLY</span></div>
|
|
818
|
+
<div class="hero-count">${data.unionApprox}</div>
|
|
819
|
+
<div class="hero-count-label">项技能的跨端并集(约)</div>
|
|
820
|
+
<div class="hero-metrics">
|
|
821
|
+
<div><strong>${directories.length}</strong><span>Agent 目录</span></div>
|
|
822
|
+
<div><strong>${otherSources.length}</strong><span>项目/工作区来源</span></div>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
</section>
|
|
826
|
+
<section class="inventory-section">
|
|
827
|
+
<div class="section-title">
|
|
828
|
+
<div>
|
|
829
|
+
<span class="section-kicker">Agent directories</span>
|
|
830
|
+
<h2>Agent 目录</h2>
|
|
831
|
+
<p class="section-subtitle">已配置目标与内置 Agent 来源的只读盘点;来源不会自动成为同步目标</p>
|
|
832
|
+
<p class="section-subtitle">状态仅检查 Skill 目录,不检测 Agent 是否安装;使用共享目录或 Hub MCP 时,原生目录可以不存在。</p>
|
|
833
|
+
</div>
|
|
834
|
+
<span class="inventory-count">${directories.length} DIRECTORIES</span>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="table-wrap">
|
|
837
|
+
<table class="table">
|
|
838
|
+
<caption class="sr-only">Agent 目录、路径、状态、技能数与用途</caption>
|
|
839
|
+
<thead><tr><th>Agent</th><th>路径</th><th>目录状态</th><th>技能数</th><th>用途</th></tr></thead>
|
|
840
|
+
<tbody>${agentRows || '<tr><td colspan="5">无启用 Agent 目录</td></tr>'}</tbody>
|
|
841
|
+
</table>
|
|
842
|
+
</div>
|
|
843
|
+
</section>
|
|
844
|
+
<section class="inventory-section">
|
|
845
|
+
<div class="section-title">
|
|
846
|
+
<div>
|
|
847
|
+
<span class="section-kicker">Project / workspace sources</span>
|
|
848
|
+
<h2>项目/工作区来源</h2>
|
|
849
|
+
<p class="section-subtitle">配置中显式声明的项目与工作区来源;只读盘点,不自动同步或导入</p>
|
|
850
|
+
</div>
|
|
851
|
+
<span class="inventory-count">${otherSources.length} SOURCES</span>
|
|
852
|
+
</div>
|
|
853
|
+
<div class="table-wrap">
|
|
854
|
+
<table class="table">
|
|
855
|
+
<caption class="sr-only">本地来源、作用域、路径、状态与技能数</caption>
|
|
856
|
+
<thead><tr><th>ID</th><th>scope</th><th>路径</th><th>目录状态</th><th>技能数</th></tr></thead>
|
|
857
|
+
<tbody>${sourceRows || '<tr><td colspan="5">无 sources</td></tr>'}</tbody>
|
|
858
|
+
</table>
|
|
859
|
+
</div>
|
|
860
|
+
</section>`;
|
|
861
|
+
} catch (err) {
|
|
862
|
+
app.innerHTML = errorBox(err);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/* ── 路由 ──────────────────────────── */
|
|
867
|
+
function setNav(tab) {
|
|
868
|
+
document.querySelectorAll("[data-nav]").forEach((a) => {
|
|
869
|
+
const active = a.dataset.nav === tab;
|
|
870
|
+
a.classList.toggle("active", active);
|
|
871
|
+
if (active) a.setAttribute("aria-current", "page");
|
|
872
|
+
else a.removeAttribute("aria-current");
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function route() {
|
|
877
|
+
const hash = location.hash.replace(/^#/, "") || "/";
|
|
878
|
+
const seg = hash.split("/").filter(Boolean);
|
|
879
|
+
if (seg[0] === "skill" && seg[1]) {
|
|
880
|
+
viewDetail(decodeURIComponent(seg[1]));
|
|
881
|
+
} else if (seg[0] === "inventory") {
|
|
882
|
+
viewInventory();
|
|
883
|
+
} else if (seg[0] === "usage") {
|
|
884
|
+
viewUsage();
|
|
885
|
+
} else {
|
|
886
|
+
viewList();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/* ── 搜索(防抖) ──────────────────── */
|
|
891
|
+
let searchTimer = null;
|
|
892
|
+
const applySearch = () => {
|
|
893
|
+
state.query = searchInput.value;
|
|
894
|
+
state.offset = 0;
|
|
895
|
+
if (location.hash && location.hash !== "#/" && location.hash !== "#") {
|
|
896
|
+
location.hash = "#/";
|
|
897
|
+
} else {
|
|
898
|
+
viewList();
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
searchInput.addEventListener("input", () => {
|
|
902
|
+
clearTimeout(searchTimer);
|
|
903
|
+
searchTimer = setTimeout(applySearch, 300);
|
|
904
|
+
});
|
|
905
|
+
document.addEventListener("keydown", (event) => {
|
|
906
|
+
const typing =
|
|
907
|
+
event.target instanceof window.HTMLElement &&
|
|
908
|
+
(event.target.matches("input, textarea, select") || event.target.isContentEditable);
|
|
909
|
+
if (event.key === "/" && !typing && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
910
|
+
event.preventDefault();
|
|
911
|
+
searchInput.focus();
|
|
912
|
+
}
|
|
913
|
+
if (event.key === "Escape" && document.activeElement === searchInput) {
|
|
914
|
+
if (searchInput.value) {
|
|
915
|
+
clearTimeout(searchTimer);
|
|
916
|
+
searchInput.value = "";
|
|
917
|
+
applySearch();
|
|
918
|
+
} else {
|
|
919
|
+
searchInput.blur();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
window.addEventListener("hashchange", route);
|
|
925
|
+
loadStats();
|
|
926
|
+
route();
|
|
927
|
+
})();
|