@csntgao/uni-base 0.6.2 → 0.6.3

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/AGENTS.md CHANGED
@@ -180,7 +180,7 @@ pnpm --filter @uniplat/ai-employee-web-components pack
180
180
  5. 用户输入始终通过宿主 transport 的 HTTP 能力发送,不通过 WebSocket 发送。
181
181
  6. `4401` 仅表示 JWT 无效或过期,需要重新调用 `getAccessToken()` 并退避重连;`4400` 和 `4403` 是不可自动恢复的协议或权限错误。`1013` 以及认证帧中的 `SERVICE_*`、`KANBAN_AUTH_FAILED`、`REALTIME_UNAVAILABLE` 表示服务暂不可用,最多退避重连 3 次,不能误报为凭证失效。
182
182
  7. 重连前优先通过 HTTP 恢复历史;历史恢复失败时才请求 WebSocket replay 兜底。
183
- 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。
183
+ 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。服务端缺少消息 ID 时的合成 ID 只能取消息本身,不得把 JSON Patch 的 `path`/`op` 等投递信封算进指纹,否则同一条消息经 replay 与实时两条路径会得到不同 ID 而重复显示。本地已发送的用户消息在回声窗口内不消费匹配条目,replay 与实时的多次回声都要挡住;首条消息与后续消息都必须登记该窗口。
184
184
  9. 主动断开和销毁后不得遗留 socket、心跳、重连任务或事件监听。
185
185
  10. 未指定既有 `sessionId` 时,打开抽屉和点击“开启新对话”只能建立本地草稿,不得创建后端空 Session。首条消息必须通过 `createSessionWithFirstMessage()` 原子提交 `request_id + ae_code + agent_code + content + trusted_context + attachments`;失败重试同一操作必须复用原 `request_id`,成功后才绑定服务端返回的 Session 并订阅实时通道。已有 Session 的后续消息继续使用 `sendMessage(sessionId, content)`。
186
186
 
package/CLAUDE.md CHANGED
@@ -180,7 +180,7 @@ pnpm --filter @uniplat/ai-employee-web-components pack
180
180
  5. 用户输入始终通过宿主 transport 的 HTTP 能力发送,不通过 WebSocket 发送。
181
181
  6. `4401` 仅表示 JWT 无效或过期,需要重新调用 `getAccessToken()` 并退避重连;`4400` 和 `4403` 是不可自动恢复的协议或权限错误。`1013` 以及认证帧中的 `SERVICE_*`、`KANBAN_AUTH_FAILED`、`REALTIME_UNAVAILABLE` 表示服务暂不可用,最多退避重连 3 次,不能误报为凭证失效。
182
182
  7. 重连前优先通过 HTTP 恢复历史;历史恢复失败时才请求 WebSocket replay 兜底。
183
- 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。
183
+ 8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。服务端缺少消息 ID 时的合成 ID 只能取消息本身,不得把 JSON Patch 的 `path`/`op` 等投递信封算进指纹,否则同一条消息经 replay 与实时两条路径会得到不同 ID 而重复显示。本地已发送的用户消息在回声窗口内不消费匹配条目,replay 与实时的多次回声都要挡住;首条消息与后续消息都必须登记该窗口。
184
184
  9. 主动断开和销毁后不得遗留 socket、心跳、重连任务或事件监听。
185
185
  10. 未指定既有 `sessionId` 时,打开抽屉和点击“开启新对话”只能建立本地草稿,不得创建后端空 Session。首条消息必须通过 `createSessionWithFirstMessage()` 原子提交 `request_id + ae_code + agent_code + content + trusted_context + attachments`;失败重试同一操作必须复用原 `request_id`,成功后才绑定服务端返回的 Session 并订阅实时通道。已有 Session 的后续消息继续使用 `sendMessage(sessionId, content)`。
186
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@csntgao/uni-base",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@11.3.0",
6
6
  "publishConfig": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniplat/ai-employee-client",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Browser client for Uniplat AI Employee sessions and realtime messages",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -313,12 +313,7 @@ export class AiEmployeeClient {
313
313
  content,
314
314
  createdAt: now.toISOString(),
315
315
  }
