From f64f1279e79c0e452cd2e8f0023234cf10b1f83e Mon Sep 17 00:00:00 2001
From: oliver
Date: Sun, 13 Sep 2026 11:13:29 +0800
Subject: [PATCH] Fix knowledge-base browse feedback and accept MANIFEST.json
paths.
Browse no longer fails silently, falls back to Host directoryPicker RPC, and resolve treats a MANIFEST.json file path as its parent package root.
Co-authored-by: Cursor
---
lib/client.js | 95 +++++++++++++++++++++++++++++-------
lib/index.js | 21 ++++++--
src/client/NetxopsCard.tsx | 10 +++-
src/client/controller.ts | 77 ++++++++++++++++++++++++-----
src/client/index.ts | 22 ++++++---
src/client/kb-status-view.ts | 6 ++-
src/client/locales.ts | 10 +++-
src/netx/kb-manifest.ts | 23 +++++++--
test/kb-manifest.test.mjs | 31 ++++++++++--
9 files changed, 246 insertions(+), 49 deletions(-)
diff --git a/lib/client.js b/lib/client.js
index 0b6bba5..5a5cfd2 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -621,7 +621,10 @@ async function fetchKbStatus(call, signal) {
return unconfiguredKbSnapshot();
}
async function resolveKbPath(call, path, signal) {
- const result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal);
+ let result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal);
+ if (!(result !== null && typeof result === "object" && result.ok === true)) {
+ result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { args: { path } }, signal);
+ }
if (result !== null && typeof result === "object" && result.ok === true) {
return asKbSnapshot(result.value);
}
@@ -1376,7 +1379,7 @@ function NetxopsCard(props) {
/* @__PURE__ */ jsx_runtime.jsx("button", {
type: "button",
className: "dsh-nx-btn",
- disabled: disabled || !state.kbDirectoryPickerReady,
+ disabled,
onClick: () => {
props.browseKbRoot();
},
@@ -1388,6 +1391,15 @@ function NetxopsCard(props) {
className: state.kbRoot.invalid ? "dsh-nx-invalid" : "dsh-nx-hint",
children: state.kbRoot.invalid ? t("invalid") : state.kbDirectoryPickerReady ? t("kbRootHint") : t("kbBrowseUnavailable")
}),
+ /* @__PURE__ */ jsx_runtime.jsx("p", {
+ className: "dsh-nx-hint",
+ children: t("kbSaveHint")
+ }),
+ state.kbUiError ? /* @__PURE__ */ jsx_runtime.jsx("p", {
+ className: "dsh-nx-invalid",
+ role: "status",
+ children: fillTemplate(t("kbBrowseFailed"), { detail: state.kbUiError })
+ }) : null,
state.kbStatus?.status === "error" && state.kbStatus.errorMessage ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-invalid",
role: "status",
@@ -1806,6 +1818,7 @@ class NetxopsCardController {
kbDirectoryPicker;
kbBrowseInFlight = false;
kbStatusInFlight = false;
+ kbUiError = null;
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
sessionsExportStatus = null;
sessionsExportBusy = false;
@@ -2002,17 +2015,57 @@ class NetxopsCardController {
} catch {}
}
browseKbRoot() {
- const picker = this.kbDirectoryPicker;
- if (picker === undefined || this.kbBrowseInFlight)
+ if (this.kbBrowseInFlight)
return;
this.kbBrowseInFlight = true;
- picker.pick().then((path) => {
- if (typeof path === "string" && path.trim() !== "") {
- this.form.actions().edit("kbRoot", path);
- this.refreshKbPreview();
+ this.kbUiError = null;
+ this.store.set(this.projection());
+ const applyPath = (path) => {
+ if (typeof path !== "string" || path.trim() === "") {
+ return;
}
- }).catch(() => {}).finally(() => {
+ this.form.actions().edit("kbRoot", path.trim());
+ this.refreshKbPreview();
+ };
+ const fail = (error) => {
+ this.kbUiError = error instanceof Error ? error.message : String(error);
+ this.store.set(this.projection());
+ };
+ const picker = this.kbDirectoryPicker;
+ if (picker !== undefined) {
+ picker.pick().then(applyPath).catch(fail).finally(() => {
+ this.kbBrowseInFlight = false;
+ this.store.set(this.projection());
+ });
+ return;
+ }
+ const call = this.rpcCall;
+ if (call === undefined) {
this.kbBrowseInFlight = false;
+ this.kbUiError = "directory picker unavailable — paste an absolute folder path and Save";
+ this.store.set(this.projection());
+ return;
+ }
+ call("/api", "directoryPicker/pick", { args: {} }).then((result) => {
+ if (typeof result === "string" || result === null) {
+ applyPath(result);
+ return;
+ }
+ if (result !== null && typeof result === "object") {
+ const row = result;
+ if (row.ok === false) {
+ fail(row.error?.message || "directoryPicker/pick failed");
+ return;
+ }
+ if ("value" in row) {
+ applyPath(row.value);
+ return;
+ }
+ }
+ applyPath(result);
+ }).catch(fail).finally(() => {
+ this.kbBrowseInFlight = false;
+ this.store.set(this.projection());
});
}
exportAllSessions() {
@@ -2071,7 +2124,8 @@ class NetxopsCardController {
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
kbStatus: this.kbPreview ?? this.kbStatus,
- kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined,
+ kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
+ kbUiError: this.kbUiError,
imDeliveryCatalog: this.imDeliveryCatalog,
sessionsExportStatus: this.sessionsExportStatus,
sessionsExportBusy: this.sessionsExportBusy,
@@ -2185,7 +2239,9 @@ var en = {
kbRoot: "Package root",
kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.",
kbBrowse: "Browse…",
- kbBrowseUnavailable: "Directory picker unavailable — paste an absolute path.",
+ kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.",
+ kbBrowseFailed: "Browse failed: {detail}",
+ kbSaveHint: "Click Save after choosing a path to apply the knowledge base.",
kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}",
kbStatusUnconfigured: "Knowledge base: not configured (pure netx)",
kbStatusError: "Knowledge base: error — {detail}",
@@ -2255,7 +2311,9 @@ var zh = {
kbRoot: "知识包根目录",
kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。",
kbBrowse: "浏览…",
- kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对路径。",
+ kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。",
+ kbBrowseFailed: "浏览失败:{detail}",
+ kbSaveHint: "选好路径后请点「保存」,知识库才会生效。",
kbStatusConfigured: "知识库: {operator}({country}) v{version}",
kbStatusUnconfigured: "知识库: 未配置(纯 netx)",
kbStatusError: "知识库: 错误 — {detail}",
@@ -2350,13 +2408,16 @@ function apply(ctx) {
card.setAlarmPushRpc(undefined);
}, "netxops: clear alarm-push rpc");
});
- ctx.inject(["remote.directoryPicker"], (dpCtx) => {
- const picker = dpCtx.remote?.directoryPicker;
- if (!picker || typeof picker.pick !== "function") {
- dpCtx.logger.warn("netxops: remote.directoryPicker.pick unavailable — browse button disabled");
+ const bindDirectoryPicker = (picker) => {
+ if (!picker || typeof picker.pick !== "function")
return;
- }
card.setDirectoryPicker(picker);
+ };
+ bindDirectoryPicker(ctx.remote?.directoryPicker);
+ ctx.inject(["remote.directoryPicker"], (dpCtx) => {
+ const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined;
+ const viaNested = dpCtx.remote?.directoryPicker;
+ bindDirectoryPicker(viaGet ?? viaNested);
dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined);
}, "netxops: clear directory picker");
diff --git a/lib/index.js b/lib/index.js
index 3ea7653..dec5043 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -844,7 +844,7 @@ function registerKbContextSkill(ctx, snapshot) {
// src/netx/kb-manifest.ts
import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs";
-import { dirname as dirname2, join as join3, resolve } from "node:path";
+import { basename, dirname as dirname2, join as join3, resolve } from "node:path";
var EMPTY_CONTENT = {
regions: false,
theory: false,
@@ -1000,11 +1000,26 @@ function resolveKbRoot(kbRoot, maxDepth = 3) {
const trimmed = kbRoot.trim();
if (!trimmed)
return unconfiguredKbSnapshot();
- const located = findManifest(trimmed, maxDepth);
+ let root = resolve(trimmed);
+ try {
+ const st = statSync(root);
+ if (st.isFile()) {
+ if (basename(root).toLowerCase() === "manifest.json") {
+ root = dirname2(root);
+ } else {
+ return errorSnapshot(`kbRoot is not a directory: ${root}`);
+ }
+ } else if (!st.isDirectory()) {
+ return errorSnapshot(`kbRoot is not a directory: ${root}`);
+ }
+ } catch {
+ return errorSnapshot(`kbRoot not found: ${root}`);
+ }
+ const located = findManifest(root, maxDepth);
if (located.error)
return errorSnapshot(located.error);
if (located.paths.length === 0) {
- return errorSnapshot(`no MANIFEST.json under ${resolve(trimmed)} (maxDepth=${maxDepth})`);
+ return errorSnapshot(`no MANIFEST.json under ${root} (maxDepth=${maxDepth})`);
}
if (located.paths.length > 1) {
return errorSnapshot(`ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`);
diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx
index de4e630..734bb44 100644
--- a/src/client/NetxopsCard.tsx
+++ b/src/client/NetxopsCard.tsx
@@ -521,7 +521,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
+ {t('kbSaveHint')}
+ {state.kbUiError
+ ? (
+
+ {fillTemplate(t('kbBrowseFailed'), { detail: state.kbUiError })}
+
+ )
+ : null}
{state.kbStatus?.status === 'error' && state.kbStatus.errorMessage
? {state.kbStatus.errorMessage}
: null}
diff --git a/src/client/controller.ts b/src/client/controller.ts
index e5e4fca..101812d 100644
--- a/src/client/controller.ts
+++ b/src/client/controller.ts
@@ -91,6 +91,8 @@ export interface NetxopsCardState extends CardShell {
kbStatus: KbSnapshot | null
/** Soft-injected `remote.directoryPicker` is available. */
kbDirectoryPickerReady: boolean
+ /** Last browse / resolve UI error (picker refuse, RPC miss, …). */
+ kbUiError: string | null
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
imDeliveryCatalog: ImDeliveryCatalog
/** Bulk session-export readiness from Host RPC; null when RPC is absent. */
@@ -143,6 +145,7 @@ export class NetxopsCardController {
private kbDirectoryPicker: DirectoryPickerRemote | undefined
private kbBrowseInFlight = false
private kbStatusInFlight = false
+ private kbUiError: string | null = null
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
private sessionsExportStatus: SessionsExportStatus | null = null
private sessionsExportBusy = false
@@ -400,24 +403,73 @@ export class NetxopsCardController {
}
/**
- * Open the Host OS directory chooser and write the path into kbRoot.
+ * Open the Host OS directory chooser (or /api directoryPicker/pick fallback)
+ * and write the path into kbRoot.
*/
browseKbRoot(): void {
- const picker = this.kbDirectoryPicker
- if (picker === undefined || this.kbBrowseInFlight) return
+ if (this.kbBrowseInFlight) return
this.kbBrowseInFlight = true
- void picker.pick()
- .then((path) => {
- if (typeof path === 'string' && path.trim() !== '') {
- this.form.actions().edit('kbRoot', path)
- void this.refreshKbPreview()
+ this.kbUiError = null
+ this.store.set(this.projection())
+
+ const applyPath = (path: unknown): void => {
+ if (typeof path !== 'string' || path.trim() === '') {
+ // Cancel / empty — no error
+ return
+ }
+ this.form.actions().edit('kbRoot', path.trim())
+ void this.refreshKbPreview()
+ }
+
+ const fail = (error: unknown): void => {
+ this.kbUiError = error instanceof Error ? error.message : String(error)
+ this.store.set(this.projection())
+ }
+
+ const picker = this.kbDirectoryPicker
+ if (picker !== undefined) {
+ void picker.pick()
+ .then(applyPath)
+ .catch(fail)
+ .finally(() => {
+ this.kbBrowseInFlight = false
+ this.store.set(this.projection())
+ })
+ return
+ }
+
+ // Fallback: workspace controller remote verb on the Host /api channel.
+ const call = this.rpcCall
+ if (call === undefined) {
+ this.kbBrowseInFlight = false
+ this.kbUiError = 'directory picker unavailable — paste an absolute folder path and Save'
+ this.store.set(this.projection())
+ return
+ }
+ void call('/api', 'directoryPicker/pick', { args: {} })
+ .then((result) => {
+ // Remotes often return the path directly, or { ok, value }.
+ if (typeof result === 'string' || result === null) {
+ applyPath(result)
+ return
}
+ if (result !== null && typeof result === 'object') {
+ const row = result as { ok?: boolean; value?: unknown; error?: { message?: string } }
+ if (row.ok === false) {
+ fail(row.error?.message || 'directoryPicker/pick failed')
+ return
+ }
+ if ('value' in row) {
+ applyPath(row.value)
+ return
+ }
+ }
+ applyPath(result)
})
- .catch(() => {
- // Picker cancel / host refusal — leave draft unchanged.
- })
+ .catch(fail)
.finally(() => {
this.kbBrowseInFlight = false
+ this.store.set(this.projection())
})
}
@@ -482,7 +534,8 @@ export class NetxopsCardController {
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
kbStatus: this.kbPreview ?? this.kbStatus,
- kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined,
+ kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
+ kbUiError: this.kbUiError,
imDeliveryCatalog: this.imDeliveryCatalog,
sessionsExportStatus: this.sessionsExportStatus,
sessionsExportBusy: this.sessionsExportBusy,
diff --git a/src/client/index.ts b/src/client/index.ts
index 02d809d..d38eb29 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -79,14 +79,24 @@ export function apply(ctx: ClientContext): void {
})
// Soft-inject Host directory picker for knowledge-base browse.
+ // Prefer the dedicated remote namespace; also accept ctx.remote.directoryPicker.
+ const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise } | undefined): void => {
+ if (!picker || typeof picker.pick !== 'function') return
+ card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise })
+ }
+ bindDirectoryPicker(
+ (ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } })
+ .remote?.directoryPicker,
+ )
ctx.inject(['remote.directoryPicker'], (dpCtx) => {
- const picker = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } })
+ const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function'
+ ? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as
+ | { pick?: (signal?: AbortSignal) => Promise }
+ | undefined
+ : undefined
+ const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } })
.remote?.directoryPicker
- if (!picker || typeof picker.pick !== 'function') {
- dpCtx.logger.warn('netxops: remote.directoryPicker.pick unavailable — browse button disabled')
- return
- }
- card.setDirectoryPicker(picker)
+ bindDirectoryPicker(viaGet ?? viaNested)
dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined)
}, 'netxops: clear directory picker')
diff --git a/src/client/kb-status-view.ts b/src/client/kb-status-view.ts
index c55b13f..11a3318 100644
--- a/src/client/kb-status-view.ts
+++ b/src/client/kb-status-view.ts
@@ -64,7 +64,11 @@ export async function resolveKbPath(
path: string,
signal?: AbortSignal,
): Promise {
- const result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal)
+ // Prefer flat `{ path }` (netxops channel); also try `{ args: { path } }`.
+ let result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal)
+ if (!(result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true)) {
+ result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { args: { path } }, signal)
+ }
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asKbSnapshot((result as { value?: unknown }).value)
}
diff --git a/src/client/locales.ts b/src/client/locales.ts
index ee57bef..51a5464 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -23,6 +23,8 @@ export type NetxopsLocaleKey =
| 'kbRootHint'
| 'kbBrowse'
| 'kbBrowseUnavailable'
+ | 'kbBrowseFailed'
+ | 'kbSaveHint'
| 'kbStatusConfigured'
| 'kbStatusUnconfigured'
| 'kbStatusError'
@@ -92,7 +94,9 @@ export const en: Record = {
kbRoot: 'Package root',
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.',
kbBrowse: 'Browse…',
- kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute path.',
+ kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.',
+ kbBrowseFailed: 'Browse failed: {detail}',
+ kbSaveHint: 'Click Save after choosing a path to apply the knowledge base.',
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}',
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
kbStatusError: 'Knowledge base: error — {detail}',
@@ -163,7 +167,9 @@ export const zh: Record = {
kbRoot: '知识包根目录',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。',
kbBrowse: '浏览…',
- kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对路径。',
+ kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
+ kbBrowseFailed: '浏览失败:{detail}',
+ kbSaveHint: '选好路径后请点「保存」,知识库才会生效。',
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
kbStatusError: '知识库: 错误 — {detail}',
diff --git a/src/netx/kb-manifest.ts b/src/netx/kb-manifest.ts
index d251f9e..08011cb 100644
--- a/src/netx/kb-manifest.ts
+++ b/src/netx/kb-manifest.ts
@@ -12,7 +12,7 @@
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
-import { dirname, join, resolve } from 'node:path'
+import { basename, dirname, join, resolve } from 'node:path'
/** Known content flags; unknown keys are preserved when boolean. */
export interface KbContentFlags {
@@ -218,15 +218,32 @@ export function parseManifest(raw: string): {
/**
* Resolve kbRoot → KbSnapshot (unconfigured / configured / error).
+ * Accepts a directory, or a path directly to MANIFEST.json (uses its parent).
*/
export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot {
const trimmed = kbRoot.trim()
if (!trimmed) return unconfiguredKbSnapshot()
- const located = findManifest(trimmed, maxDepth)
+ let root = resolve(trimmed)
+ try {
+ const st = statSync(root)
+ if (st.isFile()) {
+ if (basename(root).toLowerCase() === 'manifest.json') {
+ root = dirname(root)
+ } else {
+ return errorSnapshot(`kbRoot is not a directory: ${root}`)
+ }
+ } else if (!st.isDirectory()) {
+ return errorSnapshot(`kbRoot is not a directory: ${root}`)
+ }
+ } catch {
+ return errorSnapshot(`kbRoot not found: ${root}`)
+ }
+
+ const located = findManifest(root, maxDepth)
if (located.error) return errorSnapshot(located.error)
if (located.paths.length === 0) {
- return errorSnapshot(`no MANIFEST.json under ${resolve(trimmed)} (maxDepth=${maxDepth})`)
+ return errorSnapshot(`no MANIFEST.json under ${root} (maxDepth=${maxDepth})`)
}
if (located.paths.length > 1) {
return errorSnapshot(
diff --git a/test/kb-manifest.test.mjs b/test/kb-manifest.test.mjs
index bf3aef3..9fdfd98 100644
--- a/test/kb-manifest.test.mjs
+++ b/test/kb-manifest.test.mjs
@@ -144,8 +144,31 @@ test('findManifest respects maxDepth', () => {
})
})
-test('missing kbRoot path → error', () => {
- const snap = resolveKbRoot(join(tmpdir(), 'netxops-kb-missing-' + Date.now()))
- assert.equal(snap.status, 'error')
- assert.match(snap.errorMessage, /not found/)
+test('path to MANIFEST.json file resolves via parent dir', () => {
+ withTemp((root) => {
+ const manifestPath = join(root, 'MANIFEST.json')
+ writeFileSync(manifestPath, validManifest(), 'utf8')
+ const snap = resolveKbRoot(manifestPath)
+ assert.equal(snap.status, 'configured')
+ assert.equal(snap.realRoot, root)
+ assert.equal(snap.operatorName, 'IOH')
+ })
+})
+
+test('contract content flags hasRegions etc. are accepted', () => {
+ withTemp((root) => {
+ writeFileSync(join(root, 'MANIFEST.json'), validManifest({
+ content: {
+ hasRegions: true,
+ hasTheory: true,
+ hasPacket: true,
+ hasCommon: true,
+ hasSkills: true,
+ },
+ }), 'utf8')
+ const snap = resolveKbRoot(root)
+ assert.equal(snap.status, 'configured')
+ assert.equal(snap.content.hasRegions, true)
+ assert.equal(snap.content.hasSkills, true)
+ })
})