@stofone/dsh-agent-roster 0.11.0 → 0.12.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/CHANGELOG.md +35 -1
- package/README.md +18 -1
- package/README.zh.md +17 -1
- package/client.js +196 -9
- package/index.js +82 -0
- package/lib/search.js +155 -0
- package/locale/en.json +15 -1
- package/locale/zh.json +15 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.12.0] - 2026-11-03
|
|
8
|
+
|
|
9
|
+
### The panel answers "which one", not "show me everything"
|
|
10
|
+
|
|
11
|
+
Until now the panel was organised by **collection**: expand a source, then read a list. But the real
|
|
12
|
+
question is almost always the opposite — "I need someone who watches data quality" — so the entry
|
|
13
|
+
point is now one search field above the tabs.
|
|
14
|
+
|
|
15
|
+
- `GET /plugins/dsh-agent-roster/search?q=` searches this machine and every subscribed source in one
|
|
16
|
+
request, reusing the collection cache (60s) so repeated typing does not hit the network again.
|
|
17
|
+
- Ranking is explainable: every match reports `hits[{field, label, text}]` ("标签:数据质量"), and id
|
|
18
|
+
matches outrank title matches outrank tag matches outrank member roles. Multiple words are **AND**:
|
|
19
|
+
"data kubernetes" matches nothing here, instead of matching everything that has "data".
|
|
20
|
+
- A result that lives in another library says so ("来自 agent-roster-collective") and can be pulled
|
|
21
|
+
from the row itself; a local one opens straight into the roster list filtered to that id.
|
|
22
|
+
- `lib/search.js` is a pure function — no filesystem, no network — so the ranking is unit-tested.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `lib/search.js` with `searchRosters`, `searchableFields`, `tokenize`; 6 tests.
|
|
27
|
+
- `/plugins/dsh-agent-roster/search` route (local + remote, bounded source concurrency).
|
|
28
|
+
- Panel lookup field, result rows with match provenance, and `lookup.*` strings in both locales.
|
|
29
|
+
|
|
30
|
+
## [0.11.1] - 2026-11-03
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Empty states hugged the panel edge: the rosters and teams tabs rendered their "nothing here yet" block
|
|
35
|
+
straight into a page whose padding is `0` (each tab supplies its own gutter), so the sentence sat against
|
|
36
|
+
the window border while the tab labels and lists were indented. Both now sit in `.rstr-empty-wrap`, the
|
|
37
|
+
same 16/28px measure the source tab and the health tab use.
|
|
38
|
+
|
|
7
39
|
## [0.11.0] - 2026-11-03
|
|
8
40
|
|
|
9
41
|
### Changed
|
|
@@ -548,7 +580,9 @@
|
|
|
548
580
|
- 工具返回值含 `undefined` 键,运行时按 `value is not lossless JSON` 整次拒绝;缺失值统一写 `null`,
|
|
549
581
|
且所有返回值先过 `lib/json.js#lossless`。
|
|
550
582
|
|
|
551
|
-
[Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.
|
|
583
|
+
[Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.12.0...main
|
|
584
|
+
[0.12.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.1...v0.12.0
|
|
585
|
+
[0.11.1]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.0...v0.11.1
|
|
552
586
|
[0.11.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.7...v0.11.0
|
|
553
587
|
[0.10.7]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.6...v0.10.7
|
|
554
588
|
[0.10.6]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.5...v0.10.6
|
package/README.md
CHANGED
|
@@ -306,6 +306,23 @@ id belongs to the team, and the plugin never writes team state. What it does own
|
|
|
306
306
|
failure that only exists on a red card never reaches the next compile, which is exactly how a gate
|
|
307
307
|
turns into decoration.
|
|
308
308
|
|
|
309
|
+
## Find one roster, not a catalogue
|
|
310
|
+
|
|
311
|
+
The panel used to open on a list of collections. That answers "what is published"; the question people
|
|
312
|
+
actually arrive with is "who can do this". So the first control is now one search field above the tabs:
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
要找什么能力?例:数据质量、发版、代码评审
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
It searches this machine and every subscribed source in one request. Each row says **why** it matched
|
|
319
|
+
(`标签:数据质量 · 成员角色:数据质量工程师`) and where it lives (`来自 agent-roster-collective`), and
|
|
320
|
+
carries the action you need next — open it locally, or pull that collection from the row.
|
|
321
|
+
|
|
322
|
+
Multiple words are AND, not OR: `data kubernetes` returns nothing rather than every roster that merely
|
|
323
|
+
mentions "data". Ranking is a pure function in `lib/search.js`, so it is unit-tested rather than
|
|
324
|
+
eyeballed.
|
|
325
|
+
|
|
309
326
|
## Tools
|
|
310
327
|
|
|
311
328
|
| Tool | Purpose |
|
|
@@ -492,7 +509,7 @@ test/ unit tests
|
|
|
492
509
|
|
|
493
510
|
Verified on this machine:
|
|
494
511
|
|
|
495
|
-
- `npm test` —
|
|
512
|
+
- `npm test` — 307/307 passing (format, parse, validate, registry, paths, bridge, verify buckets, digests and provenance, roster diff, capability requirements and the capability gate, library organization, registry schema, lossless JSON, write plan/apply, all routes, panel snapshot and hints, dictionary consistency, tool contracts, client loader contract, library snapshot, config view, package manifest, declared fields and captain block, schema/constant alignment, the v2 role/roster/compiled layers and their schema alignment, v1→v2 migration, run records feeding the plan, remote registry and one-click install).
|
|
496
513
|
- `npm run smoke` — `apply()` registers all six tools through a real `defineTool` from a DSH bundle; `roster_list` / `roster_show` / `roster_create` / `roster_verify` / `roster_diff` execute, every output survives `lossless` checks, `path` overrides are honored, effect disposal runs cleanly. The same command then loads `client.js` against a stubbed module table and asserts the three slot registrations, style injection and effect disposal.
|
|
497
514
|
- CLI: `validate --dir examples` returns exit 0 over both example libraries.
|
|
498
515
|
- Installed into a real profile with the plugin manager (`plugin_manager install_bundle` against this directory → `link:` dependency + bundle entry); the profile re-reads the linked files on restart.
|
package/README.zh.md
CHANGED
|
@@ -292,6 +292,22 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
|
|
|
292
292
|
|
|
293
293
|
回执由执行施工图的队长会话写,不由插件写:任务 id 属于团队,插件从不写团队状态。插件负责的是合并——只活在红卡上的失败,下一次编译就消失了,门也就变成了装饰。
|
|
294
294
|
|
|
295
|
+
## 找一份档案,而不是翻一个目录
|
|
296
|
+
|
|
297
|
+
面板原来从「合集」进:先展开一个来源,再看里面有什么。那回答的是「发布了什么」,而人真正带来的
|
|
298
|
+
问题是「谁会做这件事」。所以现在第一个控件就是页签上方的一个搜索框:
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
要找什么能力?例:数据质量、发版、代码评审
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
一次请求同时搜本机与所有订阅来源。每一行都写清**为什么命中**(`标签:数据质量 · 成员角色:数据质量工程师`)
|
|
305
|
+
与**它住在哪**(`来自 agent-roster-collective`),并且把下一步动作放在行内:本机的直接打开,远端的
|
|
306
|
+
当场把这个集合拉下来。
|
|
307
|
+
|
|
308
|
+
多个词是「且」不是「或」:`data kubernetes` 一条都不返,而不是把所有提到 data 的档案都堆上来。
|
|
309
|
+
排序逻辑是 `lib/search.js` 里的纯函数,因此它被单测钉住,而不是靠肉眼看着顺。
|
|
310
|
+
|
|
295
311
|
## 工具
|
|
296
312
|
|
|
297
313
|
| 工具 | 作用 |
|
|
@@ -463,7 +479,7 @@ test/ 单测
|
|
|
463
479
|
|
|
464
480
|
本机已验证:
|
|
465
481
|
|
|
466
|
-
- `npm test` ——
|
|
482
|
+
- `npm test` —— 307/307 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
|
|
467
483
|
- `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
|
|
468
484
|
- CLI:`validate --dir examples` 对两座示例库退出码 0。
|
|
469
485
|
- 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
|
package/client.js
CHANGED
|
@@ -34,6 +34,7 @@ window.__ModuleLoader__.load({
|
|
|
34
34
|
const INSTALL_ROUTE = "/plugins/dsh-agent-roster/install";
|
|
35
35
|
const COLLECTION_ROUTE = "/plugins/dsh-agent-roster/collection";
|
|
36
36
|
const APPLY_ROUTE = "/plugins/dsh-agent-roster/apply";
|
|
37
|
+
const SEARCH_ROUTE = "/plugins/dsh-agent-roster/search";
|
|
37
38
|
|
|
38
39
|
const EFFORTS = ["low", "medium", "high", "default"];
|
|
39
40
|
const LANGUAGES = ["auto", "zh", "en"];
|
|
@@ -1283,6 +1284,39 @@ window.__ModuleLoader__.load({
|
|
|
1283
1284
|
const [importing, setImporting] = useState(false);
|
|
1284
1285
|
const [removing, setRemoving] = useState(null);
|
|
1285
1286
|
const [toast, setToast] = useState(null);
|
|
1287
|
+
// 「找一份档案」是首屏动作,不是某个页签里的筛选器:它同时问本机与所有订阅来源。
|
|
1288
|
+
const [lookup, setLookup] = useState("");
|
|
1289
|
+
const [lookupState, setLookupState] = useState({ status: "idle", body: null });
|
|
1290
|
+
const [pullingCollection, setPullingCollection] = useState("");
|
|
1291
|
+
|
|
1292
|
+
useEffect(() => {
|
|
1293
|
+
const needle = lookup.trim();
|
|
1294
|
+
if (needle.length < 2) {
|
|
1295
|
+
setLookupState({ status: "idle", body: null });
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const controller = new AbortController();
|
|
1299
|
+
// 停止输入之后才发请求:每敲一个字都打一次,等于自己给自己造并发。
|
|
1300
|
+
const timer = setTimeout(() => {
|
|
1301
|
+
setLookupState((current) => ({ status: "loading", body: current.body }));
|
|
1302
|
+
readJson(`${SEARCH_ROUTE}?q=${encodeURIComponent(needle)}`, { signal: controller.signal })
|
|
1303
|
+
.then((result) => {
|
|
1304
|
+
if (controller.signal.aborted) return;
|
|
1305
|
+
setLookupState(
|
|
1306
|
+
result.ok && result.body !== null
|
|
1307
|
+
? { status: "ready", body: result.body }
|
|
1308
|
+
: { status: "error", body: null },
|
|
1309
|
+
);
|
|
1310
|
+
})
|
|
1311
|
+
.catch(() => {
|
|
1312
|
+
if (!controller.signal.aborted) setLookupState({ status: "error", body: null });
|
|
1313
|
+
});
|
|
1314
|
+
}, 220);
|
|
1315
|
+
return () => {
|
|
1316
|
+
clearTimeout(timer);
|
|
1317
|
+
controller.abort();
|
|
1318
|
+
};
|
|
1319
|
+
}, [lookup]);
|
|
1286
1320
|
|
|
1287
1321
|
useEffect(() => {
|
|
1288
1322
|
if (toast === null) return;
|
|
@@ -1321,6 +1355,38 @@ window.__ModuleLoader__.load({
|
|
|
1321
1355
|
});
|
|
1322
1356
|
}, [rosters, query, selectedTags]);
|
|
1323
1357
|
|
|
1358
|
+
async function openMatch(match) {
|
|
1359
|
+
if (match.scope === "local") {
|
|
1360
|
+
setLookup("");
|
|
1361
|
+
setTab("rosters");
|
|
1362
|
+
setQuery(match.rosterId);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
setPullingCollection(match.collectionId ?? "");
|
|
1366
|
+
try {
|
|
1367
|
+
const result = await readJson(INSTALL_ROUTE, {
|
|
1368
|
+
method: "POST",
|
|
1369
|
+
headers: { "content-type": "application/json" },
|
|
1370
|
+
body: JSON.stringify({ collection: match.collectionId }),
|
|
1371
|
+
});
|
|
1372
|
+
if (!result.ok || result.body === null) {
|
|
1373
|
+
setToast(`${t("misc.failed")} (${String(result.status)})`);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const summary = t("source.pulled", {
|
|
1377
|
+
written: result.body.written ?? 0,
|
|
1378
|
+
unchanged: result.body.unchanged ?? 0,
|
|
1379
|
+
conflicts: result.body.conflicts ?? 0,
|
|
1380
|
+
});
|
|
1381
|
+
setToast(summary);
|
|
1382
|
+
library.reload();
|
|
1383
|
+
} catch {
|
|
1384
|
+
setToast(t("misc.failed"));
|
|
1385
|
+
} finally {
|
|
1386
|
+
setPullingCollection("");
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1324
1390
|
function toggleTag(tag) {
|
|
1325
1391
|
setSelectedTags((current) => {
|
|
1326
1392
|
const next = new Set(current);
|
|
@@ -1454,16 +1520,97 @@ window.__ModuleLoader__.load({
|
|
|
1454
1520
|
),
|
|
1455
1521
|
);
|
|
1456
1522
|
|
|
1523
|
+
// 搜索态压过页签:输入了问题就只看答案,不再让人先去挑一个合集。
|
|
1524
|
+
let lookupView = null;
|
|
1525
|
+
const needle = lookup.trim();
|
|
1526
|
+
if (needle !== "") {
|
|
1527
|
+
const body = lookupState.body;
|
|
1528
|
+
const matches = body?.matches ?? [];
|
|
1529
|
+
lookupView = h(
|
|
1530
|
+
"div",
|
|
1531
|
+
{ className: "rstr-results" },
|
|
1532
|
+
h(
|
|
1533
|
+
"p",
|
|
1534
|
+
{ className: "rstr-results-head" },
|
|
1535
|
+
lookupState.status === "loading" && body === null
|
|
1536
|
+
? t("lookup.searching")
|
|
1537
|
+
: t("lookup.counts", {
|
|
1538
|
+
total: String(matches.length),
|
|
1539
|
+
local: String(body?.counts?.local ?? 0),
|
|
1540
|
+
remote: String(body?.counts?.remote ?? 0),
|
|
1541
|
+
}),
|
|
1542
|
+
),
|
|
1543
|
+
lookupState.status === "error"
|
|
1544
|
+
? h("p", { className: "rstr-muted" }, t("lookup.failed"))
|
|
1545
|
+
: null,
|
|
1546
|
+
lookupState.status === "ready" && matches.length === 0
|
|
1547
|
+
? h("p", { className: "rstr-muted" }, t("lookup.empty", { q: needle }))
|
|
1548
|
+
: null,
|
|
1549
|
+
matches.map((match) =>
|
|
1550
|
+
h(
|
|
1551
|
+
"article",
|
|
1552
|
+
{ key: `${match.scope}:${match.collectionId ?? ""}:${match.rosterId}`, className: "rstr-row" },
|
|
1553
|
+
h(
|
|
1554
|
+
"div",
|
|
1555
|
+
{ className: "rstr-row-body" },
|
|
1556
|
+
h("code", { className: "rstr-row-id", title: match.rosterId }, match.rosterId),
|
|
1557
|
+
h(
|
|
1558
|
+
"div",
|
|
1559
|
+
{ className: "rstr-row-text" },
|
|
1560
|
+
h("h3", { className: "rstr-row-title" }, match.title === "" ? match.rosterId : match.title),
|
|
1561
|
+
h(
|
|
1562
|
+
"p",
|
|
1563
|
+
{ className: "rstr-row-desc" },
|
|
1564
|
+
match.hits.map((hit) => `${hit.label}:${hit.text}`).join(" · "),
|
|
1565
|
+
),
|
|
1566
|
+
),
|
|
1567
|
+
h(
|
|
1568
|
+
"div",
|
|
1569
|
+
{ className: "rstr-row-stats" },
|
|
1570
|
+
h("span", { className: "rstr-stat" }, t("lookup.members", { count: String(match.members) })),
|
|
1571
|
+
h(
|
|
1572
|
+
"span",
|
|
1573
|
+
{ className: "rstr-stat" },
|
|
1574
|
+
match.scope === "local"
|
|
1575
|
+
? t("lookup.local")
|
|
1576
|
+
: t("lookup.remoteFrom", { collection: match.collectionTitle ?? match.collectionId ?? "" }),
|
|
1577
|
+
),
|
|
1578
|
+
),
|
|
1579
|
+
h(
|
|
1580
|
+
"div",
|
|
1581
|
+
{ className: "rstr-row-actions" },
|
|
1582
|
+
h(
|
|
1583
|
+
"button",
|
|
1584
|
+
{
|
|
1585
|
+
type: "button",
|
|
1586
|
+
className: "rstr-btn rstr-btn-sm",
|
|
1587
|
+
disabled: match.scope === "remote" && pullingCollection !== "",
|
|
1588
|
+
onClick: () => openMatch(match),
|
|
1589
|
+
},
|
|
1590
|
+
match.scope === "local" ? t("lookup.open") : t("lookup.pull"),
|
|
1591
|
+
),
|
|
1592
|
+
),
|
|
1593
|
+
),
|
|
1594
|
+
),
|
|
1595
|
+
),
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1457
1599
|
let content = null;
|
|
1458
1600
|
|
|
1459
1601
|
if (tab === "rosters") {
|
|
1460
1602
|
if (rosters.length === 0) {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1603
|
+
// 空状态与列表共享同一条左沿:页面本身 padding 为 0,留白由各页签自己给。
|
|
1604
|
+
content = h(
|
|
1605
|
+
"div",
|
|
1606
|
+
{ className: "rstr-empty-wrap" },
|
|
1607
|
+
h(EmptyState, {
|
|
1608
|
+
title: t("panel.emptyTitle"),
|
|
1609
|
+
body: t("panel.emptyBody"),
|
|
1610
|
+
cta: targets.length === 0 ? undefined : t("panel.emptyCta"),
|
|
1611
|
+
onCta: () => setEditor({ roster: null, rootDir: defaultRoot }),
|
|
1612
|
+
}),
|
|
1613
|
+
);
|
|
1467
1614
|
} else {
|
|
1468
1615
|
content = h(
|
|
1469
1616
|
"div",
|
|
@@ -1561,7 +1708,7 @@ window.__ModuleLoader__.load({
|
|
|
1561
1708
|
if (tab === "teams") {
|
|
1562
1709
|
content =
|
|
1563
1710
|
teams.length === 0
|
|
1564
|
-
? h(EmptyState, { title: t("team.emptyTitle"), body: t("team.emptyBody") })
|
|
1711
|
+
? h("div", { className: "rstr-empty-wrap" }, h(EmptyState, { title: t("team.emptyTitle"), body: t("team.emptyBody") }))
|
|
1565
1712
|
: h("div", { className: "rstr-cards" }, teams.map((team) => h(TeamCard, { key: team.id, team })));
|
|
1566
1713
|
}
|
|
1567
1714
|
|
|
@@ -1594,8 +1741,31 @@ window.__ModuleLoader__.load({
|
|
|
1594
1741
|
{ className: "rstr-page" },
|
|
1595
1742
|
header,
|
|
1596
1743
|
banner,
|
|
1597
|
-
|
|
1598
|
-
|
|
1744
|
+
h(
|
|
1745
|
+
"div",
|
|
1746
|
+
{ className: "rstr-lookup" },
|
|
1747
|
+
h("input", {
|
|
1748
|
+
className: "rstr-input rstr-lookup-input",
|
|
1749
|
+
value: lookup,
|
|
1750
|
+
placeholder: t("lookup.placeholder"),
|
|
1751
|
+
"aria-label": t("lookup.placeholder"),
|
|
1752
|
+
onChange: (event) => setLookup(event.target.value),
|
|
1753
|
+
onKeyDown: (event) => {
|
|
1754
|
+
if (event.key !== "Escape") return;
|
|
1755
|
+
setLookup("");
|
|
1756
|
+
event.stopPropagation();
|
|
1757
|
+
},
|
|
1758
|
+
}),
|
|
1759
|
+
lookup === ""
|
|
1760
|
+
? h("span", { className: "rstr-lookup-hint" }, t("lookup.hint"))
|
|
1761
|
+
: h(
|
|
1762
|
+
"button",
|
|
1763
|
+
{ type: "button", className: "rstr-btn rstr-btn-sm rstr-btn-ghost", onClick: () => setLookup("") },
|
|
1764
|
+
t("lookup.clear"),
|
|
1765
|
+
),
|
|
1766
|
+
),
|
|
1767
|
+
lookupView === null ? tabs : null,
|
|
1768
|
+
lookupView === null ? content : lookupView,
|
|
1599
1769
|
h(
|
|
1600
1770
|
"footer",
|
|
1601
1771
|
{ className: "rstr-foot" },
|
|
@@ -2342,6 +2512,19 @@ window.__ModuleLoader__.load({
|
|
|
2342
2512
|
|
|
2343
2513
|
/* ---- 账本(行) ------------------------------------------------------- */
|
|
2344
2514
|
|
|
2515
|
+
.rstr-lookup { display: flex; align-items: center; gap: 10px; padding: 0 28px 12px; min-width: 0; }
|
|
2516
|
+
|
|
2517
|
+
.rstr-lookup-input { flex: 1 1 auto; max-width: 560px; }
|
|
2518
|
+
|
|
2519
|
+
.rstr-lookup-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
2520
|
+
|
|
2521
|
+
/* 清除按钮不参与压缩:窄面板里被挤成竖排的两个字最难认。 */
|
|
2522
|
+
.rstr-lookup > .rstr-btn { flex: 0 0 auto; white-space: nowrap; }
|
|
2523
|
+
|
|
2524
|
+
.rstr-results { display: flex; flex-direction: column; min-width: 0; padding: 0 28px 28px; border-top: 1px solid var(--dsw-alias-border-l2); }
|
|
2525
|
+
|
|
2526
|
+
.rstr-results-head { margin: 12px 0 4px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
2527
|
+
|
|
2345
2528
|
.rstr-cards { display: flex; flex-direction: column; min-width: 0; border-top: 1px solid var(--dsw-alias-border-l2); }
|
|
2346
2529
|
|
|
2347
2530
|
.rstr-row {
|
|
@@ -2600,6 +2783,10 @@ window.__ModuleLoader__.load({
|
|
|
2600
2783
|
|
|
2601
2784
|
/* ---- 空态 / 健康 / 页脚 ------------------------------------------------ */
|
|
2602
2785
|
|
|
2786
|
+
.rstr-empty-wrap {
|
|
2787
|
+
padding: 16px 28px 28px;
|
|
2788
|
+
min-width: 0;
|
|
2789
|
+
}
|
|
2603
2790
|
.rstr-empty { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; padding: 40px 2px; color: var(--dsw-alias-label-primary); }
|
|
2604
2791
|
.rstr-empty-title { margin: 0; font-size: 15px; font-weight: 600; }
|
|
2605
2792
|
.rstr-health { display: flex; flex-direction: column; gap: 14px; padding: 16px 28px 28px; }
|
package/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { validateRoster2 } from "./lib/roster2.js";
|
|
|
26
26
|
import { sendError, sendJson, createAuthGate, readJsonBody, requireMethod } from "./lib/http.js";
|
|
27
27
|
import { buildPanelSnapshot, installCollection, loadRemotePanel, resolveRoots } from "./lib/service.js";
|
|
28
28
|
import { createTtlCache, fetchCollection } from "./lib/remote.js";
|
|
29
|
+
import { searchRosters } from "./lib/search.js";
|
|
29
30
|
import { registerRosterTools } from "./lib/tools.js";
|
|
30
31
|
import { deleteRosterDocument, readRosterDocument, writeRosterDocument } from "./lib/write.js";
|
|
31
32
|
|
|
@@ -173,6 +174,87 @@ export function apply(ctx, config) {
|
|
|
173
174
|
"collection route",
|
|
174
175
|
);
|
|
175
176
|
|
|
177
|
+
// 搜索是「找一份档案」的入口:一次请求横跨本机与所有订阅来源。
|
|
178
|
+
// 远端的拉取复用 collection 的 60s 缓存,所以连续输入不会对同一个集合重复打网络。
|
|
179
|
+
register(
|
|
180
|
+
"/plugins/dsh-agent-roster/search",
|
|
181
|
+
async (req, res) => {
|
|
182
|
+
if (guard(req, res)) return;
|
|
183
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
184
|
+
const query = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
185
|
+
const q = (query.get("q") ?? "").trim();
|
|
186
|
+
if (q === "") {
|
|
187
|
+
sendJson(res, 200, { ok: true, q: "", tokens: [], total: 0, matches: [], counts: { local: 0, remote: 0 }, diagnostics: [] });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const library = collectLibrary(ctx, settings, { name: pkg.name, version: pkg.version });
|
|
192
|
+
const entries = [];
|
|
193
|
+
let localCount = 0;
|
|
194
|
+
for (const workspace of library.workspaces) {
|
|
195
|
+
for (const roster of workspace.rosters) {
|
|
196
|
+
entries.push({ scope: "local", roster, libraryId: workspace.rootDir ?? null, file: roster.file ?? null });
|
|
197
|
+
localCount += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const diagnostics = [];
|
|
202
|
+
const collections = [];
|
|
203
|
+
try {
|
|
204
|
+
const remote = await loadRemotePanel(settings, {});
|
|
205
|
+
for (const item of remote.diagnostics ?? []) if (item.severity !== "info") diagnostics.push(item);
|
|
206
|
+
for (const collection of remote.collections ?? []) collections.push(collection);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
diagnostics.push({
|
|
209
|
+
severity: "warn",
|
|
210
|
+
code: "search-index-unavailable",
|
|
211
|
+
message: `订阅索引拉不到,本次只搜索本机档案:${error instanceof Error ? error.message : String(error)}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 来源两两并发:一个慢集合不该把所有来源都拖住,也不该一次打三十个请求。
|
|
216
|
+
const sourceConcurrency = 2;
|
|
217
|
+
let cursor = 0;
|
|
218
|
+
const fetchNext = async () => {
|
|
219
|
+
while (cursor < collections.length) {
|
|
220
|
+
const collection = collections[cursor];
|
|
221
|
+
cursor += 1;
|
|
222
|
+
const key = `${collection.source ?? ""}::${collection.id}`;
|
|
223
|
+
try {
|
|
224
|
+
const cached = collectionCache.get(key);
|
|
225
|
+
const fetched = cached ?? collectionCache.set(key, await fetchCollection(collection));
|
|
226
|
+
for (const roster of fetched.rosters ?? []) {
|
|
227
|
+
entries.push({
|
|
228
|
+
scope: "remote",
|
|
229
|
+
roster,
|
|
230
|
+
collectionId: collection.id,
|
|
231
|
+
collectionTitle: collection.title ?? null,
|
|
232
|
+
file: roster.file ?? null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
for (const item of fetched.diagnostics ?? []) if (item.severity === "error") diagnostics.push(item);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
severity: "warn",
|
|
239
|
+
code: "search-source-failed",
|
|
240
|
+
message: `${collection.id} 拉取失败:${error instanceof Error ? error.message : String(error)}`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
await Promise.all(Array.from({ length: Math.min(sourceConcurrency, collections.length) }, fetchNext));
|
|
246
|
+
|
|
247
|
+
const result = searchRosters(q, entries);
|
|
248
|
+
const remoteCount = entries.length - localCount;
|
|
249
|
+
sendJson(res, 200, {
|
|
250
|
+
...result,
|
|
251
|
+
counts: { local: localCount, remote: remoteCount },
|
|
252
|
+
diagnostics: diagnostics.slice(0, 20),
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
"search route",
|
|
256
|
+
);
|
|
257
|
+
|
|
176
258
|
register(
|
|
177
259
|
"/plugins/dsh-agent-roster/locale",
|
|
178
260
|
async (req, res) => {
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 找一份档案,而不是浏览一堆合集。
|
|
3
|
+
*
|
|
4
|
+
* 面板原来的组织单位是「合集」:先展开一个来源,再在里面翻。但真实任务几乎总是反向的——
|
|
5
|
+
* 「我需要一个能盯数据质量的队伍」,先知道自己要什么,再去找谁有。所以这里把搜索做成
|
|
6
|
+
* 一等能力:一个问题,横跨本机与所有订阅来源,命中的每一行都要说清**为什么命中**。
|
|
7
|
+
*
|
|
8
|
+
* 两条刻意的规则:
|
|
9
|
+
* 1. **多词是「且」**:`data quality` 要求每个词都落在某个字段里,任一落空即不入选。
|
|
10
|
+
* 否则「评分」会把一堆只沾了「分」的档案捞上来,搜索结果比列表更难读。
|
|
11
|
+
* 2. **命中要有出处**:每行返回 `hits[{field,label,text}]`,界面直接显示「命中 标签:数据质量」。
|
|
12
|
+
* 只给分数是没法解释的——用户凭什么信这一条排在前面。
|
|
13
|
+
*
|
|
14
|
+
* 纯函数:不读文件、不联网,输入就是已经装配好的档案视图。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const SEARCH_LIMIT = 20;
|
|
18
|
+
|
|
19
|
+
/** 字段权重:id 是名字,title 是自我描述,tags 是别人怎么找它。 */
|
|
20
|
+
export const SEARCH_FIELDS = [
|
|
21
|
+
{ field: "id", weight: 100, label: "档案 id" },
|
|
22
|
+
{ field: "title", weight: 56, label: "标题" },
|
|
23
|
+
{ field: "tags", weight: 48, label: "标签" },
|
|
24
|
+
{ field: "skills", weight: 40, label: "技能要求" },
|
|
25
|
+
{ field: "requires", weight: 36, label: "能力要求" },
|
|
26
|
+
{ field: "memberRole", weight: 34, label: "成员角色" },
|
|
27
|
+
{ field: "memberName", weight: 26, label: "成员名" },
|
|
28
|
+
{ field: "memberTitle", weight: 22, label: "成员标题" },
|
|
29
|
+
{ field: "description", weight: 18, label: "描述" },
|
|
30
|
+
{ field: "notes", weight: 12, label: "备注" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const WEIGHT = new Map(SEARCH_FIELDS.map((item) => [item.field, item]));
|
|
34
|
+
|
|
35
|
+
/** 把一个词切成可比较的片段:空白与中文标点都算分隔。 */
|
|
36
|
+
export function tokenize(query) {
|
|
37
|
+
if (typeof query !== "string") return [];
|
|
38
|
+
return query
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.split(/[\s,,、;;|/]+/)
|
|
41
|
+
.map((token) => token.trim())
|
|
42
|
+
.filter((token) => token !== "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function push(list, field, text) {
|
|
46
|
+
if (typeof text !== "string") return;
|
|
47
|
+
const value = text.trim();
|
|
48
|
+
if (value === "") return;
|
|
49
|
+
const meta = WEIGHT.get(field);
|
|
50
|
+
if (meta === undefined) return;
|
|
51
|
+
list.push({ field, label: meta.label, text: value, weight: meta.weight });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 把一份档案摊成「可被搜到的字段」。同一字段里的多个值各自成条,
|
|
56
|
+
* 这样「标签:数据质量」能精确指出是哪一条标签命中,而不是含糊地说"标签命中"。
|
|
57
|
+
*/
|
|
58
|
+
export function searchableFields(roster) {
|
|
59
|
+
const fields = [];
|
|
60
|
+
if (roster === null || typeof roster !== "object") return fields;
|
|
61
|
+
push(fields, "id", roster.id);
|
|
62
|
+
push(fields, "title", roster.title);
|
|
63
|
+
push(fields, "description", roster.description);
|
|
64
|
+
push(fields, "notes", roster.notes);
|
|
65
|
+
for (const tag of Array.isArray(roster.tags) ? roster.tags : []) push(fields, "tags", tag);
|
|
66
|
+
for (const member of Array.isArray(roster.members) ? roster.members : []) {
|
|
67
|
+
push(fields, "memberName", member?.name);
|
|
68
|
+
push(fields, "memberRole", member?.role);
|
|
69
|
+
push(fields, "memberTitle", member?.title);
|
|
70
|
+
for (const skill of Array.isArray(member?.skills) ? member.skills : []) push(fields, "skills", skill);
|
|
71
|
+
}
|
|
72
|
+
const requires = roster.requires !== null && typeof roster.requires === "object" ? roster.requires : null;
|
|
73
|
+
if (requires !== null) {
|
|
74
|
+
for (const skill of Array.isArray(requires.skills) ? requires.skills : []) push(fields, "skills", skill);
|
|
75
|
+
for (const policy of Array.isArray(requires.toolPolicies) ? requires.toolPolicies : []) push(fields, "requires", policy);
|
|
76
|
+
}
|
|
77
|
+
return fields;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 一条 token 落在哪个字段上最值钱。
|
|
82
|
+
* 前缀命中与完全命中各加一点:搜 `data` 时 `data-quality` 应该排在 `reconcile-metadata` 前面。
|
|
83
|
+
*/
|
|
84
|
+
function bestMatch(token, fields) {
|
|
85
|
+
let best = null;
|
|
86
|
+
for (const item of fields) {
|
|
87
|
+
const haystack = item.text.toLowerCase();
|
|
88
|
+
const index = haystack.indexOf(token);
|
|
89
|
+
if (index === -1) continue;
|
|
90
|
+
let score = item.weight;
|
|
91
|
+
if (index === 0) score += item.weight * 0.4;
|
|
92
|
+
if (haystack === token) score += item.weight * 0.8;
|
|
93
|
+
if (best === null || score > best.score) best = { score, item };
|
|
94
|
+
}
|
|
95
|
+
return best;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} query
|
|
100
|
+
* @param {{scope: "local" | "remote", roster: object, libraryId?: string|null,
|
|
101
|
+
* collectionId?: string|null, collectionTitle?: string|null, file?: string|null}[]} entries
|
|
102
|
+
* @returns {{ok: true, q: string, tokens: string[], total: number,
|
|
103
|
+
* matches: {scope: string, rosterId: string, title: string, description: string,
|
|
104
|
+
* tags: string[], members: number, libraryId: string|null,
|
|
105
|
+
* collectionId: string|null, collectionTitle: string|null, file: string|null,
|
|
106
|
+
* score: number, hits: {field: string, label: string, text: string}[]}[]}}
|
|
107
|
+
*/
|
|
108
|
+
export function searchRosters(query, entries, { limit = SEARCH_LIMIT } = {}) {
|
|
109
|
+
const tokens = tokenize(query);
|
|
110
|
+
if (tokens.length === 0) return { ok: true, q: "", tokens: [], total: 0, matches: [] };
|
|
111
|
+
|
|
112
|
+
const matches = [];
|
|
113
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
114
|
+
const roster = entry?.roster;
|
|
115
|
+
if (roster === null || typeof roster !== "object") continue;
|
|
116
|
+
const fields = searchableFields(roster);
|
|
117
|
+
let score = 0;
|
|
118
|
+
const hits = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
let complete = true;
|
|
121
|
+
for (const token of tokens) {
|
|
122
|
+
const found = bestMatch(token, fields);
|
|
123
|
+
if (found === null) {
|
|
124
|
+
complete = false;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
score += found.score;
|
|
128
|
+
if (!seen.has(found.item.field)) {
|
|
129
|
+
seen.add(found.item.field);
|
|
130
|
+
hits.push({ field: found.item.field, label: found.item.label, text: found.item.text });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!complete) continue;
|
|
134
|
+
const id = typeof roster.id === "string" ? roster.id : "";
|
|
135
|
+
// 同名匹配下,短 id 更可能是"那一个":`data-quality` 先于 `data-quality-and-observability-review`。
|
|
136
|
+
score += Math.max(0, 24 - id.length);
|
|
137
|
+
matches.push({
|
|
138
|
+
scope: entry.scope === "remote" ? "remote" : "local",
|
|
139
|
+
rosterId: id,
|
|
140
|
+
title: typeof roster.title === "string" ? roster.title : "",
|
|
141
|
+
description: typeof roster.description === "string" ? roster.description : "",
|
|
142
|
+
tags: Array.isArray(roster.tags) ? roster.tags.slice(0, 6) : [],
|
|
143
|
+
members: Array.isArray(roster.members) ? roster.members.length : 0,
|
|
144
|
+
libraryId: entry.libraryId ?? null,
|
|
145
|
+
collectionId: entry.collectionId ?? null,
|
|
146
|
+
collectionTitle: entry.collectionTitle ?? null,
|
|
147
|
+
file: entry.file ?? null,
|
|
148
|
+
score: Math.round(score),
|
|
149
|
+
hits,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
matches.sort((left, right) => right.score - left.score || left.rosterId.localeCompare(right.rosterId));
|
|
154
|
+
return { ok: true, q: query.trim(), tokens, total: matches.length, matches: matches.slice(0, limit) };
|
|
155
|
+
}
|
package/locale/en.json
CHANGED
|
@@ -301,5 +301,19 @@
|
|
|
301
301
|
},
|
|
302
302
|
"source.filter": "Filter this collection",
|
|
303
303
|
"source.filterPlaceholder": "Filter by id, title or member",
|
|
304
|
-
"source.filterCount": "{shown} of {total}"
|
|
304
|
+
"source.filterCount": "{shown} of {total}",
|
|
305
|
+
"lookup": {
|
|
306
|
+
"placeholder": "What should it do? e.g. data quality, release, review",
|
|
307
|
+
"hint": "Searches this machine and every subscribed source",
|
|
308
|
+
"clear": "Clear",
|
|
309
|
+
"searching": "Searching…",
|
|
310
|
+
"counts": "{total} matched · candidates: {local} local · {remote} remote",
|
|
311
|
+
"empty": "Nothing matched “{q}”. Try another wording, or check Sources for a subscription that has it.",
|
|
312
|
+
"failed": "Search failed: the host did not answer.",
|
|
313
|
+
"members": "{count} members",
|
|
314
|
+
"local": "local",
|
|
315
|
+
"remoteFrom": "from {collection}",
|
|
316
|
+
"open": "Open",
|
|
317
|
+
"pull": "Pull"
|
|
318
|
+
}
|
|
305
319
|
}
|
package/locale/zh.json
CHANGED
|
@@ -301,5 +301,19 @@
|
|
|
301
301
|
},
|
|
302
302
|
"source.filter": "在这份集合里查找",
|
|
303
303
|
"source.filterPlaceholder": "按 id、标题或成员名过滤",
|
|
304
|
-
"source.filterCount": "{shown} / {total}"
|
|
304
|
+
"source.filterCount": "{shown} / {total}",
|
|
305
|
+
"lookup": {
|
|
306
|
+
"placeholder": "要找什么能力?例:数据质量、发版、代码评审",
|
|
307
|
+
"hint": "同时搜本机与已订阅的来源",
|
|
308
|
+
"clear": "清除",
|
|
309
|
+
"searching": "正在搜索…",
|
|
310
|
+
"counts": "命中 {total} 份 · 候选:本机 {local} · 远端 {remote}",
|
|
311
|
+
"empty": "没有找到「{q}」。换个说法,或到「档案源」看看是否订阅了它。",
|
|
312
|
+
"failed": "搜索失败:宿主没有响应。",
|
|
313
|
+
"members": "{count} 名成员",
|
|
314
|
+
"local": "本机",
|
|
315
|
+
"remoteFrom": "来自 {collection}",
|
|
316
|
+
"open": "打开",
|
|
317
|
+
"pull": "拉取"
|
|
318
|
+
}
|
|
305
319
|
}
|
package/package.json
CHANGED