316
- for (const [pendingContent, sentAt] of this.pendingLocalMessages) {
317
- if (now.getTime() - sentAt > LOCAL_MESSAGE_MATCH_WINDOW_MS) {
318
- this.pendingLocalMessages.delete(pendingContent)
319
- }
320
- }
321
- this.pendingLocalMessages.set(content, now.getTime())
316
+ this.rememberPendingLocalMessage(content)
322
317
  this.appendMessage(message, 'local')
323
318
  this.setProcessing(true)
324
319
  return message
@@ -374,6 +369,9 @@ export class AiEmployeeClient {
374
369
  content,
375
370
  createdAt: result.createdAt ?? result.startedAt,
376
371
  }
372
+ // 首条消息同样要登记本地回声窗口:服务端 replay 与实时事件可能带着与
373
+ // createSessionWithFirstMessage 不同的消息 ID 回来。
374
+ this.rememberPendingLocalMessage(content)
377
375
  this.appendMessage(message, 'local')
378
376
  this.setProcessing(true)
379
377
  this.replayHistoryOnNextSubscription = true
@@ -610,11 +608,27 @@ export class AiEmployeeClient {
610
608
  this.emit('ready', { sessionId })
611
609
  }
612
610
 
611
+ private rememberPendingLocalMessage(content: string): void {
612
+ const now = Date.now()
613
+ for (const [pendingContent, sentAt] of this.pendingLocalMessages) {
614
+ if (now - sentAt > LOCAL_MESSAGE_MATCH_WINDOW_MS) {
615
+ this.pendingLocalMessages.delete(pendingContent)
616
+ }
617
+ }
618
+ this.pendingLocalMessages.set(content, now)
619
+ }
620
+
621
+ /**
622
+ * 本地已渲染的用户消息会被服务端以 replay 和实时事件多次回送,且不保证复用
623
+ * 同一个消息 ID。窗口期内不消费该条目,多次回声都要挡住;本地发送本身每次都
624
+ * 会追加气泡,所以重复发送同样内容不会因此丢消息。
625
+ */
613
626
  private matchesPendingLocalMessage(message: AiEmployeeMessage): boolean {
614
627
  const sentAt = this.pendingLocalMessages.get(message.content)
615
628
  if (sentAt === undefined) return false
629
+ if (Date.now() - sentAt <= LOCAL_MESSAGE_MATCH_WINDOW_MS) return true
616
630
  this.pendingLocalMessages.delete(message.content)
617
- return Date.now() - sentAt <= LOCAL_MESSAGE_MATCH_WINDOW_MS
631
+ return false
618
632
  }
619
633
 
620
634
  private appendMessage(message: AiEmployeeMessage, source: 'local' | 'realtime'): void {
@@ -96,7 +96,7 @@ function parseSessionPayload(payload: JsonRecord, sessionId: string): ParsedSess
96
96
  for (const rawPatch of patches) {
97
97
  const patch = asRecord(rawPatch)
98
98
  const value = asRecord(patch.value)
99
- const parsed = messageFromEntry(value, sessionId, patch)
99
+ const parsed = messageFromEntry(value, sessionId)
100
100
  if (parsed) messages.push(parsed)
101
101
  }
102
102
 
@@ -129,11 +129,7 @@ function parseSessionPayload(payload: JsonRecord, sessionId: string): ParsedSess
129
129
  return { messages, fallbackResult, processing }
130
130
  }
131
131
 
