Add Host-global thinking and reply language controls.

Inject systemPrompt sections from Netx Ops settings so every preset can switch CoT and final-reply language without changing API/alarm lang.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-15 20:15:54 +08:00
parent 53c47fbc6a
commit d52c05e774
14 changed files with 652 additions and 25 deletions

View file

@ -7,8 +7,9 @@
# github: packages as transitive deps of this package). Install them in the
# same `dsh plugin add` line — see docs/INSTALL.md.
#
# After install, open Settings → Plugins to edit apiUrl / lang / groups
# (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN.
# After install, open Settings → Plugins to edit apiUrl / lang / thinking+reply
# language / groups (settings card client half; see docs/INSTALL.md).
# Token: credential NETX_API_TOKEN. Model language is Host-global (all presets).
- insert:
- id: netxops
@ -16,6 +17,8 @@
config:
apiUrl: http://127.0.0.1:8890
lang: zh
thinkingLanguage: auto
replyLanguage: follow-user
nmsProvider: zte-ume
tokenCredentialRef: NETX_API_TOKEN
toolCallTimeoutMs: 120000

View file

@ -8,7 +8,7 @@ One install wires **all of**:
|-------|----------------|
| Host tools | Capability groups **ops / topology** (one group ↔ one skill). Default ops in Ops preset; topology and all public off. Other agents may mount `dsh-netxops/tools-ops|topology` |
| Companion plugins | **dsh-im-ops** + **dsh-ops-cron** — add as **direct** profile deps in the same command (DSH/pnpm blocks `github:` as transitive deps) |
| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token / capability groups) |
| Plugins card | Settings → Plugins → **Netx Ops** (URL / API lang / thinking+reply language / token / capability groups) |
| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot); playbooks follow the same group toggles |
You do **not** run `link-preset.ps1` for normal use. That script is only a manual fallback.

View file

@ -11,7 +11,7 @@
Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate tools only. DSH no longer spawns `python -m netx_mcp`. Model-facing NMS tools renamed to `*Nms*`; ZTE UME remains the `nmsProvider=zte-ume` adapter.
**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / capability groups / `nmsProvider` via settings namespace `netxops`.
**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / `thinkingLanguage` / `replyLanguage` / capability groups / `nmsProvider` via settings namespace `netxops`. Thinking/reply language inject Host-global `systemPrompt` (all presets).
## Stay in oclaw (until retired)

View file

@ -22,6 +22,8 @@
config:
apiUrl: http://127.0.0.1:8890
lang: zh
thinkingLanguage: auto
replyLanguage: follow-user
tokenCredentialRef: NETX_API_TOKEN
toolCallTimeoutMs: 120000
installAgentPreset: true

View file

