@xmanrui/dsh-im 4.14.0 → 4.16.0

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.
Files changed (69) hide show
  1. package/README.en.md +10 -2
  2. package/README.md +17 -6
  3. package/lib/client.js +1980 -616
  4. package/lib/index.js +303 -293
  5. package/package.json +6 -2
  6. package/plugin-src/client/access-policy-settings.js +6 -0
  7. package/plugin-src/client/channels/feishu/index.js +16 -4
  8. package/plugin-src/client/channels/wecom-app/api.js +124 -0
  9. package/plugin-src/client/channels/wecom-app/index.js +612 -0
  10. package/plugin-src/client/channels/wecom-app/styles.js +25 -0
  11. package/plugin-src/client/delivery-settings.js +7 -0
  12. package/plugin-src/client/i18n.js +64 -1
  13. package/plugin-src/client/index.js +21 -0
  14. package/plugin-src/client/model-setting.js +135 -69
  15. package/plugin-src/client/session-channel-logos.js +241 -0
  16. package/plugin-src/client/styles.js +22 -7
  17. package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
  18. package/plugin-src/host/channels/discord/index.mjs +8 -10
  19. package/plugin-src/host/channels/feishu/index.mjs +8 -15
  20. package/plugin-src/host/channels/office/index.mjs +9 -5
  21. package/plugin-src/host/channels/qq/index.mjs +8 -15
  22. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  23. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
  24. package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
  25. package/plugin-src/host/channels/shared/startup.mjs +47 -0
  26. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  27. package/plugin-src/host/channels/slack/index.mjs +8 -10
  28. package/plugin-src/host/channels/telegram/index.mjs +8 -10
  29. package/plugin-src/host/channels/wecom/index.mjs +8 -15
  30. package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
  31. package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
  32. package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
  33. package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
  34. package/plugin-src/host/channels/weixin/index.mjs +8 -15
  35. package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
  36. package/plugin-src/host/delivery-adapter.mjs +4 -0
  37. package/plugin-src/host/delivery-suggestions.mjs +4 -0
  38. package/plugin-src/host/index.mjs +21 -2
  39. package/plugin-src/host/session-title-prefix.mjs +122 -0
  40. package/scripts/verify-model-setting.mjs +54 -0
  41. package/scripts/verify-session-channel-logos.mjs +59 -0
  42. package/scripts/verify-session-title-prefix.mjs +149 -0
  43. package/src/channels/feishu/bridge.mjs +305 -12
  44. package/src/channels/feishu/feishu-cards.mjs +38 -0
  45. package/src/channels/feishu/feishu-channel.mjs +88 -20
  46. package/src/channels/feishu/plugin-controller.mjs +1 -0
  47. package/src/channels/feishu/repair-manager.mjs +3 -2
  48. package/src/channels/shared/bot-workspace-store.mjs +11 -3
  49. package/src/channels/shared/command-catalog.mjs +102 -0
  50. package/src/channels/shared/i18n-en/feishu.mjs +14 -2
  51. package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
  52. package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
  53. package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
  54. package/src/channels/shared/i18n-en.mjs +2 -0
  55. package/src/channels/shared/model-setting.mjs +43 -8
  56. package/src/channels/shared/session-channel-labels.mjs +26 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +2 -26
  58. package/src/channels/telegram/telegram-api.mjs +18 -6
  59. package/src/channels/telegram/telegram-runtime.mjs +34 -32
  60. package/src/channels/wecom/send-error.mjs +31 -0
  61. package/src/channels/wecom/wecom-bridge.mjs +59 -17
  62. package/src/channels/wecom-app/callback-server.mjs +471 -0
  63. package/src/channels/wecom-app/config-store.mjs +240 -0
  64. package/src/channels/wecom-app/harness-client.mjs +11 -0
  65. package/src/channels/wecom-app/state-store.mjs +107 -0
  66. package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
  67. package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
  68. package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
  69. package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
