@dyyz1993/create-agent 2.0.0 → 2.1.0

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 (165) hide show
  1. package/package.json +1 -1
  2. package/src/commands/create.ts +31 -31
  3. package/src/commands/workspace.ts +112 -105
  4. package/src/lib/copy.ts +24 -1
  5. package/templates/agent/electron/main.js +46 -0
  6. package/templates/agent/electron/preload.js +5 -0
  7. package/templates/agent/electron-builder.json +40 -0
  8. package/templates/agent/eslint.config.mjs +17 -2
  9. package/templates/agent/package.json +69 -3
  10. package/templates/agent/src/__tests__/hybrid-mode.test.ts +126 -0
  11. package/templates/agent/src/__tests__/server-config-security.test.ts +55 -0
  12. package/templates/agent/src/bun/index.ts +96 -41
  13. package/templates/agent/src/bun/three.d.ts +1 -0
  14. package/templates/agent/src/gateway/__tests__/ws-handler-token.test.ts +118 -0
  15. package/templates/agent/src/gateway/http-routes.ts +1 -1
  16. package/templates/agent/src/gateway/ws-handler.ts +84 -56
  17. package/templates/agent/src/mainview/App.tsx +29 -26
  18. package/templates/agent/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  19. package/templates/agent/src/mainview/__tests__/setup.ts +32 -29
  20. package/templates/agent/src/mainview/__tests__/theme-variables.test.ts +36 -0
  21. package/templates/agent/src/mainview/components/chat/ChatPanel.tsx +88 -88
  22. package/templates/agent/src/mainview/components/common/ErrorBoundary.tsx +1 -58
  23. package/templates/agent/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  24. package/templates/agent/src/mainview/components/common/ThemeToggle.tsx +1 -0
  25. package/templates/agent/src/mainview/components/feed/FeedPanel.tsx +232 -210
  26. package/templates/agent/src/mainview/components/file-preview/VirtualizedCodeView.tsx +105 -81
  27. package/templates/agent/src/mainview/components/search/SearchPanel.tsx +427 -378
  28. package/templates/agent/src/mainview/components/todo/TodoPanel.tsx +149 -122
  29. package/templates/agent/src/mainview/hooks/use-input-history.ts +70 -61
  30. package/templates/agent/src/mainview/lib/api-client.ts +179 -172
  31. package/templates/agent/src/mainview/main.tsx +4 -10
  32. package/templates/agent/src/mainview/stores/use-feed-store.ts +107 -107
  33. package/templates/agent/src/mainview/utils/drop-handler.ts +114 -115
  34. package/templates/agent/src/server-config.ts +34 -32
  35. package/templates/agent/src/server.ts +42 -54
  36. package/templates/agent/src/shared/handlers/__tests__/chat-concurrency.test.ts +127 -0
  37. package/templates/agent/src/shared/handlers/__tests__/handler-isolation.test.ts +244 -0
  38. package/templates/agent/src/shared/handlers/bash.ts +65 -65
  39. package/templates/agent/src/shared/handlers/chat.ts +189 -155
  40. package/templates/agent/src/shared/handlers/debug.ts +12 -0
  41. package/templates/agent/src/shared/handlers/feed.ts +31 -32
  42. package/templates/agent/src/shared/handlers/git.ts +286 -243
  43. package/templates/agent/src/shared/handlers/index.ts +1 -0
  44. package/templates/agent/src/shared/handlers/rules.ts +31 -31
  45. package/templates/agent/src/shared/handlers/todo.ts +31 -31
  46. package/templates/agent/src/shared/http-routes.ts +250 -0
  47. package/templates/agent/src/shared/lib/__tests__/web-server.test.ts +190 -0
  48. package/templates/agent/src/shared/lib/bash-security.ts +43 -43
  49. package/templates/agent/src/shared/lib/logger.ts +117 -95
  50. package/templates/agent/src/shared/lib/web-server.ts +128 -0
  51. package/templates/agent/src/shared/modules/debug.ts +8 -0
  52. package/templates/agent/src/shared/rpc-schema.ts +16 -3
  53. package/templates/agent/test-upload.txt +0 -0
  54. package/templates/agent/tsconfig.ipc.json +14 -0
  55. package/templates/agent/tsconfig.json +13 -3
  56. package/templates/chat/eslint.config.mjs +15 -2
  57. package/templates/chat/package.json +13 -2
  58. package/templates/chat/src/__tests__/hybrid-mode.test.ts +125 -0
  59. package/templates/chat/src/__tests__/server-config-security.test.ts +55 -0
  60. package/templates/chat/src/__tests__/server-config.test.ts +46 -44
  61. package/templates/chat/src/bun/index.ts +94 -44
  62. package/templates/chat/src/bun/three.d.ts +1 -0
  63. package/templates/chat/src/gateway/http-routes.ts +1 -1
  64. package/templates/chat/src/gateway/ws-handler.ts +84 -56
  65. package/templates/chat/src/mainview/App.tsx +6 -0
  66. package/templates/chat/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  67. package/templates/chat/src/mainview/__tests__/theme-variables.test.ts +36 -0
  68. package/templates/chat/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  69. package/templates/chat/src/mainview/components/common/ThemeToggle.tsx +1 -0
  70. package/templates/chat/src/mainview/hooks/use-input-history.ts +70 -61
  71. package/templates/chat/src/mainview/lib/api-client.ts +180 -156
  72. package/templates/chat/src/mainview/lib/rpc-cache.ts +84 -0
  73. package/templates/chat/src/mainview/main.tsx +10 -6
  74. package/templates/chat/src/server-config.ts +33 -31
  75. package/templates/chat/src/server.ts +42 -60
  76. package/templates/chat/src/shared/handlers/chat.ts +196 -162
  77. package/templates/chat/src/shared/handlers/debug.ts +12 -0
  78. package/templates/chat/src/shared/handlers/index.ts +1 -0
  79. package/templates/chat/src/shared/http-routes.ts +250 -0
  80. package/templates/chat/src/shared/lib/__tests__/logger.test.ts +92 -0
  81. package/templates/chat/src/shared/lib/logger.ts +108 -95
  82. package/templates/chat/src/shared/lib/web-server.ts +128 -0
  83. package/templates/chat/src/shared/modules/debug.ts +8 -0
  84. package/templates/chat/src/shared/rpc-schema.ts +3 -2
  85. package/templates/chat/test-upload.txt +0 -0
  86. package/templates/chat/tsconfig.ipc.json +14 -0
  87. package/templates/chat/tsconfig.json +12 -2
  88. package/templates/general/eslint.config.mjs +15 -2
  89. package/templates/general/package.json +13 -2
  90. package/templates/general/src/__tests__/hybrid-mode.test.ts +125 -0
  91. package/templates/general/src/__tests__/server-config-security.test.ts +55 -0
  92. package/templates/general/src/__tests__/server-config.test.ts +46 -44
  93. package/templates/general/src/bun/index.ts +94 -44
  94. package/templates/general/src/bun/three.d.ts +1 -0
  95. package/templates/general/src/gateway/http-routes.ts +1 -1
  96. package/templates/general/src/gateway/ws-handler.ts +84 -56
  97. package/templates/general/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  98. package/templates/general/src/mainview/__tests__/theme-variables.test.ts +36 -0
  99. package/templates/general/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  100. package/templates/general/src/mainview/components/common/ThemeToggle.tsx +1 -0
  101. package/templates/general/src/mainview/components/feed/FeedPanel.tsx +241 -219
  102. package/templates/general/src/mainview/components/file-preview/VirtualizedCodeView.tsx +101 -81
  103. package/templates/general/src/mainview/components/layout/AppLayout.tsx +6 -0
  104. package/templates/general/src/mainview/components/search/SearchPanel.tsx +429 -378
  105. package/templates/general/src/mainview/hooks/use-input-history.ts +70 -61
  106. package/templates/general/src/mainview/lib/api-client.ts +180 -156
  107. package/templates/general/src/mainview/lib/rpc-cache.ts +84 -0
  108. package/templates/general/src/mainview/main.tsx +10 -6
  109. package/templates/general/src/mainview/stores/use-feed-store.ts +107 -107
  110. package/templates/general/src/mainview/utils/drop-handler.ts +114 -115
  111. package/templates/general/src/server-config.ts +33 -31
  112. package/templates/general/src/server.ts +42 -60
  113. package/templates/general/src/shared/handlers/__tests__/chat.test.ts +165 -160
  114. package/templates/general/src/shared/handlers/chat.ts +189 -155
  115. package/templates/general/src/shared/handlers/debug.ts +12 -0
  116. package/templates/general/src/shared/handlers/feed.ts +38 -40
  117. package/templates/general/src/shared/handlers/git.ts +286 -243
  118. package/templates/general/src/shared/handlers/index.ts +1 -0
  119. package/templates/general/src/shared/http-routes.ts +250 -0
  120. package/templates/general/src/shared/lib/__tests__/logger.test.ts +92 -0
  121. package/templates/general/src/shared/lib/logger.ts +117 -95
  122. package/templates/general/src/shared/lib/web-server.ts +128 -0
  123. package/templates/general/src/shared/modules/debug.ts +8 -0
  124. package/templates/general/src/shared/rpc-schema.ts +12 -2
  125. package/templates/general/test-upload.txt +0 -0
  126. package/templates/general/tsconfig.ipc.json +14 -0
  127. package/templates/general/tsconfig.json +12 -2
  128. package/templates/shared/__tests__/cors-security.test.ts +165 -0
  129. package/templates/shared/__tests__/file-path-security.test.ts +260 -0
  130. package/templates/shared/__tests__/http-routes.test.ts +174 -0
  131. package/templates/shared/__tests__/logger.test.ts +186 -0
  132. package/templates/shared/components/ErrorBoundary.tsx +59 -0
  133. package/templates/shared/env.d.ts +11 -0
  134. package/templates/shared/http-routes.ts +250 -0
  135. package/templates/shared/logger.ts +129 -0
  136. package/templates/shared/test-upload.txt +0 -0
  137. package/templates/shared/vite-base.config.ts +92 -0
  138. package/templates/shared/vitest-base.config.ts +42 -0
  139. package/templates/agent/eslint-plugin-rpc/index.js +0 -50
  140. package/templates/agent/eslint-plugin-rpc/package.json +0 -6
  141. package/templates/agent/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  142. package/templates/agent/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  143. package/templates/agent/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  144. package/templates/agent/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  145. package/templates/agent/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  146. package/templates/agent/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  147. package/templates/agent/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  148. package/templates/chat/eslint-plugin-rpc/index.js +0 -50
  149. package/templates/chat/eslint-plugin-rpc/package.json +0 -6
  150. package/templates/chat/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  151. package/templates/chat/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  152. package/templates/chat/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  153. package/templates/chat/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  154. package/templates/chat/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  155. package/templates/chat/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  156. package/templates/chat/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  157. package/templates/general/eslint-plugin-rpc/index.js +0 -50
  158. package/templates/general/eslint-plugin-rpc/package.json +0 -6
  159. package/templates/general/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  160. package/templates/general/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  161. package/templates/general/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  162. package/templates/general/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  163. package/templates/general/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  164. package/templates/general/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  165. package/templates/general/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
