From fd1a86ec2f2de295b3267488c80735f899702929 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 4 Sep 2026 22:16:14 +0800 Subject: [PATCH] Add Netx Ops Plugins settings card for URL and token. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the browser half so Settings → Plugins can configure apiUrl/lang/python and NETX_API_TOKEN without OS env vars. Co-authored-by: Cursor --- .gitignore | 4 +- README.md | 28 +- docs/INSTALL.md | 54 ++- docs/PORTING.md | 3 +- examples/local-debug/README.md | 7 +- lib/client.js | 655 +++++++++++++++++++++++++++++++++ package.json | 32 +- scripts/build-client.mjs | 69 ++++ src/client/NetxopsCard.tsx | 184 +++++++++ src/client/card-form.ts | 221 +++++++++++ src/client/controller.ts | 115 ++++++ src/client/index.ts | 50 +++ src/client/locales.ts | 78 ++++ src/client/styles.ts | 49 +++ 14 files changed, 1492 insertions(+), 57 deletions(-) create mode 100644 lib/client.js create mode 100644 scripts/build-client.mjs create mode 100644 src/client/NetxopsCard.tsx create mode 100644 src/client/card-form.ts create mode 100644 src/client/controller.ts create mode 100644 src/client/index.ts create mode 100644 src/client/locales.ts create mode 100644 src/client/styles.ts diff --git a/.gitignore b/.gitignore index ab8c77b..3b4a272 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ -lib/ +# keep published client artifact; ignore other build junk under lib/ +lib/* +!lib/client.js *.log .DS_Store .env diff --git a/README.md b/README.md index 358b9b2..929d891 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,44 @@ # netxops — Netx Ops for DeepSeek Harness -Public **DeepSeek Harness** package: host bridge (settings + credentials → netx MCP) and an **ops agent preset** (persona + playbooks). +Public **DeepSeek Harness** package: host bridge + **Plugins settings card** + ops agent preset. - GitHub: https://github.com/hansjone/netxops - Topic: [`dsh-plugin`](https://github.com/topics/dsh-plugin) -- npm name: `dsh-netxops` +- Package: `dsh-netxops` - Brand: **Netx Ops** ## What you get | Piece | Where | Purpose | |-------|--------|---------| -| Host plugin `dsh-netxops` | `src/index.ts` + `cordis.patch.yml` | `apiUrl` / `lang` in settings; token as credential `NETX_API_TOKEN`; mounts `mcp__netx__*` | +| Host plugin | `src/index.ts` + `cordis.patch.yml` | settings `netxops` + credential `NETX_API_TOKEN` → `mcp__netx__*` | +| Plugins card | `src/client/` → `lib/client.js` | Settings → Plugins → fill URL / token in UI | | Agent preset | `presets/netxops/` | Persona + UME / managed-NE skills | -**Not OS env:** do not put `NETX_API_URL` / `NETX_API_TOKEN` in system environment for day-to-day use — configure inside DSH (settings + credentials). See [docs/INSTALL.md](docs/INSTALL.md). +**No OS env required** for day-to-day use. See [docs/INSTALL.md](docs/INSTALL.md). -**Not in v1:** topology canvas MCP, Excel report plugin, Plugins settings **card** UI (Host namespace is ready; browser card follows DSH cookbook). +**Not in v1:** topology canvas MCP, Excel report plugin. ## Quick start ```powershell -# 1) Host bridge + MCP tools dsh plugin --profile web add github:hansjone/netxops - -# 2) Token (credentials store, same family as model keys) -powershell -File .\scripts\set-netx-token.ps1 -Token "nxt_…" - -# 3) Ops preset (persona + skills) powershell -File .\scripts\link-preset.ps1 +# Then: Settings → Plugins → Netx Ops → paste token + API URL → Save ``` -Then open DSH → session preset **Netx Ops**. - ## Local debug -Against [`DeepSeekHarness`](https://github.com/deepseek-ai/deepseek-harness) checkout: [examples/local-debug/README.md](examples/local-debug/README.md). +[examples/local-debug/README.md](examples/local-debug/README.md) + +```powershell +bun run bundle # rebuild Plugins card after src/client changes +``` ## Related - Data plane: [hansjone/netx](https://github.com/hansjone/netx) -- Porting from oclaw: [docs/PORTING.md](docs/PORTING.md) +- Porting: [docs/PORTING.md](docs/PORTING.md) ## License diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 427725e..fd2ea47 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -11,9 +11,9 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx python -c "import netx_mcp; print('ok')" ``` -You do **not** need to export `NETX_API_URL` / `NETX_API_TOKEN` in the OS environment for normal use. +You do **not** need OS environment variables for `NETX_API_URL` / `NETX_API_TOKEN`. -## 1) Install host bridge (URL + token + MCP tools) +## 1) Install host bridge + Plugins settings card ```bash dsh plugin --profile add github:hansjone/netxops @@ -21,43 +21,25 @@ dsh plugin --profile add github:hansjone/netxops dsh plugin --profile add /path/to/netxops ``` -This applies `cordis.patch.yml`, which mounts package `dsh-netxops`. That plugin: +This mounts `dsh-netxops` (host + browser halves): -- Registers settings namespace **`netxops`** (`apiUrl`, `lang`, `pythonCommand`, …) -- Reads bearer token from credentials reference **`NETX_API_TOKEN`** (same store as model keys: `~/.dsh/.credentials.yaml`) -- Spawns `python -m netx_mcp` and registers `mcp__netx__*` on the **host** tool registry +- Host: settings namespace **`netxops`**, credential **`NETX_API_TOKEN`**, spawns `mcp__netx__*` +- Browser: **Settings → Plugins → Netx Ops** card (API URL / lang / python + token) -### Fill API URL / language +### Fill in the UI (recommended) -**Target UX:** Settings → **Plugins** → **Netx Ops** card (settings `apiUrl` / `lang`). -The Host settings namespace is already registered; a dedicated Plugins settings card (browser half) follows the [DSH settings-card cookbook](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md) and will land in a follow-up. +1. Open `dsh web` → **Settings** → **Plugins** → **Plugin configuration** +2. Expand **Netx Ops** +3. Paste **API token** (stored as credential `NETX_API_TOKEN`, never in settings JSON) +4. Set **API URL** (e.g. `http://127.0.0.1:8890`), optional lang / python command +5. **Save** — MCP remounts with the new values -**Until the card ships**, change composition defaults in the profile patch / plugin config: - -```yaml -# ~/.dsh/profiles//… or override the dsh-netxops row -config: - apiUrl: http://10.0.0.5:8890 - lang: zh - pythonCommand: python -``` - -Or edit the installed bundle row after `dsh plugin add`. - -### Fill netx API token (secret) - -Same credential store as DeepSeek API keys — **not** a process env var: +### Optional CLI for token only ```powershell powershell -File .\scripts\set-netx-token.ps1 -Token "nxt_…" ``` -```bash -./scripts/set-netx-token.sh 'nxt_…' -``` - -This writes / updates `refs.NETX_API_TOKEN` under `%USERPROFILE%\.dsh\.credentials.yaml` (or `$DSH_HOME`). Restart is not required if DSH is watching credentials; otherwise restart `dsh web`. - ## 2) Install the agent preset (persona + skills) ```powershell @@ -72,12 +54,18 @@ Open `dsh web`, new session → preset **Netx Ops**. ## Verify -1. Tools include `mcp__netx__queryUmeAlarms` (and siblings). -2. “Critical Top by host” → aggregate + Result/Evidence shell. -3. Single `host_name` alarm query → host-scoped tools only. +1. Settings → Plugins shows **Netx Ops** card; token badge becomes “Configured” after save. +2. Tools include `mcp__netx__queryUmeAlarms` (and siblings). +3. “Critical Top by host” / single `host_name` alarm query work. See [TOOL_MAP.md](TOOL_MAP.md). ## Local debug (DeepSeekHarness checkout) See [examples/local-debug/README.md](../examples/local-debug/README.md). + +Rebuild the Plugins card after editing `src/client/`: + +```powershell +bun run bundle +``` diff --git a/docs/PORTING.md b/docs/PORTING.md index 4e83f81..ebf0f2d 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -11,7 +11,7 @@ Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate MCP only. -**Config UX:** not OS env. Token via DSH credentials (same as model keys); `apiUrl` via settings / composition config. Plugins settings **card** (browser half) TBD per DSH cookbook. +**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / `pythonCommand` via settings namespace `netxops`. ## Stay in oclaw @@ -19,7 +19,6 @@ Gateway, WhatsApp/Weixin, Admin MCP UI, specialist router, memory-wiki, ops-ai H ## Phase 2 candidates -- Plugins settings card (`dsh.client`) for apiUrl + token on Settings → Plugins - `ume_alarm_xlsx_report` + thin HTTP client as a DSH tool plugin - UME sync context inject - `ops-ip-knowledge-playbook` + KB content diff --git a/examples/local-debug/README.md b/examples/local-debug/README.md index f09c254..d4c434c 100644 --- a/examples/local-debug/README.md +++ b/examples/local-debug/README.md @@ -28,6 +28,7 @@ Adjust paths if your checkout differs. ## C. Verify -1. New session → preset **Netx Ops** -2. Tools include `mcp__netx__aggregateUmeAlarms` -3. Ask Critical Top / single host alarms +1. Settings → Plugins → **Netx Ops** card shows URL / token fields (after `bun run bundle` if you edited `src/client/`) +2. New session → preset **Netx Ops** +3. Tools include `mcp__netx__aggregateUmeAlarms` +4. Ask Critical Top / single host alarms diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..b34e257 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,655 @@ +window.__ModuleLoader__.load({ + id: "dsh-netxops", + factory: (require) => { + var module = { exports: {} }; + var exports = module.exports; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __hasOwnProp = Object.prototype.hasOwnProperty; +function __accessProp(key) { + return this[key]; +} +var __toCommonJS = (from) => { + var entry = (__moduleCache ??= new WeakMap).get(from), desc; + if (entry) + return entry; + entry = __defProp({}, "__esModule", { value: true }); + if (from && typeof from === "object" || typeof from === "function") { + for (var key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(entry, key)) + __defProp(entry, key, { + get: __accessProp.bind(from, key), + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable + }); + } + __moduleCache.set(from, entry); + return entry; +}; +var __moduleCache; +var __returnValue = (v) => v; +function __exportSetter(name, newValue) { + this[name] = __returnValue.bind(null, newValue); +} +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { + get: all[name], + enumerable: true, + configurable: true, + set: __exportSetter.bind(all, name) + }); +}; + +// src/client/index.ts +var exports_client = {}; +__export(exports_client, { + inject: () => inject, + apply: () => apply +}); +module.exports = __toCommonJS(exports_client); + +// src/client/NetxopsCard.tsx +var import_react = require("react"); + +// src/client/styles.ts +var STYLE_ID = "dsh-netxops-card-css"; +var CSS = ` +.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s} +.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)} +.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)} +.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px} +.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} +.dsh-nx-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)} +.dsh-nx-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-pending{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} +.dsh-nx-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s} +.dsh-nx-chevronOpen{transform:rotate(180deg)} +.dsh-nx-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px} +.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0} +.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)} +.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px} +.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px} +.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} +.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} +.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)} +.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)} +.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)} +.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} +.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)} +.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} +.dsh-nx-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer} +.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)} +.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)} +.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)} +.dsh-nx-btn:disabled{opacity:.4;cursor:default} +`; +function ensureStyles() { + if (typeof document === "undefined") + return; + if (document.getElementById(STYLE_ID)) + return; + const tag = document.createElement("style"); + tag.id = STYLE_ID; + tag.textContent = CSS; + document.head.appendChild(tag); +} + +// src/client/NetxopsCard.tsx +var jsx_dev_runtime = require("react/jsx-dev-runtime"); +function ValueField(props) { + return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", { + className: "dsh-nx-field", + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", { + className: "dsh-nx-fieldHead", + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("label", { + className: "dsh-nx-label", + htmlFor: props.id, + children: props.label + }, undefined, false, undefined, this), + props.field.overridden ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: "dsh-nx-badges", + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: "dsh-nx-badge", + children: props.overriddenLabel + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", { + type: "button", + className: "dsh-nx-reset", + disabled: props.disabled, + onClick: props.onReset, + children: props.resetLabel + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this) : null + ] + }, undefined, true, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("input", { + id: props.id, + className: props.field.invalid ? "dsh-nx-input dsh-nx-inputInvalid" : "dsh-nx-input", + type: "text", + value: props.field.text, + disabled: props.disabled, + "aria-invalid": props.field.invalid || undefined, + onChange: (event) => { + props.onEdit(event.target.value); + } + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", { + className: props.field.invalid ? "dsh-nx-invalid" : "dsh-nx-hint", + children: props.field.invalid ? props.invalidLabel : props.hint + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this); +} +function NetxopsCard(props) { + ensureStyles(); + const { t } = props; + const state = props.useNetxopsCard((snapshot) => snapshot); + const [open, setOpen] = import_react.useState(false); + const saveStarted = import_react.useRef(false); + import_react.useEffect(() => { + if (state.saving) { + saveStarted.current = true; + return; + } + if (!saveStarted.current) + return; + saveStarted.current = false; + if (!state.dirty && !state.failed) + setOpen(false); + }, [state.dirty, state.failed, state.saving]); + if (!state.available) + return null; + const title = t("title"); + const disabled = !state.writable; + const blocked = !state.dirty || state.invalid || state.saving; + return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("li", { + className: open ? "dsh-nx-card dsh-nx-cardOpen" : "dsh-nx-card", + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", { + type: "button", + className: "dsh-nx-header", + "aria-expanded": open, + "aria-label": `${t(open ? "collapse" : "expand")}: ${title}`, + onClick: () => { + setOpen(!open); + }, + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: "dsh-nx-headText", + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: "dsh-nx-name", + children: title + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: "dsh-nx-desc", + children: t("description") + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this), + state.dirty ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: "dsh-nx-pending", + children: t("unsaved") + }, undefined, false, undefined, this) : null, + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: open ? "dsh-nx-chevron dsh-nx-chevronOpen" : "dsh-nx-chevron", + "aria-hidden": true, + children: "▾" + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this), + open ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", { + className: "dsh-nx-body", + children: [ + !state.writable ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", { + className: "dsh-nx-readOnly", + role: "status", + children: t("readOnly") + }, undefined, false, undefined, this) : null, + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", { + className: "dsh-nx-field", + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", { + className: "dsh-nx-fieldHead", + children: [ + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("label", { + className: "dsh-nx-label", + htmlFor: "netxops-api-token", + children: t("apiToken") + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: "dsh-nx-badges", + children: /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", { + className: state.apiTokenConfigured ? "dsh-nx-badge" : "dsh-nx-badgeMuted", + children: state.apiTokenConfigured ? t("apiTokenSet") : t("apiTokenUnset") + }, undefined, false, undefined, this) + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("input", { + id: "netxops-api-token", + className: "dsh-nx-input", + type: "password", + autoComplete: "off", + value: state.apiToken.text, + disabled: !state.apiTokenWritable, + onChange: (event) => { + props.edit("apiToken", event.target.value); + } + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", { + className: "dsh-nx-hint", + children: t("apiTokenHint") + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV(ValueField, { + id: "netxops-api-url", + label: t("apiUrl"), + hint: t("apiUrlHint"), + field: state.apiUrl, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + invalidLabel: t("invalid"), + disabled, + onEdit: (text) => { + props.edit("apiUrl", text); + }, + onReset: () => { + props.resetField("apiUrl"); + } + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV(ValueField, { + id: "netxops-lang", + label: t("lang"), + hint: t("langHint"), + field: state.lang, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + invalidLabel: t("invalid"), + disabled, + onEdit: (text) => { + props.edit("lang", text); + }, + onReset: () => { + props.resetField("lang"); + } + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV(ValueField, { + id: "netxops-python", + label: t("pythonCommand"), + hint: t("pythonCommandHint"), + field: state.pythonCommand, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + invalidLabel: t("invalid"), + disabled, + onEdit: (text) => { + props.edit("pythonCommand", text); + }, + onReset: () => { + props.resetField("pythonCommand"); + } + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", { + className: "dsh-nx-footer", + children: [ + state.failed ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", { + className: "dsh-nx-failed", + role: "status", + children: t("saveFailed") + }, undefined, false, undefined, this) : null, + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", { + type: "button", + className: "dsh-nx-btn dsh-nx-discard", + disabled: !state.dirty || state.saving, + onClick: props.discard, + children: t("discard") + }, undefined, false, undefined, this), + /* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", { + type: "button", + className: "dsh-nx-btn dsh-nx-save", + disabled: blocked, + onClick: props.save, + children: t(state.saving ? "saving" : "save") + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this) + ] + }, undefined, true, undefined, this) : null + ] + }, undefined, true, undefined, this); +} + +// src/client/card-form.ts +var import_dsh_client_store = require("@deepseek-ai/dsh-client-store"); +function textField(field) { + return { + field, + format: (value) => typeof value === "string" ? value : "", + parse: (text) => { + const trimmed = text.trim(); + return trimmed === "" ? { kind: "clear" } : { kind: "set", value: trimmed }; + } + }; +} + +class CardForm { + scope; + specs; + secretSpecs; + staged = new Map; + listeners = new Set; + saving = false; + failed = false; + constructor(scope, specs, secrets = []) { + this.scope = scope; + this.specs = new Map(specs.map((spec) => [spec.field, spec])); + this.secretSpecs = new Map(secrets.map((spec) => [spec.field, spec])); + scope.subscribe(() => { + this.publish(); + }); + } + bind(project) { + const store = import_dsh_client_store.createSnapshotStore(project()); + this.listeners.add(() => { + store.set(project()); + }); + return store; + } + shell() { + const snapshot = this.scope.getSnapshot(); + const plan = this.plan(); + return { + available: snapshot.status === "ready", + writable: snapshot.writable, + dirty: plan.length > 0, + invalid: plan.some((item) => item.run === undefined), + saving: this.saving, + failed: this.failed + }; + } + field(field) { + const staged = this.staged.get(field); + if (this.secretSpecs.has(field)) { + return { text: staged?.text ?? "", overridden: false, invalid: false }; + } + const spec = this.spec(field); + if (staged === undefined) { + return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }; + } + const write = staged.clear ? { kind: "clear" } : spec.parse(staged.text); + return { + text: staged.text, + overridden: write?.kind === "set", + invalid: write === undefined + }; + } + actions() { + return { + edit: (field, text) => { + this.stage(field, { text, clear: false }); + }, + resetField: (field) => { + this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true }); + }, + save: () => { + this.save(); + }, + discard: () => { + if (this.staged.size === 0 && !this.failed) + return; + this.staged.clear(); + this.failed = false; + this.publish(); + } + }; + } + async save() { + const plan = this.plan(); + const writes = plan.flatMap((item) => item.run === undefined ? [] : [item.run]); + if (plan.length === 0 || this.saving || writes.length !== plan.length) + return; + this.saving = true; + this.failed = false; + this.publish(); + let landed = true; + for (const write of writes) { + landed = await write() && landed; + } + if (landed) + this.staged.clear(); + this.saving = false; + this.failed = !landed; + this.publish(); + } + plan() { + const plan = []; + for (const [field, staged] of this.staged) { + const secret = this.secretSpecs.get(field); + if (secret !== undefined) { + const value = staged.text.trim(); + if (value !== "") + plan.push({ field, run: () => secret.write(value) }); + continue; + } + const spec = this.spec(field); + if (staged.clear) { + if (this.stored(field)) + plan.push({ field, run: () => this.clear(field) }); + continue; + } + if (staged.text === spec.format(this.sectionValue(field))) + continue; + const write = spec.parse(staged.text); + if (write === undefined) + plan.push({ field, run: undefined }); + else if (write.kind === "clear") + plan.push({ field, run: () => this.clear(field) }); + else + plan.push({ field, run: () => this.store(field, write.value) }); + } + return plan; + } + async clear(field) { + await this.scope.unset(field); + return !this.stored(field); + } + async store(field, value) { + await this.scope.set(field, value); + return this.userLayer()?.[field] === value; + } + stage(field, edit) { + this.staged.set(field, edit); + this.failed = false; + this.publish(); + } + spec(field) { + const spec = this.specs.get(field); + if (spec === undefined) + throw new Error(`netxops card has no field ${field}`); + return spec; + } + snapshotOf() { + return this.scope.getSnapshot(); + } + sectionValue(field) { + return this.snapshotOf().value?.[field]; + } + baseValue(field) { + return this.snapshotOf().base?.[field]; + } + userLayer() { + return this.snapshotOf().user; + } + stored(field) { + const user = this.userLayer(); + return user !== undefined && Object.hasOwn(user, field); + } + publish() { + for (const listener of this.listeners) + listener(); + } +} + +// src/client/controller.ts +var NETXOPS_NS = "netxops"; +var DEFAULT_TOKEN_REF = "NETX_API_TOKEN"; +var API_TOKEN_FIELD = "apiToken"; + +class NetxopsCardController { + scope; + ctx; + form; + store; + credential = { ref: "", configured: false, writable: true }; + constructor(scope, ctx) { + this.scope = scope; + this.ctx = ctx; + this.form = new CardForm(scope, [textField("apiUrl"), textField("lang"), textField("pythonCommand")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]); + this.store = this.form.bind(() => this.projection()); + scope.subscribe(() => { + this.readCredential(); + }); + this.readCredential(); + } + projection() { + return { + ...this.form.shell(), + apiUrl: this.form.field("apiUrl"), + lang: this.form.field("lang"), + pythonCommand: this.form.field("pythonCommand"), + apiToken: this.form.field(API_TOKEN_FIELD), + apiTokenConfigured: this.credential.configured, + apiTokenWritable: this.credential.writable + }; + } + async readCredential() { + const ref = refOf(this.scope.getSnapshot()); + if (ref !== this.credential.ref) { + this.credential = { ref, configured: false, writable: true }; + this.store.set(this.projection()); + } + const response = await this.ctx.remote.credentials.describe([ref]); + if (!response.ok || ref !== refOf(this.scope.getSnapshot())) + return; + const view = response.value[ref]; + const next = { + ref, + configured: view?.configured ?? false, + writable: view?.writable ?? true + }; + if (next.configured === this.credential.configured && next.writable === this.credential.writable) + return; + this.credential = next; + this.store.set(this.projection()); + } + refreshCredential(ref) { + if (ref !== this.credential.ref) + return; + this.readCredential(); + } + inject() { + return { hooks: { netxopsCard: this.store }, ...this.form.actions() }; + } + async writeToken(value) { + await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value); + await this.readCredential(); + return this.credential.configured; + } +} +function refOf(snapshot) { + const declared = snapshot.value?.tokenCredentialRef; + return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF; +} + +// src/client/locales.ts +var en = { + title: "Netx Ops", + description: "UME API endpoint and bearer token for mcp__netx__* tools.", + apiUrl: "API URL", + apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890", + lang: "Language", + langHint: "Passed as NETX_LANG (zh / en).", + pythonCommand: "Python command", + pythonCommandHint: "Executable that can run `python -m netx_mcp`.", + apiToken: "API token", + apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.", + apiTokenSet: "Configured", + apiTokenUnset: "Not set", + overridden: "Overridden", + reset: "Reset", + invalid: "Invalid value", + expand: "Expand", + collapse: "Collapse", + unsaved: "Unsaved", + readOnly: "This document is read-only.", + saveFailed: "Save failed — drafts kept for correction.", + discard: "Discard", + save: "Save", + saving: "Saving…" +}; +var zh = { + title: "Netx Ops", + description: "UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。", + apiUrl: "API 地址", + apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890", + lang: "语言", + langHint: "传给 MCP 的 NETX_LANG(zh / en)。", + pythonCommand: "Python 命令", + pythonCommandHint: "能执行 `python -m netx_mcp` 的解释器。", + apiToken: "API Token", + apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。", + apiTokenSet: "已配置", + apiTokenUnset: "未设置", + overridden: "已覆盖", + reset: "重置", + invalid: "无效值", + expand: "展开", + collapse: "收起", + unsaved: "未保存", + readOnly: "当前文档只读。", + saveFailed: "保存失败,草稿已保留以便修改。", + discard: "丢弃", + save: "保存", + saving: "保存中…" +}; + +// src/client/index.ts +var LOCALE_NS = "settings.netxops"; +var inject = [ + "slots", + "locale", + "remote", + "remote.credentials", + "settingsScope" +]; +function apply(ctx) { + ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), "netxops: locales"); + const card = new NetxopsCardController(ctx.settingsScope.bind({ namespace: NETXOPS_NS }), ctx); + ctx.effect(() => ctx.remote.$on("credentials/reference-updated", (ref) => { + card.refreshCredential(ref); + }), "netxops: credential invalidations"); + ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({ + name: "settings.plugin.item", + key: NETXOPS_NS, + locale: LOCALE_NS, + inject: () => card.inject() + }, NetxopsCard)); +} + + return module.exports; + } +}); diff --git a/package.json b/package.json index 7f364c8..3443f11 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,19 @@ { "name": "dsh-netxops", - "version": "0.1.1", - "description": "DeepSeek Harness Netx Ops: host bridge (settings/credentials → netx MCP) + agent preset", + "version": "0.1.3", + "description": "DeepSeek Harness Netx Ops: host bridge + Plugins settings card + agent preset", "license": "MIT", "type": "module", "private": false, "main": "./src/index.ts", "exports": { ".": "./src/index.ts", + "./client": "./lib/client.js", "./package.json": "./package.json" }, "files": [ "src/", + "lib/client.js", "presets/", "cordis.patch.yml", "docs/", @@ -36,9 +38,21 @@ "url": "https://github.com/hansjone/netxops/issues" }, "homepage": "https://github.com/hansjone/netxops#readme", + "scripts": { + "bundle": "bun run scripts/build-client.mjs" + }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-plugins", + "@deepseek-ai/dsh-api-remotes" + ] } }, "peerDependencies": { @@ -53,6 +67,18 @@ "@deepseek-ai/dsh-tool-skill": "*", "@deepseek-ai/dsh-tool-fs": "*", "@deepseek-ai/dsh-tool-jobs": "*", - "@deepseek-ai/dsh-agent-instructions": "*" + "@deepseek-ai/dsh-agent-instructions": "*", + "@deepseek-ai/dsh-client-store": "*", + "@deepseek-ai/dsh-client-locale": "*", + "@deepseek-ai/dsh-client-ui-settings": "*", + "@deepseek-ai/dsh-client-ui-settings-plugins": "*", + "@deepseek-ai/dsh-client-ui-slots": "*", + "@deepseek-ai/dsh-client-ui-renderer": "*", + "@deepseek-ai/dsh-api-remotes": "*", + "react": "^18.2.0 || ^19.0.0" + }, + "devDependencies": { + "@types/react": "~18.3.1", + "typescript": "^5.8.0" } } diff --git a/scripts/build-client.mjs b/scripts/build-client.mjs new file mode 100644 index 0000000..e15cfaf --- /dev/null +++ b/scripts/build-client.mjs @@ -0,0 +1,69 @@ +/** + * Build lib/client.js as a DSH ModuleLoader factory artifact using Bun's bundler. + * Usage: bun run scripts/build-client.mjs + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const entry = join(root, 'src/client/index.ts') +const outFile = join(root, 'lib', 'client.js') +const id = 'dsh-netxops' + +const external = [ + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@deepseek-ai/cordis', + '@deepseek-ai/dsh-client-store', + '@deepseek-ai/dsh-client-locale', + '@deepseek-ai/dsh-client-locale/client', + '@deepseek-ai/dsh-client-ui-settings', + '@deepseek-ai/dsh-client-ui-settings/client', + '@deepseek-ai/dsh-client-ui-settings-plugins', + '@deepseek-ai/dsh-client-ui-settings-plugins/client', + '@deepseek-ai/dsh-client-ui-slots', + '@deepseek-ai/dsh-client-ui-renderer', + '@deepseek-ai/dsh-client-ui-renderer/client', + '@deepseek-ai/dsh-client-ui-primitives', + '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remotes/client', + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-connection/client', +] + +const result = await Bun.build({ + entrypoints: [entry], + target: 'browser', + format: 'cjs', + sourcemap: 'none', + minify: false, + external, +}) + +if (!result.success) { + console.error(result.logs) + throw new Error('Bun.build failed') +} + +const raw = await result.outputs[0].text() +const body = raw + .replace(/^"use strict";\s*/m, '') + .replace(/Object\.defineProperty\(exports,\s*"__esModule"[\s\S]*?;\s*/m, '') + +const artifact = `window.__ModuleLoader__.load({ + id: ${JSON.stringify(id)}, + factory: (require) => { + var module = { exports: {} }; + var exports = module.exports; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); +${body} + return module.exports; + } +}); +` + +mkdirSync(dirname(outFile), { recursive: true }) +writeFileSync(outFile, artifact, 'utf8') +console.log(`wrote ${outFile} (${artifact.length} bytes)`) diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx new file mode 100644 index 0000000..28bd6b8 --- /dev/null +++ b/src/client/NetxopsCard.tsx @@ -0,0 +1,184 @@ +/** + * Netx Ops Plugins settings card — apiUrl / lang / python + credential token. + */ + +import { useEffect, useRef, useState } from 'react' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { NetxopsCardFace, NetxopsCardState } from './controller.ts' +import type { NetxopsLocaleKey } from './locales.ts' +import type { CardFieldState } from './card-form.ts' +import { ensureStyles } from './styles.ts' +import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' + +export type NetxopsCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.netxops'> + & InjectFace + +function ValueField(props: { + id: string + label: string + hint: string + field: CardFieldState + overriddenLabel: string + resetLabel: string + invalidLabel: string + disabled: boolean + onEdit: (text: string) => void + onReset: () => void +}) { + return ( +
+
+ + {props.field.overridden + ? ( + + {props.overriddenLabel} + + + ) + : null} +
+ { props.onEdit(event.target.value) }} + /> +

+ {props.field.invalid ? props.invalidLabel : props.hint} +

+
+ ) +} + +export function NetxopsCard(props: NetxopsCardProps) { + ensureStyles() + const { t } = props + const state: NetxopsCardState = props.useNetxopsCard(snapshot => snapshot) + const [open, setOpen] = useState(false) + const saveStarted = useRef(false) + + useEffect(() => { + if (state.saving) { + saveStarted.current = true + return + } + if (!saveStarted.current) return + saveStarted.current = false + if (!state.dirty && !state.failed) setOpen(false) + }, [state.dirty, state.failed, state.saving]) + + if (!state.available) return null + + const title = t('title' satisfies NetxopsLocaleKey) + const disabled = !state.writable + const blocked = !state.dirty || state.invalid || state.saving + + return ( +
  • + + {open + ? ( +
    + {!state.writable ?

    {t('readOnly')}

    : null} +
    +
    + + + + {state.apiTokenConfigured ? t('apiTokenSet') : t('apiTokenUnset')} + + +
    + { props.edit('apiToken', event.target.value) }} + /> +

    {t('apiTokenHint')}

    +
    + { props.edit('apiUrl', text) }} + onReset={() => { props.resetField('apiUrl') }} + /> + { props.edit('lang', text) }} + onReset={() => { props.resetField('lang') }} + /> + { props.edit('pythonCommand', text) }} + onReset={() => { props.resetField('pythonCommand') }} + /> +
    + {state.failed ?

    {t('saveFailed')}

    : null} + + +
    +
    + ) + : null} +
  • + ) +} diff --git a/src/client/card-form.ts b/src/client/card-form.ts new file mode 100644 index 0000000..980a385 --- /dev/null +++ b/src/client/card-form.ts @@ -0,0 +1,221 @@ +/** + * Shared staged-form model for the Netx Ops Plugins settings card. + * Self-contained (cannot value-import ui-settings-plugins — client bundle purity). + */ + +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' + +export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' } + +export interface CardFieldSpec { + field: string + format: (value: unknown) => string + parse: (text: string) => FieldWrite | undefined +} + +export interface CardSecretSpec { + field: string + write: (text: string) => Promise +} + +export interface CardFieldState { + text: string + overridden: boolean + invalid: boolean +} + +export interface CardShell { + available: boolean + writable: boolean + dirty: boolean + invalid: boolean + saving: boolean + failed: boolean +} + +export interface CardActions { + edit: (field: string, text: string) => void + resetField: (field: string) => void + save: () => void + discard: () => void +} + +interface StagedEdit { + text: string + clear: boolean +} + +interface PlannedWrite { + field: string + run: (() => Promise) | undefined +} + +export function textField(field: string): CardFieldSpec { + return { + field, + format: value => typeof value === 'string' ? value : '', + parse: (text) => { + const trimmed = text.trim() + return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed } + }, + } +} + +export class CardForm { + private readonly specs: Map + private readonly secretSpecs: Map + private readonly staged = new Map() + private readonly listeners = new Set<() => void>() + private saving = false + private failed = false + + constructor( + private readonly scope: SettingsScope, + specs: CardFieldSpec[], + secrets: CardSecretSpec[] = [], + ) { + this.specs = new Map(specs.map(spec => [spec.field, spec])) + this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec])) + scope.subscribe(() => { this.publish() }) + } + + bind(project: () => S): SnapshotStore { + const store = createSnapshotStore(project()) + this.listeners.add(() => { store.set(project()) }) + return store + } + + shell(): CardShell { + const snapshot = this.scope.getSnapshot() + const plan = this.plan() + return { + available: snapshot.status === 'ready', + writable: snapshot.writable, + dirty: plan.length > 0, + invalid: plan.some(item => item.run === undefined), + saving: this.saving, + failed: this.failed, + } + } + + field(field: string): CardFieldState { + const staged = this.staged.get(field) + if (this.secretSpecs.has(field)) { + return { text: staged?.text ?? '', overridden: false, invalid: false } + } + const spec = this.spec(field) + if (staged === undefined) { + return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false } + } + const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text) + return { + text: staged.text, + overridden: write?.kind === 'set', + invalid: write === undefined, + } + } + + actions(): CardActions { + return { + edit: (field, text) => { this.stage(field, { text, clear: false }) }, + resetField: (field) => { + this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true }) + }, + save: () => { void this.save() }, + discard: () => { + if (this.staged.size === 0 && !this.failed) return + this.staged.clear() + this.failed = false + this.publish() + }, + } + } + + async save(): Promise { + const plan = this.plan() + const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run]) + if (plan.length === 0 || this.saving || writes.length !== plan.length) return + this.saving = true + this.failed = false + this.publish() + let landed = true + for (const write of writes) { + landed = await write() && landed + } + if (landed) this.staged.clear() + this.saving = false + this.failed = !landed + this.publish() + } + + private plan(): PlannedWrite[] { + const plan: PlannedWrite[] = [] + for (const [field, staged] of this.staged) { + const secret = this.secretSpecs.get(field) + if (secret !== undefined) { + const value = staged.text.trim() + if (value !== '') plan.push({ field, run: () => secret.write(value) }) + continue + } + const spec = this.spec(field) + if (staged.clear) { + if (this.stored(field)) plan.push({ field, run: () => this.clear(field) }) + continue + } + if (staged.text === spec.format(this.sectionValue(field))) continue + const write = spec.parse(staged.text) + if (write === undefined) plan.push({ field, run: undefined }) + else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) }) + else plan.push({ field, run: () => this.store(field, write.value) }) + } + return plan + } + + private async clear(field: string): Promise { + await this.scope.unset(field) + return !this.stored(field) + } + + private async store(field: string, value: unknown): Promise { + await this.scope.set(field, value) + return this.userLayer()?.[field] === value + } + + private stage(field: string, edit: StagedEdit): void { + this.staged.set(field, edit) + this.failed = false + this.publish() + } + + private spec(field: string): CardFieldSpec { + const spec = this.specs.get(field) + if (spec === undefined) throw new Error(`netxops card has no field ${field}`) + return spec + } + + private snapshotOf(): SettingsScopeSnapshot { + return this.scope.getSnapshot() + } + + private sectionValue(field: string): unknown { + return (this.snapshotOf().value as Record | undefined)?.[field] + } + + private baseValue(field: string): unknown { + return (this.snapshotOf().base as Record | undefined)?.[field] + } + + private userLayer(): Record | undefined { + return this.snapshotOf().user as Record | undefined + } + + private stored(field: string): boolean { + const user = this.userLayer() + return user !== undefined && Object.hasOwn(user, field) + } + + private publish(): void { + for (const listener of this.listeners) listener() + } +} diff --git a/src/client/controller.ts b/src/client/controller.ts new file mode 100644 index 0000000..3863b97 --- /dev/null +++ b/src/client/controller.ts @@ -0,0 +1,115 @@ +/** + * Staged form over settings namespace `netxops` + credential NETX_API_TOKEN. + */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' +import { + CardForm, textField, + type CardActions, type CardFieldState, type CardShell, +} from './card-form.ts' + +export const NETXOPS_NS = 'netxops' +const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN' +const API_TOKEN_FIELD = 'apiToken' + +export interface NetxopsSettings { + apiUrl?: string + lang?: string + pythonCommand?: string + tokenCredentialRef?: string +} + +interface CredentialState { + ref: string + configured: boolean + writable: boolean +} + +export interface NetxopsCardState extends CardShell { + apiUrl: CardFieldState + lang: CardFieldState + pythonCommand: CardFieldState + apiToken: CardFieldState + apiTokenConfigured: boolean + apiTokenWritable: boolean +} + +export interface NetxopsCardFace extends CardActions { + hooks: { + netxopsCard: SnapshotStore + } +} + +export class NetxopsCardController { + private readonly form: CardForm + private readonly store: SnapshotStore + private credential: CredentialState = { ref: '', configured: false, writable: true } + + constructor( + private readonly scope: SettingsScope, + private readonly ctx: ClientContext, + ) { + this.form = new CardForm( + scope, + [textField('apiUrl'), textField('lang'), textField('pythonCommand')], + [{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }], + ) + this.store = this.form.bind(() => this.projection()) + scope.subscribe(() => { void this.readCredential() }) + void this.readCredential() + } + + private projection(): NetxopsCardState { + return { + ...this.form.shell(), + apiUrl: this.form.field('apiUrl'), + lang: this.form.field('lang'), + pythonCommand: this.form.field('pythonCommand'), + apiToken: this.form.field(API_TOKEN_FIELD), + apiTokenConfigured: this.credential.configured, + apiTokenWritable: this.credential.writable, + } + } + + private async readCredential(): Promise { + const ref = refOf(this.scope.getSnapshot()) + if (ref !== this.credential.ref) { + this.credential = { ref, configured: false, writable: true } + this.store.set(this.projection()) + } + const response = await this.ctx.remote.credentials.describe([ref]) + if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return + const view = response.value[ref] + const next: CredentialState = { + ref, + configured: view?.configured ?? false, + writable: view?.writable ?? true, + } + if (next.configured === this.credential.configured && next.writable === this.credential.writable) return + this.credential = next + this.store.set(this.projection()) + } + + refreshCredential(ref: string): void { + if (ref !== this.credential.ref) return + void this.readCredential() + } + + inject(): NetxopsCardFace { + return { hooks: { netxopsCard: this.store }, ...this.form.actions() } + } + + private async writeToken(value: string): Promise { + await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value) + await this.readCredential() + return this.credential.configured + } +} + +function refOf(snapshot: SettingsScopeSnapshot): string { + const declared = snapshot.value?.tokenCredentialRef + return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..f7203d4 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,50 @@ +/** + * Browser half — Settings → Plugins → Netx Ops card. + */ + +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 type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import { NetxopsCard } from './NetxopsCard.tsx' +import { NETXOPS_NS, NetxopsCardController } from './controller.ts' +import { en, zh, type NetxopsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + 'settings.netxops': NetxopsLocaleKey + } +} + +const LOCALE_NS = 'settings.netxops' + +export const inject = [ + 'slots', + 'locale', + 'remote', + 'remote.credentials', + 'settingsScope', +] + +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'netxops: locales') + + const card = new NetxopsCardController( + ctx.settingsScope.bind({ namespace: NETXOPS_NS }), + ctx, + ) + + ctx.effect( + () => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }), + 'netxops: credential invalidations', + ) + + ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ + name: 'settings.plugin.item', + key: NETXOPS_NS, + locale: LOCALE_NS, + inject: () => card.inject(), + }, NetxopsCard)) +} diff --git a/src/client/locales.ts b/src/client/locales.ts new file mode 100644 index 0000000..9ab88f7 --- /dev/null +++ b/src/client/locales.ts @@ -0,0 +1,78 @@ +/** Locale keys for the Netx Ops Plugins settings card. */ + +export type NetxopsLocaleKey = + | 'title' + | 'description' + | 'apiUrl' + | 'apiUrlHint' + | 'lang' + | 'langHint' + | 'pythonCommand' + | 'pythonCommandHint' + | 'apiToken' + | 'apiTokenHint' + | 'apiTokenSet' + | 'apiTokenUnset' + | 'overridden' + | 'reset' + | 'invalid' + | 'expand' + | 'collapse' + | 'unsaved' + | 'readOnly' + | 'saveFailed' + | 'discard' + | 'save' + | 'saving' + +export const en: Record = { + title: 'Netx Ops', + description: 'UME API endpoint and bearer token for mcp__netx__* tools.', + apiUrl: 'API URL', + apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890', + lang: 'Language', + langHint: 'Passed as NETX_LANG (zh / en).', + pythonCommand: 'Python command', + pythonCommandHint: 'Executable that can run `python -m netx_mcp`.', + apiToken: 'API token', + apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.', + apiTokenSet: 'Configured', + apiTokenUnset: 'Not set', + overridden: 'Overridden', + reset: 'Reset', + invalid: 'Invalid value', + expand: 'Expand', + collapse: 'Collapse', + unsaved: 'Unsaved', + readOnly: 'This document is read-only.', + saveFailed: 'Save failed — drafts kept for correction.', + discard: 'Discard', + save: 'Save', + saving: 'Saving…', +} + +export const zh: Record = { + title: 'Netx Ops', + description: 'UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。', + apiUrl: 'API 地址', + apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890', + lang: '语言', + langHint: '传给 MCP 的 NETX_LANG(zh / en)。', + pythonCommand: 'Python 命令', + pythonCommandHint: '能执行 `python -m netx_mcp` 的解释器。', + apiToken: 'API Token', + apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。', + apiTokenSet: '已配置', + apiTokenUnset: '未设置', + overridden: '已覆盖', + reset: '重置', + invalid: '无效值', + expand: '展开', + collapse: '收起', + unsaved: '未保存', + readOnly: '当前文档只读。', + saveFailed: '保存失败,草稿已保留以便修改。', + discard: '丢弃', + save: '保存', + saving: '保存中…', +} diff --git a/src/client/styles.ts b/src/client/styles.ts new file mode 100644 index 0000000..0e2057e --- /dev/null +++ b/src/client/styles.ts @@ -0,0 +1,49 @@ +/** Inject shared Netx Ops card styles once. */ + +const STYLE_ID = 'dsh-netxops-card-css' + +const CSS = ` +.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s} +.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)} +.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)} +.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px} +.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} +.dsh-nx-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)} +.dsh-nx-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-pending{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} +.dsh-nx-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s} +.dsh-nx-chevronOpen{transform:rotate(180deg)} +.dsh-nx-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px} +.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0} +.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)} +.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px} +.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px} +.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} +.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} +.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)} +.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)} +.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)} +.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} +.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} +.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)} +.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} +.dsh-nx-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer} +.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)} +.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)} +.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)} +.dsh-nx-btn:disabled{opacity:.4;cursor:default} +` + +export function ensureStyles(): void { + if (typeof document === 'undefined') return + if (document.getElementById(STYLE_ID)) return + const tag = document.createElement('style') + tag.id = STYLE_ID + tag.textContent = CSS + document.head.appendChild(tag) +}