mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-12 06:33:16 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
de7c6a0c4b
commit
f64f1279e7
9 changed files with 246 additions and 49 deletions
|
|
@ -621,7 +621,10 @@ async function fetchKbStatus(call, signal) {
|
||||||
return unconfiguredKbSnapshot();
|
return unconfiguredKbSnapshot();
|
||||||
}
|
}
|
||||||
async function resolveKbPath(call, path, signal) {
|
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) {
|
if (result !== null && typeof result === "object" && result.ok === true) {
|
||||||
return asKbSnapshot(result.value);
|
return asKbSnapshot(result.value);
|
||||||
}
|
}
|
||||||
|
|
@ -1376,7 +1379,7 @@ function NetxopsCard(props) {
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("button", {
|
/* @__PURE__ */ jsx_runtime.jsx("button", {
|
||||||
type: "button",
|
type: "button",
|
||||||
className: "dsh-nx-btn",
|
className: "dsh-nx-btn",
|
||||||
disabled: disabled || !state.kbDirectoryPickerReady,
|
disabled,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
props.browseKbRoot();
|
props.browseKbRoot();
|
||||||
},
|
},
|
||||||
|
|
@ -1388,6 +1391,15 @@ function NetxopsCard(props) {
|
||||||
className: state.kbRoot.invalid ? "dsh-nx-invalid" : "dsh-nx-hint",
|
className: state.kbRoot.invalid ? "dsh-nx-invalid" : "dsh-nx-hint",
|
||||||
children: state.kbRoot.invalid ? t("invalid") : state.kbDirectoryPickerReady ? t("kbRootHint") : t("kbBrowseUnavailable")
|
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", {
|
state.kbStatus?.status === "error" && state.kbStatus.errorMessage ? /* @__PURE__ */ jsx_runtime.jsx("p", {
|
||||||
className: "dsh-nx-invalid",
|
className: "dsh-nx-invalid",
|
||||||
role: "status",
|
role: "status",
|
||||||
|
|
@ -1806,6 +1818,7 @@ class NetxopsCardController {
|
||||||
kbDirectoryPicker;
|
kbDirectoryPicker;
|
||||||
kbBrowseInFlight = false;
|
kbBrowseInFlight = false;
|
||||||
kbStatusInFlight = false;
|
kbStatusInFlight = false;
|
||||||
|
kbUiError = null;
|
||||||
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||||
sessionsExportStatus = null;
|
sessionsExportStatus = null;
|
||||||
sessionsExportBusy = false;
|
sessionsExportBusy = false;
|
||||||
|
|
@ -2002,17 +2015,57 @@ class NetxopsCardController {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
browseKbRoot() {
|
browseKbRoot() {
|
||||||
const picker = this.kbDirectoryPicker;
|
if (this.kbBrowseInFlight)
|
||||||
if (picker === undefined || this.kbBrowseInFlight)
|
|
||||||
return;
|
return;
|
||||||
this.kbBrowseInFlight = true;
|
this.kbBrowseInFlight = true;
|
||||||
picker.pick().then((path) => {
|
this.kbUiError = null;
|
||||||
if (typeof path === "string" && path.trim() !== "") {
|
this.store.set(this.projection());
|
||||||
this.form.actions().edit("kbRoot", path);
|
const applyPath = (path) => {
|
||||||
this.refreshKbPreview();
|
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.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() {
|
exportAllSessions() {
|
||||||
|
|
@ -2071,7 +2124,8 @@ class NetxopsCardController {
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus,
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
kbStatus: this.kbPreview ?? this.kbStatus,
|
kbStatus: this.kbPreview ?? this.kbStatus,
|
||||||
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined,
|
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
|
||||||
|
kbUiError: this.kbUiError,
|
||||||
imDeliveryCatalog: this.imDeliveryCatalog,
|
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||||
sessionsExportStatus: this.sessionsExportStatus,
|
sessionsExportStatus: this.sessionsExportStatus,
|
||||||
sessionsExportBusy: this.sessionsExportBusy,
|
sessionsExportBusy: this.sessionsExportBusy,
|
||||||
|
|
@ -2185,7 +2239,9 @@ var en = {
|
||||||
kbRoot: "Package root",
|
kbRoot: "Package root",
|
||||||
kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.",
|
kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.",
|
||||||
kbBrowse: "Browse…",
|
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}",
|
kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}",
|
||||||
kbStatusUnconfigured: "Knowledge base: not configured (pure netx)",
|
kbStatusUnconfigured: "Knowledge base: not configured (pure netx)",
|
||||||
kbStatusError: "Knowledge base: error — {detail}",
|
kbStatusError: "Knowledge base: error — {detail}",
|
||||||
|
|
@ -2255,7 +2311,9 @@ var zh = {
|
||||||
kbRoot: "知识包根目录",
|
kbRoot: "知识包根目录",
|
||||||
kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。",
|
kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。",
|
||||||
kbBrowse: "浏览…",
|
kbBrowse: "浏览…",
|
||||||
kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对路径。",
|
kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。",
|
||||||
|
kbBrowseFailed: "浏览失败:{detail}",
|
||||||
|
kbSaveHint: "选好路径后请点「保存」,知识库才会生效。",
|
||||||
kbStatusConfigured: "知识库: {operator}({country}) v{version}",
|
kbStatusConfigured: "知识库: {operator}({country}) v{version}",
|
||||||
kbStatusUnconfigured: "知识库: 未配置(纯 netx)",
|
kbStatusUnconfigured: "知识库: 未配置(纯 netx)",
|
||||||
kbStatusError: "知识库: 错误 — {detail}",
|
kbStatusError: "知识库: 错误 — {detail}",
|
||||||
|
|
@ -2350,13 +2408,16 @@ function apply(ctx) {
|
||||||
card.setAlarmPushRpc(undefined);
|
card.setAlarmPushRpc(undefined);
|
||||||
}, "netxops: clear alarm-push rpc");
|
}, "netxops: clear alarm-push rpc");
|
||||||
});
|
});
|
||||||
ctx.inject(["remote.directoryPicker"], (dpCtx) => {
|
const bindDirectoryPicker = (picker) => {
|
||||||
const picker = dpCtx.remote?.directoryPicker;
|
if (!picker || typeof picker.pick !== "function")
|
||||||
if (!picker || typeof picker.pick !== "function") {
|
|
||||||
dpCtx.logger.warn("netxops: remote.directoryPicker.pick unavailable — browse button disabled");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
card.setDirectoryPicker(picker);
|
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(() => () => {
|
dpCtx.effect(() => () => {
|
||||||
card.setDirectoryPicker(undefined);
|
card.setDirectoryPicker(undefined);
|
||||||
}, "netxops: clear directory picker");
|
}, "netxops: clear directory picker");
|
||||||
|
|
|
||||||
21
lib/index.js
21
lib/index.js
|
|
@ -844,7 +844,7 @@ function registerKbContextSkill(ctx, snapshot) {
|
||||||
|
|
||||||
// src/netx/kb-manifest.ts
|
// src/netx/kb-manifest.ts
|
||||||
import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs";
|
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 = {
|
var EMPTY_CONTENT = {
|
||||||
regions: false,
|
regions: false,
|
||||||
theory: false,
|
theory: false,
|
||||||
|
|
@ -1000,11 +1000,26 @@ function resolveKbRoot(kbRoot, maxDepth = 3) {
|
||||||
const trimmed = kbRoot.trim();
|
const trimmed = kbRoot.trim();
|
||||||
if (!trimmed)
|
if (!trimmed)
|
||||||
return unconfiguredKbSnapshot();
|
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)
|
if (located.error)
|
||||||
return errorSnapshot(located.error);
|
return errorSnapshot(located.error);
|
||||||
if (located.paths.length === 0) {
|
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) {
|
if (located.paths.length > 1) {
|
||||||
return errorSnapshot(`ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`);
|
return errorSnapshot(`ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`);
|
||||||
|
|
|
||||||
|
|
@ -521,7 +521,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="dsh-nx-btn"
|
className="dsh-nx-btn"
|
||||||
disabled={disabled || !state.kbDirectoryPickerReady}
|
disabled={disabled}
|
||||||
onClick={() => { props.browseKbRoot() }}
|
onClick={() => { props.browseKbRoot() }}
|
||||||
>
|
>
|
||||||
{t('kbBrowse')}
|
{t('kbBrowse')}
|
||||||
|
|
@ -534,6 +534,14 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
? t('kbRootHint')
|
? t('kbRootHint')
|
||||||
: t('kbBrowseUnavailable')}
|
: t('kbBrowseUnavailable')}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="dsh-nx-hint">{t('kbSaveHint')}</p>
|
||||||
|
{state.kbUiError
|
||||||
|
? (
|
||||||
|
<p className="dsh-nx-invalid" role="status">
|
||||||
|
{fillTemplate(t('kbBrowseFailed'), { detail: state.kbUiError })}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
{state.kbStatus?.status === 'error' && state.kbStatus.errorMessage
|
{state.kbStatus?.status === 'error' && state.kbStatus.errorMessage
|
||||||
? <p className="dsh-nx-invalid" role="status">{state.kbStatus.errorMessage}</p>
|
? <p className="dsh-nx-invalid" role="status">{state.kbStatus.errorMessage}</p>
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ export interface NetxopsCardState extends CardShell {
|
||||||
kbStatus: KbSnapshot | null
|
kbStatus: KbSnapshot | null
|
||||||
/** Soft-injected `remote.directoryPicker` is available. */
|
/** Soft-injected `remote.directoryPicker` is available. */
|
||||||
kbDirectoryPickerReady: boolean
|
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). */
|
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
|
||||||
imDeliveryCatalog: ImDeliveryCatalog
|
imDeliveryCatalog: ImDeliveryCatalog
|
||||||
/** Bulk session-export readiness from Host RPC; null when RPC is absent. */
|
/** Bulk session-export readiness from Host RPC; null when RPC is absent. */
|
||||||
|
|
@ -143,6 +145,7 @@ export class NetxopsCardController {
|
||||||
private kbDirectoryPicker: DirectoryPickerRemote | undefined
|
private kbDirectoryPicker: DirectoryPickerRemote | undefined
|
||||||
private kbBrowseInFlight = false
|
private kbBrowseInFlight = false
|
||||||
private kbStatusInFlight = false
|
private kbStatusInFlight = false
|
||||||
|
private kbUiError: string | null = null
|
||||||
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||||
private sessionsExportStatus: SessionsExportStatus | null = null
|
private sessionsExportStatus: SessionsExportStatus | null = null
|
||||||
private sessionsExportBusy = false
|
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 {
|
browseKbRoot(): void {
|
||||||
const picker = this.kbDirectoryPicker
|
if (this.kbBrowseInFlight) return
|
||||||
if (picker === undefined || this.kbBrowseInFlight) return
|
|
||||||
this.kbBrowseInFlight = true
|
this.kbBrowseInFlight = true
|
||||||
void picker.pick()
|
this.kbUiError = null
|
||||||
.then((path) => {
|
this.store.set(this.projection())
|
||||||
if (typeof path === 'string' && path.trim() !== '') {
|
|
||||||
this.form.actions().edit('kbRoot', path)
|
const applyPath = (path: unknown): void => {
|
||||||
void this.refreshKbPreview()
|
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(() => {
|
.catch(fail)
|
||||||
// Picker cancel / host refusal — leave draft unchanged.
|
|
||||||
})
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.kbBrowseInFlight = false
|
this.kbBrowseInFlight = false
|
||||||
|
this.store.set(this.projection())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -482,7 +534,8 @@ export class NetxopsCardController {
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus,
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
kbStatus: this.kbPreview ?? this.kbStatus,
|
kbStatus: this.kbPreview ?? this.kbStatus,
|
||||||
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined,
|
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
|
||||||
|
kbUiError: this.kbUiError,
|
||||||
imDeliveryCatalog: this.imDeliveryCatalog,
|
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||||
sessionsExportStatus: this.sessionsExportStatus,
|
sessionsExportStatus: this.sessionsExportStatus,
|
||||||
sessionsExportBusy: this.sessionsExportBusy,
|
sessionsExportBusy: this.sessionsExportBusy,
|
||||||
|
|
|
||||||
|
|
@ -79,14 +79,24 @@ export function apply(ctx: ClientContext): void {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Soft-inject Host directory picker for knowledge-base browse.
|
// 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<string | null> } | undefined): void => {
|
||||||
|
if (!picker || typeof picker.pick !== 'function') return
|
||||||
|
card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise<string | null> })
|
||||||
|
}
|
||||||
|
bindDirectoryPicker(
|
||||||
|
(ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
|
||||||
|
.remote?.directoryPicker,
|
||||||
|
)
|
||||||
ctx.inject(['remote.directoryPicker'], (dpCtx) => {
|
ctx.inject(['remote.directoryPicker'], (dpCtx) => {
|
||||||
const picker = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
|
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<string | null> }
|
||||||
|
| undefined
|
||||||
|
: undefined
|
||||||
|
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
|
||||||
.remote?.directoryPicker
|
.remote?.directoryPicker
|
||||||
if (!picker || typeof picker.pick !== 'function') {
|
bindDirectoryPicker(viaGet ?? viaNested)
|
||||||
dpCtx.logger.warn('netxops: remote.directoryPicker.pick unavailable — browse button disabled')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
card.setDirectoryPicker(picker)
|
|
||||||
dpCtx.effect(() => () => {
|
dpCtx.effect(() => () => {
|
||||||
card.setDirectoryPicker(undefined)
|
card.setDirectoryPicker(undefined)
|
||||||
}, 'netxops: clear directory picker')
|
}, 'netxops: clear directory picker')
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,11 @@ export async function resolveKbPath(
|
||||||
path: string,
|
path: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<KbSnapshot> {
|
): Promise<KbSnapshot> {
|
||||||
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) {
|
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||||
return asKbSnapshot((result as { value?: unknown }).value)
|
return asKbSnapshot((result as { value?: unknown }).value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export type NetxopsLocaleKey =
|
||||||
| 'kbRootHint'
|
| 'kbRootHint'
|
||||||
| 'kbBrowse'
|
| 'kbBrowse'
|
||||||
| 'kbBrowseUnavailable'
|
| 'kbBrowseUnavailable'
|
||||||
|
| 'kbBrowseFailed'
|
||||||
|
| 'kbSaveHint'
|
||||||
| 'kbStatusConfigured'
|
| 'kbStatusConfigured'
|
||||||
| 'kbStatusUnconfigured'
|
| 'kbStatusUnconfigured'
|
||||||
| 'kbStatusError'
|
| 'kbStatusError'
|
||||||
|
|
@ -92,7 +94,9 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
kbRoot: 'Package root',
|
kbRoot: 'Package root',
|
||||||
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.',
|
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.',
|
||||||
kbBrowse: 'Browse…',
|
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}',
|
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}',
|
||||||
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
|
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
|
||||||
kbStatusError: 'Knowledge base: error — {detail}',
|
kbStatusError: 'Knowledge base: error — {detail}',
|
||||||
|
|
@ -163,7 +167,9 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
kbRoot: '知识包根目录',
|
kbRoot: '知识包根目录',
|
||||||
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。',
|
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。',
|
||||||
kbBrowse: '浏览…',
|
kbBrowse: '浏览…',
|
||||||
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对路径。',
|
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
|
||||||
|
kbBrowseFailed: '浏览失败:{detail}',
|
||||||
|
kbSaveHint: '选好路径后请点「保存」,知识库才会生效。',
|
||||||
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
|
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
|
||||||
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
|
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
|
||||||
kbStatusError: '知识库: 错误 — {detail}',
|
kbStatusError: '知识库: 错误 — {detail}',
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
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. */
|
/** Known content flags; unknown keys are preserved when boolean. */
|
||||||
export interface KbContentFlags {
|
export interface KbContentFlags {
|
||||||
|
|
@ -218,15 +218,32 @@ export function parseManifest(raw: string): {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve kbRoot → KbSnapshot (unconfigured / configured / error).
|
* 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 {
|
export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot {
|
||||||
const trimmed = kbRoot.trim()
|
const trimmed = kbRoot.trim()
|
||||||
if (!trimmed) return unconfiguredKbSnapshot()
|
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.error) return errorSnapshot(located.error)
|
||||||
if (located.paths.length === 0) {
|
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) {
|
if (located.paths.length > 1) {
|
||||||
return errorSnapshot(
|
return errorSnapshot(
|
||||||
|
|
|
||||||
|
|
@ -144,8 +144,31 @@ test('findManifest respects maxDepth', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('missing kbRoot path → error', () => {
|
test('path to MANIFEST.json file resolves via parent dir', () => {
|
||||||
const snap = resolveKbRoot(join(tmpdir(), 'netxops-kb-missing-' + Date.now()))
|
withTemp((root) => {
|
||||||
assert.equal(snap.status, 'error')
|
const manifestPath = join(root, 'MANIFEST.json')
|
||||||
assert.match(snap.errorMessage, /not found/)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue