@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.
- package/README.md +7 -1
- package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
- package/client/bootstrap/loadConsoleEntries.ts +39 -1
- package/client/console-utils/remoteApi.test.ts +68 -0
- package/client/console-utils/remoteApi.ts +19 -1
- package/client/index.ts +16 -0
- package/client/mediaSrc.test.ts +34 -0
- package/client/mediaSrc.ts +16 -1
- package/client/persistence/idb-store.test.ts +128 -0
- package/client/persistence/idb-store.ts +16 -16
- package/client/websocket/hooks.test.ts +21 -0
- package/client/websocket/hooks.ts +56 -27
- package/client/websocket/manager.test.ts +216 -0
- package/client/websocket/manager.ts +57 -28
- package/client/websocket/remote-settings.ts +13 -1
- package/dist/bootstrap/loadConsoleEntries.d.ts +9 -0
- package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
- package/dist/bootstrap/loadConsoleEntries.js +36 -1
- package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
- package/dist/console-utils/remoteApi.d.ts.map +1 -1
- package/dist/console-utils/remoteApi.js +22 -1
- package/dist/console-utils/remoteApi.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mediaSrc.d.ts.map +1 -1
- package/dist/mediaSrc.js +17 -1
- package/dist/mediaSrc.js.map +1 -1
- package/dist/persistence/idb-store.d.ts.map +1 -1
- package/dist/persistence/idb-store.js +15 -17
- package/dist/persistence/idb-store.js.map +1 -1
- package/dist/websocket/hooks.d.ts +9 -0
- package/dist/websocket/hooks.d.ts.map +1 -1
- package/dist/websocket/hooks.js +52 -31
- package/dist/websocket/hooks.js.map +1 -1
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +47 -27
- package/dist/websocket/manager.js.map +1 -1
- package/dist/websocket/remote-settings.d.ts.map +1 -1
- package/dist/websocket/remote-settings.js +16 -1
- package/dist/websocket/remote-settings.js.map +1 -1
- 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-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
+
});
|
package/client/mediaSrc.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 [
|
|
34
|
-
const [
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 }),
|