@zhin.js/client 1.1.4 → 1.1.6

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 (147) hide show
  1. package/README.md +94 -432
  2. package/client/agent-stream.ts +121 -0
  3. package/client/app.test.ts +37 -0
  4. package/client/app.ts +130 -138
  5. package/client/bootstrap/loadConsoleEntries.test.ts +23 -12
  6. package/client/bootstrap/loadConsoleEntries.ts +40 -27
  7. package/client/console-client-context.ts +24 -0
  8. package/client/console-client.test.ts +46 -0
  9. package/client/console-client.ts +70 -0
  10. package/client/console-events.ts +38 -0
  11. package/client/console-utils/remoteApi.test.ts +91 -0
  12. package/client/console-utils/remoteApi.ts +47 -9
  13. package/client/index.ts +92 -28
  14. package/client/mediaSrc.test.ts +49 -0
  15. package/client/mediaSrc.ts +84 -11
  16. package/client/message-content/MarkdownContent.tsx +128 -0
  17. package/client/message-content/markdown.test.ts +48 -0
  18. package/client/message-content/markdown.ts +247 -0
  19. package/client/persistence/idb-store.test.ts +121 -0
  20. package/client/persistence/idb-store.ts +40 -21
  21. package/client/segments.test.ts +16 -0
  22. package/client/segments.ts +39 -0
  23. package/client/transport/console-transport.test.ts +394 -0
  24. package/client/transport/console-transport.ts +676 -0
  25. package/client/transport/hooks.test.ts +21 -0
  26. package/client/transport/hooks.ts +535 -0
  27. package/client/transport/index.ts +23 -0
  28. package/client/{websocket → transport}/types.ts +16 -17
  29. package/client/types.ts +5 -8
  30. package/client/workroom/WorkroomRunsPage.tsx +355 -0
  31. package/client/workroom/registerWorkroomConsole.test.ts +25 -0
  32. package/client/workroom/registerWorkroomConsole.ts +17 -0
  33. package/dist/agent-stream.d.ts +17 -0
  34. package/dist/agent-stream.d.ts.map +1 -0
  35. package/dist/agent-stream.js +73 -0
  36. package/dist/agent-stream.js.map +1 -0
  37. package/dist/app.d.ts +17 -52
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/app.js +110 -95
  40. package/dist/app.js.map +1 -1
  41. package/dist/bootstrap/loadConsoleEntries.d.ts +10 -7
  42. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  43. package/dist/bootstrap/loadConsoleEntries.js +37 -11
  44. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  45. package/dist/console-client-context.d.ts +9 -0
  46. package/dist/console-client-context.d.ts.map +1 -0
  47. package/dist/console-client-context.js +13 -0
  48. package/dist/console-client-context.js.map +1 -0
  49. package/dist/console-client.d.ts +25 -0
  50. package/dist/console-client.d.ts.map +1 -0
  51. package/dist/console-client.js +45 -0
  52. package/dist/console-client.js.map +1 -0
  53. package/dist/console-events.d.ts +8 -0
  54. package/dist/console-events.d.ts.map +1 -0
  55. package/dist/console-events.js +26 -0
  56. package/dist/console-events.js.map +1 -0
  57. package/dist/console-utils/remoteApi.d.ts +2 -1
  58. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  59. package/dist/console-utils/remoteApi.js +46 -8
  60. package/dist/console-utils/remoteApi.js.map +1 -1
  61. package/dist/index.d.ts +14 -7
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +18 -8
  64. package/dist/index.js.map +1 -1
  65. package/dist/mediaSrc.d.ts +1 -1
  66. package/dist/mediaSrc.d.ts.map +1 -1
  67. package/dist/mediaSrc.js +91 -11
  68. package/dist/mediaSrc.js.map +1 -1
  69. package/dist/message-content/MarkdownContent.d.ts +11 -0
  70. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  71. package/dist/message-content/MarkdownContent.js +68 -0
  72. package/dist/message-content/MarkdownContent.js.map +1 -0
  73. package/dist/message-content/markdown.d.ts +47 -0
  74. package/dist/message-content/markdown.d.ts.map +1 -0
  75. package/dist/message-content/markdown.js +219 -0
  76. package/dist/message-content/markdown.js.map +1 -0
  77. package/dist/persistence/idb-store.d.ts +5 -2
  78. package/dist/persistence/idb-store.d.ts.map +1 -1
  79. package/dist/persistence/idb-store.js +29 -20
  80. package/dist/persistence/idb-store.js.map +1 -1
  81. package/dist/segments.d.ts +9 -0
  82. package/dist/segments.d.ts.map +1 -0
  83. package/dist/segments.js +35 -0
  84. package/dist/segments.js.map +1 -0
  85. package/dist/transport/console-transport.d.ts +217 -0
  86. package/dist/transport/console-transport.d.ts.map +1 -0
  87. package/dist/transport/console-transport.js +563 -0
  88. package/dist/transport/console-transport.js.map +1 -0
  89. package/dist/transport/hooks.d.ts +253 -0
  90. package/dist/transport/hooks.d.ts.map +1 -0
  91. package/dist/transport/hooks.js +483 -0
  92. package/dist/transport/hooks.js.map +1 -0
  93. package/dist/transport/index.d.ts +4 -0
  94. package/dist/transport/index.d.ts.map +1 -0
  95. package/dist/transport/index.js +4 -0
  96. package/dist/transport/index.js.map +1 -0
  97. package/dist/{websocket → transport}/types.d.ts +14 -13
  98. package/dist/transport/types.d.ts.map +1 -0
  99. package/dist/{websocket → transport}/types.js +4 -9
  100. package/dist/transport/types.js.map +1 -0
  101. package/dist/types.d.ts +4 -4
  102. package/dist/types.d.ts.map +1 -1
  103. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  104. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  105. package/dist/workroom/WorkroomRunsPage.js +111 -0
  106. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  107. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  108. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  109. package/dist/workroom/registerWorkroomConsole.js +16 -0
  110. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  111. package/package.json +18 -8
  112. package/client/runtime/index.ts +0 -12
  113. package/client/websocket/hooks.ts +0 -289
  114. package/client/websocket/index.ts +0 -6
  115. package/client/websocket/instance.ts +0 -24
  116. package/client/websocket/manager.ts +0 -426
  117. package/client/websocket/remote-settings.ts +0 -25
  118. package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
  119. package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
  120. package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
  121. package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
  122. package/dist/runtime/index.d.ts +0 -6
  123. package/dist/runtime/index.d.ts.map +0 -1
  124. package/dist/runtime/index.js +0 -10
  125. package/dist/runtime/index.js.map +0 -1
  126. package/dist/websocket/hooks.d.ts +0 -105
  127. package/dist/websocket/hooks.d.ts.map +0 -1
  128. package/dist/websocket/hooks.js +0 -247
  129. package/dist/websocket/hooks.js.map +0 -1
  130. package/dist/websocket/index.d.ts +0 -7
  131. package/dist/websocket/index.d.ts.map +0 -1
  132. package/dist/websocket/index.js +0 -7
  133. package/dist/websocket/index.js.map +0 -1
  134. package/dist/websocket/instance.d.ts +0 -5
  135. package/dist/websocket/instance.d.ts.map +0 -1
  136. package/dist/websocket/instance.js +0 -21
  137. package/dist/websocket/instance.js.map +0 -1
  138. package/dist/websocket/manager.d.ts +0 -113
  139. package/dist/websocket/manager.d.ts.map +0 -1
  140. package/dist/websocket/manager.js +0 -403
  141. package/dist/websocket/manager.js.map +0 -1
  142. package/dist/websocket/remote-settings.d.ts +0 -5
  143. package/dist/websocket/remote-settings.d.ts.map +0 -1
  144. package/dist/websocket/remote-settings.js +0 -23
  145. package/dist/websocket/remote-settings.js.map +0 -1
  146. package/dist/websocket/types.d.ts.map +0 -1
  147. package/dist/websocket/types.js.map +0 -1
