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:
oliver 2026-09-13 11:13:29 +08:00
parent de7c6a0c4b
commit f64f1279e7
9 changed files with 246 additions and 49 deletions

View file

@ -521,7 +521,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
<button
type="button"
className="dsh-nx-btn"
disabled={disabled || !state.kbDirectoryPickerReady}
disabled={disabled}
onClick={() => { props.browseKbRoot() }}
>
{t('kbBrowse')}
@ -534,6 +534,14 @@ export function NetxopsCard(props: NetxopsCardProps) {
? t('kbRootHint')
: t('kbBrowseUnavailable')}
</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
? <p className="dsh-nx-invalid" role="status">{state.kbStatus.errorMessage}</p>
: null}

View file

@ -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,

View file

@ -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<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) => {
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
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')

View file

@ -64,7 +64,11 @@ export async function resolveKbPath(
path: string,
signal?: AbortSignal,
): 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) {
return asKbSnapshot((result as { value?: unknown }).value)
}

View file

@ -23,6 +23,8 @@ export type NetxopsLocaleKey =
| 'kbRootHint'
| 'kbBrowse'
| 'kbBrowseUnavailable'
| 'kbBrowseFailed'
| 'kbSaveHint'
| 'kbStatusConfigured'
| 'kbStatusUnconfigured'
| 'kbStatusError'
@ -92,7 +94,9 @@ export const en: Record<NetxopsLocaleKey, string> = {
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<NetxopsLocaleKey, string> = {
kbRoot: '知识包根目录',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。',
kbBrowse: '浏览…',
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对路径。',
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
kbBrowseFailed: '浏览失败:{detail}',
kbSaveHint: '选好路径后请点「保存」,知识库才会生效。',
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
kbStatusError: '知识库: 错误 — {detail}',

View file

@ -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(