@zhin.js/client 2.0.6 → 2.1.1

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 (43) hide show
  1. package/README.md +7 -1
  2. package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
  3. package/client/bootstrap/loadConsoleEntries.ts +39 -1
  4. package/client/console-utils/remoteApi.test.ts +68 -0
  5. package/client/console-utils/remoteApi.ts +19 -1
  6. package/client/index.ts +16 -0
  7. package/client/mediaSrc.test.ts +34 -0
  8. package/client/mediaSrc.ts +16 -1
  9. package/client/persistence/idb-store.test.ts +128 -0
  10. package/client/persistence/idb-store.ts +16 -16
  11. package/client/websocket/hooks.test.ts +21 -0
  12. package/client/websocket/hooks.ts +56 -27
  13. package/client/websocket/manager.test.ts +216 -0
  14. package/client/websocket/manager.ts +57 -28
  15. package/client/websocket/remote-settings.ts +13 -1
  16. package/dist/bootstrap/loadConsoleEntries.d.ts +9 -0
  17. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  18. package/dist/bootstrap/loadConsoleEntries.js +36 -1
  19. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  20. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  21. package/dist/console-utils/remoteApi.js +22 -1
  22. package/dist/console-utils/remoteApi.js.map +1 -1
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/mediaSrc.d.ts.map +1 -1
  28. package/dist/mediaSrc.js +17 -1
  29. package/dist/mediaSrc.js.map +1 -1
  30. package/dist/persistence/idb-store.d.ts.map +1 -1
  31. package/dist/persistence/idb-store.js +15 -17
  32. package/dist/persistence/idb-store.js.map +1 -1
  33. package/dist/websocket/hooks.d.ts +9 -0
  34. package/dist/websocket/hooks.d.ts.map +1 -1
  35. package/dist/websocket/hooks.js +52 -31
  36. package/dist/websocket/hooks.js.map +1 -1
  37. package/dist/websocket/manager.d.ts.map +1 -1
  38. package/dist/websocket/manager.js +47 -27
  39. package/dist/websocket/manager.js.map +1 -1
  40. package/dist/websocket/remote-settings.d.ts.map +1 -1
  41. package/dist/websocket/remote-settings.js +16 -1
  42. package/dist/websocket/remote-settings.js.map +1 -1
  43. package/package.json +3 -2
package/README.md CHANGED
@@ -23,9 +23,15 @@ Peer:`react >= 18`(`createPluginRegisterHostApi` 需要 React 引用)。
23
23
  | `app` | 路由与工具注册单例(`addRoute`、`addTool` 等) |
24
24
  | `configureConsole` / `getRuntimeEnv` | 运行时环境(development / production) |
25
25
  | `createRegistryStore` / `useRegistry` | 可选 registry store |
26
+ | `ENDPOINT_RPC` / `INBOX_RPC` / `SIDE_EVENT_RPC` / `SIDE_EVENT_PUSH` | `@zhin.js/console-protocol` 的规范 RPC 与推送名称 |
27
+ | `normalizeConsolePushType` / `normalizeConsolePushMessage` | 在 SDK 边界兼容旧 `endpoint:*` 推送并输出规范事件与 payload |
28
+ | `parseConsoleInboxEvent` | 一次完成 Inbox 推送名称、身份别名和 message/request/notice 分类 |
29
+ | `ConsoleEndpointSummary` / `EndpointManagementCapability` | Host 与 Remote Console 共享的 Endpoint wire 类型 |
26
30
 
27
31
  类型与 Entry 契约来自 `@zhin.js/contract`。
28
32
 
33
+ 业务 UI 应使用 SDK 导出的协议常量和 Endpoint 类型,不要硬编码旧 `endpoint:list`、`endpoint:sendMessage` 等名称,也不要复制 `EndpointInfo`。SDK 会把旧 Host 推送别名归一化为 `message.receive` / `request.receive` / `notice.receive`,并统一 `endpointId`、`channelId` 字段。
34
+
29
35
  ## 启动:加载插件 Console Entry
30
36
 
31
37
  Remote Console 壳层在登录后调用:
@@ -117,4 +123,4 @@ pnpm --filter @zhin.js/client build
117
123
 
118
124
  - [Remote Console 使用说明](../../../docs/console-remote.md)
119
125
  - [Console 栈概览](../README.md)