@@ -0,0 +1,247 @@
1
+ export type MarkdownInline =
2
+ | { readonly type: 'text'; readonly value: string }
3
+ | { readonly type: 'strong' | 'emphasis' | 'strike' | 'code'; readonly value: string }
4
+ | { readonly type: 'link'; readonly value: string; readonly href: string };
5
+
6
+ export type MarkdownBlock =
7
+ | { readonly type: 'heading'; readonly level: number; readonly content: MarkdownInline[] }
8
+ | { readonly type: 'paragraph' | 'quote'; readonly content: MarkdownInline[] }
9
+ | { readonly type: 'list'; readonly ordered: boolean; readonly items: ReadonlyArray<{ readonly content: MarkdownInline[]; readonly checked?: boolean }> }
10
+ | { readonly type: 'code'; readonly language: string; readonly value: string; readonly closed: boolean }
11
+ | { readonly type: 'table'; readonly header: ReadonlyArray<MarkdownInline[]>; readonly rows: ReadonlyArray<ReadonlyArray<MarkdownInline[]>> }
12
+ | { readonly type: 'rule' };
13
+
14
+ export type CodeTokenKind = 'plain' | 'comment' | 'string' | 'number' | 'keyword' | 'literal';
15
+
16
+ export interface CodeToken {
17
+ readonly kind: CodeTokenKind;
18
+ readonly value: string;
19
+ }
20
+
21
+ // Excluding another opening bracket from link labels prevents the regexp
22
+ // engine from rescanning an attacker-controlled run of `[` characters.
23
+ const INLINE_PATTERN = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)|(\[[^\[\]\n]+\]\([^\s()]+(?:\s+"[^"\n]*")?\))|(https?:\/\/[^\s<>()]+)/g;
24
+ const TABLE_DIVIDER = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
25
+
26
+ export function isSafeMarkdownHref(href: string): boolean {
27
+ const value = href.trim();
28
+ if (!value) return false;
29
+ if (/^(https?:|mailto:)/i.test(value)) return true;
30
+ return /^(\/|#|\.\/|\.\.\/)/.test(value);
31
+ }
32
+
33
+ export function parseMarkdownInline(value: string): MarkdownInline[] {
34
+ const tokens: MarkdownInline[] = [];
35
+ let cursor = 0;
36
+ let match: RegExpExecArray | null;
37
+ INLINE_PATTERN.lastIndex = 0;
38
+
39
+ while ((match = INLINE_PATTERN.exec(value)) !== null) {
40
+ if (match.index > cursor) tokens.push({ type: 'text', value: value.slice(cursor, match.index) });
41
+ const raw = match[0];
42
+ if (match[1]) {
43
+ tokens.push({ type: 'code', value: raw.slice(1, -1) });
44
+ } else if (match[2]) {
45
+ tokens.push({ type: 'strong', value: raw.slice(2, -2) });
46
+ } else if (match[3]) {
47
+ tokens.push({ type: 'strike', value: raw.slice(2, -2) });
48
+ } else if (match[4]) {
49
+ tokens.push({ type: 'emphasis', value: raw.slice(1, -1) });
50
+ } else if (match[5]) {
51
+ const parsed = raw.match(/^\[([^\[\]]+)\]\(([^\s()]+)(?:\s+"[^"\n]*")?\)$/);
52
+ if (parsed && isSafeMarkdownHref(parsed[2])) {
53
+ tokens.push({ type: 'link', value: parsed[1], href: parsed[2] });
54
+ } else {
55
+ tokens.push({ type: 'text', value: raw });
56
+ }
57
+ } else if (match[6]) {
58
+ tokens.push({ type: 'link', value: raw, href: raw });
59
+ }
60
+ cursor = INLINE_PATTERN.lastIndex;
61
+ }
62
+
63
+ if (cursor < value.length) tokens.push({ type: 'text', value: value.slice(cursor) });
64
+ const merged: MarkdownInline[] = [];
65
+ for (const token of tokens) {
66
+ const previous = merged.at(-1);
67
+ if (token.type === 'text' && previous?.type === 'text') {
68
+ merged[merged.length - 1] = { type: 'text', value: previous.value + token.value };
69
+ } else {
70
+ merged.push(token);
71
+ }
72
+ }
73
+ return merged.length > 0 ? merged : [{ type: 'text', value }];
74
+ }
75
+
76
+ function splitTableRow(line: string): string[] {
77
+ return line
78
+ .trim()
79
+ .replace(/^\|/, '')
80
+ .replace(/\|$/, '')
81
+ .split(/(?<!\\)\|/)
82
+ .map((cell) => cell.trim().replace(/\\\|/g, '|'));
83
+ }
84
+
85
+ function startsBlock(lines: readonly string[], index: number): boolean {
86
+ const line = lines[index] ?? '';
87
+ return /^(?:```|~~~|#{1,6}\s+|>\s?|\s*(?:[-+*]|\d+[.)])\s+|\s*(?:-{3,}|\*{3,}|_{3,})\s*$)/.test(line)
88
+ || (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? ''));
89
+ }
90
+
91
+ export function parseMarkdown(value: string): MarkdownBlock[] {
92
+ const lines = value.replace(/\r\n?/g, '\n').split('\n');
93
+ const blocks: MarkdownBlock[] = [];
94
+ let index = 0;
95
+
96
+ while (index < lines.length) {
97
+ const line = lines[index];
98
+ if (!line.trim()) {
99
+ index += 1;
100
+ continue;
101
+ }
102
+
103
+ const fence = line.match(/^\s*(```|~~~)\s*([^\s`]*)?.*$/);
104
+ if (fence) {
105
+ const marker = fence[1];
106
+ const language = (fence[2] ?? '').toLowerCase();
107
+ const code: string[] = [];
108
+ let closed = false;
109
+ index += 1;
110
+ while (index < lines.length) {
111
+ if (lines[index].trim() === marker) {
112
+ closed = true;
113
+ index += 1;
114
+ break;
115
+ }
116
+ code.push(lines[index]);
117
+ index += 1;
118
+ }
119
+ blocks.push({ type: 'code', language, value: code.join('\n'), closed });
120
+ continue;
121
+ }
122
+
123
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
124
+ if (heading) {
125
+ blocks.push({ type: 'heading', level: heading[1].length, content: parseMarkdownInline(heading[2]) });
126
+ index += 1;
127
+ continue;
128
+ }
129
+
130
+ if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
131
+ blocks.push({ type: 'rule' });
132
+ index += 1;
133
+ continue;
134
+ }
135
+
136
+ if (/^>\s?/.test(line)) {
137
+ const quoted: string[] = [];
138
+ while (index < lines.length && /^>\s?/.test(lines[index])) {
139
+ quoted.push(lines[index].replace(/^>\s?/, ''));
140
+ index += 1;
141
+ }
142
+ blocks.push({ type: 'quote', content: parseMarkdownInline(quoted.join('\n')) });
143
+ continue;
144
+ }
145
+
146
+ const listMatch = line.match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
147
+ if (listMatch) {
148
+ const ordered = /^\d/.test(listMatch[1]);
149
+ const items: Array<{ content: MarkdownInline[]; checked?: boolean }> = [];
150
+ while (index < lines.length) {
151
+ const item = lines[index].match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
152
+ if (!item || /^\d/.test(item[1]) !== ordered) break;
153
+ const task = item[2].match(/^\[([ xX])\]\s+(.+)$/);
154
+ items.push({
155
+ content: parseMarkdownInline(task ? task[2] : item[2]),
156
+ ...(task ? { checked: task[1].toLowerCase() === 'x' } : {}),
157
+ });
158
+ index += 1;
159
+ }
160
+ blocks.push({ type: 'list', ordered, items });
161
+ continue;
162
+ }
163
+
164
+ if (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? '')) {
165
+ const header = splitTableRow(line).map(parseMarkdownInline);
166
+ const rows: MarkdownInline[][][] = [];
167
+ index += 2;
168
+ while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
169
+ rows.push(splitTableRow(lines[index]).map(parseMarkdownInline));
170
+ index += 1;
171
+ }
172
+ blocks.push({ type: 'table', header, rows });
173
+ continue;
174
+ }
175
+
176
+ const paragraph = [line];
177
+ index += 1;
178
+ while (index < lines.length && lines[index].trim() && !startsBlock(lines, index)) {
179
+ paragraph.push(lines[index]);
180
+ index += 1;
181
+ }
182
+ blocks.push({ type: 'paragraph', content: parseMarkdownInline(paragraph.join('\n')) });
183
+ }
184
+
185
+ return blocks;
186
+ }
187
+
188
+ const KEYWORDS: Record<string, ReadonlySet<string>> = {
189
+ javascript: new Set(['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'static', 'switch', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'yield']),
190
+ typescript: new Set(['abstract', 'any', 'as', 'asserts', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'keyof', 'let', 'namespace', 'never', 'new', 'number', 'object', 'of', 'readonly', 'return', 'satisfies', 'static', 'string', 'switch', 'symbol', 'throw', 'try', 'type', 'typeof', 'undefined', 'unknown', 'var', 'void', 'while', 'yield']),
191
+ python: new Set(['and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']),
192
+ shell: new Set(['case', 'do', 'done', 'elif', 'else', 'esac', 'export', 'fi', 'for', 'function', 'if', 'in', 'local', 'then', 'while']),
193
+ css: new Set(['@import', '@media', '@supports', 'and', 'important', 'not', 'only']),
194
+ };
195
+
196
+ const LANGUAGE_ALIASES: Record<string, string> = {
197
+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
198
+ ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
199
+ py: 'python', bash: 'shell', sh: 'shell', zsh: 'shell', jsonc: 'json',
200
+ };
201
+
202
+ export function highlightCodeLine(line: string, language = ''): CodeToken[] {
203
+ const normalized = LANGUAGE_ALIASES[language] ?? language;
204
+ const keywords = KEYWORDS[normalized] ?? KEYWORDS.javascript;
205
+ const tokens: CodeToken[] = [];
206
+ let index = 0;
207
+
208
+ const push = (kind: CodeTokenKind, value: string) => {
209
+ if (value) tokens.push({ kind, value });
210
+ };
211
+
212
+ while (index < line.length) {
213
+ const rest = line.slice(index);
214
+ const comment = normalized === 'python' || normalized === 'shell'
215
+ ? rest.match(/^#.*/)
216
+ : rest.match(/^\/\/.*|^\/\*.*?\*\//);
217
+ if (comment) {
218
+ push('comment', comment[0]);
219
+ index += comment[0].length;
220
+ continue;
221
+ }
222
+ const string = rest.match(/^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/);
223
+ if (string) {
224
+ push('string', string[0]);
225
+ index += string[0].length;
226
+ continue;
227
+ }
228
+ const number = rest.match(/^(?:0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i);
229
+ if (number) {
230
+ push('number', number[0]);
231
+ index += number[0].length;
232
+ continue;
233
+ }
234
+ const word = rest.match(/^[A-Za-z_$@][\w$-]*/);
235
+ if (word) {
236
+ const literal = /^(?:true|false|null|undefined|None|True|False)$/.test(word[0]);
237
+ push(literal ? 'literal' : keywords.has(word[0]) ? 'keyword' : 'plain', word[0]);
238
+ index += word[0].length;
239
+ continue;
240
+ }
241
+ const plain = rest.match(/^[^A-Za-z_$@'"`\d/#]+/) ?? rest.match(/^./);
242
+ push('plain', plain?.[0] ?? '');
243
+ index += plain?.[0].length ?? 1;
244
+ }
245
+
246
+ return tokens;
247
+ }
@@ -0,0 +1,121 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { applyConsoleEvent, idbListInbox } from "./idb-store.js";
4
+
5
+ /**
6
+ * 最小内存 IndexedDB stub:仅实现 idb-store 用到的 open/upgrade/getAll/put 语义,
7
+ * 含版本检查(低版本 open 触发 VersionError)。
8
+ */
9
+ type StoreData = Map<string, Record<string, unknown>>;
10
+ interface DbData {
11
+ version: number;
12
+ stores: Map<string, StoreData>;
13
+ }
14
+
15
+ function createFakeIndexedDB() {
16
+ const dbs = new Map<string, DbData>();
17
+
18
+ function makeRequest<T>(run: (req: IDBRequest<T>) => void): IDBRequest<T> {
19
+ const req = {
20
+ result: undefined as T,
21
+ error: null as unknown,
22
+ onsuccess: null as (() => void) | null,
23
+ onerror: null as (() => void) | null,
24
+ onupgradeneeded: null as (() => void) | null,
25
+ } as unknown as IDBRequest<T>;
26
+ queueMicrotask(() => run(req));
27
+ return req;
28
+ }
29
+
30
+ function makeDb(data: DbData): IDBDatabase {
31
+ return {
32
+ objectStoreNames: {
33
+ contains: (name: string) => data.stores.has(name),
34
+ },
35
+ createObjectStore: (name: string) => {
36
+ data.stores.set(name, new Map());
37
+ },
38
+ transaction: (name: string) => {
39
+ const store = data.stores.get(name);
40
+ const tx = {
41
+ oncomplete: null as (() => void) | null,
42
+ onerror: null as (() => void) | null,
43
+ error: null as unknown,
44
+ objectStore: () => ({
45
+ getAll: () =>
46
+ makeRequest<Record<string, unknown>[]>((req) => {
47
+ (req as { result: unknown }).result = [...(store?.values() ?? [])];
48
+ req.onsuccess?.();
49
+ }),
50
+ put: (record: Record<string, unknown>) => {
51
+ store?.set(String(record.id), record);
52
+ queueMicrotask(() => tx.oncomplete?.());
53
+ },
54
+ }),
55
+ };
56
+ return tx;
57
+ },
58
+ close: () => undefined,
59
+ } as unknown as IDBDatabase;
60
+ }
61
+
62
+ return {
63
+ open(name: string, version = 1) {
64
+ return makeRequest<IDBDatabase>((req) => {
65
+ let data = dbs.get(name);
66
+ if (data && data.version > version) {
67
+ (req as { error: unknown }).error = new Error("VersionError");
68
+ req.onerror?.();
69
+ return;
70
+ }
71
+ const upgrading = !data || data.version < version;
72
+ if (!data) {
73
+ data = { version, stores: new Map() };
74
+ dbs.set(name, data);
75
+ }
76
+ data.version = version;
77
+ (req as { result: unknown }).result = makeDb(data);
78
+ if (upgrading) req.onupgradeneeded?.();
79
+ req.onsuccess?.();
80
+ });
81
+ },
82
+ };
83
+ }
84
+
85
+ const PUSH = {
86
+ type: "message.receive",
87
+ data: { adapter: "icqq", endpointKey: "bot-1", channelId: "1001" },
88
+ };
89
+
90
+ describe("applyConsoleEvent inbox record ids", () => {
91
+ beforeEach(() => {
92
+ vi.stubGlobal("indexedDB", createFakeIndexedDB());
93
+ });
94
+
95
+ afterEach(() => {
96
+ vi.unstubAllGlobals();
97
+ vi.restoreAllMocks();
98
+ });
99
+
100
+ it("keeps two events of the same conversation within one millisecond (no key overwrite)", async () => {
101
+ // 回归:旧主键 `${adapter}:${endpointKey}:${type}:${Date.now()}` 在同毫秒会互相覆盖。
102
+ vi.spyOn(Date, "now").mockReturnValue(1700000000000);
103
+ await applyConsoleEvent(PUSH);
104
+ await applyConsoleEvent(PUSH);
105
+
106
+ const rows = await idbListInbox("icqq", "bot-1", "message");
107
+ expect(rows).toHaveLength(2);
108
+ expect(new Set(rows.map((r) => r.id)).size).toBe(2);
109
+ });
110
+
111
+ it('deduplicates the same resumable event across history and live delivery', async () => {
112
+ const event = { ...PUSH, runtimeId: 'runtime-a', eventId: 7, timestamp: 1700000000000 };
113
+ await applyConsoleEvent(event);
114
+ await applyConsoleEvent(event);
115
+
116
+ const rows = await idbListInbox('icqq', 'bot-1', 'message');
117
+ expect(rows).toHaveLength(1);
118
+ expect(rows[0]?.id).toBe('runtime-a:7');
119
+ expect(rows[0]?.updatedAt).toBe(1700000000000);
120
+ });
121
+ });
@@ -1,12 +1,14 @@
1
+ import { parseConsoleInboxEvent } from "@zhin.js/console-protocol";
2
+
1
3
  const DB_NAME = "zhin-console";
2
- const DB_VERSION = 1;
4
+ const DB_VERSION = 2;
3
5
  const STORE_INBOX = "inbox";
4
6
  const STORE_PENDING = "pending";
5
7
 
6
8
  export type InboxRecord = {
7
9
  id: string;
8
10
  adapter: string;
9
- botId: string;
11
+ endpoint_id: string;
10
12
  kind: "message" | "request" | "notice";
11
13
  payload: unknown;
12
14
  updatedAt: number;
@@ -42,7 +44,7 @@ export async function idbPutInbox(record: InboxRecord): Promise<void> {
42
44
 
43
45
  export async function idbListInbox(
44
46
  adapter: string,
45
- botId: string,
47
+ endpoint_id: string,
46
48
  kind: InboxRecord["kind"],
47
49
  ): Promise<InboxRecord[]> {
48
50
  const db = await openDb();
@@ -53,24 +55,41 @@ export async function idbListInbox(
53
55
  req.onerror = () => reject(req.error);
54
56
  });
55
57
  db.close();
56
- return all.filter((r) => r.adapter === adapter && r.botId === botId && r.kind === kind);
58
+ return all.filter(
59
+ (r) =>
60
+ r.adapter === adapter &&
61
+ r.endpoint_id === endpoint_id &&
62
+ r.kind === kind,
63
+ );
57
64
  }
58
65
 
59
- export async function applyConsoleEvent(event: { type: string; data?: unknown }): Promise<void> {
60
- const t = event.type;
61
- if (t === "bot:message" || t === "bot:request" || t === "bot:notice") {
62
- const data = event.data as Record<string, unknown> | undefined;
63
- if (!data) return;
64
- const adapter = String(data.adapter ?? "");
65
- const botId = String(data.botId ?? data.bot ?? "");
66
- const id = `${adapter}:${botId}:${t}:${Date.now()}`;
67
- await idbPutInbox({
68
- id,
69
- adapter,
70
- botId,
71
- kind: t === "bot:message" ? "message" : t === "bot:request" ? "request" : "notice",
72
- payload: data,
73
- updatedAt: Date.now(),
74
- });
75
- }
66
+ export async function applyConsoleEvent(event: {
67
+ type: string;
68
+ data?: unknown;
69
+ runtimeId?: string;
70
+ eventId?: number;
71
+ timestamp?: number;
72
+ }): Promise<void> {
73
+ const parsed = parseConsoleInboxEvent(event);
74
+ if (!parsed) return;
75
+ const updatedAt = typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)
76
+ ? event.timestamp
77
+ : Date.now();
78
+ const resumableId = typeof event.runtimeId === 'string'
79
+ && event.runtimeId
80
+ && Number.isSafeInteger(event.eventId)
81
+ && (event.eventId ?? 0) > 0
82
+ ? `${event.runtimeId}:${event.eventId}`
83
+ : null;
84
+ await idbPutInbox({
85
+ // Resumable Host events are idempotent across history/live redelivery.
86
+ // Unsequenced sources use a collision-safe append id without shared state.
87
+ id: resumableId
88
+ ?? `${parsed.adapter}:${parsed.endpointKey}:${parsed.type}:${updatedAt}:${crypto.randomUUID()}`,
89
+ adapter: parsed.adapter,
90
+ endpoint_id: parsed.endpointKey,
91
+ kind: parsed.kind,
92
+ payload: parsed.payload,
93
+ updatedAt,
94
+ });
76
95
  }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { segmentsForAgentPanel, segmentsForImDelivery } from '../client/segments.js';
3
+
4
+ describe('console segmentsForImDelivery', () => {
5
+ it('inbox view strips AI-only segments', () => {
6
+ const full = [
7
+ { type: 'thinking', data: { text: 'plan' } },
8
+ { type: 'text', data: { text: 'hi' } },
9
+ { type: 'tool_call', data: { name: 'bash' } },
10
+ ];
11
+ expect(segmentsForImDelivery(full)).toEqual([
12
+ { type: 'text', data: { text: 'hi' } },
13
+ ]);
14
+ expect(segmentsForAgentPanel(full)).toEqual(full);
15
+ });
16
+ });
@@ -0,0 +1,39 @@
1
+ import type { MessageSegment } from './types.js';
2
+
3
+ /** IM 可见段白名单(与 core segment-contract/delivery 对齐) */
4
+ const IM_VISIBLE_TYPES = new Set([
5
+ 'text', 'mention', 'image', 'video', 'audio', 'voice', 'record', 'file',
6
+ 'face', 'reply', 'forward', 'link', 'dice', 'rps', 'markdown', 'html',
7
+ 'qrcode', 'tts', 'keyboard', 'action',
8
+ ]);
9
+
10
+ const AI_ONLY_TYPES = new Set(['thinking', 'tool_call']);
11
+
12
+ function isSegmentLike(value: unknown): value is MessageSegment {
13
+ return (
14
+ typeof value === 'object'
15
+ && value !== null
16
+ && typeof (value as MessageSegment).type === 'string'
17
+ && 'data' in value
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Inbox / 用户 IM 视图:仅展示 IM-visible 段(剔除 thinking、tool_call)。
23
+ * Agent 面板应使用全量 segments,勿调用此函数。
24
+ */
25
+ export function segmentsForImDelivery(segments: readonly unknown[]): MessageSegment[] {
26
+ const out: MessageSegment[] = [];
27
+ for (const item of segments) {
28
+ if (!isSegmentLike(item)) continue;
29
+ if (AI_ONLY_TYPES.has(item.type)) continue;
30
+ if (!IM_VISIBLE_TYPES.has(item.type)) continue;
31
+ out.push(item);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** Agent 面板全量 segments(identity,便于双视图 API 对称) */
37
+ export function segmentsForAgentPanel(segments: readonly MessageSegment[]): MessageSegment[] {
38
+ return [...segments];
39
+ }