mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 11:10:44 +08:00
Adapt settings client for DSH 0.2.0 configForms without breaking boot.
Soft-inject configForms/settingsScope and mark Config fields volatile so desktop 0.2.0 no longer waits forever on the removed settingsScope service. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
30d4df23fa
commit
9b8e873c18
8 changed files with 358 additions and 166 deletions
|
|
@ -3,7 +3,7 @@
|
|||
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
|
||||
*/
|
||||
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
|
||||
import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts'
|
||||
|
||||
export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' }
|
||||
|
|
@ -110,7 +110,7 @@ export class CardForm<T> {
|
|||
private failed = false
|
||||
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<T>,
|
||||
private readonly scope: SettingsFormScope<T>,
|
||||
specs: CardFieldSpec[],
|
||||
secrets: CardSecretSpec[] = [],
|
||||
) {
|
||||
|
|
@ -235,7 +235,7 @@ export class CardForm<T> {
|
|||
return spec
|
||||
}
|
||||
|
||||
private snapshotOf(): SettingsScopeSnapshot<T> {
|
||||
private snapshotOf(): SettingsFormSnapshot<T> {
|
||||
return this.scope.getSnapshot()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
|
||||
import type { SnapshotStore } from './snapshot-store.ts'
|
||||
import {
|
||||
CardForm, textField, booleanField, booleanFieldPersistFalse,
|
||||
|
|
@ -175,7 +175,7 @@ export class NetxopsCardController {
|
|||
private exportInFlight: Promise<void> | undefined
|
||||
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<NetxopsSettings>,
|
||||
private readonly scope: SettingsFormScope<NetxopsSettings>,
|
||||
private readonly ctx: ClientContext,
|
||||
) {
|
||||
this.form = new CardForm(
|
||||
|
|
@ -708,7 +708,7 @@ export class NetxopsCardController {
|
|||
}
|
||||
}
|
||||
|
||||
function refOf(snapshot: SettingsScopeSnapshot<NetxopsSettings>): string {
|
||||
function refOf(snapshot: SettingsFormSnapshot<NetxopsSettings>): string {
|
||||
const declared = snapshot.value?.tokenCredentialRef
|
||||
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
/**
|
||||
* Browser half — Settings → Netx Ops section (uds-auth-style page).
|
||||
*
|
||||
* Do not hard-inject `remote.credentials`: shipped `@deepseek-ai/dsh` 0.1.1-rc.2
|
||||
* remotes assembly does not mount that namespace (Models/Plugins cards only
|
||||
* inject `remote`). Soft-inject when a newer Host provides it.
|
||||
* Hard-inject only services present on both DSH ≤0.1.5 and ≥0.1.7.
|
||||
* Do NOT hard-inject `settingsScope`: DSH ≥0.1.7 / 0.2.0 removed it
|
||||
* (`configForms` replaced it) and a hard wait kills web boot.
|
||||
*
|
||||
* Soft-inject `configForms` first (0.2.0 desktop), then `settingsScope`
|
||||
* (≤0.1.5). Soft-inject credentials / connection / directoryPicker when present.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { NetxopsCard } from './NetxopsCard.tsx'
|
||||
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
|
||||
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
|
||||
import type { SettingsFormScope } from './settings-form.ts'
|
||||
import type { NetxopsSettings } from './controller.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
|
|
@ -23,12 +27,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|||
|
||||
const LOCALE_NS = 'settings.netxops'
|
||||
|
||||
/** Match shipped settings shell inject (no remote.credentials). */
|
||||
/**
|
||||
* Services that exist on every supported DSH client roster.
|
||||
* Settings transport (`configForms` / `settingsScope`) is soft-injected below.
|
||||
*/
|
||||
export const inject = [
|
||||
'slots',
|
||||
'locale',
|
||||
'remote',
|
||||
'settingsScope',
|
||||
]
|
||||
|
||||
export function apply(ctx: ClientContext): void {
|
||||
|
|
@ -45,69 +50,98 @@ export function apply(ctx: ClientContext): void {
|
|||
}
|
||||
}, 'netxops: locales')
|
||||
|
||||
const card = new NetxopsCardController(
|
||||
ctx.settingsScope.bind({ namespace: NETXOPS_NS }),
|
||||
ctx,
|
||||
)
|
||||
const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string
|
||||
let mounted = false
|
||||
|
||||
// Optional: newer remotes that mount credentials unlock the token field.
|
||||
ctx.inject(['remote.credentials'], (credCtx) => {
|
||||
card.setCredentialsAvailable(true)
|
||||
credCtx.effect(() => {
|
||||
const off = credCtx.remote.$on('credentials/reference-updated', (ref) => {
|
||||
card.refreshCredential(String(ref))
|
||||
})
|
||||
return () => {
|
||||
off()
|
||||
card.setCredentialsAvailable(false)
|
||||
const mountCard = (scope: SettingsFormScope<NetxopsSettings>): void => {
|
||||
if (mounted) return
|
||||
mounted = true
|
||||
|
||||
const card = new NetxopsCardController(scope, ctx)
|
||||
|
||||
// Optional: newer remotes that mount credentials unlock the token field.
|
||||
ctx.inject(['remote.credentials'], (credCtx) => {
|
||||
card.setCredentialsAvailable(true)
|
||||
credCtx.effect(() => {
|
||||
const off = credCtx.remote.$on('credentials/reference-updated', (ref) => {
|
||||
card.refreshCredential(String(ref))
|
||||
})
|
||||
return () => {
|
||||
off()
|
||||
card.setCredentialsAvailable(false)
|
||||
}
|
||||
}, 'netxops: credential invalidations')
|
||||
})
|
||||
|
||||
// Soft-inject Connection so the card can poll host WSS status + KB status.
|
||||
ctx.inject(['connection'], (connCtx) => {
|
||||
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
|
||||
if (typeof call !== 'function') {
|
||||
connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled')
|
||||
return
|
||||
}
|
||||
}, 'netxops: credential invalidations')
|
||||
})
|
||||
card.setAlarmPushRpc(call)
|
||||
connCtx.effect(() => () => {
|
||||
card.setAlarmPushRpc(undefined)
|
||||
}, 'netxops: clear alarm-push rpc')
|
||||
})
|
||||
|
||||
// Soft-inject Connection so the card can poll host WSS status + KB status.
|
||||
ctx.inject(['connection'], (connCtx) => {
|
||||
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
|
||||
if (typeof call !== 'function') {
|
||||
connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled')
|
||||
// Soft-inject Host directory picker for knowledge-base browse.
|
||||
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 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
|
||||
bindDirectoryPicker(viaGet ?? viaNested)
|
||||
dpCtx.effect(() => () => {
|
||||
card.setDirectoryPicker(undefined)
|
||||
}, 'netxops: clear directory picker')
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: NETXOPS_NS,
|
||||
order: 24,
|
||||
label: () => t('title'),
|
||||
locale: LOCALE_NS,
|
||||
inject: () => card.inject(),
|
||||
}, NetxopsCard))
|
||||
}
|
||||
|
||||
// DSH ≥0.1.7 / 0.2.0 — profile entry id matches cordis.patch.yml `id: netxops`.
|
||||
ctx.inject(['configForms'], (formsCtx) => {
|
||||
const forms = (formsCtx as { configForms?: { get: (id: string) => SettingsFormScope<NetxopsSettings> } })
|
||||
.configForms
|
||||
if (!forms || typeof forms.get !== 'function') {
|
||||
formsCtx.logger?.warn?.('netxops: configForms present but .get missing')
|
||||
return
|
||||
}
|
||||
card.setAlarmPushRpc(call)
|
||||
connCtx.effect(() => () => {
|
||||
card.setAlarmPushRpc(undefined)
|
||||
}, 'netxops: clear alarm-push rpc')
|
||||
formsCtx.logger?.info?.('netxops: settings card via configForms')
|
||||
mountCard(forms.get(NETXOPS_NS))
|
||||
})
|
||||
|
||||
// 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 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
|
||||
bindDirectoryPicker(viaGet ?? viaNested)
|
||||
dpCtx.effect(() => () => {
|
||||
card.setDirectoryPicker(undefined)
|
||||
}, 'netxops: clear directory picker')
|
||||
// DSH ≤0.1.5 — settingsScope binder.
|
||||
ctx.inject(['settingsScope'], (scopeCtx) => {
|
||||
const binder = (scopeCtx as {
|
||||
settingsScope?: { bind: (spec: { namespace: string }) => SettingsFormScope<NetxopsSettings> }
|
||||
}).settingsScope
|
||||
if (!binder || typeof binder.bind !== 'function') {
|
||||
scopeCtx.logger?.warn?.('netxops: settingsScope present but .bind missing')
|
||||
return
|
||||
}
|
||||
if (mounted) return
|
||||
scopeCtx.logger?.info?.('netxops: settings card via settingsScope')
|
||||
mountCard(binder.bind({ namespace: NETXOPS_NS }))
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: NETXOPS_NS,
|
||||
order: 24,
|
||||
label: () => t('title'),
|
||||
locale: LOCALE_NS,
|
||||
inject: () => card.inject(),
|
||||
}, NetxopsCard))
|
||||
}
|
||||
|
|
|
|||
24
src/client/settings-form.ts
Normal file
24
src/client/settings-form.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Shared settings-form face used by the Plugins card.
|
||||
*
|
||||
* DSH ≤0.1.5 exposes `ctx.settingsScope.bind({ namespace })`.
|
||||
* DSH ≥0.1.7 / 0.2.0 exposes `ctx.configForms.get(entryId)`.
|
||||
* Snapshot + set/unset/subscribe shapes match, so the card stays agnostic.
|
||||
*/
|
||||
|
||||
export interface SettingsFormSnapshot<T> {
|
||||
status: string
|
||||
value?: T
|
||||
base?: unknown
|
||||
user?: unknown
|
||||
revision?: number
|
||||
writable: boolean
|
||||
mode?: string
|
||||
}
|
||||
|
||||
export interface SettingsFormScope<T> {
|
||||
getSnapshot(): SettingsFormSnapshot<T>
|
||||
subscribe(listener: () => void): () => void
|
||||
set(field: string, value: unknown): Promise<boolean | void>
|
||||
unset(field: string): Promise<boolean | void>
|
||||
}
|
||||
117
src/index.ts
117
src/index.ts
|
|
@ -168,30 +168,40 @@ export interface Config {
|
|||
groupKbPublic: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH ≥0.1.7 / 0.2.0 only projects Config fields marked `.volatile()` into the
|
||||
* settings UI. Zero volatile fields → the whole entry is silently filtered.
|
||||
* Older schemastery without `volatile` keeps the bare schema (≤0.1.5 path).
|
||||
*/
|
||||
function vol<S>(schema: S): S {
|
||||
const candidate = schema as { volatile?: () => S }
|
||||
return typeof candidate.volatile === 'function' ? candidate.volatile() : schema
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiUrl: z.string().default('http://127.0.0.1:8890'),
|
||||
lang: z.string().default('zh'),
|
||||
thinkingLanguage: z.string().default('auto'),
|
||||
replyLanguage: z.string().default('follow-user'),
|
||||
tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF),
|
||||
toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000),
|
||||
installAgentPreset: z.boolean().default(true),
|
||||
alarmPushEnabled: z.boolean().default(false),
|
||||
alarmDeliverDsh: z.boolean().default(true),
|
||||
alarmDeliverIm: z.boolean().default(false),
|
||||
imTargets: z.string().default(''),
|
||||
imBotId: z.string().default(''),
|
||||
imTargetId: z.string().default(''),
|
||||
nmsProvider: z.string().default('zte-ume'),
|
||||
groupOpsInPreset: z.boolean().default(true),
|
||||
groupOpsPublic: z.boolean().default(false),
|
||||
groupTopologyInPreset: z.boolean().default(false),
|
||||
groupTopologyPublic: z.boolean().default(false),
|
||||
groupBizMonitorInPreset: z.boolean().default(true),
|
||||
groupBizMonitorPublic: z.boolean().default(false),
|
||||
kbRoot: z.string().default(''),
|
||||
groupKbInPreset: z.boolean().default(true),
|
||||
groupKbPublic: z.boolean().default(false),
|
||||
apiUrl: vol(z.string().default('http://127.0.0.1:8890')),
|
||||
lang: vol(z.string().default('zh')),
|
||||
thinkingLanguage: vol(z.string().default('auto')),
|
||||
replyLanguage: vol(z.string().default('follow-user')),
|
||||
tokenCredentialRef: vol(z.string().role('credential-ref').default(DEFAULT_TOKEN_REF)),
|
||||
toolCallTimeoutMs: vol(z.number().step(1).min(1000).default(120_000)),
|
||||
installAgentPreset: vol(z.boolean().default(true)),
|
||||
alarmPushEnabled: vol(z.boolean().default(false)),
|
||||
alarmDeliverDsh: vol(z.boolean().default(true)),
|
||||
alarmDeliverIm: vol(z.boolean().default(false)),
|
||||
imTargets: vol(z.string().default('')),
|
||||
imBotId: vol(z.string().default('')),
|
||||
imTargetId: vol(z.string().default('')),
|
||||
nmsProvider: vol(z.string().default('zte-ume')),
|
||||
groupOpsInPreset: vol(z.boolean().default(true)),
|
||||
groupOpsPublic: vol(z.boolean().default(false)),
|
||||
groupTopologyInPreset: vol(z.boolean().default(false)),
|
||||
groupTopologyPublic: vol(z.boolean().default(false)),
|
||||
groupBizMonitorInPreset: vol(z.boolean().default(true)),
|
||||
groupBizMonitorPublic: vol(z.boolean().default(false)),
|
||||
kbRoot: vol(z.string().default('')),
|
||||
groupKbInPreset: vol(z.boolean().default(true)),
|
||||
groupKbPublic: vol(z.boolean().default(false)),
|
||||
})
|
||||
|
||||
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
||||
|
|
@ -243,10 +253,27 @@ async function resolveToken(ctx: Context, refName: string): Promise<string> {
|
|||
return hit?.value ?? ''
|
||||
}
|
||||
|
||||
/** Normalize settings.describe() across DSH generations (array or { namespaces }). */
|
||||
function describeRows(describe: unknown): Array<{ ns?: string, value?: unknown }> {
|
||||
if (typeof describe !== 'function') return []
|
||||
try {
|
||||
const raw = (describe as () => unknown)()
|
||||
if (Array.isArray(raw)) return raw as Array<{ ns?: string, value?: unknown }>
|
||||
if (raw && typeof raw === 'object' && Array.isArray((raw as { namespaces?: unknown }).namespaces)) {
|
||||
return (raw as { namespaces: Array<{ ns?: string, value?: unknown }> }).namespaces
|
||||
}
|
||||
} catch {
|
||||
// describe can throw while the provider is settling
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the settings namespace on both dsh generations:
|
||||
* Register / follow the settings namespace across DSH generations:
|
||||
* - 0.1.1-rc.2: standalone `installSettingsSection`
|
||||
* - ≥0.1.2-rc.1: `settings.installSection` on the provider
|
||||
* - ≥0.1.7 / 0.2.0: Config is projected from the Loader entry; no owner scope —
|
||||
* re-read via `settings.describe()` and poll for UI writes (no scope.watch).
|
||||
*/
|
||||
function installNetxopsSettings(
|
||||
ctx: Context,
|
||||
|
|
@ -271,7 +298,47 @@ function installNetxopsSettings(
|
|||
}
|
||||
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
|
||||
const settings = settingsCtx.settings as {
|
||||
installSection?: (
|
||||
context: Context,
|
||||
ns: string,
|
||||
schema: z<Config>,
|
||||
base: Config,
|
||||
sectionHooks: typeof hooks,
|
||||
) => void
|
||||
describe?: () => unknown
|
||||
}
|
||||
|
||||
if (typeof settings.installSection === 'function') {
|
||||
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
|
||||
return
|
||||
}
|
||||
|
||||
// DSH ≥0.1.7: profile Config projection — follow describe() for live values.
|
||||
const readLive = (): Config => {
|
||||
const row = describeRows(settings.describe).find(item => item.ns === NETXOPS_SETTINGS_NAMESPACE)
|
||||
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
|
||||
return { ...entry, ...(row.value as Partial<Config>) }
|
||||
}
|
||||
return entry
|
||||
}
|
||||
hooks.setSource(readLive)
|
||||
hooks.onChange()
|
||||
|
||||
let lastFingerprint = JSON.stringify(readLive())
|
||||
const timer = setInterval(() => {
|
||||
const next = readLive()
|
||||
const fingerprint = JSON.stringify(next)
|
||||
if (fingerprint === lastFingerprint) return
|
||||
lastFingerprint = fingerprint
|
||||
hooks.onChange()
|
||||
}, 2_000)
|
||||
settingsCtx.effect(() => () => {
|
||||
clearInterval(timer)
|
||||
}, 'netxops: settings describe poll')
|
||||
settingsCtx.logger.info(
|
||||
'netxops: following settings via describe() (no installSection — DSH ≥0.1.7 path)',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue