diff --git a/cordis.patch.yml b/cordis.patch.yml index 1c44e5b..bf23083 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index de502d0..bdf956b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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. diff --git a/docs/PORTING.md b/docs/PORTING.md index 55f1836..3484525 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 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) diff --git a/examples/local-debug/patch.cordis.yml b/examples/local-debug/patch.cordis.yml index f598583..62bc899 100644 --- a/examples/local-debug/patch.cordis.yml +++ b/examples/local-debug/patch.cordis.yml @@ -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 diff --git a/lib/client.js b/lib/client.js index da50b51..44e2215 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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", diff --git a/lib/index.js b/lib/index.js index 1d34b3f..a5e7909 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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 = () => { diff --git a/package.json b/package.json index 97646af..e34d852 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 3050e5c..7943898 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -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 ( +
+
+ + {props.field.overridden + ? ( + + {props.overriddenLabel} + + + ) + : null} +
+ +

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

+
+ ) +} + 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') }} /> + { props.edit('thinkingLanguage', text) }} + onReset={() => { props.resetField('thinkingLanguage') }} + /> + { props.edit('replyLanguage', text) }} + onReset={() => { props.resetField('replyLanguage') }} + /> = { 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 = { 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', diff --git a/src/client/styles.ts b/src/client/styles.ts index ff6e350..52c88d9 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -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)} diff --git a/src/index.ts b/src/index.ts index 1a522b1..3f326ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = 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 => { + 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 diff --git a/src/netx/model-language.ts b/src/netx/model-language.ts new file mode 100644 index 0000000..b63933d --- /dev/null +++ b/src/netx/model-language.ts @@ -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 = { + 'zh-CN': { id: 'zh-CN', name: 'Simplified Chinese', native: '简体中文' }, + en: { id: 'en', name: 'English', native: 'English' }, +} + +const REPLY_META: Record = { + 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).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(' ') +} diff --git a/test/model-language.test.mjs b/test/model-language.test.mjs new file mode 100644 index 0000000..0fa650e --- /dev/null +++ b/test/model-language.test.mjs @@ -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'), /简体中文/) +})