@zhin.js/client 1.1.4 → 1.1.5

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 (104) hide show
  1. package/README.md +89 -433
  2. package/client/agent-stream.ts +121 -0
  3. package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
  4. package/client/bootstrap/loadConsoleEntries.ts +40 -8
  5. package/client/console-events.ts +37 -0
  6. package/client/console-utils/remoteApi.test.ts +91 -0
  7. package/client/console-utils/remoteApi.ts +47 -2
  8. package/client/index.ts +75 -19
  9. package/client/mediaSrc.test.ts +49 -0
  10. package/client/mediaSrc.ts +84 -11
  11. package/client/message-content/MarkdownContent.tsx +128 -0
  12. package/client/message-content/markdown.test.ts +48 -0
  13. package/client/message-content/markdown.ts +247 -0
  14. package/client/persistence/idb-store.test.ts +139 -0
  15. package/client/persistence/idb-store.ts +43 -21
  16. package/client/runtime/index.ts +1 -1
  17. package/client/segments.test.ts +16 -0
  18. package/client/segments.ts +39 -0
  19. package/client/types.ts +5 -5
  20. package/client/websocket/hooks.test.ts +21 -0
  21. package/client/websocket/hooks.ts +56 -27
  22. package/client/websocket/index.ts +6 -1
  23. package/client/websocket/manager.test.ts +349 -0
  24. package/client/websocket/manager.ts +245 -54
  25. package/client/websocket/remote-settings.ts +25 -3
  26. package/client/websocket/types.ts +5 -0
  27. package/client/workroom/WorkroomRunsPage.tsx +355 -0
  28. package/client/workroom/registerWorkroomConsole.test.ts +26 -0
  29. package/client/workroom/registerWorkroomConsole.ts +17 -0
  30. package/dist/agent-stream.d.ts +17 -0
  31. package/dist/agent-stream.d.ts.map +1 -0
  32. package/dist/agent-stream.js +73 -0
  33. package/dist/agent-stream.js.map +1 -0
  34. package/dist/bootstrap/loadConsoleEntries.d.ts +10 -1
  35. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  36. package/dist/bootstrap/loadConsoleEntries.js +37 -2
  37. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  38. package/dist/console-events.d.ts +7 -0
  39. package/dist/console-events.d.ts.map +1 -0
  40. package/dist/console-events.js +26 -0
  41. package/dist/console-events.js.map +1 -0
  42. package/dist/console-utils/remoteApi.d.ts +2 -0
  43. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  44. package/dist/console-utils/remoteApi.js +46 -2
  45. package/dist/console-utils/remoteApi.js.map +1 -1
  46. package/dist/index.d.ts +9 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -3
  49. package/dist/index.js.map +1 -1
  50. package/dist/mediaSrc.d.ts +1 -1
  51. package/dist/mediaSrc.d.ts.map +1 -1
  52. package/dist/mediaSrc.js +91 -11
  53. package/dist/mediaSrc.js.map +1 -1
  54. package/dist/message-content/MarkdownContent.d.ts +11 -0
  55. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  56. package/dist/message-content/MarkdownContent.js +68 -0
  57. package/dist/message-content/MarkdownContent.js.map +1 -0
  58. package/dist/message-content/markdown.d.ts +47 -0
  59. package/dist/message-content/markdown.d.ts.map +1 -0
  60. package/dist/message-content/markdown.js +219 -0
  61. package/dist/message-content/markdown.js.map +1 -0
  62. package/dist/persistence/idb-store.d.ts +5 -2
  63. package/dist/persistence/idb-store.d.ts.map +1 -1
  64. package/dist/persistence/idb-store.js +31 -20
  65. package/dist/persistence/idb-store.js.map +1 -1
  66. package/dist/runtime/index.d.ts +1 -1
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/segments.d.ts +9 -0
  69. package/dist/segments.d.ts.map +1 -0
  70. package/dist/segments.js +35 -0
  71. package/dist/segments.js.map +1 -0
  72. package/dist/types.d.ts +4 -3
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/websocket/hooks.d.ts +9 -0
  75. package/dist/websocket/hooks.d.ts.map +1 -1
  76. package/dist/websocket/hooks.js +52 -31
  77. package/dist/websocket/hooks.js.map +1 -1
  78. package/dist/websocket/index.d.ts +1 -1
  79. package/dist/websocket/index.d.ts.map +1 -1
  80. package/dist/websocket/index.js +1 -1
  81. package/dist/websocket/index.js.map +1 -1
  82. package/dist/websocket/manager.d.ts +20 -2
  83. package/dist/websocket/manager.d.ts.map +1 -1
  84. package/dist/websocket/manager.js +222 -44
  85. package/dist/websocket/manager.js.map +1 -1
  86. package/dist/websocket/remote-settings.d.ts.map +1 -1
  87. package/dist/websocket/remote-settings.js +27 -3
  88. package/dist/websocket/remote-settings.js.map +1 -1
  89. package/dist/websocket/types.d.ts +4 -0
  90. package/dist/websocket/types.d.ts.map +1 -1
  91. package/dist/websocket/types.js.map +1 -1
  92. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  93. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  94. package/dist/workroom/WorkroomRunsPage.js +111 -0
  95. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  96. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  97. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  98. package/dist/workroom/registerWorkroomConsole.js +16 -0
  99. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  100. package/package.json +18 -8
  101. package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
  102. package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
  103. package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
  104. package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
