@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.
- package/README.md +94 -432
- package/client/agent-stream.ts +121 -0
- package/client/app.test.ts +37 -0
- package/client/app.ts +130 -138
- package/client/bootstrap/loadConsoleEntries.test.ts +23 -12
- package/client/bootstrap/loadConsoleEntries.ts +40 -27
- package/client/console-client-context.ts +24 -0
- package/client/console-client.test.ts +46 -0
- package/client/console-client.ts +70 -0
- package/client/console-events.ts +38 -0
- package/client/console-utils/remoteApi.test.ts +91 -0
- package/client/console-utils/remoteApi.ts +47 -9
- package/client/index.ts +92 -28
- package/client/mediaSrc.test.ts +49 -0
- package/client/mediaSrc.ts +84 -11
- package/client/message-content/MarkdownContent.tsx +128 -0
- package/client/message-content/markdown.test.ts +48 -0
- package/client/message-content/markdown.ts +247 -0
- package/client/persistence/idb-store.test.ts +121 -0
- package/client/persistence/idb-store.ts +40 -21
- package/client/segments.test.ts +16 -0
- package/client/segments.ts +39 -0
- package/client/transport/console-transport.test.ts +394 -0
- package/client/transport/console-transport.ts +676 -0
- package/client/transport/hooks.test.ts +21 -0
- package/client/transport/hooks.ts +535 -0
- package/client/transport/index.ts +23 -0
- package/client/{websocket → transport}/types.ts +16 -17
- package/client/types.ts +5 -8
- package/client/workroom/WorkroomRunsPage.tsx +355 -0
- package/client/workroom/registerWorkroomConsole.test.ts +25 -0
- package/client/workroom/registerWorkroomConsole.ts +17 -0
- package/dist/agent-stream.d.ts +17 -0
- package/dist/agent-stream.d.ts.map +1 -0
- package/dist/agent-stream.js +73 -0
- package/dist/agent-stream.js.map +1 -0
- package/dist/app.d.ts +17 -52
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +110 -95
- package/dist/app.js.map +1 -1
- package/dist/bootstrap/loadConsoleEntries.d.ts +10 -7
- package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
- package/dist/bootstrap/loadConsoleEntries.js +37 -11
- package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
- package/dist/console-client-context.d.ts +9 -0
- package/dist/console-client-context.d.ts.map +1 -0
- package/dist/console-client-context.js +13 -0
- package/dist/console-client-context.js.map +1 -0
- package/dist/console-client.d.ts +25 -0
- package/dist/console-client.d.ts.map +1 -0
- package/dist/console-client.js +45 -0
- package/dist/console-client.js.map +1 -0
- package/dist/console-events.d.ts +8 -0
- package/dist/console-events.d.ts.map +1 -0
- package/dist/console-events.js +26 -0
- package/dist/console-events.js.map +1 -0
- package/dist/console-utils/remoteApi.d.ts +2 -1
- package/dist/console-utils/remoteApi.d.ts.map +1 -1
- package/dist/console-utils/remoteApi.js +46 -8
- package/dist/console-utils/remoteApi.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -8
- package/dist/index.js.map +1 -1
- package/dist/mediaSrc.d.ts +1 -1
- package/dist/mediaSrc.d.ts.map +1 -1
- package/dist/mediaSrc.js +91 -11
- package/dist/mediaSrc.js.map +1 -1
- package/dist/message-content/MarkdownContent.d.ts +11 -0
- package/dist/message-content/MarkdownContent.d.ts.map +1 -0
- package/dist/message-content/MarkdownContent.js +68 -0
- package/dist/message-content/MarkdownContent.js.map +1 -0
- package/dist/message-content/markdown.d.ts +47 -0
- package/dist/message-content/markdown.d.ts.map +1 -0
- package/dist/message-content/markdown.js +219 -0
- package/dist/message-content/markdown.js.map +1 -0
- package/dist/persistence/idb-store.d.ts +5 -2
- package/dist/persistence/idb-store.d.ts.map +1 -1
- package/dist/persistence/idb-store.js +29 -20
- package/dist/persistence/idb-store.js.map +1 -1
- package/dist/segments.d.ts +9 -0
- package/dist/segments.d.ts.map +1 -0
- package/dist/segments.js +35 -0
- package/dist/segments.js.map +1 -0
- package/dist/transport/console-transport.d.ts +217 -0
- package/dist/transport/console-transport.d.ts.map +1 -0
- package/dist/transport/console-transport.js +563 -0
- package/dist/transport/console-transport.js.map +1 -0
- package/dist/transport/hooks.d.ts +253 -0
- package/dist/transport/hooks.d.ts.map +1 -0
- package/dist/transport/hooks.js +483 -0
- package/dist/transport/hooks.js.map +1 -0
- package/dist/transport/index.d.ts +4 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +4 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/{websocket → transport}/types.d.ts +14 -13
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/{websocket → transport}/types.js +4 -9
- package/dist/transport/types.js.map +1 -0
- package/dist/types.d.ts +4 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
- package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
- package/dist/workroom/WorkroomRunsPage.js +111 -0
- package/dist/workroom/WorkroomRunsPage.js.map +1 -0
- package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
- package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
- package/dist/workroom/registerWorkroomConsole.js +16 -0
- package/dist/workroom/registerWorkroomConsole.js.map +1 -0
- package/package.json +18 -8
- package/client/runtime/index.ts +0 -12
- package/client/websocket/hooks.ts +0 -289
- package/client/websocket/index.ts +0 -6
- package/client/websocket/instance.ts +0 -24
- package/client/websocket/manager.ts +0 -426
- package/client/websocket/remote-settings.ts +0 -25
- package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
- package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
- package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
- package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
- package/dist/runtime/index.d.ts +0 -6
- package/dist/runtime/index.d.ts.map +0 -1
- package/dist/runtime/index.js +0 -10
- package/dist/runtime/index.js.map +0 -1
- package/dist/websocket/hooks.d.ts +0 -105
- package/dist/websocket/hooks.d.ts.map +0 -1
- package/dist/websocket/hooks.js +0 -247
- package/dist/websocket/hooks.js.map +0 -1
- package/dist/websocket/index.d.ts +0 -7
- package/dist/websocket/index.d.ts.map +0 -1
- package/dist/websocket/index.js +0 -7
- package/dist/websocket/index.js.map +0 -1
- package/dist/websocket/instance.d.ts +0 -5
- package/dist/websocket/instance.d.ts.map +0 -1
- package/dist/websocket/instance.js +0 -21
- package/dist/websocket/instance.js.map +0 -1
- package/dist/websocket/manager.d.ts +0 -113
- package/dist/websocket/manager.d.ts.map +0 -1
- package/dist/websocket/manager.js +0 -403
- package/dist/websocket/manager.js.map +0 -1
- package/dist/websocket/remote-settings.d.ts +0 -5
- package/dist/websocket/remote-settings.d.ts.map +0 -1
- package/dist/websocket/remote-settings.js +0 -23
- package/dist/websocket/remote-settings.js.map +0 -1
- package/dist/websocket/types.d.ts.map +0 -1
- package/dist/websocket/types.js.map +0 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { apiFetch, consoleRpc, 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
|
+
|
|
69
|
+
it("sends canonical Console RPC envelopes and returns typed data", async () => {
|
|
70
|
+
installBrowserGlobals("sponsor-token");
|
|
71
|
+
const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => ({
|
|
72
|
+
ok: true,
|
|
73
|
+
status: 200,
|
|
74
|
+
json: async () => ({ success: true, data: { ready: true } }),
|
|
75
|
+
request: init,
|
|
76
|
+
}));
|
|
77
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
78
|
+
|
|
79
|
+
await expect(consoleRpc<{ ready: boolean }>("workroom.profile.status", {
|
|
80
|
+
projectId: "alpha",
|
|
81
|
+
})).resolves.toEqual({ ready: true });
|
|
82
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
83
|
+
"http://localhost:5173/api/console/request",
|
|
84
|
+
expect.objectContaining({ method: "POST" }),
|
|
85
|
+
);
|
|
86
|
+
const init = fetchMock.mock.calls[0]![1] as RequestInit;
|
|
87
|
+
expect(JSON.parse(String(init.body))).toMatchObject({
|
|
88
|
+
type: "workroom.profile.status", projectId: "alpha",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -3,13 +3,23 @@ const API_BASE_KEY = "zhin_api_base";
|
|
|
3
3
|
|
|
4
4
|
export function getApiBase(): string {
|
|
5
5
|
const stored = localStorage.getItem(API_BASE_KEY)?.trim();
|
|
6
|
-
if (stored) return stored
|
|
6
|
+
if (stored) return trimTrailingSlash(stored);
|
|
7
7
|
if (typeof window !== "undefined") return window.location.origin;
|
|
8
8
|
return "";
|
|
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 {
|
|
@@ -18,13 +28,6 @@ export function resolveApiUrl(path: string): string {
|
|
|
18
28
|
return `${base}${p}`;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
export function resolveWebSocketUrl(path: string): string {
|
|
22
|
-
const httpUrl = resolveApiUrl(path);
|
|
23
|
-
const url = new URL(httpUrl);
|
|
24
|
-
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
25
|
-
return url.href;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
31
|
/** fetch with Bearer token; relative paths resolve against {@link getApiBase}. */
|
|
29
32
|
export async function apiFetch(
|
|
30
33
|
input: RequestInfo | URL,
|
|
@@ -41,8 +44,43 @@ export async function apiFetch(
|
|
|
41
44
|
|
|
42
45
|
const res = await fetch(url, { ...init, headers });
|
|
43
46
|
if (res.status === 401) {
|
|
47
|
+
// runtime token(Demo 预置)过期后也必须失效,否则 getToken() 永远优先命中它,
|
|
48
|
+
// local/sessionStorage 清理不掉,造成 401 死循环。
|
|
49
|
+
clearRuntimeToken();
|
|
50
|
+
sessionStorage.removeItem(TOKEN_KEY);
|
|
44
51
|
localStorage.removeItem(TOKEN_KEY);
|
|
45
52
|
window.dispatchEvent(new CustomEvent("zhin:auth-required"));
|
|
46
53
|
}
|
|
47
54
|
return res;
|
|
48
55
|
}
|
|
56
|
+
|
|
57
|
+
/** Typed POST adapter for the canonical Plugin Runtime Console RPC endpoint. */
|
|
58
|
+
export async function consoleRpc<T>(
|
|
59
|
+
type: string,
|
|
60
|
+
data: Readonly<Record<string, unknown>> = {},
|
|
61
|
+
): Promise<T> {
|
|
62
|
+
const requestId = Date.now();
|
|
63
|
+
const res = await apiFetch('/api/console/request', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ type, requestId, ...data }),
|
|
67
|
+
});
|
|
68
|
+
const body = await res.json() as { success?: boolean; data?: T; error?: string };
|
|
69
|
+
if (!res.ok || !body.success) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
70
|
+
return body.data as T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function trimTrailingSlash(value: string): string {
|
|
74
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getRuntimeToken(): string | null {
|
|
78
|
+
if (typeof window === "undefined") return null;
|
|
79
|
+
const token = (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
|
|
80
|
+
return token?.trim() || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearRuntimeToken(): void {
|
|
84
|
+
if (typeof window === "undefined") return;
|
|
85
|
+
delete (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
|
|
86
|
+
}
|
package/client/index.ts
CHANGED
|
@@ -1,43 +1,96 @@
|
|
|
1
1
|
// Types
|
|
2
2
|
export * from "./types";
|
|
3
|
+
export {
|
|
4
|
+
ENDPOINT_RPC,
|
|
5
|
+
CONSOLE_EVENT_RECOVERY_GAP_EVENT,
|
|
6
|
+
ENDPOINT_MANAGEMENT_CAPABILITIES,
|
|
7
|
+
INBOX_RPC,
|
|
8
|
+
SIDE_EVENT_PUSH,
|
|
9
|
+
SIDE_EVENT_RPC,
|
|
10
|
+
parseConsoleInboxEvent,
|
|
11
|
+
type ConsoleInboxEvent,
|
|
12
|
+
type ConsoleInboxEventKind,
|
|
13
|
+
type ConsoleInboxMessageRow,
|
|
14
|
+
type ConsoleInboxNoticeRow,
|
|
15
|
+
type ConsoleInboxNoticesQuery,
|
|
16
|
+
type ConsoleInboxNoticesResult,
|
|
17
|
+
type ConsoleInboxRequestRow,
|
|
18
|
+
type ConsoleEndpointPhase,
|
|
19
|
+
type ConsoleEndpointSummary,
|
|
20
|
+
type ConsoleEventActor,
|
|
21
|
+
type ConsoleEventChannel,
|
|
22
|
+
type ConsoleEventParent,
|
|
23
|
+
type EndpointManagementCapability,
|
|
24
|
+
parseConsoleSseFrame,
|
|
25
|
+
type ConsoleEventDelivery,
|
|
26
|
+
type ConsoleEventData,
|
|
27
|
+
type ConsoleEndpointEventData,
|
|
28
|
+
type ConsoleEventEnvelope,
|
|
29
|
+
type ConsoleEventHistoryPage,
|
|
30
|
+
type ConsoleEventHistoryQuery,
|
|
31
|
+
type ConsoleEventPayloadMap,
|
|
32
|
+
type ConsoleMessageEventData,
|
|
33
|
+
type ConsoleNoticeEventData,
|
|
34
|
+
type ConsoleRequestEventData,
|
|
35
|
+
type KnownConsoleEventType,
|
|
36
|
+
type KnownConsoleEventEnvelope,
|
|
37
|
+
type ParsedConsoleSseFrame,
|
|
38
|
+
} from "@zhin.js/console-protocol";
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
fetchConsoleEventHistory,
|
|
42
|
+
type FetchConsoleEventHistoryOptions,
|
|
43
|
+
} from './console-events.js';
|
|
3
44
|
|
|
4
45
|
// Media URL resolution
|
|
5
46
|
export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
|
|
6
47
|
|
|
7
|
-
//
|
|
48
|
+
// Safe rich message rendering (Markdown + code blocks; no raw HTML execution)
|
|
49
|
+
export {
|
|
50
|
+
MarkdownContent,
|
|
51
|
+
CodeBlock,
|
|
52
|
+
type MarkdownContentProps,
|
|
53
|
+
} from "./message-content/MarkdownContent.js";
|
|
54
|
+
export {
|
|
55
|
+
parseMarkdown,
|
|
56
|
+
parseMarkdownInline,
|
|
57
|
+
highlightCodeLine,
|
|
58
|
+
isSafeMarkdownHref,
|
|
59
|
+
type MarkdownBlock,
|
|
60
|
+
type MarkdownInline,
|
|
61
|
+
type CodeToken,
|
|
62
|
+
type CodeTokenKind,
|
|
63
|
+
} from "./message-content/markdown.js";
|
|
64
|
+
|
|
65
|
+
// Segment IM visibility (inbox vs agent panel)
|
|
66
|
+
export { segmentsForImDelivery, segmentsForAgentPanel } from "./segments.js";
|
|
67
|
+
|
|
68
|
+
// Owner-scoped Console client and application registry
|
|
8
69
|
export {
|
|
9
|
-
|
|
10
|
-
|
|
70
|
+
ConsoleApp,
|
|
71
|
+
createConsoleApp,
|
|
11
72
|
type AddRouteInput,
|
|
12
73
|
type AddToolInput,
|
|
13
74
|
type ConsoleRouteRecord,
|
|
14
75
|
type RouteTreeNode,
|
|
15
76
|
type ToolTreeNode,
|
|
77
|
+
type SidebarRenderer,
|
|
78
|
+
type ToolbarRenderer,
|
|
16
79
|
type ConsoleRouteRenderer,
|
|
17
|
-
} from "./app";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export type {
|
|
24
|
-
PluginRegisterHostApi,
|
|
25
|
-
PluginAddRouteInput,
|
|
26
|
-
PluginAddToolInput,
|
|
27
|
-
ConsolePluginRegister,
|
|
28
|
-
ConsoleClientEntry,
|
|
29
|
-
ConsoleEntriesResponse,
|
|
30
|
-
ConsoleEntry,
|
|
31
|
-
ConsoleFileAddEntryInput,
|
|
32
|
-
RuntimeEnv,
|
|
33
|
-
} from "@zhin.js/console-types";
|
|
34
|
-
|
|
80
|
+
} from "./app.js";
|
|
81
|
+
export {
|
|
82
|
+
ConsoleClient,
|
|
83
|
+
createConsoleClient,
|
|
84
|
+
type ConsoleClientOptions,
|
|
85
|
+
} from './console-client.js';
|
|
35
86
|
export {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
87
|
+
ConsoleClientProvider,
|
|
88
|
+
useConsoleClient,
|
|
89
|
+
type ConsoleClientProviderProps,
|
|
90
|
+
} from './console-client-context.js';
|
|
39
91
|
|
|
40
|
-
|
|
92
|
+
// Owner-scoped REST/SSE transport
|
|
93
|
+
export * from "./transport/index.js";
|
|
41
94
|
|
|
42
95
|
export {
|
|
43
96
|
createRegistryStore,
|
|
@@ -52,16 +105,27 @@ export {
|
|
|
52
105
|
getApiBase,
|
|
53
106
|
getToken,
|
|
54
107
|
resolveApiUrl,
|
|
55
|
-
resolveWebSocketUrl,
|
|
56
108
|
} from "./console-utils/remoteApi.js";
|
|
57
109
|
|
|
58
110
|
export {
|
|
59
111
|
fetchConsoleEntries,
|
|
60
|
-
createPluginRegisterHostApi,
|
|
61
112
|
getRegisterFn,
|
|
62
113
|
loadConsoleEntries,
|
|
63
114
|
registerConsolePluginsFromEntries,
|
|
64
|
-
|
|
115
|
+
resolveEntryRegister,
|
|
65
116
|
type FetchConsoleEntriesOptions,
|
|
66
117
|
type LoadConsoleEntriesOptions,
|
|
67
118
|
} from "./bootstrap/loadConsoleEntries.js";
|
|
119
|
+
|
|
120
|
+
export { registerWorkroomConsole } from "./workroom/registerWorkroomConsole.js";
|
|
121
|
+
export { default as WorkroomRunsPage } from "./workroom/WorkroomRunsPage.js";
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
startAgentSession,
|
|
125
|
+
continueAgentSession,
|
|
126
|
+
subscribeAgentStream,
|
|
127
|
+
iterateAgentStreamNdjson,
|
|
128
|
+
foldAgentStreamNdjson,
|
|
129
|
+
type AgentStreamClientOptions,
|
|
130
|
+
type SubscribeAgentStreamOptions,
|
|
131
|
+
} from "./agent-stream.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
|
|
35
|
+
it('rejects executable data URLs, credentials, and malformed base64', () => {
|
|
36
|
+
expect(resolveMediaSrc('data:text/html;base64,PHNjcmlwdD4=', 'image')).toBeUndefined();
|
|
37
|
+
expect(resolveMediaSrc('data:image/svg+xml;base64,PHN2Zz4=', 'image')).toBeUndefined();
|
|
38
|
+
expect(resolveMediaSrc('https://user:secret@example.com/a.png', 'image')).toBeUndefined();
|
|
39
|
+
expect(resolveMediaSrc('base64://not:base64', 'image')).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts only data MIME types matching the rendered media kind', () => {
|
|
43
|
+
expect(resolveMediaSrc('data:image/png;base64,aGVsbG8=', 'image')).toBe(
|
|
44
|
+
'data:image/png;base64,aGVsbG8=',
|
|
45
|
+
);
|
|
46
|
+
expect(resolveMediaSrc('data:image/png;base64,aGVsbG8=', 'video')).toBeUndefined();
|
|
47
|
+
expect(resolveMediaSrc('javascript:alert(1)', 'image')).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
package/client/mediaSrc.ts
CHANGED
|
@@ -3,6 +3,56 @@ export type MediaKind = 'image' | 'video' | 'audio'
|
|
|
3
3
|
|
|
4
4
|
const BASE64_PROTO = 'base64://'
|
|
5
5
|
|
|
6
|
+
const SAFE_DATA_MIME: Readonly<Record<MediaKind, ReadonlySet<string>>> = Object.freeze({
|
|
7
|
+
image: new Set(['image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp']),
|
|
8
|
+
video: new Set(['video/mp4', 'video/ogg', 'video/webm']),
|
|
9
|
+
audio: new Set(['audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/wav', 'audio/webm']),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function resolveSafeRemoteUrl(value: string): string | undefined {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(value)
|
|
15
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && parsed.protocol !== 'blob:') {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
if (parsed.username || parsed.password) return undefined
|
|
19
|
+
return parsed.href
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveSafeDataUrl(value: string, kind: MediaKind): string | undefined {
|
|
26
|
+
if (!value.startsWith('data:')) return undefined
|
|
27
|
+
const separator = value.indexOf(',')
|
|
28
|
+
if (separator < 0) return undefined
|
|
29
|
+
const header = value.slice('data:'.length, separator).toLowerCase()
|
|
30
|
+
if (!header.endsWith(';base64')) return undefined
|
|
31
|
+
const mime = header.slice(0, -';base64'.length)
|
|
32
|
+
if (!SAFE_DATA_MIME[kind].has(mime)) return undefined
|
|
33
|
+
const payload = compactBase64(value.slice(separator + 1))
|
|
34
|
+
return payload ? `data:${mime};base64,${payload}` : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compactBase64(value: string): string | undefined {
|
|
38
|
+
let compact = ''
|
|
39
|
+
for (const character of value) {
|
|
40
|
+
if (character === ' ' || character === '\n' || character === '\r' || character === '\t') continue
|
|
41
|
+
const code = character.charCodeAt(0)
|
|
42
|
+
const allowed = (code >= 48 && code <= 57)
|
|
43
|
+
|| (code >= 65 && code <= 90)
|
|
44
|
+
|| (code >= 97 && code <= 122)
|
|
45
|
+
|| character === '+' || character === '/' || character === '='
|
|
46
|
+
if (!allowed) return undefined
|
|
47
|
+
compact += character
|
|
48
|
+
}
|
|
49
|
+
const padding = compact.indexOf('=')
|
|
50
|
+
if (padding >= 0 && (compact.length - padding > 2 || compact.slice(padding).replace(/=/g, '') !== '')) {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
return compact || undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
6
56
|
/**
|
|
7
57
|
* 将消息段中的 url/file 等转为浏览器可用的媒体地址。
|
|
8
58
|
*
|
|
@@ -21,31 +71,54 @@ export function resolveMediaSrc(
|
|
|
21
71
|
const s = raw.trim()
|
|
22
72
|
if (!s) return undefined
|
|
23
73
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
74
|
+
const dataUrl = resolveSafeDataUrl(s, kind)
|
|
75
|
+
if (dataUrl) return dataUrl
|
|
76
|
+
|
|
77
|
+
const remoteUrl = resolveSafeRemoteUrl(s)
|
|
78
|
+
if (remoteUrl) return remoteUrl
|
|
27
79
|
|
|
28
80
|
if (!s.startsWith(BASE64_PROTO)) {
|
|
29
|
-
return
|
|
81
|
+
return undefined
|
|
30
82
|
}
|
|
31
83
|
|
|
32
|
-
const payload = s.slice(BASE64_PROTO.length).
|
|
84
|
+
const payload = s.slice(BASE64_PROTO.length).trimStart()
|
|
33
85
|
if (!payload) return undefined
|
|
34
86
|
|
|
35
87
|
// 已是 type/subtype;base64, 片段(不含 data: 前缀)
|
|
36
|
-
|
|
37
|
-
|
|
88
|
+
const declared = resolveSafeDataUrl(`data:${payload}`, kind)
|
|
89
|
+
if (declared) {
|
|
90
|
+
return declared
|
|
38
91
|
}
|
|
39
92
|
|
|
40
93
|
const defaultMime =
|
|
41
94
|
kind === 'image' ? 'image/png' : kind === 'video' ? 'video/mp4' : 'audio/mpeg'
|
|
42
|
-
const b64 = payload
|
|
43
|
-
return `data:${defaultMime};base64,${b64}`
|
|
95
|
+
const b64 = compactBase64(payload)
|
|
96
|
+
return b64 ? `data:${defaultMime};base64,${b64}` : undefined
|
|
44
97
|
}
|
|
45
98
|
|
|
46
|
-
/** 从 segment.data
|
|
99
|
+
/** 从 segment.data 取常见媒体字段(优先 canonical MediaRef) */
|
|
47
100
|
export function pickMediaRawUrl(data: Record<string, unknown> | undefined): string | undefined {
|
|
48
101
|
if (!data) return undefined
|
|
102
|
+
const media = data.media
|
|
103
|
+
if (media && typeof media === 'object' && media !== null) {
|
|
104
|
+
const value = (media as { value?: unknown }).value
|
|
105
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
106
|
+
}
|
|
49
107
|
const v = data.url ?? data.file ?? data.src ?? data.href
|
|
50
|
-
|
|
108
|
+
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
109
|
+
// agent 出站媒体段(media-publisher)直接携带纯 base64,无 url/file;
|
|
110
|
+
// 有 mime 时拼成 data: URL 透传,否则走 base64:// 让 resolveMediaSrc 按 kind 补默认 MIME
|
|
111
|
+
const b64 = data.base64
|
|
112
|
+
if (typeof b64 === 'string' && b64.trim()) {
|
|
113
|
+
const payload = b64.trim()
|
|
114
|
+
if (payload.startsWith('data:') || payload.startsWith('base64://')
|
|
115
|
+
|| payload.startsWith('http://') || payload.startsWith('https://')) {
|
|
116
|
+
return payload
|
|
117
|
+
}
|
|
118
|
+
const mime = typeof data.mime === 'string' && data.mime.trim() ? data.mime.trim() : ''
|
|
119
|
+
return mime
|
|
120
|
+
? `data:${mime};base64,${payload.replace(/\s/g, '')}`
|
|
121
|
+
: `base64://${payload}`
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
51
124
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
highlightCodeLine,
|
|
4
|
+
parseMarkdown,
|
|
5
|
+
type MarkdownInline,
|
|
6
|
+
} from './markdown.js';
|
|
7
|
+
|
|
8
|
+
export interface MarkdownContentProps {
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function InlineContent({ content }: { readonly content: readonly MarkdownInline[] }) {
|
|
14
|
+
const nodes: ReactNode[] = [];
|
|
15
|
+
content.forEach((token, index) => {
|
|
16
|
+
const key = `${token.type}-${index}`;
|
|
17
|
+
const value = token.value.split('\n').map((line, lineIndex, lines) => (
|
|
18
|
+
<Fragment key={`${key}-line-${lineIndex}`}>
|
|
19
|
+
{line}
|
|
20
|
+
{lineIndex < lines.length - 1 ? <br /> : null}
|
|
21
|
+
</Fragment>
|
|
22
|
+
));
|
|
23
|
+
if (token.type === 'strong') nodes.push(<strong key={key}>{value}</strong>);
|
|
24
|
+
else if (token.type === 'emphasis') nodes.push(<em key={key}>{value}</em>);
|
|
25
|
+
else if (token.type === 'strike') nodes.push(<del key={key}>{value}</del>);
|
|
26
|
+
else if (token.type === 'code') nodes.push(<code key={key} className="im-inline-code">{token.value}</code>);
|
|
27
|
+
else if (token.type === 'link') nodes.push(
|
|
28
|
+
<a key={key} href={token.href} target={token.href.startsWith('#') ? undefined : '_blank'} rel="noreferrer" className="im-message-link">{value}</a>,
|
|
29
|
+
);
|
|
30
|
+
else nodes.push(<Fragment key={key}>{value}</Fragment>);
|
|
31
|
+
});
|
|
32
|
+
return <>{nodes}</>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function CodeBlock({ code, language = '', closed = true }: { readonly code: string; readonly language?: string; readonly closed?: boolean }) {
|
|
36
|
+
const [copied, setCopied] = useState(false);
|
|
37
|
+
const [wrapped, setWrapped] = useState(false);
|
|
38
|
+
const [expanded, setExpanded] = useState(false);
|
|
39
|
+
const lines = useMemo(() => code.split('\n'), [code]);
|
|
40
|
+
const isLong = lines.length > 18 || code.length > 1800;
|
|
41
|
+
const visibleLines = isLong && !expanded ? lines.slice(0, 18) : lines;
|
|
42
|
+
|
|
43
|
+
const copy = async () => {
|
|
44
|
+
try {
|
|
45
|
+
await navigator.clipboard.writeText(code);
|
|
46
|
+
setCopied(true);
|
|
47
|
+
window.setTimeout(() => setCopied(false), 1600);
|
|
48
|
+
} catch {
|
|
49
|
+
setCopied(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<figure className="zhin-code-block">
|
|
55
|
+
<figcaption className="zhin-code-toolbar">
|
|
56
|
+
<span className="zhin-code-language">{language || 'plain text'}</span>
|
|
57
|
+
{!closed ? <span className="zhin-code-streaming">生成中</span> : null}
|
|
58
|
+
<span className="zhin-code-actions">
|
|
59
|
+
<button type="button" onClick={() => setWrapped((value) => !value)} aria-pressed={wrapped}>{wrapped ? '不折行' : '折行'}</button>
|
|
60
|
+
<button type="button" onClick={() => void copy()}>{copied ? '已复制' : '复制'}</button>
|
|
61
|
+
</span>
|
|
62
|
+
</figcaption>
|
|
63
|
+
<pre className={wrapped ? 'zhin-code-pre zhin-code-pre--wrap' : 'zhin-code-pre'}>
|
|
64
|
+
<code>
|
|
65
|
+
{visibleLines.map((line, lineIndex) => (
|
|
66
|
+
<span className="zhin-code-line" key={`${lineIndex}-${line}`}>
|
|
67
|
+
<span className="zhin-code-line-number" aria-hidden="true">{lineIndex + 1}</span>
|
|
68
|
+
<span className="zhin-code-line-content">
|
|
69
|
+
{highlightCodeLine(line, language).map((token, tokenIndex) => (
|
|
70
|
+
<span key={`${token.kind}-${tokenIndex}`} className={`zhin-code-token zhin-code-token--${token.kind}`}>{token.value}</span>
|
|
71
|
+
))}
|
|
72
|
+
{'\n'}
|
|
73
|
+
</span>
|
|
74
|
+
</span>
|
|
75
|
+
))}
|
|
76
|
+
</code>
|
|
77
|
+
</pre>
|
|
78
|
+
{isLong ? (
|
|
79
|
+
<button type="button" className="zhin-code-expand" onClick={() => setExpanded((value) => !value)}>
|
|
80
|
+
{expanded ? '收起代码' : `展开其余 ${lines.length - visibleLines.length} 行`}
|
|
81
|
+
</button>
|
|
82
|
+
) : null}
|
|
83
|
+
</figure>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function MarkdownContent({ text, className }: MarkdownContentProps) {
|
|
88
|
+
const blocks = useMemo(() => parseMarkdown(text), [text]);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className={['im-markdown-body', 'zhin-markdown', className].filter(Boolean).join(' ')}>
|
|
92
|
+
{blocks.map((block, index) => {
|
|
93
|
+
if (block.type === 'heading') {
|
|
94
|
+
const Heading = `h${block.level}` as keyof React.JSX.IntrinsicElements;
|
|
95
|
+
return <Heading key={index} className={`im-markdown-heading im-markdown-heading-${block.level}`}><InlineContent content={block.content} /></Heading>;
|
|
96
|
+
}
|
|
97
|
+
if (block.type === 'paragraph') return <p key={index} className="im-markdown-paragraph"><InlineContent content={block.content} /></p>;
|
|
98
|
+
if (block.type === 'quote') return <blockquote key={index} className="im-markdown-quote"><InlineContent content={block.content} /></blockquote>;
|
|
99
|
+
if (block.type === 'rule') return <hr key={index} className="zhin-markdown-rule" />;
|
|
100
|
+
if (block.type === 'code') return <CodeBlock key={index} code={block.value} language={block.language} closed={block.closed} />;
|
|
101
|
+
if (block.type === 'list') {
|
|
102
|
+
const List = block.ordered ? 'ol' : 'ul';
|
|
103
|
+
return (
|
|
104
|
+
<List key={index} className={`im-markdown-list${block.ordered ? ' im-markdown-list-ordered' : ''}`}>
|
|
105
|
+
{block.items.map((item, itemIndex) => (
|
|
106
|
+
<li key={itemIndex} className={item.checked === undefined ? undefined : 'zhin-markdown-task'}>
|
|
107
|
+
{item.checked === undefined ? null : <input type="checkbox" checked={item.checked} readOnly aria-label={item.checked ? '已完成' : '未完成'} />}
|
|
108
|
+
<InlineContent content={item.content} />
|
|
109
|
+
</li>
|
|
110
|
+
))}
|
|
111
|
+
</List>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (block.type === 'table') {
|
|
115
|
+
return (
|
|
116
|
+
<div className="zhin-markdown-table-wrap" key={index}>
|
|
117
|
+
<table className="zhin-markdown-table">
|
|
118
|
+
<thead><tr>{block.header.map((cell, cellIndex) => <th key={cellIndex}><InlineContent content={cell} /></th>)}</tr></thead>
|
|
119
|
+
<tbody>{block.rows.map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex}><InlineContent content={cell} /></td>)}</tr>)}</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { highlightCodeLine, isSafeMarkdownHref, parseMarkdown, parseMarkdownInline } from './markdown.js';
|
|
2
|
+
|
|
3
|
+
describe('message markdown', () => {
|
|
4
|
+
it('parses rich blocks including tables and tasks', () => {
|
|
5
|
+
const blocks = parseMarkdown(`# Result
|
|
6
|
+
|
|
7
|
+
- [x] inspect runtime
|
|
8
|
+
- [ ] publish patch
|
|
9
|
+
|
|
10
|
+
| field | value |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| status | **ready** |`);
|
|
13
|
+
|
|
14
|
+
expect(blocks.map((block) => block.type)).toEqual(['heading', 'list', 'table']);
|
|
15
|
+
expect(blocks[1]).toMatchObject({ type: 'list', items: [{ checked: true }, { checked: false }] });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('keeps incomplete streaming fences renderable', () => {
|
|
19
|
+
expect(parseMarkdown('```ts\nconst ready = true')).toEqual([
|
|
20
|
+
{ type: 'code', language: 'ts', value: 'const ready = true', closed: false },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects unsafe markdown links without emitting active links', () => {
|
|
25
|
+
expect(isSafeMarkdownHref('javascript:alert(1)')).toBe(false);
|
|
26
|
+
expect(isSafeMarkdownHref('https://zhin.dev/docs')).toBe(true);
|
|
27
|
+
expect(parseMarkdownInline('[run](javascript:alert(1))')).toEqual([
|
|
28
|
+
{ type: 'text', value: '[run](javascript:alert(1))' },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats repeated unmatched link openers as plain text', () => {
|
|
33
|
+
const value = '['.repeat(20_000);
|
|
34
|
+
expect(parseMarkdownInline(value)).toEqual([{ type: 'text', value }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('tokenizes common code without producing html', () => {
|
|
38
|
+
expect(highlightCodeLine('const answer = "<safe>" // note', 'ts')).toEqual([
|
|
39
|
+
{ kind: 'keyword', value: 'const' },
|
|
40
|
+
{ kind: 'plain', value: ' ' },
|
|
41
|
+
{ kind: 'plain', value: 'answer' },
|
|
42
|
+
{ kind: 'plain', value: ' = ' },
|
|
43
|
+
{ kind: 'string', value: '"<safe>"' },
|
|
44
|
+
{ kind: 'plain', value: ' ' },
|
|
45
|
+
{ kind: 'comment', value: '// note' },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|