@xmanrui/dsh-im 4.19.0 → 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.0",
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",
@@ -72,10 +72,87 @@ function AliasDialog({ bot, onSave, onClose }) {
72
72
  return globalThis.document?.body ? createPortal(content, document.body) : content;
73
73
  }
74
74
 
75
+ function BotNameTooltip({ anchorRef, id, name, onDismiss }) {
76
+ const tooltipRef = React.useRef(null);
77
+ const [position, setPosition] = React.useState(null);
78
+ React.useLayoutEffect(() => {
79
+ const anchor = anchorRef.current;
80
+ const tooltip = tooltipRef.current;
81
+ if (!anchor || !tooltip) return undefined;
82
+ const document = anchor.ownerDocument;
83
+ const view = document.defaultView;
84
+ const place = () => {
85
+ const rect = anchor.getBoundingClientRect();
86
+ const { width, height } = tooltip.getBoundingClientRect();
87
+ const viewport = document.documentElement;
88
+ const margin = 8;
89
+ const below = rect.bottom + 6;
90
+ setPosition({
91
+ left: Math.max(margin, Math.min(rect.left, viewport.clientWidth - width - margin)),
92
+ top: Math.max(margin, Math.min(
93
+ below + height <= viewport.clientHeight - margin ? below : rect.top - height - 6,
94
+ viewport.clientHeight - height - margin,
95
+ )),
96
+ });
97
+ };
98
+ const dismiss = (event) => {
99
+ if (event.key === 'Escape') { event.stopPropagation(); onDismiss(); }
100
+ };
101
+ place();
102
+ view.addEventListener('resize', place);
103
+ // Capture nested scrolling containers as well as page scrolling.
104
+ document.addEventListener('scroll', onDismiss, true);
105
+ document.addEventListener('keydown', dismiss, true);
106
+ return () => {
107
+ view.removeEventListener('resize', place);
108
+ document.removeEventListener('scroll', onDismiss, true);
109
+ document.removeEventListener('keydown', dismiss, true);
110
+ };
111
+ }, [anchorRef, name, onDismiss]);
112
+ const body = anchorRef.current?.ownerDocument.body;
113
+ return body ? createPortal(h('span', {
114
+ ref: tooltipRef, id, role: 'tooltip', className: 'dim-botNameTooltip',
115
+ style: position ?? { visibility: 'hidden' },
116
+ }, name), body) : null;
117
+ }
118
+
75
119
  export function BotName({ bot, id, disabled = false, onSave }) {
76
120
  const [open, setOpen] = React.useState(false);
121
+ const nameRef = React.useRef(null);
122
+ const tooltipId = React.useId();
123
+ const [truncated, setTruncated] = React.useState(false);
124
+ const [hovered, setHovered] = React.useState(false);
125
+ const [focused, setFocused] = React.useState(false);
126
+ const [dismissed, setDismissed] = React.useState(false);
127
+ const dismissTooltip = React.useCallback(() => setDismissed(true), []);
128
+ const measure = React.useCallback(() => {
129
+ const node = nameRef.current;
130
+ setTruncated(Boolean(node && node.scrollWidth > node.clientWidth));
131
+ }, []);
132
+ React.useEffect(() => {
133
+ const node = nameRef.current;
134
+ if (!node) return undefined;
135
+ measure();
136
+ const view = node.ownerDocument.defaultView;
137
+ const observer = view.ResizeObserver ? new view.ResizeObserver(measure) : null;
138
+ observer?.observe(node);
139
+ view.addEventListener('resize', measure);
140
+ return () => { observer?.disconnect(); view.removeEventListener('resize', measure); };
141
+ }, [bot.name, measure]);
142
+ const showTooltip = truncated && !dismissed && !open && (hovered || focused);
77
143
  return h('div', { className: 'dim-aliasName' },
78
- h('h3', { id, title: bot.name }, bot.name),
144
+ h('h3', {
145
+ id, ref: nameRef, tabIndex: truncated ? 0 : undefined,
146
+ 'aria-describedby': showTooltip ? tooltipId : undefined,
147
+ onMouseEnter: () => { measure(); setHovered(true); setDismissed(false); },
148
+ onMouseLeave: () => setHovered(false),
149
+ onFocus: () => { measure(); setFocused(true); setDismissed(false); },
150
+ onBlur: () => setFocused(false),
151
+ onClick: dismissTooltip,
152
+ }, bot.name),
153
+ showTooltip ? h(BotNameTooltip, {
154
+ anchorRef: nameRef, id: tooltipId, name: bot.name, onDismiss: dismissTooltip,
155
+ }) : null,
79
156
  h('span', {
80
157
  className: 'dim-aliasEntry',
81
158
  onClick: (event) => event.stopPropagation(),
@@ -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
+ }
@@ -3,9 +3,15 @@ export const IM_STYLE_ID = 'xmanrui-dsh-im-settings';
3
3
  const CSS = String.raw`
4
4
  .dim-aliasName { display: flex; align-items: center; gap: 4px; min-width: 0; }
5
5
  .dim-aliasName h3 { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
6
+ .dim-aliasName h3:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; border-radius: 3px; }
7
+ .dim-botNameTooltip { position: fixed; z-index: 1000; box-sizing: border-box; width: max-content; max-width: min(320px, calc(100vw - 16px)); padding: 6px 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 8px 24px rgb(31 35 41 / 14%); font: 500 12px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; white-space: normal; overflow-wrap: anywhere; pointer-events: none; animation: dim-botNameTooltip-in .15s ease; }
8
+ @keyframes dim-botNameTooltip-in { from { opacity: 0; } to { opacity: 1; } }
9
+ @media (prefers-reduced-motion: reduce) { .dim-botNameTooltip { animation: none; } }
6
10
  .dim-aliasEntry { display: inline-flex; flex: none; }
7
- .dim-aliasEdit { display: grid; place-items: center; width: 28px; height: 28px; padding: 4px; border: 0; border-radius: 5px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; cursor: pointer; }
8
- .dim-aliasEdit:hover:not(:disabled) { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
11
+ .dim-aliasEdit { display: grid; place-items: center; width: 28px; height: 28px; padding: 4px; border: 0; border-radius: 5px; color: var(--dsw-alias-label-tertiary, #8f959e); background: transparent; cursor: pointer; }
12
+ .dim-aliasEdit svg { opacity: .55; transition: opacity .15s ease; }
13
+ .dim-aliasName:hover .dim-aliasEdit:not(:disabled) svg, .dim-aliasEdit:focus-visible svg { opacity: 1; }
14
+ .dim-aliasEdit:hover:not(:disabled), .dim-aliasEdit:focus-visible { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
9
15
  .dim-aliasDialog { box-sizing: border-box; width: min(380px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; padding: 22px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 12px 36px rgb(0 0 0 / 18%); font: 13px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
10
16
  .dim-aliasDialog * { box-sizing: border-box; }
11
17
  .dim-aliasDialog::backdrop { background: rgb(15 17 21 / 30%); }
@@ -401,9 +407,9 @@ const CSS = String.raw`
401
407
  /* Header tooltips may extend beyond the card; the collapsible body clips its own content. */
402
408
  .dim-panel .dim-botCard { position: relative; min-width: 0; width: 100%; max-width: 100%; overflow: visible; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); }
403
409
  .dim-panel .dim-botCard::before { display: none; }
404
- .dim-panel .dim-botCardBody { position: relative; min-width: 0; width: 100%; max-width: 100%; padding: 12px; }
410
+ .dim-panel .dim-botCardBody { position: relative; min-width: 0; width: 100%; max-width: 100%; padding: 12px 8px; }
405
411
  .dim-collapsibleAccount { min-width: 0; display: flex; flex-direction: column; }
406
- .dim-collapsibleHead { min-width: 0; display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; -webkit-user-select: none; }
412
+ .dim-collapsibleHead { min-width: 0; display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; -webkit-user-select: none; }
407
413
  .dim-collapsibleHead:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; border-radius: 8px; }
408
414
  .dim-collapsibleHeaderContent { min-width: 0; flex: 1 1 auto; display: flex; align-items: center; }
409
415
  .dim-collapsibleChevron { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 9px; height: 9px; border-right: 1.6px solid var(--dsw-alias-label-tertiary, #8f959e); border-bottom: 1.6px solid var(--dsw-alias-label-tertiary, #8f959e); transform: rotate(-45deg); transition: transform .22s cubic-bezier(.4, 0, .2, 1); transform-origin: 50% 50%; }
@@ -413,14 +419,17 @@ const CSS = String.raw`
413
419
  .dim-collapsibleAccount.is-open > .dim-collapsibleBody { grid-template-rows: 1fr; }
414
420
  .dim-collapsibleBodyInner { min-height: 0; overflow: hidden; }
415
421
  .dim-collapsibleAccount:not(.is-open) .dim-collapsibleBodyInner { visibility: hidden; }
416
- .dim-panel .dim-botCardTop { min-width: 0; max-width: 100%; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
417
- .dim-panel .dim-botIdentity { min-width: 0; flex: 1 1 0; display: flex; align-items: center; gap: 10px; }
422
+ /* Reclaim horizontal spacing for names while keeping status on the same row,
423
+ including when a channel's mobile stylesheet requests a column layout. */
424
+ .dim-panel .dim-botCardTop { min-width: 0; width: 100%; max-width: 100%; display: flex; flex-direction: row; flex-wrap: nowrap; align-items: flex-start; justify-content: space-between; gap: 6px; }
425
+ .dim-panel .dim-botIdentity { min-width: 0; flex: 1 1 0; display: flex; align-items: center; gap: 6px; }
418
426
  .dim-panel .dim-botAvatar { flex: none; width: 38px; height: 38px; display: grid; place-items: center; overflow: hidden; border-radius: 11px; box-shadow: none; }
419
427
  .dim-panel .dim-botAvatar svg { width: 27px; height: 27px; }
420
- .dim-panel .dim-botName { min-width: 0; }
428
+ .dim-panel .dim-botName { min-width: 0; flex: 1; }
429
+ .dim-panel .dim-aliasName { gap: 2px; }
421
430
  .dim-panel .dim-botName h3 { overflow: hidden; margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; font-weight: 650; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
422
431
  .dim-panel .dim-botName p { overflow: hidden; margin: 4px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
423
- .dim-panel .dim-botCardTools { flex: none; display: flex; align-items: flex-start; gap: 8px; }
432
+ .dim-panel .dim-botCardTools { flex: none; display: flex; align-items: flex-start; gap: 4px; }
424
433
  .dim-panel .dim-botHealthGroup { min-width: 0; max-width: 100%; flex: none; display: grid; justify-items: end; gap: 5px; }
425
434
  .dim-panel .dim-botCard .dim-botHealth { flex: none; min-height: 0; display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; border-radius: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; }
426
435
  .dim-panel .dim-lastChecked { display: inline-flex; align-items: baseline; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); font: inherit; font-size: 11px; font-weight: 400; line-height: normal; white-space: nowrap; }
@@ -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) {
@@ -3,10 +3,7 @@ import {
3
3
  consumeDshImInputOrigin,
4
4
  textFromHarnessContent,
5
5
  } from '../../src/channels/shared/harness-client.mjs';
6
- import {
7
- isSessionSyncMirrored,
8
- releaseSessionSyncMirror,
9
- } from '../../src/channels/shared/session-sync-registry.mjs';
6
+ import { deliverSessionSyncMirror } from '../../src/channels/shared/session-sync-registry.mjs';
10
7
 
11
8
  const DSH_USER_PREFIX = '[来自 DSH]\n';
12
9
  const DSH_ASSISTANT_PREFIX = '[DSH 助手]\n';
@@ -159,15 +156,19 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
159
156
  turns.delete(sessionId);
160
157
  if (state.origin !== 'dsh' || !completedTurn(event.data?.reason)
161
158
  || !state.recipients?.size || !state.assistant.text) return;
162
- // Per-target suppression: targets whose process-card mirror delivered
163
- // the answer are skipped; every other synced target still gets the text.
164
- const mirrored = [];
165
- const plain = [];
166
- for (const target of state.recipients.values()) {
167
- if (isSessionSyncMirrored(sessionId, target.targetId, state.turn)) mirrored.push(target);
168
- else plain.push(target);
169
- }
170
- for (const target of mirrored) releaseSessionSyncMirror(sessionId, target.targetId);
159
+ // A pending card is not proof of delivery. Only suppress this target's
160
+ // text after its renderer confirms the complete final answer is visible.
161
+ // Targets without a renderer retain the original text delivery path.
162
+ const recipients = [...state.recipients.values()];
163
+ const rendered = await Promise.all(recipients.map(async (target) => {
164
+ try {
165
+ return await deliverSessionSyncMirror(target, sessionId, state.turn, state.assistant.text);
166
+ } catch (error) {
167
+ logFailure('card delivery', target, error);
168
+ return false;
169
+ }
170
+ }));
171
+ const plain = recipients.filter((_target, index) => !rendered[index]);
171
172
  if (plain.length === 0) return;
172
173
  await deliver(
173
174
  sessionId,