@@ -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,10 +1,5 @@
1
- import type {
2
- ConsoleClientEntry,
3
- ConsoleEntriesResponse,
4
- ConsolePluginRegister,
5
- PluginRegisterHostApi,
6
- } from "@zhin.js/console-types";
7
- import { DEFAULT_CONSOLE_BASE_PATH } from "@zhin.js/console-types";
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';
8
3
 
9
4
  export type FetchConsoleEntriesOptions = {
10
5
  entriesUrl?: string;
@@ -97,6 +92,43 @@ export function getRegisterFn(mod: Record<string, unknown> | null | undefined):
97
92
  return null;
98
93
  }
99
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
+
100
132
  export async function registerConsolePluginsFromEntries(
101
133
  entries: ConsoleClientEntry[],
102
134
  hostApi: PluginRegisterHostApi,
@@ -109,7 +141,7 @@ export async function registerConsolePluginsFromEntries(
109
141
  try {
110
142
  const specifier = resolvePluginImportUrl(entry.resolvedModule, assetOrigin);
111
143
  const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
112
- const register = getRegisterFn(mod);
144
+ const register = resolveEntryRegister(mod, entry);
113
145
  if (register) await register(hostApi);
114
146
  else throw new Error(`entry "${entry.id}" has no register export`);
115
147
  } catch (error) {
@@ -0,0 +1,37 @@
1
+ import type {
2
+ ConsoleEventHistoryPage,
3
+ ConsoleEventHistoryQuery,
4
+ } from '@zhin.js/console-protocol';
5
+ import { getStoredToken, resolveApiUrl } from './websocket/remote-settings.js';
6
+
7
+ export interface FetchConsoleEventHistoryOptions {
8
+ readonly signal?: AbortSignal;
9
+ }
10
+
11
+ /** Read a typed page from the Host's bounded Console event journal. */
12
+ export async function fetchConsoleEventHistory(
13
+ query: ConsoleEventHistoryQuery = {},
14
+ options: FetchConsoleEventHistoryOptions = {},
15
+ ): Promise<ConsoleEventHistoryPage> {
16
+ const params = new URLSearchParams();
17
+ if (query.runtimeId) params.set('runtimeId', query.runtimeId);
18
+ if (query.after !== undefined) params.set('after', String(query.after));
19
+ if (query.limit !== undefined) params.set('limit', String(query.limit));
20
+ const token = getStoredToken();
21
+ const headers: Record<string, string> = { Accept: 'application/json' };
22
+ if (token) headers.Authorization = `Bearer ${token}`;
23
+ const suffix = params.size ? `?${params}` : '';
24
+ const response = await fetch(resolveApiUrl(`/api/events/history${suffix}`), {
25
+ headers,
26
+ signal: options.signal,
27
+ });
28
+ const body = await response.json() as {
29
+ success?: boolean;
30
+ data?: ConsoleEventHistoryPage;
31
+ error?: string;
32
+ };
33
+ if (!response.ok || body.success !== true || !body.data) {
34
+ throw new Error(body.error ?? `Console event history failed: HTTP ${response.status}`);
35
+ }
36
+ return body.data;
37
+ }
@@ -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.replace(/\/$/, "");
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
- return 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,8 +51,43 @@ 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();
57
+ sessionStorage.removeItem(TOKEN_KEY);
44
58
  localStorage.removeItem(TOKEN_KEY);
45
59
  window.dispatchEvent(new CustomEvent("zhin:auth-required"));
46
60
  }
47
61
  return res;
48
62
  }
63
+
64
+ /** Typed POST adapter for the canonical Plugin Runtime Console RPC endpoint. */
65
+ export async function consoleRpc<T>(
66
+ type: string,
67
+ data: Readonly<Record<string, unknown>> = {},
68
+ ): Promise<T> {
69
+ const requestId = Date.now();
70
+ const res = await apiFetch('/api/console/request', {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ type, requestId, ...data }),
74
+ });
75
+ const body = await res.json() as { success?: boolean; data?: T; error?: string };
76
+ if (!res.ok || !body.success) throw new Error(body.error ?? `HTTP ${res.status}`);
77
+ return body.data as T;
78
+ }
79
+
80
+ function trimTrailingSlash(value: string): string {
81
+ return value.endsWith("/") ? value.slice(0, -1) : value;
82
+ }
83
+
84
+ function getRuntimeToken(): string | null {
85
+ if (typeof window === "undefined") return null;
86
+ const token = (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
87
+ return token?.trim() || null;
88
+ }
89
+
90
+ function clearRuntimeToken(): void {
91
+ if (typeof window === "undefined") return;
92
+ delete (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
93
+ }
package/client/index.ts CHANGED
@@ -1,10 +1,70 @@
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
+ normalizeConsolePushMessage,
11
+ normalizeConsolePushType,
12
+ parseConsoleInboxEvent,
13
+ type ConsoleInboxEvent,
14
+ type ConsoleInboxEventKind,
15
+ type ConsoleInboxNoticeRow,
16
+ type ConsoleInboxNoticesQuery,
17
+ type ConsoleInboxNoticesResult,
18
+ type ConsoleEndpointPhase,
19
+ type ConsoleEndpointSummary,
20
+ type ConsoleEventActor,
21
+ type ConsoleEventChannel,
22
+ type EndpointManagementCapability,
23
+ parseConsoleSseFrame,
24
+ type ConsoleEventDelivery,
25
+ type ConsoleEventData,
26
+ type ConsoleEndpointEventData,
27
+ type ConsoleEventEnvelope,
28
+ type ConsoleEventHistoryPage,
29
+ type ConsoleEventHistoryQuery,
30
+ type ConsoleEventPayloadMap,
31
+ type ConsoleMessageEventData,
32
+ type ConsoleNoticeEventData,
33
+ type ConsoleRequestEventData,
34
+ type KnownConsoleEventType,
35
+ type KnownConsoleEventEnvelope,
36
+ type ParsedConsoleSseFrame,
37
+ } from "@zhin.js/console-protocol";
38
+
39
+ export {
40
+ fetchConsoleEventHistory,
41
+ type FetchConsoleEventHistoryOptions,
42
+ } from './console-events.js';
3
43
 
4
44
  // Media URL resolution
5
45
  export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
6
46
 
7
- // Console app singleton (page-manager style)
47
+ // Safe rich message rendering (Markdown + code blocks; no raw HTML execution)
48
+ export {
49
+ MarkdownContent,
50
+ CodeBlock,
51
+ type MarkdownContentProps,
52
+ } from "./message-content/MarkdownContent.js";
53
+ export {
54
+ parseMarkdown,
55
+ parseMarkdownInline,
56
+ highlightCodeLine,
57
+ isSafeMarkdownHref,
58
+ type MarkdownBlock,
59
+ type MarkdownInline,
60
+ type CodeToken,
61
+ type CodeTokenKind,
62
+ } from "./message-content/markdown.js";
63
+
64
+ // Segment IM visibility (inbox vs agent panel)
65
+ export { segmentsForImDelivery, segmentsForAgentPanel } from "./segments.js";
66
+
67
+ // Console app singleton (pagemanager / registry style)
8
68
  export {
9
69
  app,
10
70
  type ConsoleApp,
@@ -19,24 +79,6 @@ export {
19
79
  // WebSocket (business data only)
20
80
  export * from "./websocket";
21
81
 
22
- // Re-export console-types
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
-
35
- export {
36
- DEFAULT_CONSOLE_BASE_PATH,
37
- CONSOLE_HOST_REACT_NAMESPACE_KEY,
38
- } from "@zhin.js/console-types";
39
-
40
82
  export { configureConsole, getRuntimeEnv } from "./runtime/index.js";
41
83
 
42
84
  export {
@@ -61,7 +103,21 @@ export {
61
103
  getRegisterFn,
62
104
  loadConsoleEntries,
63
105
  registerConsolePluginsFromEntries,
106
+ resolveEntryRegister,
64
107
  type CreatePluginRegisterHostApiOptions,
65
108
  type FetchConsoleEntriesOptions,
66
109
  type LoadConsoleEntriesOptions,
67
110
  } from "./bootstrap/loadConsoleEntries.js";
111
+
112
+ export { registerWorkroomConsole } from "./workroom/registerWorkroomConsole.js";
113
+ export { default as WorkroomRunsPage } from "./workroom/WorkroomRunsPage.js";
114
+
115
+ export {
116
+ startAgentSession,
117
+ continueAgentSession,
118
+ subscribeAgentStream,
119
+ iterateAgentStreamNdjson,
120
+ foldAgentStreamNdjson,
121
+ type AgentStreamClientOptions,
122
+ type SubscribeAgentStreamOptions,
123
+ } 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
+ });
@@ -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
- if (s.startsWith('data:') || s.startsWith('blob:') || s.startsWith('http://') || s.startsWith('https://')) {
25
- return s
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 s
81
+ return undefined
30
82
  }
31
83
 
32
- const payload = s.slice(BASE64_PROTO.length).replace(/^\s+/, '')
84
+ const payload = s.slice(BASE64_PROTO.length).trimStart()
33
85
  if (!payload) return undefined
34
86
 
35
87
  // 已是 type/subtype;base64, 片段(不含 data: 前缀)
36
- if (/^[\w+.-]+\/[\w+.-]+;base64,/i.test(payload)) {
37
- return `data:${payload}`
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.replace(/\s/g, '')
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
- return typeof v === 'string' && v.trim() ? v.trim() : undefined
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
  }