@xmanrui/dsh-im 4.13.0 → 4.15.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.
@@ -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.`);