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