@ -667,11 +667,14 @@ var CSS = `
.dsh-nx-field-head>label,.dsh-nx-field-head>.dsh-nx-field-label{flex:1;min-width:0}
.dsh-nx-settings-field input[type=text],
.dsh-nx-settings-field input[type=password],
.dsh-nx-settings-field input:not([type]){
.dsh-nx-settings-field input:not([type]),
.dsh-nx-settings-field select{
box-sizing:border-box;width:100%;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font:inherit;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:inherit
}
.dsh-nx-settings-field input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:1px}
.dsh-nx-settings-field input:disabled{opacity:.55;cursor:default}
.dsh-nx-settings-field input:focus-visible,
.dsh-nx-settings-field select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:1px}
.dsh-nx-settings-field input:disabled,
.dsh-nx-settings-field select:disabled{opacity:.55;cursor:default}
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-state-error-primary,#d54941)!important}
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8f959e)}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#d54941)}
@ -967,6 +970,63 @@ function ValueField(props) {
]
});
}
function SelectField(props) {
const known = props.options.some((row) => row.value === props.field.text);
return /* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-settings-field",
children: [
/* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-field-head",
children: [
/* @__PURE__ */ jsx_runtime.jsx("label", {
htmlFor: props.id,
children: props.label
}),
props.field.overridden ? /* @__PURE__ */ jsx_runtime.jsxs("span", {
className: "dsh-nx-badges",
children: [
/* @__PURE__ */ jsx_runtime.jsx("span", {
className: "dsh-nx-badge",
children: props.overriddenLabel
}),
/* @__PURE__ */ jsx_runtime.jsx("button", {
type: "button",
className: "dsh-nx-reset",
disabled: props.disabled,
onClick: props.onReset,
children: props.resetLabel
})
]
}) : null
]
}),
/* @__PURE__ */ jsx_runtime.jsxs("select", {
id: props.id,
className: props.field.invalid ? "dsh-nx-inputInvalid" : undefined,
value: props.field.text,
disabled: props.disabled,
"aria-invalid": props.field.invalid || undefined,
onChange: (event) => {
props.onEdit(event.target.value);
},
children: [
!known && props.field.text ? /* @__PURE__ */ jsx_runtime.jsx("option", {
value: props.field.text,
children: props.field.text
}) : null,
props.options.map((row) => /* @__PURE__ */ jsx_runtime.jsx("option", {
value: row.value,
children: row.label
}, row.value))
]
}),
/* @__PURE__ */ jsx_runtime.jsx("p", {
className: props.field.invalid ? "dsh-nx-invalid" : "dsh-nx-hint",
children: props.field.invalid ? props.invalidLabel : props.hint
})
]
});
}
function ImDeliveryPicker(props) {
const options = props.catalog.options;
const selected = resolveImTargets({
@ -1275,6 +1335,48 @@ function NetxopsCard(props) {
props.resetField("lang");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(SelectField, {
id: "netxops-thinking-language",
label: t("thinkingLanguage"),
hint: t("thinkingLanguageHint"),
field: state.thinkingLanguage,
options: [
{ value: "auto", label: "auto" },
{ value: "zh-CN", label: "zh-CN" },
{ value: "en", label: "en" }
],
overriddenLabel: t("overridden"),
resetLabel: t("reset"),
invalidLabel: t("invalid"),
disabled,
onEdit: (text) => {
props.edit("thinkingLanguage", text);
},
onReset: () => {
props.resetField("thinkingLanguage");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(SelectField, {
id: "netxops-reply-language",
label: t("replyLanguage"),
hint: t("replyLanguageHint"),
field: state.replyLanguage,
options: [
{ value: "follow-user", label: "follow-user" },
{ value: "zh", label: "zh" },
{ value: "en", label: "en" }
],
overriddenLabel: t("overridden"),
resetLabel: t("reset"),
invalidLabel: t("invalid"),
disabled,
onEdit: (text) => {
props.edit("replyLanguage", text);
},
onReset: () => {
props.resetField("replyLanguage");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
id: "netxops-nms-provider",
label: t("nmsProvider"),
@ -1860,6 +1962,8 @@ class NetxopsCardController {
this.form = new CardForm(scope, [
textField("apiUrl"),
textField("lang"),
textField("thinkingLanguage"),
textField("replyLanguage"),
textField("nmsProvider"),
textField("kbRoot"),
booleanFieldPersistFalse("groupOpsInPreset"),
@ -2127,6 +2231,8 @@ class NetxopsCardController {
...this.form.shell(),
apiUrl: this.form.field("apiUrl"),
lang: this.form.field("lang"),
thinkingLanguage: this.form.field("thinkingLanguage"),
replyLanguage: this.form.field("replyLanguage"),
nmsProvider: this.form.field("nmsProvider"),
kbRoot: this.form.field("kbRoot"),
groupOpsInPreset: this.form.field("groupOpsInPreset"),
@ -2281,8 +2387,12 @@ var en = {
sectionExport: "Session export",
apiUrl: "API URL",
apiUrlHint: "e.g. http://127.0.0.1:8890",
lang: "Language",
langHint: "zh / en",
lang: "API / alarm language",
langHint: "zh / en — netx API query + alarm prompt copy only (not model reply)",
thinkingLanguage: "Thinking language",
thinkingLanguageHint: "auto / zh-CN / en — model chain-of-thought for all presets",
replyLanguage: "Reply language",
replyLanguageHint: "follow-user / zh / en — final answers for all presets",
capabilityGroups: "Capability groups",
nmsProvider: "NMS provider",
nmsProviderHint: "zte-ume",
@ -2354,8 +2464,12 @@ var zh = {
sectionExport: "会话导出",
apiUrl: "API 地址",
apiUrlHint: "例如 http://127.0.0.1:8890",
lang: "语言",
langHint: "zh / en",
lang: "API / 告警语言",
langHint: "zh / en — 仅影响 netx API 与告警文案,不控制模型回复",
thinkingLanguage: "思考语言",
thinkingLanguageHint: "auto / zh-CN / en — 全预设模型思考过程语言",
replyLanguage: "回复语言",
replyLanguageHint: "follow-user / zh / en — 全预设最终回答语言",
capabilityGroups: "能力组",
nmsProvider: "NMS 提供方",
nmsProviderHint: "zte-ume",

View file

@ -1245,6 +1245,112 @@ function resetKbContext() {
applyKbEnv(unconfiguredKbSnapshot());
}
// src/netx/model-language.ts
var THINKING_LANGUAGE_DEFAULT = "auto";
var REPLY_LANGUAGE_DEFAULT = "follow-user";
var THINKING_LANGUAGE_IDS = ["zh-CN", "en"];
var REPLY_LANGUAGE_IDS = ["zh", "en"];
var THINKING_META = {
"zh-CN": { id: "zh-CN", name: "Simplified Chinese", native: "简体中文" },
en: { id: "en", name: "English", native: "English" }
};
var REPLY_META = {
zh: { id: "zh", name: "Simplified Chinese", native: "简体中文" },
en: { id: "en", name: "English", native: "English" }
};
function normalizeThinkingLanguage(raw) {
const value = String(raw ?? "").trim();
if (!value || value === THINKING_LANGUAGE_DEFAULT)
return THINKING_LANGUAGE_DEFAULT;
const lower = value.toLowerCase().replace(/_/g, "-");
if (lower === "zh" || lower === "zh-cn" || lower === "zh-hans" || lower === "chinese")
return "zh-CN";
if (lower === "en" || lower.startsWith("en-") || lower === "english")
return "en";
if (THINKING_LANGUAGE_IDS.includes(value)) {
return value;
}
return THINKING_LANGUAGE_DEFAULT;
}
function normalizeReplyLanguage(raw) {
const value = String(raw ?? "").trim();
if (!value || value === REPLY_LANGUAGE_DEFAULT || value === "auto" || value === "default") {
return REPLY_LANGUAGE_DEFAULT;
}
const lower = value.toLowerCase().replace(/_/g, "-");
if (lower === "zh" || lower === "zh-cn" || lower === "zh-hans" || lower === "chinese")
return "zh";
if (lower === "en" || lower.startsWith("en-") || lower === "english")
return "en";
if (REPLY_LANGUAGE_IDS.includes(value)) {
return value;
}
return REPLY_LANGUAGE_DEFAULT;
}
function resolveThinkingLanguage(setting, systemLocale) {
const normalized = normalizeThinkingLanguage(setting);
if (normalized !== THINKING_LANGUAGE_DEFAULT)
return normalized;
return languageFromLocale(systemLocale) ?? "en";
}
function languageFromLocale(locale) {
const tag = String(locale ?? "").trim().toLowerCase().replace(/_/g, "-");
if (!tag)
return;
if (tag === "zh" || tag.startsWith("zh-hans") || tag.startsWith("zh-cn") || tag === "zh-sg") {
return "zh-CN";
}
if (tag.startsWith("zh"))
return "zh-CN";
if (tag === "en" || tag.startsWith("en-"))
return "en";
return;
}
function readSystemLocalePreference(settings) {
if (!settings || typeof settings.get !== "function")
return;
let section;
try {
section = settings.get("locale");
} catch {
return;
}
if (section === null || typeof section !== "object" || Array.isArray(section))
return;
const preference = section.preference;
return typeof preference === "string" && preference.trim() ? preference.trim() : undefined;
}
function thinkingInstruction(language) {
const meta = THINKING_META[language];
return [
`The operator configured the language of your thinking/reasoning process: ${meta.native} (${meta.name}).`,
`Write your entire internal reasoning — chain-of-thought, analysis, planning, and deliberation — in ${meta.name}.`,
"Keep code, identifiers, file paths, commands, and technical terms as they are.",
"Your final answer to the user follows the reply-language setting (or the user language when reply language is follow-user); this thinking setting never alone changes the final answer language."
].join(" ");
}
function thinkingReminder(language) {
const meta = THINKING_META[language];
return [
`[Thinking language] The operator wants your reasoning/thinking process written in ${meta.native} (${meta.name}).`,
`Continue your entire chain-of-thought in ${meta.name}.`,
"Final answer language follows the reply-language setting / user language."
].join(" ");
}
function replyInstruction(setting) {
const normalized = normalizeReplyLanguage(setting);
if (normalized === REPLY_LANGUAGE_DEFAULT)
return "";
const meta = REPLY_META[normalized];
return [
"## Reply language requirement",
`Regardless of the language the user writes in, you must always write the entire reply in ${meta.native} (${meta.name}) —`,
"including explanations, summaries, body text, titles, and error descriptions.",
"Keep code, commands, file paths, API names, host names, and proper nouns in their original form; do not translate them.",
"This requirement outranks language hints in the user message or persona until the Netx Ops reply-language setting changes."
].join(" ");
}
// src/netx/runtime.ts
var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store");
function store3() {
@ -3837,6 +3943,8 @@ var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
var 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(120000),
installAgentPreset: z.boolean().default(true),
@ -4009,6 +4117,40 @@ function apply(ctx, config = Config({})) {
if (String(ref) === source().tokenCredentialRef)
publish();
});
ctx.inject(["systemPrompt", "settings"], (promptCtx) => {
const systemPrompt = promptCtx.systemPrompt;
const effectiveThinking = () => {
const settings = promptCtx.get("settings");
const current = source();
return resolveThinkingLanguage(normalizeThinkingLanguage(current.thinkingLanguage), readSystemLocalePreference(settings));
};
promptCtx.effect(() => {
const disposers = [];
const push = (dispose) => {
if (typeof dispose === "function")
disposers.push(dispose);
};
push(systemPrompt.section({
name: "netxops:thinking-language",
order: 85,
text: () => thinkingInstruction(effectiveThinking())
}));
push(systemPrompt.context({
name: "netxops:thinking-language-reminder",
order: 1000,
text: () => thinkingReminder(effectiveThinking())
}));
push(systemPrompt.section({
name: "netxops:reply-language",
order: 890,
text: () => replyInstruction(normalizeReplyLanguage(source().replyLanguage))
}));
return () => {
for (const dispose of disposers.splice(0))
dispose();
};
}, "netxops: model language prompts");
});
ctx.inject(["tools"], (toolsCtx) => {
let unregisterTools;
const remountPublicTools = () => {

View file

@ -1,6 +1,6 @@
{
"name": "dsh-netxops",
"version": "0.1.30",
"version": "0.1.31",
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT",
"type": "module",

View file

@ -155,6 +155,57 @@ function ValueField(props: {
)
}
function SelectField(props: {
id: string
label: string
hint: string
field: CardFieldState
options: ReadonlyArray<{ value: string, label: string }>
overriddenLabel: string
resetLabel: string
invalidLabel: string
disabled: boolean
onEdit: (text: string) => void
onReset: () => void
}) {
const known = props.options.some((row) => row.value === props.field.text)
return (
<div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head">
<label htmlFor={props.id}>{props.label}</label>
{props.field.overridden
? (
<span className="dsh-nx-badges">
<span className="dsh-nx-badge">{props.overriddenLabel}</span>
<button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}>
{props.resetLabel}
</button>
</span>
)
: null}
</div>
<select
id={props.id}
className={props.field.invalid ? 'dsh-nx-inputInvalid' : undefined}
value={props.field.text}
disabled={props.disabled}
aria-invalid={props.field.invalid || undefined}
onChange={(event) => { props.onEdit(event.target.value) }}
>
{!known && props.field.text
? <option value={props.field.text}>{props.field.text}</option>
: null}
{props.options.map((row) => (
<option key={row.value} value={row.value}>{row.label}</option>
))}
</select>
<p className={props.field.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
{props.field.invalid ? props.invalidLabel : props.hint}
</p>
</div>
)
}
function ImDeliveryPicker(props: {
catalog: ImDeliveryCatalog
targetsJson: string
@ -435,6 +486,40 @@ export function NetxopsCard(props: NetxopsCardProps) {
onEdit={(text) => { props.edit('lang', text) }}
onReset={() => { props.resetField('lang') }}
/>
<SelectField
id="netxops-thinking-language"
label={t('thinkingLanguage')}
hint={t('thinkingLanguageHint')}
field={state.thinkingLanguage}
options={[
{ value: 'auto', label: 'auto' },
{ value: 'zh-CN', label: 'zh-CN' },
{ value: 'en', label: 'en' },
]}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('thinkingLanguage', text) }}
onReset={() => { props.resetField('thinkingLanguage') }}
/>
<SelectField
id="netxops-reply-language"
label={t('replyLanguage')}
hint={t('replyLanguageHint')}
field={state.replyLanguage}
options={[
{ value: 'follow-user', label: 'follow-user' },
{ value: 'zh', label: 'zh' },
{ value: 'en', label: 'en' },
]}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('replyLanguage', text) }}
onReset={() => { props.resetField('replyLanguage') }}
/>
<ValueField
id="netxops-nms-provider"
label={t('nmsProvider')}

View file

@ -42,6 +42,8 @@ const STATUS_POLL_MS = 2_000
export interface NetxopsSettings {
apiUrl?: string
lang?: string
thinkingLanguage?: string
replyLanguage?: string
tokenCredentialRef?: string
alarmPushEnabled?: boolean
alarmDeliverDsh?: boolean
@ -73,6 +75,8 @@ interface CredentialState {
export interface NetxopsCardState extends CardShell {
apiUrl: CardFieldState
lang: CardFieldState
thinkingLanguage: CardFieldState
replyLanguage: CardFieldState
groupOpsInPreset: CardFieldState
groupOpsPublic: CardFieldState
groupTopologyInPreset: CardFieldState
@ -172,6 +176,8 @@ export class NetxopsCardController {
[
textField('apiUrl'),
textField('lang'),
textField('thinkingLanguage'),
textField('replyLanguage'),
textField('nmsProvider'),
textField('kbRoot'),
booleanFieldPersistFalse('groupOpsInPreset'),
@ -521,6 +527,8 @@ export class NetxopsCardController {
...this.form.shell(),
apiUrl: this.form.field('apiUrl'),
lang: this.form.field('lang'),
thinkingLanguage: this.form.field('thinkingLanguage'),
replyLanguage: this.form.field('replyLanguage'),
nmsProvider: this.form.field('nmsProvider'),
kbRoot: this.form.field('kbRoot'),
groupOpsInPreset: this.form.field('groupOpsInPreset'),

View file

@ -12,6 +12,10 @@ export type NetxopsLocaleKey =
| 'apiUrlHint'
| 'lang'
| 'langHint'
| 'thinkingLanguage'
| 'thinkingLanguageHint'
| 'replyLanguage'
| 'replyLanguageHint'
| 'capabilityGroups'
| 'nmsProvider'
| 'nmsProviderHint'
@ -83,8 +87,12 @@ export const en: Record<NetxopsLocaleKey, string> = {
sectionExport: 'Session export',
apiUrl: 'API URL',
apiUrlHint: 'e.g. http://127.0.0.1:8890',
lang: 'Language',
langHint: 'zh / en',
lang: 'API / alarm language',
langHint: 'zh / en — netx API query + alarm prompt copy only (not model reply)',
thinkingLanguage: 'Thinking language',
thinkingLanguageHint: 'auto / zh-CN / en — model chain-of-thought for all presets',
replyLanguage: 'Reply language',
replyLanguageHint: 'follow-user / zh / en — final answers for all presets',
capabilityGroups: 'Capability groups',
nmsProvider: 'NMS provider',
nmsProviderHint: 'zte-ume',
@ -157,8 +165,12 @@ export const zh: Record<NetxopsLocaleKey, string> = {
sectionExport: '会话导出',
apiUrl: 'API 地址',
apiUrlHint: '例如 http://127.0.0.1:8890',
lang: '语言',
langHint: 'zh / en',
lang: 'API / 告警语言',
langHint: 'zh / en — 仅影响 netx API 与告警文案,不控制模型回复',
thinkingLanguage: '思考语言',
thinkingLanguageHint: 'auto / zh-CN / en — 全预设模型思考过程语言',
replyLanguage: '回复语言',
replyLanguageHint: 'follow-user / zh / en — 全预设最终回答语言',
capabilityGroups: '能力组',
nmsProvider: 'NMS 提供方',
nmsProviderHint: 'zte-ume',

View file

@ -16,11 +16,14 @@ const CSS = `
.dsh-nx-field-head>label,.dsh-nx-field-head>.dsh-nx-field-label{flex:1;min-width:0}
.dsh-nx-settings-field input[type=text],
.dsh-nx-settings-field input[type=password],
.dsh-nx-settings-field input:not([type]){
.dsh-nx-settings-field input:not([type]),
.dsh-nx-settings-field select{
box-sizing:border-box;width:100%;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font:inherit;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:inherit
}
.dsh-nx-settings-field input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:1px}
.dsh-nx-settings-field input:disabled{opacity:.55;cursor:default}
.dsh-nx-settings-field input:focus-visible,
.dsh-nx-settings-field select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:1px}
.dsh-nx-settings-field input:disabled,
.dsh-nx-settings-field select:disabled{opacity:.55;cursor:default}
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-state-error-primary,#d54941)!important}
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8f959e)}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#d54941)}