132
- function messageFromEntry(
133
- entry: JsonRecord,
134
- sessionId: string,
135
- patch: JsonRecord,
136
- ): AiEmployeeMessage | null {
132
+ function messageFromEntry(entry: JsonRecord, sessionId: string): AiEmployeeMessage | null {
137
133
  const entryTypeValue = entry.entry_type
138
134
  const entryType =
139
135
  typeof entryTypeValue === 'string' ? entryTypeValue : stringValue(asRecord(entryTypeValue).type)
@@ -168,7 +164,9 @@ function messageFromEntry(
168
164
  role === 'user'
169
165
  ? messageId || webSocketMessageId || stringValue(entry.id)
170
166
  : webSocketMessageId || messageId || stringValue(entry.id)
171
- const fingerprint = stableString({ entry, path: patch.path, op: patch.op })
167
+ // 同一条消息经 replay 与实时两条路径投递时 patch path/op 不同,
168
+ // 合成 ID 只能取消息本身,否则同一条消息会得到不同 ID 而重复显示。
169
+ const fingerprint = stableString(entry)
172
170
  const id = explicitId || `realtime:${sessionId}:${hashText(fingerprint)}`
173
171
  return createMessage(sessionId, role, content, id, entry)
174
172
  }
@@ -185,6 +185,102 @@ describe('AiEmployeeClient', () => {
185
185
  expect(client.messages.filter((message) => message.id === 'ws-message-1')).toHaveLength(1)
186
186
  })
187
187
 
188
+ it('deduplicates the same realtime message delivered through different patch paths', async () => {
189
+ const { client, sockets } = createHarness()
190
+ const received: string[] = []
191
+ client.on('message', ({ message }) => received.push(message.content))
192
+ await client.start()
193
+ await authenticateAndReady(sockets[0]!)
194
+
195
+ // 服务端没给消息 ID 时只能合成;replay 与实时投递的 patch 路径不同,
196
+ // 合成 ID 必须仍然相同,否则同一条消息会重复显示。
197
+ const entry = {
198
+ entry_type: { type: 'assistant_message' },
199
+ content: JSON.stringify({ text: '同一条回复' }),
200
+ timestamp: '2026-09-04T02:00:00.000Z',
201
+ }
202
+ const frameWithPath = (path: string) => ({
203
+ type: 'realtime_event',
204
+ channel: 'ai_employee',
205
+ event: {
206
+ type: 'session_message',
207
+ session_id: 'session-1',
208
+ payload: { JsonPatch: [{ path, value: entry }] },
209
+ },
210
+ })
211
+ sockets[0]?.receive(frameWithPath('/0'))
212
+ sockets[0]?.receive(frameWithPath('/3'))
213
+
214
+ expect(received).toEqual(['同一条回复'])
215
+ expect(client.messages.filter((message) => message.content === '同一条回复')).toHaveLength(1)
216
+ })
217
+
218
+ it('keeps a locally sent message single across replay and live echoes', async () => {
219
+ const { client, sockets } = createHarness()
220
+ await client.start()
221
+ await authenticateAndReady(sockets[0]!)
222
+ await client.sendMessage('给我生成一个文本文件,内容是123456')
223
+
224
+ // 同一条用户消息被服务端以不同 ID 回送两次(replay + 实时)。
225
+ const echo = (messageId: string) => ({
226
+ type: 'realtime_event',
227
+ channel: 'ai_employee',
228
+ event: {
229
+ type: 'session_message',
230
+ session_id: 'session-1',
231
+ payload: {
232
+ JsonPatch: [
233
+ {
234
+ path: '/0',
235
+ value: {
236
+ message_id: messageId,
237
+ entry_type: { type: 'user_message' },
238
+ content: JSON.stringify({ text: '给我生成一个文本文件,内容是123456' }),
239
+ },
240
+ },
241
+ ],
242
+ },
243
+ },
244
+ })
245
+ sockets[0]?.receive(echo('replay-message-1'))
246
+ sockets[0]?.receive(echo('live-message-1'))
247
+
248
+ expect(
249
+ client.messages.filter((message) => message.content === '给我生成一个文本文件,内容是123456'),
250
+ ).toHaveLength(1)
251
+ })
252
+
253
+ it('keeps the first message single when the server echoes another message id', async () => {
254
+ // 草稿会话:这条走的是 createSessionWithFirstMessage 原子建会话路径。
255
+ const { client, sockets } = createHarness(createTransport(), { draft: true })
256
+ await client.start()
257
+ await authenticateAndReady(sockets[0]!)
258
+ await client.sendMessage('首条消息内容')
259
+
260
+ sockets[0]?.receive({
261
+ type: 'realtime_event',
262
+ channel: 'ai_employee',
263
+ event: {
264
+ type: 'session_message',
265
+ session_id: 'atomic-session-default',
266
+ payload: {
267
+ JsonPatch: [
268
+ {
269
+ path: '/0',
270
+ value: {
271
+ message_id: 'server-side-other-id',
272
+ entry_type: { type: 'user_message' },
273
+ content: JSON.stringify({ text: '首条消息内容' }),
274
+ },
275
+ },
276
+ ],
277
+ },
278
+ },
279
+ })
280
+
281
+ expect(client.messages.filter((message) => message.content === '首条消息内容')).toHaveLength(1)
282
+ })
283
+
188
284
  it('uses a top-level result only when the current turn has no assistant message', async () => {
189
285
  const { client, sockets } = createHarness()
190
286
  const received: string[] = []
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniplat/ai-employee-web-components",
3
- "version": "0.29.2",
3
+ "version": "0.29.3",
4
4
  "description": "Framework-independent Web Components for Uniplat account, login, application switching and AI Employee chat",
5
5
  "type": "module",
6
6
  "sideEffects": true,
@@ -6,7 +6,7 @@
6
6
  >
7
7
  > 分支:`main`
8
8
  >
9
- > 交接基线提交:`2aadb87 feat(web-components): 组织邀请页豁免强制入驻`
9
+ > 交接基线提交:`495ef35 fix(web-components): 创建组织后刷新账户菜单的组织列表`
10
10
 
11
11
  ## 1. Claude 接手后的第一步
12
12
 
@@ -25,7 +25,7 @@ git log -5 --oneline --decorate
25
25
  ```text
26
26
  main 与 origin/main 同步
27
27
  工作树无代码改动
28
- HEAD = 2aadb87
28
+ HEAD = 495ef35
29
29
  ```
30
30
 
31
31
  不要清理、覆盖或提交根目录已有的 `.tgz`。它们是历史及当前本地发布产物,已由 `.gitignore` 忽略。
@@ -38,9 +38,9 @@ HEAD = 2aadb87
38
38
 
39
39
  | 位置 | npm 名称 | 当前本地版本 | 说明 |
40
40
  | ------------------------- | -------------------------------------- | -----------: | --------------------------------------- |
41
- | 根目录 | `@csntgao/uni-base` | `0.4.0` | 当前公开发布入口;npmjs 已发布此版本 |
41
+ | 根目录 | `@csntgao/uni-base` | `0.6.2` | 当前公开发布入口;npmjs 已发布此版本 |
42
42
  | `packages/core` | `@uniplat/ai-employee-client` | `0.14.0` | UI 无关的 HTTP/WebSocket/Core 状态机 |
43
- | `packages/web-components` | `@uniplat/ai-employee-web-components` | `0.27.0` | Lit Web Components 与独立浏览器 runtime |
43
+ | `packages/web-components` | `@uniplat/ai-employee-web-components` | `0.29.2` | Lit Web Components 与独立浏览器 runtime |
44
44
  | `examples/vanilla` | `@uniplat/ai-employee-vanilla-example` | 私有示例 | 必须消费 workspace 包的构建产物 |
45
45
 
46
46
  注意:根包改名为 `@csntgao/uni-base` 不代表两个 workspace 包、已有 Web Component 标签或公开类型可以随之改名。`@uniplat/*` 名称和 `uniplat-*` 标签仍是公开契约。
@@ -80,6 +80,11 @@ d19ac72 feat(web-components): 无归属用户强制走创建组织流程
80
80
  acfeed4 feat(web-components)!: 强制入驻去掉跳过开关并恢复邀请码加入
81
81
  efd56ef fix(web-components): 去掉强制入驻邀请码入口并隐藏无身份工作台
82
82
  2aadb87 feat(web-components): 组织邀请页豁免强制入驻
83
+ e4738bb fix(web-components): 入驻流程改为居中对话框
84
+ f20ec7b feat(web-components): 入驻右上角改为按归属显示的取消
85
+ 95bbc8c feat(web-components)!: 团队创建成功页改为单个确认按钮
86
+ ffab3f9 fix(web-components): 非强制入驻的确认按钮点击无反应
87
+ 495ef35 fix(web-components): 创建组织后刷新账户菜单的组织列表
83
88
  ```
84
89
 
85
90
  新运行机制:**个人用户不允许以非员工身份留在系统中**,跳过与“先逛逛”不再存在。当前实现:
@@ -87,8 +92,17 @@ efd56ef fix(web-components): 去掉强制入驻邀请码入口并隐藏无身份
87
92
  1. 账户投影零归属时,`<uniplat-account-context>` 用原生 `dialog.showModal()`(top layer,不受
88
93
  Host 导航 `transform/overflow` 影响)自动打开强制态创建流程:不渲染跳过、无归属提示、邀请码
89
94
  入口与关闭按钮,遮罩点击、`Escape` 与 `cancel` 都不关闭。
90
- 2. 首屏出口只有创建企业、创建团队和“退出登录”(派发 `logout-requested`)。企业/团队表单页的
91
- “跳过”换成“返回”,创建成功页的认证、升级或邀请动作才关闭对话框。
95
+ 2. 首屏出口只有创建企业、创建团队和“退出登录”(派发 `logout-requested`)。企业/团队表单页在
96
+ 强制态下是“返回”回到创建方式选择。
97
+ 2.1 入驻流程一律用原生 `dialog.showModal()` 视口居中,不再用锚定胶囊的下拉浮层;普通态可由
98
+ 遮罩点击、`Escape` 或右上角“取消”关闭,强制态一律不关闭。
99
+ 2.2 右上角“取消”只在当前用户已归属某个组织时渲染(内部 `canCancel`,按投影归属数量传入),
100
+ 点击后直接收起,不再经过“确定暂不创建”确认页——该确认页与其“仍可浏览官网”文案已删除。
101
+ 2.3 创建成功页是终点:企业页保留“稍后认证 / 去认证”,团队页只有一个“确认”并派发
102
+ `onboarding-finished`;`team-upgrade-requested` 与 `team-invite-members-requested` 不再派发。
103
+ 收尾处理器无论强制与否都收起对话框(早退曾导致非强制态“确认”点不动),并再取一次投影,
104
+ 保证新建组织出现在账户菜单的切换列表里。账户菜单在投影变化时也会重新配置,不再因为元素
105
+ 实例未变而停在旧列表。
92
106
  3. **没有可跳过开关**:`organizationOnboarding` 只接收 `transport` 与可选 `invitePaths`,历史
93
107
  `mandatory: false` 不再放行(有用例守着)。
94
108
  4. 没有任何企业员工身份时,账户胶囊仍渲染,但**不显示工作台入口**,即使配置了 `workbench`;
@@ -301,13 +315,13 @@ Account Context 对外只派发净化后的 `LOAD_FAILED`,精确排错应看
301
315
 
302
316
  ## 7. 当前构建和验证状态
303
317
 
304
- 在 `2aadb87` 上最近一次完整检查结果:
318
+ 在 `495ef35` 上最近一次完整检查结果:
305
319
 
306
320
  ```text
307
321
  pnpm format:check 通过
308
322
  pnpm lint 通过
309
323
  pnpm typecheck 通过
310
- pnpm test 25 个测试文件通过;287 passed,2 skipped
324
+ pnpm test 25 个测试文件通过;292 passed,2 skipped
311
325
  pnpm build core、web-components、runtime、vanilla 全部通过
312
326
  ```
313
327
 
@@ -315,7 +329,7 @@ pnpm build core、web-components、runtime、vanilla 全部通过
315
329
 
316
330
  ```text
317
331
  /Users/gaostudio/uni-base/uniplat-ai-employee-client-0.14.0.tgz
318
- /Users/gaostudio/uni-base/uniplat-ai-employee-web-components-0.27.0.tgz
332
+ /Users/gaostudio/uni-base/uniplat-ai-employee-web-components-0.29.2.tgz
319
333
  ```
320
334
 
321
335
  根包本地产物:
@@ -348,9 +362,9 @@ pnpm --filter @uniplat/ai-employee-web-components pack
348
362
 
349
363
  ```text
350
364
  Manifest: https://10.10.10.131:8787/current.json
351
- Version: 0.27.0
352
- Module: https://10.10.10.131:8787/releases/0.27.0/index.js
353
- SHA-256: a5dfb56d11b60b0e3d475b24b2590751f830806e0907ed7013a38fdf05fae1f5
365
+ Version: 0.29.2
366
+ Module: https://10.10.10.131:8787/releases/0.29.2/index.js
367
+ SHA-256: 60fbeac569ff3cb5e27f7e4ef650fc5114343c44b42a3be3f86ace1da1bb1de9
354
368
  ```
355
369
 
356
370
  最后验证结果:
@@ -358,7 +372,7 @@ SHA-256: a5dfb56d11b60b0e3d475b24b2590751f830806e0907ed7013a38fdf05fae1f5
358
372
  - `current.json`:HTTP 200、`Cache-Control: no-store`;
359
373
  - 版本模块:HTTP 200、CORS `*`、`immutable`;
360
374
  - 远端模块 hash 与本地一致;
361
- - `current.json` 指向 `./releases/0.27.0/index.js`。
375
+ - `current.json` 指向 `./releases/0.29.2/index.js`。
362
376
 
363
377
  静态服务未启动时可执行:
364
378
 
@@ -380,17 +394,20 @@ https://registry.npmjs.org/
380
394
 
381
395
  ```text
382
396
  npm whoami csntgao(已登录)
383
- @csntgao/uni-base 0.4.0 已发布并回读校验
397
+ @csntgao/uni-base 0.6.2 已发布并回读校验
384
398
  @uniplat/ai-employee-client 未发布,PUT 返回 404(无 scope 创建权限)
385
399
  @uniplat/ai-employee-web-components 未发布,同上
386
400
  ```
387
401
 
388
- `@csntgao/uni-base@0.4.0` 的远端 shasum 为 `51397b9e1f897641ea265ba4c74621c81d4a6fc4`,
402
+ `@csntgao/uni-base@0.6.2` 的远端 shasum 为 `9335c2d6e2e32a7bec7c282ef7cce65c90b8d70a`,
389
403
  与本地打包一致。发布后远端 `npm view` 有几秒传播延迟,第一次回读到旧版本要重试再确认。注意该包没有 `main`/`exports`/`files`,发布内容是 91 个源码文件,
390
404
  不是可直接 `import` 的库;调用方仍应使用 runtime 或 workspace 包。
391
405
 
392
- 发布需要浏览器 2FA。本机 shell 不是交互终端,直接 `npm publish` 会以 `EOTP` 退出;
393
- 用伪终端包一层即可让 npm 停下来等浏览器授权:
406
+ 发布需要浏览器 2FA:该账号的第二因子是 passkey / WebAuthn,**没有 TOTP 六位码**,所以
407
+ `npm publish --otp=<code>` 这条路走不通。账号 2FA 当前仍是 `auth-and-writes`,改为 `auth-only`
408
+ 的命令是 `npm profile enable-2fa auth-only`(需要账号密码 + 一次浏览器验证),本次未完成。
409
+ 本机 shell 不是交互终端,直接 `npm publish` 会以 `EOTP` 退出;用伪终端包一层即可让 npm
410
+ 停下来等浏览器授权:
394
411
 
395
412
  ```bash
396
413
  script -q /dev/null npm publish <绝对路径或 tarball> --access public