@@ -4,81 +4,90 @@ const HISTORY_KEY = "pi-input-history";
4
4
  const MAX_ITEMS = 10;
5
5
 
6
6
  function getStorageKey(sessionId: string): string {
7
- return `${HISTORY_KEY}:${sessionId}`;
7
+ return `${HISTORY_KEY}:${sessionId}`;
8
8
  }
9
9
 
10
10
  function readHistory(sessionId: string): string[] {
11
- try {
12
- const raw = localStorage.getItem(getStorageKey(sessionId));
13
- if (!raw) return [];
14
- const parsed = JSON.parse(raw);
15
- if (Array.isArray(parsed)) return parsed.slice(0, MAX_ITEMS);
16
- } catch { /* ignore */ }
17
- return [];
11
+ try {
12
+ const raw = localStorage.getItem(getStorageKey(sessionId));
13
+ if (!raw) return [];
14
+ const parsed = JSON.parse(raw);
15
+ if (Array.isArray(parsed)) return parsed.slice(0, MAX_ITEMS);
16
+ } catch {
17
+ /* ignore */
18
+ }
19
+ return [];
18
20
  }
19
21
 
20
22
  function writeHistory(sessionId: string, items: string[]) {
21
- try {
22
- localStorage.setItem(getStorageKey(sessionId), JSON.stringify(items.slice(0, MAX_ITEMS)));
23
- } catch { /* ignore */ }
23
+ try {
24
+ localStorage.setItem(getStorageKey(sessionId), JSON.stringify(items.slice(0, MAX_ITEMS)));
25
+ } catch {
26
+ /* ignore */
27
+ }
24
28
  }