View file

@ -1,9 +1,8 @@
/**
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups /
* optional knowledge-base root) + credentials (NETX_API_TOKEN) publish a
* connection snapshot. The Ops preset mounts selected `netx__*` groups via
* `dsh-netxops/tools`; groups marked public also register on the host tool/skill
* layer for other presets.
* Host-plane Netx Ops: settings (apiUrl / lang / thinking+reply language /
* alarm push / capability groups / optional knowledge-base root) + credentials
* (NETX_API_TOKEN) publish a connection snapshot. Thinking / reply language
* inject into Host `systemPrompt` and apply to **all** agent presets.
*
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
@ -55,6 +54,15 @@ import {
resetKbContext,
watchKbContext,
} from './netx/kb-runtime.ts'
import {
normalizeReplyLanguage,
normalizeThinkingLanguage,
readSystemLocalePreference,
replyInstruction,
resolveThinkingLanguage,
thinkingInstruction,
thinkingReminder,
} from './netx/model-language.ts'
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
import {
getSessionsExportStatus,
@ -86,8 +94,18 @@ export const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
export interface Config {
/** netx REST root (no trailing slash required). */
apiUrl: string
/** Passed as `lang` query when starting with `en`. */
/** Passed as `lang` query when starting with `en` (API + alarm copy only). */
lang: string
/**
* Model thinking / chain-of-thought language for **all** presets on this Host.
* `auto` follows DSH UI locale; otherwise `zh-CN` / `en`.
*/
thinkingLanguage: string
/**
* Model final-reply language for **all** presets on this Host.
* `follow-user` = no forced reply language; otherwise `zh` / `en`.
*/
replyLanguage: string
/** Credential reference for the bearer token (never store the secret here). */
tokenCredentialRef: string
/** Per tool-call timeout (ms). */
@ -146,6 +164,8 @@ export interface Config {
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),
@ -384,6 +404,48 @@ export function apply(ctx: Context, config: Config = Config({})): void {
if (String(ref) === source().tokenCredentialRef) publish()
})
// Host-global model language: every preset on this Host inherits these
// systemPrompt sections (not scoped to the Netx Ops agent preset).
ctx.inject(['systemPrompt', 'settings'], (promptCtx) => {
type PromptSurface = {
section: (spec: { name: string, order: number, text: string | (() => string) }) => (() => void) | void
context: (spec: { name: string, order: number, text: string | (() => string) }) => (() => void) | void
}
const systemPrompt = (promptCtx as { systemPrompt: PromptSurface }).systemPrompt
const effectiveThinking = (): ReturnType<typeof resolveThinkingLanguage> => {
const settings = promptCtx.get('settings') as { get?: (ns: string) => unknown } | undefined
const current = source()
return resolveThinkingLanguage(
normalizeThinkingLanguage(current.thinkingLanguage),
readSystemLocalePreference(settings),
)
}
promptCtx.effect(() => {
const disposers: Array<() => void> = []
const push = (dispose: (() => void) | void): void => {
if (typeof dispose === 'function') disposers.push(dispose)
}
push(systemPrompt.section({
name: 'netxops:thinking-language',
order: 85,
text: () => thinkingInstruction(effectiveThinking()),
}))
push(systemPrompt.context({
name: 'netxops:thinking-language-reminder',
order: 1000,
text: () => thinkingReminder(effectiveThinking()),
}))
push(systemPrompt.section({
name: 'netxops:reply-language',
order: 890,
text: () => replyInstruction(normalizeReplyLanguage(source().replyLanguage)),
}))
return () => {
for (const dispose of disposers.splice(0)) dispose()
}
}, 'netxops: model language prompts')
})
// Optional host-layer publish: groups with `public=true` become visible to other presets.
ctx.inject(['tools'], (toolsCtx) => {
let unregisterTools: (() => void) | undefined

144
src/netx/model-language.ts Normal file
View file

@ -0,0 +1,144 @@
/**
* Model thinking / reply language control via systemPrompt injection.
* Host-global: applies to every agent preset on this Host.
*/
export const THINKING_LANGUAGE_DEFAULT = 'auto'
export const REPLY_LANGUAGE_DEFAULT = 'follow-user'
/** Thinking-language ids stored in settings (plus `auto`). */
export const THINKING_LANGUAGE_IDS = ['zh-CN', 'en'] as const
/** Reply-language ids stored in settings (plus `follow-user`). */
export const REPLY_LANGUAGE_IDS = ['zh', 'en'] as const
export type ThinkingLanguageId = (typeof THINKING_LANGUAGE_IDS)[number]
export type ReplyLanguageId = (typeof REPLY_LANGUAGE_IDS)[number]
export type ThinkingLanguageSetting = typeof THINKING_LANGUAGE_DEFAULT | ThinkingLanguageId
export type ReplyLanguageSetting = typeof REPLY_LANGUAGE_DEFAULT | ReplyLanguageId
interface LanguageMeta {
id: string
name: string
native: string
}
const THINKING_META: Record<ThinkingLanguageId, LanguageMeta> = {
'zh-CN': { id: 'zh-CN', name: 'Simplified Chinese', native: '简体中文' },
en: { id: 'en', name: 'English', native: 'English' },
}
const REPLY_META: Record<ReplyLanguageId, LanguageMeta> = {
zh: { id: 'zh', name: 'Simplified Chinese', native: '简体中文' },
en: { id: 'en', name: 'English', native: 'English' },
}
/** Normalize a stored thinking-language value. */
export function normalizeThinkingLanguage(raw: string | undefined | null): ThinkingLanguageSetting {
const value = String(raw ?? '').trim()
if (!value || value === THINKING_LANGUAGE_DEFAULT) return THINKING_LANGUAGE_DEFAULT
const lower = value.toLowerCase().replace(/_/g, '-')
if (lower === 'zh' || lower === 'zh-cn' || lower === 'zh-hans' || lower === 'chinese') return 'zh-CN'
if (lower === 'en' || lower.startsWith('en-') || lower === 'english') return 'en'
if ((THINKING_LANGUAGE_IDS as readonly string[]).includes(value)) {
return value as ThinkingLanguageId
}
return THINKING_LANGUAGE_DEFAULT
}
/** Normalize a stored reply-language value. */
export function normalizeReplyLanguage(raw: string | undefined | null): ReplyLanguageSetting {
const value = String(raw ?? '').trim()
if (!value || value === REPLY_LANGUAGE_DEFAULT || value === 'auto' || value === 'default') {
return REPLY_LANGUAGE_DEFAULT
}
const lower = value.toLowerCase().replace(/_/g, '-')
if (lower === 'zh' || lower === 'zh-cn' || lower === 'zh-hans' || lower === 'chinese') return 'zh'
if (lower === 'en' || lower.startsWith('en-') || lower === 'english') return 'en'
if ((REPLY_LANGUAGE_IDS as readonly string[]).includes(value)) {
return value as ReplyLanguageId
}
return REPLY_LANGUAGE_DEFAULT
}
/**
* Resolve effective thinking language.
* `auto` follows DSH locale preference, then browser/system tag, then English.
*/
export function resolveThinkingLanguage(
setting: string | undefined | null,
systemLocale?: string | null,
): ThinkingLanguageId {
const normalized = normalizeThinkingLanguage(setting)
if (normalized !== THINKING_LANGUAGE_DEFAULT) return normalized
return languageFromLocale(systemLocale) ?? 'en'
}
/** Map a BCP-47-ish locale tag to a thinking language. */
export function languageFromLocale(locale: string | undefined | null): ThinkingLanguageId | undefined {
const tag = String(locale ?? '').trim().toLowerCase().replace(/_/g, '-')
if (!tag) return undefined
if (tag === 'zh' || tag.startsWith('zh-hans') || tag.startsWith('zh-cn') || tag === 'zh-sg') {
return 'zh-CN'
}
if (tag.startsWith('zh')) return 'zh-CN'
if (tag === 'en' || tag.startsWith('en-')) return 'en'
return undefined
}
/**
* Read `locale.preference` from the settings service when present.
* Tolerates missing / malformed sections.
*/
export function readSystemLocalePreference(settings: {
get?: (ns: string) => unknown
} | undefined | null): string | undefined {
if (!settings || typeof settings.get !== 'function') return undefined
let section: unknown
try {
section = settings.get('locale')
} catch {
return undefined
}
if (section === null || typeof section !== 'object' || Array.isArray(section)) return undefined
const preference = (section as Record<string, unknown>).preference
return typeof preference === 'string' && preference.trim() ? preference.trim() : undefined
}
/** System-prompt section text for thinking language (empty when unresolved). */
export function thinkingInstruction(language: ThinkingLanguageId): string {
const meta = THINKING_META[language]
return [
`The operator configured the language of your thinking/reasoning process: ${meta.native} (${meta.name}).`,
`Write your entire internal reasoning — chain-of-thought, analysis, planning, and deliberation — in ${meta.name}.`,
'Keep code, identifiers, file paths, commands, and technical terms as they are.',
'Your final answer to the user follows the reply-language setting (or the user language when reply language is follow-user); this thinking setting never alone changes the final answer language.',
].join(' ')
}
/** Per-step reminder so switches apply on the next model call in existing sessions. */
export function thinkingReminder(language: ThinkingLanguageId): string {
const meta = THINKING_META[language]
return [
`[Thinking language] The operator wants your reasoning/thinking process written in ${meta.native} (${meta.name}).`,
`Continue your entire chain-of-thought in ${meta.name}.`,
'Final answer language follows the reply-language setting / user language.',
].join(' ')
}
/**
* System-prompt section for forced reply language.
* Returns empty string when `follow-user` (no injection).
*/
export function replyInstruction(setting: string | undefined | null): string {
const normalized = normalizeReplyLanguage(setting)
if (normalized === REPLY_LANGUAGE_DEFAULT) return ''
const meta = REPLY_META[normalized]
return [
'## Reply language requirement',
`Regardless of the language the user writes in, you must always write the entire reply in ${meta.native} (${meta.name}) —`,
'including explanations, summaries, body text, titles, and error descriptions.',
'Keep code, commands, file paths, API names, host names, and proper nouns in their original form; do not translate them.',
'This requirement outranks language hints in the user message or persona until the Netx Ops reply-language setting changes.',
].join(' ')
}

View file

@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
languageFromLocale,
normalizeReplyLanguage,
normalizeThinkingLanguage,
replyInstruction,
resolveThinkingLanguage,
thinkingInstruction,
thinkingReminder,
} from '../src/netx/model-language.ts'
test('normalizeThinkingLanguage accepts aliases and defaults to auto', () => {
assert.equal(normalizeThinkingLanguage(undefined), 'auto')
assert.equal(normalizeThinkingLanguage('zh'), 'zh-CN')
assert.equal(normalizeThinkingLanguage('zh-CN'), 'zh-CN')
assert.equal(normalizeThinkingLanguage('en-US'), 'en')
assert.equal(normalizeThinkingLanguage('nope'), 'auto')
})
test('normalizeReplyLanguage accepts aliases and defaults to follow-user', () => {
assert.equal(normalizeReplyLanguage(undefined), 'follow-user')
assert.equal(normalizeReplyLanguage('auto'), 'follow-user')
assert.equal(normalizeReplyLanguage('zh-CN'), 'zh')
assert.equal(normalizeReplyLanguage('english'), 'en')
})
test('resolveThinkingLanguage follows locale when auto', () => {
assert.equal(resolveThinkingLanguage('auto', 'zh-CN'), 'zh-CN')
assert.equal(resolveThinkingLanguage('auto', 'en-US'), 'en')
assert.equal(resolveThinkingLanguage('en', 'zh-CN'), 'en')
assert.equal(resolveThinkingLanguage('auto', 'de-DE'), 'en')
})
test('languageFromLocale covers chinese and english tags', () => {
assert.equal(languageFromLocale('zh-Hans-CN'), 'zh-CN')
assert.equal(languageFromLocale('en'), 'en')
assert.equal(languageFromLocale(''), undefined)
})
test('thinkingInstruction and reminder mention the target language', () => {
const instruction = thinkingInstruction('zh-CN')
assert.match(instruction, /Simplified Chinese/)
assert.match(thinkingReminder('en'), /English/)
})
test('replyInstruction is empty for follow-user and forces otherwise', () => {
assert.equal(replyInstruction('follow-user'), '')
assert.match(replyInstruction('en'), /English/)
assert.match(replyInstruction('zh'), /简体中文/)
})