120
- - [@zhin.js/host-api 管理面 API](../../host/api/README.md)
126
+ - [@zhin.js/host-http(Console/HTTP Host 传输)](../../host/http/README.md)
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { createPluginRegisterHostApi, getRegisterFn } from "./loadConsoleEntries.js";
2
+ import {
3
+ createPluginRegisterHostApi,
4
+ getRegisterFn,
5
+ resolveEntryRegister,
6
+ } from "./loadConsoleEntries.js";
3
7
 
4
8
  describe("console entries bootstrap", () => {
5
9
  it("resolves named register export", () => {
@@ -16,6 +20,29 @@ describe("console entries bootstrap", () => {
16
20
  expect(getRegisterFn({ default: {} })).toBeNull();
17
21
  });
18
22
 
23
+ it("synthesizes register from default component + entry.route", () => {
24
+ const Page = () => null;
25
+ const register = resolveEntryRegister(
26
+ { default: Page, meta: { title: "Sandbox", icon: "Box" } },
27
+ { id: "sandbox", resolvedModule: "/assets/client/sandbox.js", route: "/p-sandbox" } as any,
28
+ );
29
+ expect(typeof register).toBe("function");
30
+ const addRoute = vi.fn();
31
+ const createElement = vi.fn((c: unknown) => ({ type: c }));
32
+ register!({
33
+ React: { createElement } as any,
34
+ addRoute,
35
+ addPage: addRoute,
36
+ addTool: vi.fn(),
37
+ });
38
+ expect(addRoute).toHaveBeenCalledWith(expect.objectContaining({
39
+ path: "/p-sandbox",
40
+ name: "Sandbox",
41
+ icon: "Box",
42
+ }));
43
+ expect(createElement).toHaveBeenCalledWith(Page);
44
+ });
45
+
19
46
  it("creates a host API with addPage aliased to addRoute", () => {
20
47
  const React = {} as any;
21
48
  const addRoute = vi.fn();
@@ -29,4 +56,3 @@ describe("console entries bootstrap", () => {
29
56
  expect(api.addTool({ id: "t", name: "T" })).toBe("tool-id");
30
57
  });
31
58
  });
32
-
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_CONSOLE_BASE_PATH, type ConsoleClientEntry, type ConsoleEntriesResponse, type ConsolePluginRegister, type PluginRegisterHostApi } from '@zhin.js/contract';
2
+ import type * as React from 'react';
2
3
 
3
4
  export type FetchConsoleEntriesOptions = {
4
5
  entriesUrl?: string;
@@ -91,6 +92,43 @@ export function getRegisterFn(mod: Record<string, unknown> | null | undefined):
91
92
  return null;
92
93
  }
93
94
 
95
+ /**
96
+ * Convention pages (ADR 0046) export a React component as `default` + optional `meta`,
97
+ * without a legacy `register(api)`. Synthesize register from entry.route / entry meta
98
+ * so Remote Console can still mount them.
99
+ */
100
+ export function resolveEntryRegister(
101
+ mod: Record<string, unknown> | null | undefined,
102
+ entry: ConsoleClientEntry & { readonly route?: string; readonly title?: string },
103
+ ): ConsolePluginRegister | null {
104
+ const named = getRegisterFn(mod);
105
+ if (named) return named;
106
+ if (!mod) return null;
107
+ const Component = mod["default"];
108
+ if (typeof Component !== "function" && !(Component && typeof Component === "object")) {
109
+ return null;
110
+ }
111
+ const path = typeof entry.route === "string" && entry.route
112
+ ? entry.route
113
+ : `/${entry.id}`;
114
+ const meta = mod["meta"] && typeof mod["meta"] === "object"
115
+ ? mod["meta"] as { title?: string; icon?: unknown; hideInNav?: boolean }
116
+ : undefined;
117
+ const name = (typeof meta?.title === "string" && meta.title)
118
+ || (typeof entry.title === "string" && entry.title)
119
+ || entry.meta?.name
120
+ || entry.id;
121
+ return (api) => {
122
+ api.addRoute({
123
+ path,
124
+ name,
125
+ element: api.React.createElement(Component as React.ComponentType),
126
+ ...(meta?.icon != null ? { icon: meta.icon } : {}),
127
+ meta: { hideInMenu: meta?.hideInNav === true },
128
+ });
129
+ };
130
+ }
131
+
94
132
  export async function registerConsolePluginsFromEntries(
95
133
  entries: ConsoleClientEntry[],
96
134
  hostApi: PluginRegisterHostApi,
@@ -103,7 +141,7 @@ export async function registerConsolePluginsFromEntries(
103
141
  try {
104
142
  const specifier = resolvePluginImportUrl(entry.resolvedModule, assetOrigin);
105
143
  const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
106
- const register = getRegisterFn(mod);
144
+ const register = resolveEntryRegister(mod, entry);
107
145
  if (register) await register(hostApi);
108
146
  else throw new Error(`entry "${entry.id}" has no register export`);
109
147
  } catch (error) {
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { apiFetch, getToken } from "./remoteApi.js";
4
+
5
+ function mockStorage(map: Record<string, string> = {}): Storage {
6
+ return {
7
+ getItem: (key: string) => map[key] ?? null,
8
+ setItem: (key: string, value: string) => {
9
+ map[key] = value;
10
+ },
11
+ removeItem: (key: string) => {
12
+ delete map[key];
13
+ },
14
+ clear: () => {
15
+ for (const key of Object.keys(map)) delete map[key];
16
+ },
17
+ key: () => null,
18
+ length: 0,
19
+ };
20
+ }
21
+
22
+ function installBrowserGlobals(runtimeToken?: string) {
23
+ const win = {
24
+ location: { origin: "http://localhost:5173", protocol: "http:", host: "localhost:5173" },
25
+ __ZHIN_API_TOKEN: runtimeToken as string | undefined,
26
+ dispatchEvent: vi.fn(),
27
+ };
28
+ vi.stubGlobal("window", win);
29
+ vi.stubGlobal("localStorage", mockStorage());
30
+ vi.stubGlobal("sessionStorage", mockStorage());
31
+ return win;
32
+ }
33
+
34
+ function fakeResponse(status: number): Response {
35
+ return { ok: status >= 200 && status < 300, status } as Response;
36
+ }
37
+
38
+ describe("apiFetch 401 handling", () => {
39
+ afterEach(() => {
40
+ vi.unstubAllGlobals();
41
+ vi.restoreAllMocks();
42
+ });
43
+
44
+ it("invalidates an expired runtime token on 401 so it cannot 401-loop forever", async () => {
45
+ // 回归:runtime token(Demo 预置)优先级最高,而旧实现 401 只清 local/sessionStorage,
46
+ // 过期 demo token 会让后续每个请求都 401。
47
+ const win = installBrowserGlobals("expired-demo-token");
48
+ vi.stubGlobal("fetch", vi.fn(async () => fakeResponse(401)));
49
+
50
+ expect(getToken()).toBe("expired-demo-token");
51
+ await apiFetch("/api/system/status");
52
+
53
+ expect(win.__ZHIN_API_TOKEN).toBeUndefined();
54
+ expect(getToken()).toBeNull();
55
+ expect(win.dispatchEvent).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it("keeps the runtime token when the response is not 401", async () => {
59
+ const win = installBrowserGlobals("valid-demo-token");
60
+ vi.stubGlobal("fetch", vi.fn(async () => fakeResponse(200)));
61
+
62
+ await apiFetch("/api/system/status");
63
+
64
+ expect(win.__ZHIN_API_TOKEN).toBe("valid-demo-token");
65
+ expect(getToken()).toBe("valid-demo-token");
66
+ expect(win.dispatchEvent).not.toHaveBeenCalled();
67
+ });
68
+ });
@@ -9,7 +9,17 @@ export function getApiBase(): string {
9
9
  }
10
10
 
11
11
  export function getToken(): string | null {
12
- return getRuntimeToken() ?? localStorage.getItem(TOKEN_KEY);
12
+ const runtime = getRuntimeToken();
13
+ if (runtime) return runtime;
14
+ if (typeof localStorage !== "undefined") {
15
+ const local = localStorage.getItem(TOKEN_KEY)?.trim();
16
+ if (local) return local;
17
+ }
18
+ if (typeof sessionStorage !== "undefined") {
19
+ const session = sessionStorage.getItem(TOKEN_KEY)?.trim();
20
+ if (session) return session;
21
+ }
22
+ return null;
13
23
  }
14
24
 
15
25
  export function resolveApiUrl(path: string): string {
@@ -41,6 +51,9 @@ export async function apiFetch(
41
51
 
42
52
  const res = await fetch(url, { ...init, headers });
43
53
  if (res.status === 401) {
54
+ // runtime token(Demo 预置)过期后也必须失效,否则 getToken() 永远优先命中它,
55
+ // local/sessionStorage 清理不掉,造成 401 死循环。
56
+ clearRuntimeToken();
44
57
  sessionStorage.removeItem(TOKEN_KEY);
45
58
  localStorage.removeItem(TOKEN_KEY);
46
59
  window.dispatchEvent(new CustomEvent("zhin:auth-required"));
@@ -57,3 +70,8 @@ function getRuntimeToken(): string | null {
57
70
  const token = (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
58
71
  return token?.trim() || null;
59
72
  }
73
+
74
+ function clearRuntimeToken(): void {
75
+ if (typeof window === "undefined") return;
76
+ delete (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
77
+ }
package/client/index.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  // Types
2
2
  export * from "./types";
3
+ export {
4
+ ENDPOINT_RPC,
5
+ ENDPOINT_MANAGEMENT_CAPABILITIES,
6
+ INBOX_RPC,
7
+ SIDE_EVENT_PUSH,
8
+ SIDE_EVENT_RPC,
9
+ normalizeConsolePushMessage,
10
+ normalizeConsolePushType,
11
+ parseConsoleInboxEvent,
12
+ type ConsoleInboxEvent,
13
+ type ConsoleInboxEventKind,
14
+ type ConsoleEndpointPhase,
15
+ type ConsoleEndpointSummary,
16
+ type EndpointManagementCapability,
17
+ } from "@zhin.js/console-protocol";
3
18
 
4
19
  // Media URL resolution
5
20
  export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
@@ -46,6 +61,7 @@ export {
46
61
  getRegisterFn,
47
62
  loadConsoleEntries,
48
63
  registerConsolePluginsFromEntries,
64
+ resolveEntryRegister,
49
65
  type CreatePluginRegisterHostApiOptions,
50
66
  type FetchConsoleEntriesOptions,
51
67
  type LoadConsoleEntriesOptions,
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pickMediaRawUrl, resolveMediaSrc } from '../client/mediaSrc.js';
3
+
4
+ describe('pickMediaRawUrl', () => {
5
+ it('prefers canonical media.value / url / file', () => {
6
+ expect(pickMediaRawUrl({ media: { value: 'https://a/b.png' } })).toBe('https://a/b.png');
7
+ expect(pickMediaRawUrl({ url: 'https://a/b.png' })).toBe('https://a/b.png');
8
+ expect(pickMediaRawUrl({ file: '/tmp/a.png' })).toBe('/tmp/a.png');
9
+ });
10
+
11
+ it('reads agent outbound image segment (base64 + mime, no url)', () => {
12
+ // media-publisher 出站形态:{ type:'image', data:{ base64, mime, name } }
13
+ const raw = pickMediaRawUrl({ base64: 'aGVsbG8=', mime: 'image/png', name: 'system-status.png' });
14
+ expect(raw).toBe('data:image/png;base64,aGVsbG8=');
15
+ expect(resolveMediaSrc(raw, 'image')).toBe('data:image/png;base64,aGVsbG8=');
16
+ });
17
+
18
+ it('falls back to base64:// so resolveMediaSrc applies the kind default MIME', () => {
19
+ const raw = pickMediaRawUrl({ base64: 'aGVsbG8=' });
20
+ expect(raw).toBe('base64://aGVsbG8=');
21
+ expect(resolveMediaSrc(raw, 'image')).toBe('data:image/png;base64,aGVsbG8=');
22
+ expect(resolveMediaSrc(raw, 'audio')).toBe('data:audio/mpeg;base64,aGVsbG8=');
23
+ });
24
+
25
+ it('passes through base64 values that already carry a scheme', () => {
26
+ expect(pickMediaRawUrl({ base64: 'base64://aGVsbG8=' })).toBe('base64://aGVsbG8=');
27
+ expect(pickMediaRawUrl({ base64: 'data:image/png;base64,aGVsbG8=' })).toBe('data:image/png;base64,aGVsbG8=');
28
+ });
29
+
30
+ it('returns undefined when no media field exists', () => {
31
+ expect(pickMediaRawUrl(undefined)).toBeUndefined();
32
+ expect(pickMediaRawUrl({})).toBeUndefined();
33
+ });
34
+ });
@@ -52,5 +52,20 @@ export function pickMediaRawUrl(data: Record<string, unknown> | undefined): stri
52
52
  if (typeof value === 'string' && value.trim()) return value.trim()
53
53
  }
54
54
  const v = data.url ?? data.file ?? data.src ?? data.href
55
- return typeof v === 'string' && v.trim() ? v.trim() : undefined
55
+ if (typeof v === 'string' && v.trim()) return v.trim()
56
+ // agent 出站媒体段(media-publisher)直接携带纯 base64,无 url/file;
57
+ // 有 mime 时拼成 data: URL 透传,否则走 base64:// 让 resolveMediaSrc 按 kind 补默认 MIME
58
+ const b64 = data.base64
59
+ if (typeof b64 === 'string' && b64.trim()) {
60
+ const payload = b64.trim()
61
+ if (payload.startsWith('data:') || payload.startsWith('base64://')
62
+ || payload.startsWith('http://') || payload.startsWith('https://')) {
63
+ return payload
64
+ }
65
+ const mime = typeof data.mime === 'string' && data.mime.trim() ? data.mime.trim() : ''
66
+ return mime
67
+ ? `data:${mime};base64,${payload.replace(/\s/g, '')}`
68
+ : `base64://${payload}`
69
+ }
70
+ return undefined
56
71
  }
@@ -0,0 +1,128 @@
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", endpointId: "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}:${endpointId}:${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("reads legacy records that only carry endpointId (camelCase)", async () => {
112
+ // 旧版(DB v1 时代)记录只有 endpointId 字段,升级后仍需能被列出
113
+ const { idbPutInbox } = await import("./idb-store.js");
114
+ const legacy = {
115
+ id: "icqq:bot-1:message:1700000000000:legacy",
116
+ adapter: "icqq",
117
+ endpointId: "bot-1",
118
+ kind: "message",
119
+ payload: {},
120
+ updatedAt: 1700000000000,
121
+ };
122
+ await idbPutInbox(legacy as unknown as Parameters<typeof idbPutInbox>[0]);
123
+
124
+ const rows = await idbListInbox("icqq", "bot-1", "message");
125
+ expect(rows).toHaveLength(1);
126
+ expect(rows[0].id).toBe(legacy.id);
127
+ });
128
+ });
@@ -1,8 +1,12 @@
1
+ import { parseConsoleInboxEvent } from "@zhin.js/console-protocol";
2
+
1
3
  const DB_NAME = "zhin-console";
2
4
  const DB_VERSION = 2;
3
5
  const STORE_INBOX = "inbox";
4
6
  const STORE_PENDING = "pending";
5
7
 
8
+ let inboxEventSeq = 0;
9
+
6
10
  export type InboxRecord = {
7
11
  id: string;
8
12
  adapter: string;
@@ -63,20 +67,16 @@ export async function idbListInbox(
63
67
  }
64
68
 
65
69
  export async function applyConsoleEvent(event: { type: string; data?: unknown }): Promise<void> {
66
- const t = event.type;
67
- if (t === "message.receive" || t === "request.receive" || t === "notice.receive") {
68
- const data = event.data as Record<string, unknown> | undefined;
69
- if (!data) return;
70
- const adapter = String(data.$adapter ?? data.adapter ?? "");
71
- const endpoint_id = String(data.$endpoint ?? data.endpoint_id ?? data.endpointId ?? data.bot ?? "");
72
- const id = `${adapter}:${endpoint_id}:${t}:${Date.now()}`;
73
- await idbPutInbox({
74
- id,
75
- adapter,
76
- endpoint_id,
77
- kind: t === "message.receive" ? "message" : t === "request.receive" ? "request" : "notice",
78
- payload: data,
79
- updatedAt: Date.now(),
80
- });
81
- }
70
+ const parsed = parseConsoleInboxEvent(event);
71
+ if (!parsed) return;
72
+ const updatedAt = Date.now();
73
+ await idbPutInbox({
74
+ // 追加自增+随机后缀:同毫秒同会话的两条事件不会因主键相同而互相覆盖
75
+ id: `${parsed.adapter}:${parsed.endpointId}:${parsed.type}:${updatedAt}:${inboxEventSeq++}:${Math.random().toString(36).slice(2, 8)}`,
76
+ adapter: parsed.adapter,
77
+ endpoint_id: parsed.endpointId,
78
+ kind: parsed.kind,
79
+ payload: parsed.payload,
80
+ updatedAt,
81
+ });
82
82
  }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveKeyedValue } from "./hooks.js";
4
+
5
+ describe("resolveKeyedValue (useConfig stale-state guard)", () => {
6
+ it("returns the value when the entry key matches", () => {
7
+ const entry = { key: "plugin-a", value: { foo: 1 } };
8
+ expect(resolveKeyedValue(entry, "plugin-a")).toEqual({ foo: 1 });
9
+ });
10
+
11
+ it("treats a previous plugin's leftover state as empty after pluginName switches", () => {
12
+ // 回归:旧实现中 config state 跨 pluginName 残留,ready(config != null) 永远为真,
13
+ // 切换插件后不再自动加载新配置。
14
+ const stale = { key: "plugin-a", value: { foo: 1 } };
15
+ expect(resolveKeyedValue(stale, "plugin-b")).toBeNull();
16
+ });
17
+
18
+ it("returns null when nothing has been loaded", () => {
19
+ expect(resolveKeyedValue(null, "plugin-a")).toBeNull();
20
+ });
21
+ });
@@ -1,7 +1,25 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { getWebSocketManager } from "./instance";
3
3
  import type { UseWebSocketOptions } from "./types";
4
4
 
5
+ /**
6
+ * 每次连接会话只对同一 key 自动加载一次:失败后不再零退避死循环
7
+ * (旧行为:加载失败 → 状态保持空 → effect 反复立即重试,打爆 /api/console/request)。
8
+ * 断连后重置;key 变化(如 pluginName 切换)视为新目标重新尝试;手动重试走各 hook 返回的 load 函数。
9
+ */
10
+ function useAutoLoadOnce(connected: boolean, key: unknown, ready: boolean, fire: () => void) {
11
+ const attemptedRef = useRef<unknown>(undefined);
12
+ useEffect(() => {
13
+ if (!connected) {
14
+ attemptedRef.current = undefined;
15
+ return;
16
+ }
17
+ if (ready || attemptedRef.current === key) return;
18
+ attemptedRef.current = key;
19
+ fire();
20
+ });
21
+ }
22
+
5
23
  export function useWebSocket(options: UseWebSocketOptions = {}) {
6
24
  const { autoConnect = true } = options;
7
25
  const wsManager = getWebSocketManager();
@@ -26,14 +44,28 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
26
44
  return { connected, connect, disconnect, send, sendRequest, manager: wsManager };
27
45
  }
28
46
 
47
+ /**
48
+ * 按 key 绑定异步加载结果:key(如 pluginName)切换后,旧 key 的残留值视为 null,
49
+ * 避免 useConfig 的 ready 判断(config != null)被上一插件的 state 挡住而不再加载新配置。
50
+ * @internal
51
+ */
52
+ export function resolveKeyedValue<T>(
53
+ entry: { readonly key: string; readonly value: T } | null,
54
+ key: string,
55
+ ): T | null {
56
+ return entry && entry.key === key ? entry.value : null;
57
+ }
58
+
29
59
  export function useConfig(pluginName: string, options?: { autoLoad?: boolean; autoLoadSchema?: boolean }) {
30
60
  const { autoLoad = true, autoLoadSchema = true } = options ?? {};
31
61
  const wsManager = getWebSocketManager();
32
62
  const [connected, setConnected] = useState(wsManager.isConnected());
33
- const [config, setConfigState] = useState<unknown>(null);
34
- const [schema, setSchemaState] = useState<unknown>(null);
63
+ const [configEntry, setConfigEntry] = useState<{ key: string; value: unknown } | null>(null);
64
+ const [schemaEntry, setSchemaEntry] = useState<{ key: string; value: unknown } | null>(null);
35
65
  const [loading, setLoading] = useState(false);
36
66
  const [error, setError] = useState<string | null>(null);
67
+ const config = resolveKeyedValue(configEntry, pluginName);
68
+ const schema = resolveKeyedValue(schemaEntry, pluginName);
37
69
 
38
70
  useEffect(() => wsManager.onConnectionChange(setConnected), [wsManager]);
39
71
 
@@ -42,7 +74,7 @@ export function useConfig(pluginName: string, options?: { autoLoad?: boolean; au
42
74
  setError(null);
43
75
  try {
44
76
  const result = await wsManager.getConfig(pluginName);
45
- setConfigState(result);
77
+ setConfigEntry({ key: pluginName, value: result });
46
78
  return result;
47
79
  } catch (e) {
48
80
  setError(e instanceof Error ? e.message : "Unknown error");
@@ -71,20 +103,19 @@ export function useConfig(pluginName: string, options?: { autoLoad?: boolean; au
71
103
  const getSchema = useCallback(async () => {
72
104
  try {
73
105
  const result = await wsManager.getSchema(pluginName);
74
- if (result) setSchemaState(result);
106
+ if (result) setSchemaEntry({ key: pluginName, value: result });
75
107
  return result;
76
108
  } catch (e) {
77
109
  throw e;
78
110
  }
79
111
  }, [pluginName, wsManager]);
80
112
 
81
- useEffect(() => {
82
- if (connected && autoLoad && !config && !loading) getConfig().catch(() => {});
83
- }, [connected, autoLoad, config, loading, getConfig]);
84
-
85
- useEffect(() => {
86
- if (connected && autoLoadSchema && !schema) getSchema().catch(() => {});
87
- }, [connected, autoLoadSchema, schema, getSchema]);
113
+ useAutoLoadOnce(connected, `config:${pluginName}`, !autoLoad || config != null || loading, () => {
114
+ getConfig().catch(() => {});
115
+ });
116
+ useAutoLoadOnce(connected, `schema:${pluginName}`, !autoLoadSchema || schema != null, () => {
117
+ getSchema().catch(() => {});
118
+ });
88
119
 
89
120
  return useMemo(
90
121
  () => ({ config, schema, loading, error, connected, getConfig, setConfig, getSchema }),
@@ -136,9 +167,9 @@ export function useConfigYaml() {
136
167
  [wsManager],
137
168
  );
138
169
 
139
- useEffect(() => {
140
- if (connected && !yaml && !loading) load().catch(() => {});
141
- }, [connected, yaml, loading, load]);
170
+ useAutoLoadOnce(connected, "config-yaml", !!yaml || loading, () => {
171
+ load().catch(() => {});
172
+ });
142
173
 
143
174
  return useMemo(() => ({ yaml, pluginKeys, loading, error, load, save }), [yaml, pluginKeys, loading, error, load, save]);
144
175
  }
@@ -173,9 +204,9 @@ export function useFiles() {
173
204
  [wsManager],
174
205
  );
175
206
 
176
- useEffect(() => {
177
- if (connected && tree.length === 0 && !loading) loadTree().catch(() => {});
178
- }, [connected, tree.length, loading, loadTree]);
207
+ useAutoLoadOnce(connected, "file-tree", tree.length > 0 || loading, () => {
208
+ loadTree().catch(() => {});
209
+ });
179
210
 
180
211
  return useMemo(() => ({ tree, loading, error, loadTree, readFile, saveFile }), [tree, loading, error, loadTree, readFile, saveFile]);
181
212
  }
@@ -217,9 +248,9 @@ export function useEnvFiles() {
217
248
  [wsManager],
218
249
  );
219
250
 
220
- useEffect(() => {
221
- if (connected && files.length === 0 && !loading) listFiles().catch(() => {});
222
- }, [connected, files.length, loading, listFiles]);
251
+ useAutoLoadOnce(connected, "env-files", files.length > 0 || loading, () => {
252
+ listFiles().catch(() => {});
253
+ });
223
254
 
224
255
  return useMemo(
225
256
  () => ({ files, loading, error, listFiles, getFile, saveFile }),
@@ -275,12 +306,10 @@ export function useDatabase() {
275
306
  const kvDelete = useCallback(async (table: string, key: string) => wsManager.kvDelete(table, key), [wsManager]);
276
307
  const kvEntries = useCallback(async (table: string) => wsManager.kvGetEntries(table), [wsManager]);
277
308
 
278
- useEffect(() => {
279
- if (connected && !info && !loading) {
280
- loadInfo().catch(() => {});
281
- loadTables().catch(() => {});
282
- }
283
- }, [connected, info, loading, loadInfo, loadTables]);
309
+ useAutoLoadOnce(connected, "db-info", info != null || loading, () => {
310
+ loadInfo().catch(() => {});
311
+ loadTables().catch(() => {});
312
+ });
284
313
 
285
314
  return useMemo(
286
315
  () => ({ info, tables, loading, error, loadInfo, loadTables, dropTable, select, insert, update, remove, kvGet, kvSet, kvDelete, kvEntries }),