25
29
 
26
30
  export function useInputHistory(sessionId: string) {
27
- const historyRef = useRef<string[]>(readHistory(sessionId));
28
- const indexRef = useRef(-1);
29
- const [, forceUpdate] = useState(0);
31
+ const historyRef = useRef<string[]>(readHistory(sessionId));
32
+ const indexRef = useRef(-1);
33
+ const [, forceUpdate] = useState(0);
30
34
 
31
- const hasPrev = historyRef.current.length > 0 && indexRef.current < historyRef.current.length - 1;
32
- const hasNext = indexRef.current > 0;
35
+ const hasPrev = historyRef.current.length > 0 && indexRef.current < historyRef.current.length - 1;
36
+ const hasNext = indexRef.current > 0;
33
37
 
34
- const saveToHistory = useCallback((text: string) => {
35
- const trimmed = text.trim();
36
- if (!trimmed) return;
37
- const h = historyRef.current;
38
- const filtered = h.filter((item) => item !== trimmed);
39
- const updated = [trimmed, ...filtered].slice(0, MAX_ITEMS);
40
- historyRef.current = updated;
41
- writeHistory(sessionId, updated);
42
- indexRef.current = -1;
43
- forceUpdate((n) => n + 1);
44
- }, [sessionId]);
38
+ const saveToHistory = useCallback(
39
+ (text: string) => {
40
+ const trimmed = text.trim();
41
+ if (!trimmed) return;
42
+ const h = historyRef.current;
43
+ const filtered = h.filter((item) => item !== trimmed);
44
+ const updated = [trimmed, ...filtered].slice(0, MAX_ITEMS);
45
+ historyRef.current = updated;
46
+ writeHistory(sessionId, updated);
47
+ indexRef.current = -1;
48
+ forceUpdate((n) => n + 1);
49
+ },
50
+ [sessionId]
51
+ );
45
52
 
46
- const navigatePrev = useCallback((): string | null => {
47
- const h = historyRef.current;
48
- if (h.length === 0) return null;
49
- const nextIdx = Math.min(indexRef.current + 1, h.length - 1);
50
- indexRef.current = nextIdx;
51
- forceUpdate((n) => n + 1);
52
- return h[nextIdx];
53
- }, []);
53
+ const navigatePrev = useCallback((): string | null => {
54
+ const h = historyRef.current;
55
+ if (h.length === 0) return null;
56
+ const nextIdx = Math.min(indexRef.current + 1, h.length - 1);
57
+ indexRef.current = nextIdx;
58
+ forceUpdate((n) => n + 1);
59
+ return h[nextIdx] ?? null;
60
+ }, []);
54
61
 
55
- const navigateNext = useCallback((): string | null => {
56
- const h = historyRef.current;
57
- if (h.length === 0) return null;
58
- const nextIdx = indexRef.current - 1;
59
- if (nextIdx < 0) {
60
- indexRef.current = -1;
61
- forceUpdate((n) => n + 1);
62
- return "";
63
- }
64
- indexRef.current = nextIdx;
65
- forceUpdate((n) => n + 1);
66
- return h[nextIdx];
67
- }, []);
62
+ const navigateNext = useCallback((): string | null => {
63
+ const h = historyRef.current;
64
+ if (h.length === 0) return null;
65
+ const nextIdx = indexRef.current - 1;
66
+ if (nextIdx < 0) {
67
+ indexRef.current = -1;
68
+ forceUpdate((n) => n + 1);
69
+ return "";
70
+ }
71
+ indexRef.current = nextIdx;
72
+ forceUpdate((n) => n + 1);
73
+ return h[nextIdx] ?? null;
74
+ }, []);
68
75
 
69
- const clearHistory = useCallback(() => {
70
- historyRef.current = [];
71
- indexRef.current = -1;
72
- try {
73
- localStorage.removeItem(getStorageKey(sessionId));
74
- } catch { /* ignore */ }
75
- forceUpdate((n) => n + 1);
76
- }, [sessionId]);
76
+ const clearHistory = useCallback(() => {
77
+ historyRef.current = [];
78
+ indexRef.current = -1;
79
+ try {
80
+ localStorage.removeItem(getStorageKey(sessionId));
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ forceUpdate((n) => n + 1);
85
+ }, [sessionId]);
77
86
 
78
- const resetIndex = useCallback(() => {
79
- indexRef.current = -1;
80
- forceUpdate((n) => n + 1);
81
- }, []);
87
+ const resetIndex = useCallback(() => {
88
+ indexRef.current = -1;
89
+ forceUpdate((n) => n + 1);
90
+ }, []);
82
91
 
83
- return { saveToHistory, navigatePrev, navigateNext, clearHistory, resetIndex, hasPrev, hasNext };
92
+ return { saveToHistory, navigatePrev, navigateNext, clearHistory, resetIndex, hasPrev, hasNext };
84
93
  }
@@ -1,6 +1,13 @@
1
1
  import { createTypedClient, WebSocketTransport, IPCTransport } from "@dyyz1993/rpc-core";
2
- import type { TypedClient, MethodParams, MethodResult, EventPayload, EventMetadata } from "@dyyz1993/rpc-core";
2
+ import type {
3
+ TypedClient,
4
+ MethodParams,
5
+ MethodResult,
6
+ EventPayload,
7
+ EventMetadata,
8
+ } from "@dyyz1993/rpc-core";
3
9
  import type { RPCMethods, RPCEvents } from "../../shared/rpc-schema";
10
+ import { rpcCache, CACHEABLE_METHODS } from "./rpc-cache";
4
11
 
5
12
  /**
6
13
  * Token 来源优先级:
@@ -9,166 +16,183 @@ import type { RPCMethods, RPCEvents } from "../../shared/rpc-schema";
9
16
  * 3. 默认值(开发用)
10
17
  */
11
18
  function resolveAuthToken(): string {
12
- if (typeof window !== "undefined") {
13
- const fromQuery = new URLSearchParams(window.location.search).get("token");
14
- if (fromQuery) return fromQuery;
15
- const fromStorage = localStorage.getItem("rpc-auth-token");
16
- if (fromStorage) return fromStorage;
17
- }
18
- return "pi-agent-template-token";
19
+ if (typeof window !== "undefined") {
20
+ const fromQuery = new URLSearchParams(window.location.search).get("token");
21
+ if (fromQuery) return fromQuery;
22
+ const fromStorage = localStorage.getItem("rpc-auth-token");
23
+ if (fromStorage) return fromStorage;
24
+ }
25
+ return "pi-agent-template-token";
19
26
  }
20
27
 
21
28
  const AUTH_TOKEN = resolveAuthToken();
22
29
 
23
30
  class APIClientImpl {
24
- private client: TypedClient<RPCMethods, RPCEvents> | null = null;
25
- private initPromise: Promise<void> | null = null;
26
- private _transport: "ipc" | "websocket" = "websocket";
27
- private _baseUrl: string | null = null;
28
- private wsTransport: WebSocketTransport | null = null;
29
-
30
- /**
31
- * 桌面端同步初始化:通过 executeJavascript 接收 + __electrobunBunBridge 发送
32
- */
33
- initSyncForDesktop(): void {
34
- if (this.client) return;
35
-
36
- const ipcTransport = new IPCTransport();
37
- this._transport = "ipc";
38
- this._baseUrl = null; // 桌面端不走 HTTP
39
- this.client = createTypedClient<RPCMethods, RPCEvents>(ipcTransport);
40
- this.setupElectrobunBridge(ipcTransport);
41
- }
42
-
43
- /**
44
- * 异步初始化:Web 端使用(连接 WebSocket)
45
- */
46
- async initialize(): Promise<void> {
47
- // 已连接且 transport 正常 → 直接返回
48
- if (this.client && (this._transport === "ipc" || this.wsTransport?.isConnected())) {
49
- return;
50
- }
51
-
52
- // client 存在但 WS 断开了 → 清理后重连
53
- if (this.client && this.wsTransport && !this.wsTransport.isConnected()) {
54
- this.wsTransport.close();
55
- this.wsTransport = null;
56
- this.client = null;
57
- this.initPromise = null;
58
- }
59
-
60
- if (this.initPromise) return this.initPromise;
61
-
62
- this.initPromise = (async () => {
63
- const env = this.detectEnvironment();
64
-
65
- if (env === "electrobun") {
66
- // 不应走到这里,桌面端应通过 initSyncForDesktop() 初始化
67
- this.initSyncForDesktop();
68
- } else {
69
- this._transport = "websocket";
70
- const wsUrl = this.getWebSocketUrl();
71
- this.wsTransport = new WebSocketTransport(wsUrl);
72
- await this.wsTransport.connect();
73
- this.client = createTypedClient<RPCMethods, RPCEvents>(this.wsTransport);
74
-
75
- const wsUrlObj = new URL(wsUrl);
76
- this._baseUrl = `http://${wsUrlObj.host}`;
77
- }
78
- })();
79
-
80
- return this.initPromise;
81
- }
82
-
83
- private detectEnvironment(): "electrobun" | "browser" {
84
- if (typeof window === "undefined") return "browser";
85
- if (window.__electrobunBunBridge) return "electrobun";
86
- return "browser";
87
- }
88
-
89
- private getWebSocketUrl(): string {
90
- if (typeof window === "undefined") return `ws://localhost:3100?token=${AUTH_TOKEN}`;
91
- const customUrl = (
92
- new URLSearchParams(window.location.search).get("ws") ||
93
- localStorage.getItem("rpc-websocket-url")
94
- );
95
- if (customUrl) return customUrl.includes("token=") ? customUrl : `${customUrl}?token=${AUTH_TOKEN}`;
96
- return `ws://${window.location.host}/ws?token=${AUTH_TOKEN}`;
97
- }
98
-
99
- /**
100
- * 桌面端 IPC 桥接:
101
- * - Bun → Browser: 通过 executeJavascript 调用 window.__piAgentIPC()
102
- * - Browser → Bun: 通过 __electrobunBunBridge.postMessage 发送 Electrobun 消息格式
103
- */
104
- private setupElectrobunBridge(ipcTransport: IPCTransport): void {
105
- if (typeof window === "undefined") return;
106
-
107
- const win = window;
108
-
109
- // 1. 注册接收函数:Bun 通过 executeJavascript 调用此函数发送消息到 Browser
110
- win.__piAgentIPC = (msg: unknown) => {
111
- ipcTransport.simulateMessage(msg);
112
- };
113
-
114
- // 2. 覆写 send:将 RPC-core 消息包装成 Electrobun 消息格式,通过原生桥接发送
115
- const bridge = win.__electrobunBunBridge;
116
-
117
- if (bridge) {
118
- ipcTransport.send = async (message: unknown) => {
119
- // 包装成 Electrobun message packet,bun 端 defineRPC 注册了 "rpc-message" handler
120
- const electrobunPacket = {
121
- type: "message",
122
- id: "rpc-message",
123
- payload: JSON.stringify(message),
124
- };
125
- bridge.postMessage(JSON.stringify(electrobunPacket));
126
- };
127
- }
128
- }
129
-
130
- getTransport(): "ipc" | "websocket" {
131
- return this._transport;
132
- }
133
-
134
- /** Web HTTP 基础 URL(如 http://localhost:3100),桌面端返回 null */
135
- getBaseUrl(): string | null {
136
- return this._baseUrl;
137
- }
138
-
139
- /** 获取当前 auth token(用于需要直接调 HTTP 的场景) */
140
- getAuthToken(): string {
141
- return AUTH_TOKEN;
142
- }
143
-
144
- async call<K extends keyof RPCMethods>(
145
- method: K,
146
- params: MethodParams<RPCMethods, K>
147
- ): Promise<MethodResult<RPCMethods, K>> {
148
- await this.initialize();
149
- return this.client!.call(method, params);
150
- }
151
-
152
- async subscribe<K extends keyof RPCEvents>(
153
- eventType: K,
154
- handler: (payload: EventPayload<RPCEvents[K]>, metadata: EventMetadata<RPCEvents[K]>) => void,
155
- filter?: Record<string, unknown>
156
- ): Promise<string> {
157
- await this.initialize();
158
- return this.client!.subscribe(eventType, handler, filter);
159
- }
160
-
161
- unsubscribe(subscriptionId: string): void {
162
- this.client?.unsubscribe(subscriptionId);
163
- }
164
-
165
- isConnected(): boolean {
166
- return this.client !== null;
167
- }
168
-
169
- close(): void {
170
- this.client?.close();
171
- }
31
+ private client: TypedClient<RPCMethods, RPCEvents> | null = null;
32
+ private initPromise: Promise<void> | null = null;
33
+ private _transport: "ipc" | "websocket" = "websocket";
34
+ private _baseUrl: string | null = null;
35
+ private wsTransport: WebSocketTransport | null = null;
36
+
37
+ /**
38
+ * 桌面端同步初始化:通过 executeJavascript 接收 + __electrobunBunBridge 发送
39
+ */
40
+ initSyncForDesktop(): void {
41
+ if (this.client) return;
42
+
43
+ const ipcTransport = new IPCTransport();
44
+ this._transport = "ipc";
45
+ this._baseUrl = null;
46
+ this.client = createTypedClient<RPCMethods, RPCEvents>(ipcTransport);
47
+ this.setupElectrobunBridge(ipcTransport);
48
+ }
49
+
50
+ /**
51
+ * 异步初始化:Web 端使用(连接 WebSocket)
52
+ */
53
+ async initialize(): Promise<void> {
54
+ // 已连接且 transport 正常 → 直接返回
55
+ if (this.client && (this._transport === "ipc" || this.wsTransport?.isConnected())) {
56
+ return;
57
+ }
58
+
59
+ // client 存在但 WS 断开了 → 清理后重连
60
+ if (this.client && this.wsTransport && !this.wsTransport.isConnected()) {
61
+ this.wsTransport.close();
62
+ this.wsTransport = null;
63
+ this.client = null;
64
+ this.initPromise = null;
65
+ }
66
+
67
+ if (this.initPromise) return this.initPromise;
68
+
69
+ this.initPromise = (async () => {
70
+ const env = this.detectEnvironment();
71
+
72
+ if (env === "electrobun") {
73
+ // 不应走到这里,桌面端应通过 initSyncForDesktop() 初始化
74
+ this.initSyncForDesktop();
75
+ } else {
76
+ this._transport = "websocket";
77
+ const wsUrl = this.getWebSocketUrl();
78
+ this.wsTransport = new WebSocketTransport({
79
+ url: wsUrl,
80
+ authToken: AUTH_TOKEN,
81
+ reconnect: true,
82
+ } as never);
83
+ await this.wsTransport.connect();
84
+ this.client = createTypedClient<RPCMethods, RPCEvents>(this.wsTransport);
85
+
86
+ const wsUrlObj = new URL(wsUrl);
87
+ this._baseUrl = `http://${wsUrlObj.host}`;
88
+ }
89
+ })();
90
+
91
+ return this.initPromise;
92
+ }
93
+
94
+ private detectEnvironment(): "electrobun" | "browser" {
95
+ if (typeof window === "undefined") return "browser";
96
+ if (window.__electrobunBunBridge) return "electrobun";
97
+ return "browser";
98
+ }
99
+
100
+ private getWebSocketUrl(): string {
101
+ if (typeof window === "undefined") return `ws://localhost:3100?token=${AUTH_TOKEN}`;
102
+ const customUrl =
103
+ new URLSearchParams(window.location.search).get("ws") ||
104
+ localStorage.getItem("rpc-websocket-url");
105
+ if (customUrl)
106
+ return customUrl.includes("token=") ? customUrl : `${customUrl}?token=${AUTH_TOKEN}`;
107
+ return `ws://${window.location.host}/ws?token=${AUTH_TOKEN}`;
108
+ }
109
+
110
+ /**
111
+ * 桌面端 IPC 桥接:
112
+ * - Bun Browser: 通过 executeJavascript 调用 window.__piAgentIPC()
113
+ * - Browser → Bun: 通过 __electrobunBunBridge.postMessage 发送 Electrobun 消息格式
114
+ */
115
+ private setupElectrobunBridge(ipcTransport: IPCTransport): void {
116
+ if (typeof window === "undefined") return;
117
+
118
+ const win = window;
119
+
120
+ // 1. 注册接收函数:Bun 通过 executeJavascript 调用此函数发送消息到 Browser
121
+ win.__piAgentIPC = (msg: unknown) => {
122
+ ipcTransport.simulateMessage(msg);
123
+ };
124
+
125
+ // 2. 覆写 send:将 RPC-core 消息包装成 Electrobun 消息格式,通过原生桥接发送
126
+ const bridge = win.__electrobunBunBridge;
127
+
128
+ if (bridge) {
129
+ ipcTransport.send = async (message: unknown) => {
130
+ // 包装成 Electrobun message packet,bun 端 defineRPC 注册了 "rpc-message" handler
131
+ const electrobunPacket = {
132
+ type: "message",
133
+ id: "rpc-message",
134
+ payload: JSON.stringify(message),
135
+ };
136
+ bridge.postMessage(JSON.stringify(electrobunPacket));
137
+ };
138
+ }
139
+ }
140
+
141
+ getTransport(): "ipc" | "websocket" {
142
+ return this._transport;
143
+ }
144
+
145
+ /** Web 端 HTTP 基础 URL(如 http://localhost:3100),桌面端返回 null */
146
+ getBaseUrl(): string | null {
147
+ return this._baseUrl;
148
+ }
149
+
150
+ /** 获取当前 auth token(用于需要直接调 HTTP 的场景) */
151
+ getAuthToken(): string {
152
+ return AUTH_TOKEN;
153
+ }
154
+
155
+ async call<K extends keyof RPCMethods>(
156
+ method: K,
157
+ params: MethodParams<RPCMethods, K>
158
+ ): Promise<MethodResult<RPCMethods, K>> {
159
+ const methodStr = method as string;
160
+ const ttl = CACHEABLE_METHODS[methodStr];
161
+ if (ttl !== undefined) {
162
+ const cached = rpcCache.get<MethodResult<RPCMethods, K>>(methodStr, params, ttl);
163
+ if (cached !== null) return cached;
164
+ }
165
+
166
+ await this.initialize();
167
+ const result = await this.client!.call(method, params);
168
+
169
+ if (ttl !== undefined) {
170
+ rpcCache.set(methodStr, params, result);
171
+ }
172
+
173
+ return result;
174
+ }
175
+
176
+ async subscribe<K extends keyof RPCEvents>(
177
+ eventType: K,
178
+ handler: (payload: EventPayload<RPCEvents[K]>, metadata: EventMetadata<RPCEvents[K]>) => void,
179
+ filter?: Record<string, unknown>
180
+ ): Promise<string> {
181
+ await this.initialize();
182
+ return this.client!.subscribe(eventType, handler, filter);
183
+ }
184
+
185
+ unsubscribe(subscriptionId: string): void {
186
+ this.client?.unsubscribe(subscriptionId);
187
+ }
188
+
189
+ isConnected(): boolean {
190
+ return this.client !== null;
191
+ }
192
+
193
+ close(): void {
194
+ this.client?.close();
195
+ }
172
196
  }
173
197
 
174
198
  export const apiClient = new APIClientImpl();
@@ -0,0 +1,84 @@
1
+ interface CacheEntry<T> {
2
+ data: T;
3
+ timestamp: number;
4
+ }
5
+
6
+ class RpcCache {
7
+ private cache = new Map<string, CacheEntry<unknown>>();
8
+ private defaultTTL: number;
9
+ private pruneTimer: ReturnType<typeof setInterval> | null = null;
10
+
11
+ constructor(defaultTTL = 2000) {
12
+ this.defaultTTL = defaultTTL;
13
+ }
14
+
15
+ private key(method: string, params: unknown): string {
16
+ return `${method}:${JSON.stringify(params)}`;
17
+ }
18
+
19
+ get<T>(method: string, params: unknown, ttl?: number): T | null {
20
+ const k = this.key(method, params);
21
+ const entry = this.cache.get(k);
22
+ if (!entry) return null;
23
+
24
+ const effectiveTTL = ttl ?? this.defaultTTL;
25
+ if (Date.now() - entry.timestamp > effectiveTTL) {
26
+ this.cache.delete(k);
27
+ return null;
28
+ }
29
+
30
+ return entry.data as T;
31
+ }
32
+
33
+ set<T>(method: string, params: unknown, data: T): void {
34
+ const k = this.key(method, params);
35
+ this.cache.set(k, { data, timestamp: Date.now() });
36
+ if (!this.pruneTimer) this.startPruneTimer();
37
+ }
38
+
39
+ invalidate(method: string): void {
40
+ const prefix = `${method}:`;
41
+ for (const key of this.cache.keys()) {
42
+ if (key.startsWith(prefix)) {
43
+ this.cache.delete(key);
44
+ }
45
+ }
46
+ }
47
+
48
+ invalidateAll(methods: string[]): void {
49
+ for (const m of methods) {
50
+ this.invalidate(m);
51
+ }
52
+ }
53
+
54
+ clear(): void {
55
+ this.cache.clear();
56
+ if (this.pruneTimer) {
57
+ clearInterval(this.pruneTimer);
58
+ this.pruneTimer = null;
59
+ }
60
+ }
61
+
62
+ prune(): void {
63
+ const threshold = this.defaultTTL * 10;
64
+ const now = Date.now();
65
+ for (const [key, entry] of this.cache) {
66
+ if (now - entry.timestamp > threshold) {
67
+ this.cache.delete(key);
68
+ }
69
+ }
70
+ if (this.cache.size === 0 && this.pruneTimer) {
71
+ clearInterval(this.pruneTimer);
72
+ this.pruneTimer = null;
73
+ }
74
+ }
75
+
76
+ private startPruneTimer(): void {
77
+ if (this.pruneTimer) return;
78
+ this.pruneTimer = setInterval(() => this.prune(), this.defaultTTL * 5);
79
+ }
80
+ }
81
+
82
+ export const CACHEABLE_METHODS: Record<string, number> = {};
83
+
84
+ export const rpcCache = new RpcCache(2000);
@@ -1,20 +1,24 @@
1
1
  import "./lib/i18n";
2
2
  import { StrictMode } from "react";
3
+ import type { ReactNode } from "react";
3
4
  import { createRoot } from "react-dom/client";
4
5
  import { apiClient } from "./lib/api-client";
6
+ import { ErrorBoundary as _EB } from "@shared/components/ErrorBoundary";
5
7
  import "./index.css";
6
8
  import App from "./App";
7
9
 
10
+ const ErrorBoundary = _EB as unknown as React.FC<{ children: ReactNode }>;
11
+
8
12
  const isElectrobun = typeof window !== "undefined" && !!window.__electrobunBunBridge;
9
13
 
10
14
  if (isElectrobun) {
11
- // 桌面端:同步初始化 IPC(Electrobun 要求桥接在页面加载时同步设置)
12
- apiClient.initSyncForDesktop();
15
+ apiClient.initSyncForDesktop();
13
16
  }
14
- // Web 端 WS 初始化由 App.tsx 统一管理,避免竞争
15
17
 
16
18
  createRoot(document.getElementById("root")!).render(
17
- <StrictMode>
18
- <App />
19
- </StrictMode>
19
+ <StrictMode>
20
+ <ErrorBoundary>
21
+ <App />
22
+ </ErrorBoundary>
23
+ </StrictMode>
20
24
  );