@xmanrui/dsh-im 4.19.1 → 4.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.19.1",
3
+ "version": "4.19.2",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -52,6 +52,10 @@
52
52
  {
53
53
  "name": "geekyfoxlab",
54
54
  "url": "https://github.com/geekyfoxlab"
55
+ },
56
+ {
57
+ "name": "grloper",
58
+ "url": "https://github.com/grloper"
55
59
  }
56
60
  ],
57
61
  "license": "MIT",
@@ -51,6 +51,10 @@ import { IMESSAGE_RPC_CHANNEL } from './channels/imessage/api.js';
51
51
  import { IMessageSettingsTab } from './channels/imessage/index.js';
52
52
  import { installIMessageStyles } from './channels/imessage/styles.js';
53
53
  import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
54
+ import {
55
+ HOST_LANGUAGE_RPC_CHANNEL,
56
+ installInterfaceLanguageMirror,
57
+ } from './interface-language.js';
54
58
  import { BotSettingsContext } from './channel-card-meta.js';
55
59
  import {
56
60
  DELIVERY_RPC_CHANNEL,
@@ -398,6 +402,17 @@ export function apply(ctx) {
398
402
  const t = ctx.locale.bind(IM_LOCALE_NAMESPACE);
399
403
  setImTranslator(t);
400
404
 
405
+ // The settings page is the only place that knows the locale the interface is
406
+ // actually rendered in: DSH stores nothing when it came from the browser's
407
+ // language list. Report it so bot messages follow the same language.
408
+ ctx.effect(
409
+ () => installInterfaceLanguageMirror(ctx, {
410
+ rpcCall: (endpoint, payload, signal) =>
411
+ callManagementRpc(ctx.connection, HOST_LANGUAGE_RPC_CHANNEL, endpoint, payload, signal),
412
+ }),
413
+ 'im-settings: mirror the DSH interface language',
414
+ );
415
+
401
416
  ctx.effect(() => installSessionChannelLogos(), 'im-settings: Session channel logos');
402
417
 
403
418
  ctx.effect(() => {
@@ -0,0 +1,89 @@
1
+ import { normalizeInterfaceLanguageTag } from '../../src/channels/shared/interface-language.mjs';
2
+ import { createPollScheduler } from './lifecycle.js';
3
+
4
+ /** Host route serving the interface-language mirror (plugin-src/host/host-language-rpc.mjs). */
5
+ export const HOST_LANGUAGE_RPC_CHANNEL = '/dsh-im-language';
6
+ export const HOST_LANGUAGE_ENDPOINTS = Object.freeze({
7
+ get: 'settings.language.get',
8
+ mirror: 'settings.language.mirror',
9
+ });
10
+
11
+ /** Widening retry delays for a report the Connection could not carry yet. */
12
+ const RETRY_DELAYS_MS = Object.freeze([1_000, 4_000, 15_000]);
13
+
14
+ /**
15
+ * Report the locale the settings UI is actually rendered in to the Host, so
16
+ * bot chat messages and command menus follow the DSH interface language.
17
+ *
18
+ * DSH stores a locale preference only when the reader picks one in the
19
+ * Language row: a locale derived from the browser's language list leaves the
20
+ * Host user-settings document empty. This mirror closes that gap, and the Host
21
+ * keeps an explicit selection ranked above it (see
22
+ * src/channels/shared/interface-language.mjs).
23
+ *
24
+ * The first report runs at plugin load, when the Connection may not be
25
+ * established yet, so a failed report is retried on a widening delay rather
26
+ * than waiting for a locale change that may never come. A failure is both a
27
+ * rejected promise and a resolved `{ ok: false }` envelope — the Host returns
28
+ * the latter when it could not persist the mirror, and that must retry too.
29
+ *
30
+ * @param ctx - client cordis context providing `locale` and the event bus.
31
+ * @param options.rpcCall - management RPC caller for HOST_LANGUAGE_RPC_CHANNEL.
32
+ * @returns an idempotent disposer that stops reporting and cancels any retry.
33
+ */
34
+ export function installInterfaceLanguageMirror(ctx, {
35
+ rpcCall,
36
+ setTimeoutFn = (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
37
+ clearTimeoutFn = (timer) => globalThis.clearTimeout(timer),
38
+ retryDelaysMs = RETRY_DELAYS_MS,
39
+ } = {}) {
40
+ if (typeof rpcCall !== 'function' || typeof ctx?.locale?.getLocale !== 'function') {
41
+ return () => {};
42
+ }
43
+ const delays = retryDelaysMs.length > 0 ? retryDelaysMs : RETRY_DELAYS_MS;
44
+ const scheduler = createPollScheduler({ setTimeoutFn, clearTimeoutFn });
45
+ let mirrored = null;
46
+ let attempt = 0;
47
+
48
+ const retry = (active) => {
49
+ // Leave the tag unmirrored so the retry re-sends it. A Host that cannot
50
+ // persist the mirror still answers in its stored language; there is
51
+ // nothing for the reader to act on here.
52
+ if (mirrored === active) mirrored = null;
53
+ scheduler.schedule(report, delays[Math.min(attempt, delays.length - 1)]);
54
+ attempt += 1;
55
+ };
56
+
57
+ const report = () => {
58
+ if (scheduler.disposed) return;
59
+ const active = normalizeInterfaceLanguageTag(ctx.locale.getLocale()?.active);
60
+ if (active === null || active === mirrored) return;
61
+ mirrored = active;
62
+ Promise.resolve(rpcCall(HOST_LANGUAGE_ENDPOINTS.mirror, { locale: active })).then(
63
+ (result) => {
64
+ // Connection resolves failed business results normally; only a
65
+ // resolved ok envelope proves the Host actually persisted the mirror.
66
+ if (result?.ok === true) {
67
+ attempt = 0;
68
+ return;
69
+ }
70
+ retry(active);
71
+ },
72
+ () => retry(active),
73
+ );
74
+ };
75
+
76
+ report();
77
+ // A deliberate switch is worth reporting immediately, so it restarts the
78
+ // delay ladder instead of inheriting a previous failure's backoff.
79
+ const off = typeof ctx.on === 'function'
80
+ ? ctx.on('locale/change', () => {
81
+ attempt = 0;
82
+ report();
83
+ })
84
+ : null;
85
+ return () => {
86
+ scheduler.dispose();
87
+ if (typeof off === 'function') off();
88
+ };
89
+ }
@@ -1,7 +1,29 @@
1
1
  import { registerManagementRpc } from '../../../management-rpc.mjs';
2
+ import { onImHostLanguageChange } from '../../../../src/channels/shared/i18n.mjs';
2
3
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
4
  import { publicChannelInitializing, publicChannelStartupError } from './startup-error.mjs';
4
5
 
6
+ /**
7
+ * Keep a channel's platform-side command menu in the current host message
8
+ * language. Menus are registered with the platform when a bot connects, so a
9
+ * later language change has to re-send them; channels that publish no menu
10
+ * expose no refresh hook and are left alone.
11
+ */
12
+ function followHostLanguage(ctx, channel, controller, logger) {
13
+ if (typeof controller?.refreshCommandMenus !== 'function') return;
14
+ const unsubscribe = onImHostLanguageChange(() => {
15
+ // Never run a slow or failing platform call inside the language switch:
16
+ // subscribers are synchronous and must not delay or break one another.
17
+ void Promise.resolve()
18
+ .then(() => controller.refreshCommandMenus())
19
+ .catch((error) => logger.warn?.(
20
+ `[dsh-im] failed to re-send the ${channel} command menu after a language change`,
21
+ error,
22
+ ));
23
+ });
24
+ ctx.effect(() => unsubscribe, `dsh-im: follow the host language for ${channel} command menus`);
25
+ }
26
+
5
27
  /** Mount the native management RPC before any fallible production initialization. */
6
28
  export async function installProductionChannel(ctx, config, {
7
29
  channel, rpcChannel, createProduction, createHandler,
@@ -31,6 +53,7 @@ export async function installProductionChannel(ctx, config, {
31
53
  ? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
32
54
  const readyHandler = createHandler(production.controller);
33
55
  ctx.effect(() => closeProduction, `dsh-im: close ${channel} connections`);
56
+ followHostLanguage(ctx, channel, production.controller, logger);
34
57
  handler = readyHandler;
35
58
  } catch (error) {
36
59
  startupError = publicChannelStartupError(channel, error);
@@ -0,0 +1,71 @@
1
+ import { registerManagementRpc } from '../management-rpc.mjs';
2
+ import { normalizeInterfaceLanguageTag } from '../../src/channels/shared/interface-language.mjs';
3
+
4
+ export const HOST_LANGUAGE_RPC_CHANNEL = '/dsh-im-language';
5
+ export const HOST_LANGUAGE_ENDPOINTS = Object.freeze({
6
+ get: 'settings.language.get',
7
+ mirror: 'settings.language.mirror',
8
+ });
9
+
10
+ const ENDPOINTS = new Set(Object.values(HOST_LANGUAGE_ENDPOINTS));
11
+
12
+ export function validHostLanguagePayload(endpoint, payload) {
13
+ if (!ENDPOINTS.has(endpoint)) return false;
14
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
15
+ const keys = Object.keys(payload);
16
+ if (endpoint === HOST_LANGUAGE_ENDPOINTS.mirror) {
17
+ if (keys.length !== 1 || keys[0] !== 'locale') return false;
18
+ // An explicit null clears the mirror; anything else must be a usable tag.
19
+ return payload.locale === null || normalizeInterfaceLanguageTag(payload.locale) !== null;
20
+ }
21
+ return keys.length === 0;
22
+ }
23
+
24
+ /**
25
+ * Serve the interface-language mirror. The settings UI is the only caller: it
26
+ * knows the locale the interface is actually rendered in, including a
27
+ * browser-derived one that DSH never stores.
28
+ */
29
+ export function createHostLanguageRpcHandler({ controller, logger = null } = {}) {
30
+ if (!controller || typeof controller.snapshot !== 'function'
31
+ || typeof controller.mirror !== 'function') {
32
+ throw new TypeError('createHostLanguageRpcHandler requires a host language controller');
33
+ }
34
+ return async (endpoint, payload, signal) => {
35
+ if (!validHostLanguagePayload(endpoint, payload)) {
36
+ return { ok: false, error: { code: 'bad-request', message: 'Invalid interface language request.' } };
37
+ }
38
+ if (signal?.aborted) {
39
+ return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
40
+ }
41
+ try {
42
+ if (endpoint === HOST_LANGUAGE_ENDPOINTS.get) {
43
+ return { ok: true, value: controller.snapshot() };
44
+ }
45
+ return { ok: true, value: await controller.mirror(payload.locale) };
46
+ } catch (error) {
47
+ // The failure is almost always a settings-directory write problem; keep
48
+ // the path and the underlying message out of the browser response.
49
+ logger?.warn?.('[dsh-im] could not persist the mirrored DSH interface language', error);
50
+ return {
51
+ ok: false,
52
+ error: {
53
+ code: 'interface-language-unavailable',
54
+ message: 'interface-language-unavailable',
55
+ },
56
+ };
57
+ }
58
+ };
59
+ }
60
+
61
+ export function installHostLanguageRpc(ctx, controller, authority) {
62
+ const logger = typeof ctx?.logger === 'function'
63
+ ? ctx.logger('dsh-im:language')
64
+ : (ctx?.logger ?? null);
65
+ return registerManagementRpc(
66
+ ctx,
67
+ HOST_LANGUAGE_RPC_CHANNEL,
68
+ createHostLanguageRpcHandler({ controller, logger }),
69
+ { authority },
70
+ );
71
+ }
@@ -0,0 +1,157 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ import {
5
+ getImHostLanguage,
6
+ normalizeImHostLanguage,
7
+ onImHostLanguageChange,
8
+ setImHostLanguage,
9
+ } from '../../src/channels/shared/i18n.mjs';
10
+ import {
11
+ normalizeInterfaceLanguageTag,
12
+ resolveHostLanguageTag,
13
+ } from '../../src/channels/shared/interface-language.mjs';
14
+ import { InterfaceLanguageStore } from '../../src/channels/shared/interface-language-store.mjs';
15
+
16
+ /** Settings namespace and field owned by @deepseek-ai/dsh-client-locale. */
17
+ export const DSH_LOCALE_NAMESPACE = 'locale';
18
+ export const DSH_LOCALE_PREFERENCE_FIELD = 'preference';
19
+
20
+ /**
21
+ * Resolve the durable interface-language mirror. The dshHome resolution
22
+ * mirrors pluginPaths: config.dshHome, then DSH_HOME, then the user's home
23
+ * directory. Channel-specific dataDir values never apply.
24
+ */
25
+ export function interfaceLanguageSettingsPath(config = {}) {
26
+ const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
27
+ return resolve(dshHome, 'integrations', 'dsh-im', 'interface-language.json');
28
+ }
29
+
30
+ /**
31
+ * Own the host message language across its resolution layers. `apply()` is
32
+ * idempotent: it re-resolves the layers and hands the winner to
33
+ * setImHostLanguage, which notifies observers only on a real change.
34
+ */
35
+ export function createHostLanguageController({
36
+ store = null,
37
+ config: configLanguage,
38
+ readSettingsPreference = () => undefined,
39
+ logger = null,
40
+ } = {}) {
41
+ const pinned = normalizeInterfaceLanguageTag(configLanguage);
42
+ let readPreference = typeof readSettingsPreference === 'function'
43
+ ? readSettingsPreference
44
+ : () => undefined;
45
+
46
+ const settingsTag = () => {
47
+ // A pinned language wins outright, so never consult the settings service
48
+ // for it: a detached provider must not produce a spurious diagnostic.
49
+ if (pinned !== null) return undefined;
50
+ try {
51
+ return readPreference();
52
+ } catch (error) {
53
+ logger?.warn?.(
54
+ '[dsh-im] could not read the DSH interface language preference; using the mirrored language',
55
+ error,
56
+ );
57
+ return undefined;
58
+ }
59
+ };
60
+
61
+ const mirrorTag = () => (typeof store?.getLanguageTag === 'function' ? store.getLanguageTag() : null);
62
+
63
+ const resolution = () => resolveHostLanguageTag({
64
+ config: configLanguage,
65
+ settings: settingsTag(),
66
+ mirror: mirrorTag(),
67
+ });
68
+
69
+ const describe = (resolved) => Object.freeze({
70
+ language: normalizeImHostLanguage(resolved.tag),
71
+ tag: resolved.tag,
72
+ source: resolved.source,
73
+ pinned: pinned !== null,
74
+ });
75
+
76
+ const apply = () => {
77
+ const resolved = resolution();
78
+ setImHostLanguage(resolved.tag ?? undefined);
79
+ return describe(resolved);
80
+ };
81
+
82
+ return Object.freeze({
83
+ apply,
84
+
85
+ /** The resolution as it stands, without switching the active language. */
86
+ snapshot: () => describe(resolution()),
87
+
88
+ /** Record the interface locale reported by the settings UI, then re-resolve. */
89
+ async mirror(value) {
90
+ if (typeof store?.setLanguageTag !== 'function') return apply();
91
+ await store.setLanguageTag(value ?? null);
92
+ return apply();
93
+ },
94
+
95
+ /** Replace the settings-preference reader once the service is injectable. */
96
+ observeSettings(read) {
97
+ readPreference = typeof read === 'function' ? read : () => undefined;
98
+ return apply();
99
+ },
100
+
101
+ /** Observe committed host language changes (see onImHostLanguageChange). */
102
+ observe: onImHostLanguageChange,
103
+
104
+ /** The active host message language, for callers that only need the switch. */
105
+ language: () => getImHostLanguage(),
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Bind the host message language to DSH's interface language.
111
+ *
112
+ * The plugin's own `language` option stays authoritative for operators who set
113
+ * it. Otherwise the language follows DSH: the explicit Language-row selection
114
+ * from the Host user-settings document, and — because DSH stores nothing for a
115
+ * browser-derived locale — the language mirrored by the settings UI.
116
+ */
117
+ export function installHostLanguage(ctx, config = {}, internals = {}) {
118
+ const logger = typeof ctx?.logger === 'function'
119
+ ? ctx.logger('dsh-im:language')
120
+ : (ctx?.logger ?? null);
121
+ const store = internals.store
122
+ ?? new InterfaceLanguageStore(interfaceLanguageSettingsPath(config));
123
+ const controller = createHostLanguageController({
124
+ store,
125
+ config: config.language ?? process.env.DSH_IM_LANGUAGE,
126
+ logger,
127
+ });
128
+ // Read an already-attached settings service synchronously: channels start
129
+ // inside the same activation, and waiting for the injection callback would
130
+ // let the first bot register its command menu in the previous language and
131
+ // then need a second push. The injection below keeps it live afterwards and
132
+ // covers a provider that attaches later.
133
+ controller.observeSettings(
134
+ () => ctx?.settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD],
135
+ );
136
+ const ready = Promise.resolve()
137
+ .then(() => store.load?.())
138
+ .then(() => controller.apply(), (error) => {
139
+ logger?.error?.(
140
+ '[dsh-im] could not read the mirrored DSH interface language; falling back to Chinese',
141
+ error,
142
+ );
143
+ return controller.snapshot();
144
+ });
145
+ if (typeof ctx?.inject === 'function') {
146
+ ctx.inject(['settings'], (settingsCtx) => {
147
+ controller.observeSettings(
148
+ () => settingsCtx.settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD],
149
+ );
150
+ settingsCtx.on('settings/updated', (namespace) => {
151
+ if (namespace !== DSH_LOCALE_NAMESPACE) return;
152
+ controller.apply();
153
+ });
154
+ });
155
+ }
156
+ return Object.freeze({ ...controller, ready });
157
+ }
@@ -11,7 +11,8 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
11
11
  import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
12
12
  import { apply as applyIMessage } from './channels/imessage/index.mjs';
13
13
  import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
14
- import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
14
+ import { installHostLanguage } from './host-language.mjs';
15
+ import { installHostLanguageRpc } from './host-language-rpc.mjs';
15
16
  import { installDeliveryRpc } from './delivery-rpc.mjs';
16
17
  import { installDeliveryHttp } from './delivery-http.mjs';
17
18
  import { createDeliveryService } from './delivery-service.mjs';
@@ -36,6 +37,8 @@ function channelConfig(config, name, deliveryService) {
36
37
  }
37
38
 
38
39
  export function createImHostPlugin(internals = {}) {
40
+ const startHostLanguage = internals.installHostLanguage ?? installHostLanguage;
41
+ const startHostLanguageRpc = internals.installHostLanguageRpc ?? installHostLanguageRpc;
39
42
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
40
43
  const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
41
44
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
@@ -109,7 +112,10 @@ export function createImHostPlugin(internals = {}) {
109
112
  });
110
113
 
111
114
  async function activateChannels(ctx, config, deliveryService) {
112
- setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
115
+ // Bind the bot message language before any channel connects, so the first
116
+ // command menu a platform stores is already in the interface language.
117
+ const hostLanguage = startHostLanguage(ctx, config);
118
+ await hostLanguage?.ready;
113
119
  const startTitlePrefix = (titleCtx) => {
114
120
  // The installer owns its cleanup through ctx.effect(). Cordis startup
115
121
  // callbacks must not return its controller object as an effect.
@@ -134,6 +140,13 @@ export function createImHostPlugin(internals = {}) {
134
140
  ? ctx.logger(name)
135
141
  : (ctx?.logger ?? console);
136
142
  if (ctx?.connection?.fetch) {
143
+ if (hostLanguage) {
144
+ try {
145
+ startHostLanguageRpc(ctx, hostLanguage, config.rpcAuthority);
146
+ } catch (error) {
147
+ logger.error?.('[dsh-im] failed to activate interface language mirroring; continuing with channels', error);
148
+ }
149
+ }
137
150
  try {
138
151
  startUpdate(ctx);
139
152
  } catch (error) {