@@ -3,6 +3,7 @@ const CHANNELS = new Set([
3
3
  'feishu',
4
4
  'dingtalk',
5
5
  'wecom',
6
+ 'wecom-app',
6
7
  'qq',
7
8
  'slack',
8
9
  'telegram',
@@ -97,6 +98,8 @@ export function deliverySuggestionFromConversationKey(channel, key) {
97
98
  ['direct', 'user', 'chatId'],
98
99
  ['group', 'group', 'chatId'],
99
100
  ]);
101
+ case 'wecom-app':
102
+ return simpleSuggestion(key, [['p2p', 'user', 'chatId']]);
100
103
  case 'qq':
101
104
  return simpleSuggestion(key, [
102
105
  ['c2c', 'user', 'userOpenId'],
@@ -124,6 +127,7 @@ export function privateDeliverySuggestionFromConversationKey(channel, key) {
124
127
  feishu: 'p2p',
125
128
  dingtalk: 'p2p',
126
129
  wecom: 'direct',
130
+ 'wecom-app': 'p2p',
127
131
  qq: 'c2c',
128
132
  slack: 'direct',
129
133
  telegram: 'direct',
@@ -6,6 +6,7 @@ import { apply as applyQq } from './channels/qq/index.mjs';
6
6
  import { apply as applySlack } from './channels/slack/index.mjs';
7
7
  import { apply as applyTelegram } from './channels/telegram/index.mjs';
8
8
  import { apply as applyWecom } from './channels/wecom/index.mjs';
9
+ import { apply as applyWecomApp } from './channels/wecom-app/index.mjs';
9
10
  import { apply as applyWeixin } from './channels/weixin/index.mjs';
10
11
  import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
11
12
  import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
@@ -15,6 +16,7 @@ import { installDeliveryHttp } from './delivery-http.mjs';
15
16
  import { createDeliveryService } from './delivery-service.mjs';
16
17
  import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
17
18
  import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
19
+ import { installSessionTitlePrefix } from './session-title-prefix.mjs';
18
20
  import { installUpdateRpc } from './update-rpc.mjs';
19
21
 
20
22
  export const name = 'dsh-im-host';
@@ -44,6 +46,7 @@ export function createImHostPlugin(internals = {}) {
44
46
  const startWeixin = internals.applyWeixin ?? applyWeixin;
45
47
  const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
46
48
  const startWecom = internals.applyWecom ?? applyWecom;
49
+ const startWecomApp = internals.applyWecomApp ?? applyWecomApp;
47
50
  const startQq = internals.applyQq ?? applyQq;
48
51
  const startSlack = internals.applySlack ?? applySlack;
49
52
  const startTelegram = internals.applyTelegram ?? applyTelegram;
@@ -55,6 +58,7 @@ export function createImHostPlugin(internals = {}) {
55
58
  ['weixin', startWeixin],
56
59
  ['dingtalk', startDingtalk],
57
60
  ['wecom', startWecom],
61
+ ['wecomApp', startWecomApp],
58
62
  ['qq', startQq],
59
63
  ['slack', startSlack],
60
64
  ['telegram', startTelegram],
@@ -103,6 +107,19 @@ export function createImHostPlugin(internals = {}) {
103
107
 
104
108
  async function activateChannels(ctx, config, deliveryService) {
105
109
  setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
110
+ const startTitlePrefix = (titleCtx) => {
111
+ // The installer owns its cleanup through ctx.effect(). Cordis startup
112
+ // callbacks must not return its controller object as an effect.
113
+ installSessionTitlePrefix(titleCtx, {
114
+ logger: typeof titleCtx?.logger === 'function'
115
+ ? titleCtx.logger('dsh-im:session-title') : (titleCtx?.logger ?? console),
116
+ });
117
+ };
118
+ if (typeof ctx?.inject === 'function') {
119
+ ctx.inject(['sessions'], startTitlePrefix);
120
+ } else if (ctx?.sessions && typeof ctx.on === 'function') {
121
+ startTitlePrefix(ctx);
122
+ }
106
123
  if (typeof ctx?.inject === 'function') {
107
124
  ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
108
125
  installOutboundArtifactTool(artifactCtx);
@@ -131,14 +148,16 @@ export function createImHostPlugin(internals = {}) {
131
148
  }
132
149
  }
133
150
  const failures = [];
134
- for (const [channel, start] of channels) {
151
+ // Each channel mounts its management RPC before awaiting initialization.
152
+ // Start them together so a slow channel cannot leave later routes absent.
153
+ await Promise.all(channels.map(async ([channel, start]) => {
135
154
  try {
136
155
  await start(ctx, channelConfig(config, channel, deliveryService));
137
156
  } catch (error) {
138
157
  failures.push(error);
139
158
  logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
140
159
  }
141
- }
160
+ }));
142
161
  if (failures.length === channels.length) {
143
162
  throw new AggregateError(failures, 'dsh-im failed to activate every channel');
144
163
  }
@@ -0,0 +1,122 @@
1
+ import { getImHostLanguage } from '../../src/channels/shared/i18n.mjs';
2
+ import { SESSION_CHANNEL_LABELS as CHANNEL_LABELS } from '../../src/channels/shared/session-channel-labels.mjs';
3
+ const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
4
+ // HarnessClient uses these ids for prompts and steering, independently of
5
+ // context enhancement. Read the durable source, never the message's text.
6
+ const IM_RPC_ID = new RegExp(`^(${Object.keys(CHANNEL_LABELS).join('|')})-(?:steer-)?${UUID}$`, 'u');
7
+ const LEGACY_SESSION_ID = new RegExp(`^(${Object.keys(CHANNEL_LABELS).join('|')})-.+`, 'u');
8
+
9
+ function eventsOf(session) {
10
+ const events = typeof session?.snapshotEvents === 'function'
11
+ ? session.snapshotEvents()
12
+ : session?.events;
13
+ return Array.isArray(events) ? events : [];
14
+ }
15
+
16
+ function channelOf(session, events) {
17
+ // A Web-created fork must not acquire a channel just from its parent's
18
+ // messages. Both supported Session implementations expose this boundary.
19
+ const inherited = session.inheritedEventCount;
20
+ if (session.header?.parentSession && !Number.isSafeInteger(inherited)) return null;
21
+ for (const event of events) {
22
+ if (Number.isSafeInteger(inherited) && event.seq < inherited) continue;
23
+ if (event.type !== 'user/message' || event.data?.source?.kind !== 'user') continue;
24
+ const channel = IM_RPC_ID.exec(event.data.source.rpcId)?.[1];
25
+ if (channel) return channel;
26
+ }
27
+ if (session.header?.parentSession) return null;
28
+ return LEGACY_SESSION_ID.exec(session.id ?? session.sessionId)?.[1] ?? null;
29
+ }
30
+
31
+ export function withSessionChannelPrefix(title, channel, language = getImHostLanguage()) {
32
+ const labels = CHANNEL_LABELS[channel];
33
+ if (!labels || typeof title !== 'string' || !title.trim()) return title;
34
+ // Keep the original title intact, including Unicode graphemes. The Host
35
+ // has already normalized and capped its generated text before this point.
36
+ if (labels.some((label) => title.startsWith(`${label} · `))) return title;
37
+ return `${labels[language === 'en' ? 1 : 0]} · ${title}`;
38
+ }
39
+
40
+ function validTitleData(data) {
41
+ if (typeof data?.title !== 'string' || !data.title.trim()
42
+ || !Array.isArray(data.messageSeqs)) return false;
43
+ if (data.source?.kind === 'user') return data.messageSeqs.length === 0;
44
+ if (!['fallback', 'provider'].includes(data.source?.kind)
45
+ || data.messageSeqs.length === 0
46
+ || data.messageSeqs.some((seq) => !Number.isSafeInteger(seq) || seq < 0)) return false;
47
+ return data.source.kind !== 'provider'
48
+ || (typeof data.source.provider === 'string' && Boolean(data.source.provider));
49
+ }
50
+
51
+ /**
52
+ * Decorate accepted titles without session.rename(): rename pins them as
53
+ * user-owned and supersedes automatic generation. Appending a log-only title
54
+ * with the same source and citations preserves the Host's scheduling and
55
+ * explicit-rename semantics. No provider, prompt, or stored event is changed.
56
+ */
57
+ export function installSessionTitlePrefix(ctx, { logger = console } = {}) {
58
+ if (typeof ctx?.on !== 'function' || typeof ctx?.sessions?.get !== 'function'
59
+ || typeof ctx?.sessions?.list !== 'function') {
60
+ throw new TypeError('Session title prefixes require Host sessions and events');
61
+ }
62
+ const pending = new Map();
63
+ let closed = false;
64
+
65
+ const decorate = (session) => {
66
+ if (closed || ctx.sessions.get(session.id ?? session.sessionId) !== session
67
+ || typeof session.append !== 'function') return;
68
+ const events = eventsOf(session);
69
+ const channel = channelOf(session, events);
70
+ if (!channel) return;
71
+ const latest = events.findLast((event) => event.type === 'session/title');
72
+ if (!validTitleData(latest?.data)) return;
73
+ const title = withSessionChannelPrefix(latest.data.title, channel);
74
+ if (title === latest.data.title) return;
75
+ // There is no await between the latest-title read and this append. A
76
+ // queued old result can therefore never replace a newer/manual title.
77
+ session.append('session/title', {
78
+ ...structuredClone(latest.data),
79
+ title,
80
+ });
81
+ };
82
+
83
+ const enqueue = (session) => {
84
+ if (closed || pending.has(session)) return;
85
+ // Leave the current append/notification stack before writing another
86
+ // event, so persistence and client projections observe monotonically
87
+ // ordered events. Read the newest title when the microtask runs.
88
+ const task = Promise.resolve().then(() => {
89
+ pending.delete(session);
90
+ decorate(session);
91
+ }).catch((error) => {
92
+ logger.warn?.('[dsh-im] unable to add Session channel prefix:', error?.message ?? error);
93
+ }).finally(() => {
94
+ if (pending.get(session) === task) pending.delete(session);
95
+ });
96
+ pending.set(session, task);
97
+ };
98
+
99
+ const disposeEvent = ctx.on('session/event', (session, event) => {
100
+ if (event?.type === 'session/title'
101
+ || (event?.type === 'user/message' && IM_RPC_ID.test(event.data?.source?.rpcId))) {
102
+ enqueue(session);
103
+ }
104
+ }, { global: true });
105
+ const disposeCreated = ctx.on('session/created', enqueue, { global: true });
106
+ const close = () => {
107
+ if (closed) return;
108
+ closed = true;
109
+ disposeEvent?.();
110
+ disposeCreated?.();
111
+ };
112
+ if (typeof ctx.effect === 'function') {
113
+ ctx.effect(() => close, 'dsh-im: Session title channel prefixes');
114
+ }
115
+ for (const session of ctx.sessions.list()) enqueue(session);
116
+ return Object.freeze({
117
+ close,
118
+ async whenIdle() {
119
+ while (pending.size) await Promise.allSettled([...pending.values()]);
120
+ },
121
+ });
122
+ }
@@ -0,0 +1,54 @@
1
+ // Exercise the actual settings card in Chromium without a running Host or IM account.
2
+ // Usage: node scripts/verify-model-setting.mjs [output-directory]
3
+ import { execFile } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ import { promisify } from 'node:util';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { build } from 'esbuild';
11
+
12
+ const browser = process.env.CHROME_PATH ?? [
13
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
14
+ '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome',
15
+ ].find(existsSync);
16
+ if (!browser) throw new Error('Set CHROME_PATH to a Chromium/Chrome executable.');
17
+ const output = process.argv[2] ? resolve(process.argv[2]) : await mkdtemp(join(tmpdir(), 'dsh-im-model-ui-'));
18
+ await mkdir(output, { recursive: true });
19
+ const built = await build({
20
+ entryPoints: [resolve(import.meta.dirname, '../test/browser/model-setting.fixture.js')],
21
+ bundle: true, write: false, format: 'iife', platform: 'browser', target: 'chrome100',
22
+ define: { 'process.env.NODE_ENV': '"development"' },
23
+ });
24
+ const htmlPath = join(output, 'preview.html');
25
+ await writeFile(htmlPath, `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>DSH-IM Model Settings</title>
26
+ <style>
27
+ body{margin:0;padding:24px;background:#fff;color:#1f2329;font:14px/20px -apple-system,BlinkMacSystemFont,sans-serif}#app{max-width:760px;margin:auto}#result{max-width:760px;margin:18px auto;font:11px/17px monospace;white-space:pre-wrap;color:#687380}
28
+ @media(max-width:600px){body{padding:12px}}
29
+ @media(prefers-color-scheme:dark){:root{color-scheme:dark;--dsw-alias-bg-layer-1:#202125;--dsw-alias-bg-layer-3:#282a30;--dsw-specific-menu:#282a30;--dsw-alias-bg-module-platform:#25272c;--dsw-alias-label-primary:#e9ebef;--dsw-alias-label-secondary:#b1b5bf;--dsw-alias-label-tertiary:#9a9faa;--dsw-alias-border-l1:#34363c;--dsw-alias-border-l2:#41434c;--dsw-alias-border-l3:#7b9ff9;--dsw-alias-interactive-bg-hover:#353842}body{background:#202125;color:#e9ebef}}
30
+ </style><body><div id="app"></div><pre id="result">Running browser checks…</pre><script>${built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script')}</script></body></html>`);
31
+ for (const [name, size, extra, query] of [
32
+ ['desktop', '1100,1100', [], ''],
33
+ ['mobile', '390,1250', [], '?mobile'],
34
+ ['dark-en', '1100,1100', ['--force-dark-mode'], '?en'],
35
+ ]) {
36
+ const profile = await mkdtemp(join(tmpdir(), 'dsh-im-model-browser-'));
37
+ const { stdout, stderr } = await promisify(execFile)(browser, [
38
+ '--headless', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
39
+ `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check',
40
+ `--window-size=${size}`, '--force-device-scale-factor=1', '--virtual-time-budget=8000',
41
+ ...extra, `--screenshot=${join(output, `${name}.png`)}`, '--dump-dom', pathToFileURL(htmlPath).href + query,
42
+ ], { maxBuffer: 8 * 1024 * 1024, timeout: 15_000, killSignal: 'SIGKILL' }).catch((error) => {
43
+ // Some desktop Chromium builds finish dump-dom and the screenshot but
44
+ // hang during shutdown. Only accept a fully returned fixture result.
45
+ if (error.killed && /<body\b[^>]*\bdata-result="(?:passed|failed)"/u.test(error.stdout ?? '')) return error;
46
+ throw error;
47
+ }).finally(() => rm(profile, { recursive: true, force: true }));
48
+ await writeFile(join(output, `${name}.html`), stdout);
49
+ await writeFile(join(output, `${name}.log`), stderr);
50
+ const result = stdout.match(/<pre id="result">([\s\S]*?)<\/pre>/u)?.[1] ?? 'No browser result';
51
+ if (!stdout.includes('data-result="passed"')) throw new Error(`${name}: ${result}`);
52
+ console.log(`${name}: ${result}`);
53
+ }
54
+ console.log(`Previews: ${output}`);
@@ -0,0 +1,59 @@
1
+ // Real browser/React verification, with no running DSH Host or IM accounts.
2
+ // CHROME_PATH overrides the browser executable. An optional argument is the output directory.
3
+ import { execFile } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { resolve, join } from 'node:path';
8
+ import { promisify } from 'node:util';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { build } from 'esbuild';
11
+
12
+ const args = process.argv.slice(2);
13
+ const prepareOnly = args.includes('--prepare-only');
14
+ const outputArg = args.find((argument) => argument !== '--prepare-only');
15
+ const browser = process.env.CHROME_PATH ?? [
16
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
17
+ '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome',
18
+ ].find(existsSync);
19
+ if (!browser && !prepareOnly) throw new Error('Set CHROME_PATH to a Chromium/Chrome executable.');
20
+ const output = outputArg ? resolve(outputArg) : await mkdtemp(join(tmpdir(), 'dsh-im-session-logos-'));
21
+ await mkdir(output, { recursive: true });
22
+ const built = await build({
23
+ entryPoints: [resolve(import.meta.dirname, '../test/browser/session-channel-logos.fixture.js')],
24
+ bundle: true, write: false, format: 'iife', platform: 'browser', target: 'chrome100',
25
+ define: { 'process.env.NODE_ENV': '"development"' },
26
+ });
27
+ const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>DSH-IM Session Logo Preview</title>
28
+ <style>
29
+ *{box-sizing:border-box}body{margin:0;background:#f8f9fb;color:#202329;font:14px/20px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
30
+ .preview{display:flex;min-height:720px}aside{width:350px;flex:none;padding:24px 12px;background:#f0f2f5;border-right:1px solid #e4e6eb}header{padding:0 12px 26px}header strong{display:block;font-size:17px;line-height:26px}small{display:block;color:#7a818e;font-size:11px}.workspace{padding:8px 12px;color:#6f7684;font-size:12px}
31
+ ._sessionRow_dsh_104{height:32px;display:flex;align-items:center;gap:0;border-radius:8px;padding:0 8px;cursor:pointer;user-select:none;color:inherit}._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{background:#e0e5ec}._title_dsh_170{flex:1;min-width:0;margin:0 6px 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px}.status{width:16px;flex:none;color:#77a891;text-align:center}.time{font-size:10px;color:#8d95a1}button{font:inherit;cursor:pointer}aside button{border:0;background:none;color:#707a89;width:22px;height:24px}
32
+ main{flex:1;padding:55px 44px}.eyebrow{font-size:10px;font-weight:650;letter-spacing:.13em;color:#788292}h1{font-size:30px;line-height:40px;font-weight:650;margin:12px 0}main>p{color:#727c8a}.message{margin-top:35px;padding:18px 22px;border:1px solid #e3e7ed;border-radius:12px;background:#fff}.message strong{font-size:12px}.message p{color:#626d7c}h2{font-size:13px;font-weight:550;margin:26px 0 8px}._searchResultRow_dsh_25{display:flex;flex-direction:column;align-items:stretch;width:100%;border:1px solid #e3e7ed;border-radius:8px;padding:10px;background:white;text-align:left}._searchResultHeading_dsh_305{display:flex;min-width:0}._searchResultTitle_dsh_313{flex:1;min-width:0;font-size:14px;line-height:20px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.search small{margin-top:5px}.note{font-size:11px;margin-top:32px}#result{margin:0;padding:14px 24px;border-top:1px solid #e4e6eb;background:#eef6f1;color:#357759;font:10px/16px ui-monospace,monospace}body[data-result=failed] #result{color:#a22;background:#fee}
33
+ @media(prefers-color-scheme:dark){body{background:#1b1e23;color:#e4e7eb}aside{background:#22262d;border-color:#333941}._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{background:#353d49}.message,._searchResultRow_dsh_25{background:#252a32;border-color:#39404b;color:inherit}.message p{color:#c1c8d1}#result{background:#23382e;color:#a2d0b2;border-color:#39404b}}
34
+ </style><body><div id="app"></div><pre id="result">Running browser checks…</pre>
35
+ <script>${built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script')}</script></body></html>`;
36
+ const htmlPath = join(output, 'preview.html');
37
+ await writeFile(htmlPath, html
38
+ .replace('._sessionRow_dsh_104{', '._dsh_sessionRow,._sessionRow_dsh_104{')
39
+ .replaceAll('._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{', '._dsh_sessionRow:hover,._dsh_sessionRow.selected,._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{')
40
+ .replace('._title_dsh_170{', '._dsh_title,._title_dsh_170{')
41
+ .replace('._searchResultTitle_dsh_313{flex:1;', '._searchResultTitle_dsh_313{flex:0 auto;'));
42
+ if (prepareOnly) {
43
+ console.log(`Prepared browser fixture: ${htmlPath}`);
44
+ process.exit(0);
45
+ }
46
+ const profile = await mkdtemp(join(tmpdir(), 'dsh-im-logo-browser-'));
47
+ const screenshot = join(output, 'preview.png');
48
+ const { stdout, stderr } = await promisify(execFile)(browser, [
49
+ '--headless', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
50
+ `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check',
51
+ '--window-size=1100,800', '--force-device-scale-factor=1', '--virtual-time-budget=8000',
52
+ `--screenshot=${screenshot}`, '--dump-dom', pathToFileURL(htmlPath).href,
53
+ ], { maxBuffer: 8 * 1024 * 1024, timeout: 30_000 }).finally(() => rm(profile, { recursive: true, force: true }));
54
+ await writeFile(join(output, 'browser.log'), stderr);
55
+ await writeFile(join(output, 'result.html'), stdout);
56
+ const result = stdout.match(/<pre id="result">([\s\S]*?)<\/pre>/u)?.[1] ?? 'No browser result';
57
+ if (!stdout.includes('data-result="passed"')) throw new Error(result);
58
+ console.log(result);
59
+ console.log(`Preview: ${screenshot}`);
@@ -0,0 +1,149 @@
1
+ // Exercise the plugin against an actual built Harness checkout, without an
2
+ // LLM request or a running Host. Run:
3
+ // node scripts/verify-session-title-prefix.mjs /path/to/dsh [all-prompts|first-prompt]
4
+ import assert from 'node:assert/strict';
5
+ import { createRequire } from 'node:module';
6
+ import { resolve } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ import { createImHostPlugin } from '../plugin-src/host/index.mjs';
10
+
11
+ if (!process.argv[2]) throw new Error('Pass the path of a built DSH checkout.');
12
+ const root = resolve(process.argv[2]);
13
+ const automatic = process.argv[3] ?? 'all-prompts';
14
+ assert.ok(['all-prompts', 'first-prompt'].includes(automatic));
15
+ const fromHarness = createRequire(resolve(root, 'packages/session/session-title/package.json'));
16
+ const load = (name) => import(pathToFileURL(fromHarness.resolve(name)).href);
17
+ const { Context } = await load('@deepseek-ai/cordis');
18
+ const { default: SessionStore, SessionId } = await load('@deepseek-ai/dsh-session');
19
+ const { default: SessionProjectionRegistry } = await load('@deepseek-ai/dsh-session-projection');
20
+ const { default: SessionTitleService, SessionTitleProviderId } = await load('@deepseek-ai/dsh-session-title');
21
+ const { createUserMessage } = await load('@deepseek-ai/dsh-llm');
22
+
23
+ const ctx = new Context();
24
+ const fibers = [];
25
+ for (const [plugin, config] of [
26
+ [SessionStore],
27
+ [SessionProjectionRegistry],
28
+ [SessionTitleService, { fallbackMaxWords: 10, fallbackMaxBytes: 60, maxTitleBytes: 60 }],
29
+ ]) {
30
+ const fiber = ctx.plugin(plugin, config);
31
+ fibers.push(fiber);
32
+ await fiber.await();
33
+ }
34
+ // Use the complete Host activation path: Cordis treats returned controller
35
+ // objects as invalid effects and disposes their listeners after startup.
36
+ for (const [name, value] of Object.entries({
37
+ connection: { rpc: { handle: () => () => {} } }, credentials: {},
38
+ typertGateway: { stream() {} }, sessionController: {}, workspaceController: {},
39
+ })) ctx.provide(name, value);
40
+ const internals = Object.fromEntries(
41
+ ['Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'Qq', 'Slack', 'Telegram', 'Discord', 'Whatsapp', 'Office']
42
+ .map((channel) => [`apply${channel}`, async () => {}]),
43
+ );
44
+ Object.assign(internals, {
45
+ installUpdateRpc: () => {}, installInboundTtlRpc: () => {},
46
+ installDeliveryRpc: () => {}, installSessionSyncCoordinator: () => {},
47
+ });
48
+ const host = ctx.plugin(createImHostPlugin(internals));
49
+ fibers.push(host);
50
+ await host.await();
51
+ const settle = async () => {
52
+ await new Promise((done) => setTimeout(done, 0));
53
+ };
54
+ await settle();
55
+ const prompt = (session, text, channel = 'weixin') => session.append('user/message', createUserMessage({
56
+ content: [{ type: 'text', text }],
57
+ source: { kind: 'user', rpcId: `${channel}-00000000-0000-4000-8000-000000000000` },
58
+ }), { surfaceOp: 'append' });
59
+ const request = (session) => session.append('request/header', {
60
+ header: { config: { provider: 'test-provider', model: 'test-model' } }, reason: 'change',
61
+ });
62
+ let calls = 0;
63
+ let release;
64
+ let aborted = false;
65
+ ctx.sessionTitle.register({
66
+ id: SessionTitleProviderId('test-title-provider'),
67
+ automatic,
68
+ async generate(input) {
69
+ calls += 1;
70
+ input.signal.addEventListener('abort', () => { aborted = true; });
71
+ if (calls === 1) await new Promise((done) => { release = done; });
72
+ return { title: `自动标题 ${calls}`, messageSeqs: input.messages.map((message) => message.seq) };
73
+ },
74
+ });
75
+ const session = ctx.sessions.create(SessionId('ordinary-host-generated-id'));
76
+ session.append('turn/start', { turn: 1 });
77
+ prompt(session, '第一次消息');
78
+ request(session);
79
+ await settle();
80
+ assert.equal(ctx.sessionTitle.get(session).title, '微信 · 第一次消息');
81
+ assert.equal(ctx.sessionTitle.get(session).source.kind, 'fallback');
82
+ assert.equal(calls, 1);
83
+ assert.equal(aborted, false, 'adding the fallback prefix must not abort the active title provider');
84
+ release();
85
+ await settle();
86
+ assert.equal(ctx.sessionTitle.get(session).title, '微信 · 自动标题 1');
87
+ assert.equal(ctx.sessionTitle.get(session).source.kind, 'provider');
88
+ assert.equal(ctx.sessionTitle.get(session).source.provider, 'test-title-provider');
89
+
90
+ prompt(session, '第二次消息');
91
+ request(session);
92
+ await settle();
93
+ const automaticCalls = automatic === 'all-prompts' ? 2 : 1;
94
+ assert.equal(calls, automaticCalls, 'the original automatic generation cadence must be preserved');
95
+ assert.equal(ctx.sessionTitle.get(session).title, `微信 · 自动标题 ${automaticCalls}`);
96
+ assert.equal(ctx.sessionProjections.stateOf(session, 'title'), `微信 · 自动标题 ${automaticCalls}`);
97
+ assert.equal(aborted, false);
98
+
99
+ ctx.sessionTitle.rename(session, '自定义标题');
100
+ await settle();
101
+ assert.equal(ctx.sessionTitle.get(session).title, '微信 · 自定义标题');
102
+ assert.equal(ctx.sessionTitle.get(session).source.kind, 'user');
103
+ prompt(session, '手动命名后的消息');
104
+ request(session);
105
+ await settle();
106
+ assert.equal(calls, automaticCalls, 'a real user rename must retain the normal pin');
107
+ await ctx.sessionTitle.refresh(session);
108
+ await settle();
109
+ assert.equal(calls, automaticCalls + 1);
110
+ assert.equal(ctx.sessionTitle.get(session).title, `微信 · 自动标题 ${automaticCalls + 1}`);
111
+ assert.equal(ctx.sessionTitle.get(session).source.kind, 'provider');
112
+
113
+ const ordinary = ctx.sessions.create(SessionId('web-created'));
114
+ ordinary.append('turn/start', { turn: 1 });
115
+ prompt(ordinary, '网页消息', 'web');
116
+ await settle();
117
+ assert.equal(ctx.sessionTitle.get(ordinary).title, '网页消息');
118
+
119
+ // The production Feishu path can create its initial title before the first
120
+ // prompt. The listener must still be alive after the Host has finished loading.
121
+ let feishu;
122
+ const creator = ctx.plugin({
123
+ name: 'feishu-session-regression', inject: ['sessions'],
124
+ apply(scoped) {
125
+ feishu = scoped.sessions.create(SessionId('feishu-host-generated-id'));
126
+ feishu.append('session/title', {
127
+ title: '你好,你是谁?', messageSeqs: [], source: { kind: 'user' },
128
+ });
129
+ prompt(feishu, '你好,你是谁?', 'feishu');
130
+ },
131
+ });
132
+ fibers.push(creator);
133
+ await creator.await();
134
+ await settle();
135
+ assert.equal(ctx.sessionTitle.get(feishu).title, '飞书 · 你好,你是谁?');
136
+ assert.equal(ctx.sessionTitle.get(feishu).source.kind, 'user');
137
+
138
+ const sourceEvents = session.snapshotEvents();
139
+ for (const event of sourceEvents.filter((entry) => entry.type === 'session/title')) {
140
+ assert.equal(event.data.messageSeqs.length === 0, event.data.source.kind === 'user');
141
+ for (const seq of event.data.messageSeqs) {
142
+ assert.equal(sourceEvents[seq].type, 'user/message');
143
+ assert.equal(sourceEvents[seq].data.source.kind, 'user');
144
+ assert.ok(seq < event.seq);
145
+ }
146
+ assert.ok(!event.data.title.includes('微信 · 微信 · '));
147
+ }
148
+ for (const fiber of fibers.reverse()) await fiber.dispose();
149
+ console.log(`Session channel prefix verified against ${root} (${automatic}): active generation, later generation, projections, manual rename, refresh, Web sessions, and title-source invariants passed.`);