@xgspz/cli-ui 0.0.0-stage → 0.6.15

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 (169) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +517 -2
  3. package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
  4. package/dist/cli-ui-index-R24x28OX.js +11209 -0
  5. package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
  6. package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
  7. package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +960 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +581 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1109 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +507 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1952 -0
  149. package/src/operator.css +1952 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,232 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Icon } from "./icons";
5
+ import { jget, jsend } from "./api";
6
+ import { LoadNote, Skel, ErrNote } from "./shared";
7
+ import type { GhStatusT, GhLoginT, GhRepoT } from "./types";
8
+ import { useBoundedPoll } from "./boundedPoll";
9
+
10
+ // The "Clone from GitHub" pane of the new-project modal: shows the guided
11
+ // connect step when the workspace isn't logged in, the user's repo list when
12
+ // it is, and always accepts a pasted URL as the escape hatch.
13
+ export function GitHubClonePicker({ value, onChange }: { value: string; onChange: (spec: string, shortName?: string) => void }) {
14
+ const [status, setStatus] = useState<GhStatusT | null>(null);
15
+ const refresh = useCallback(() => {
16
+ jget<GhStatusT>("/api/github/status")
17
+ .then(setStatus)
18
+ .catch(() => setStatus({ installed: false, authenticated: false, login: null }));
19
+ }, []);
20
+ useEffect(() => { refresh(); }, [refresh]);
21
+
22
+ if (!status) return <LoadNote style={{ padding: 0, marginBottom: 14 }}>正在检查 GitHub 连接…</LoadNote>;
23
+ return (
24
+ <>
25
+ {status.authenticated ? (
26
+ <GhRepoList selected={value} onPick={(r) => onChange(r, r.split("/").pop())} />
27
+ ) : status.installed ? (
28
+ <GitHubConnect onConnected={refresh} />
29
+ ) : (
30
+ <div className="hlp" style={{ marginBottom: 14 }}>
31
+ 此工作区没有可用的 GitHub CLI(<code>gh</code>),因此只能克隆公开仓库 —— 请在下方粘贴地址。
32
+ </div>
33
+ )}
34
+ <div className="field">
35
+ <div className="lab">仓库 {status.authenticated && <span className="opt">— 也可直接粘贴地址</span>}</div>
36
+ <input type="text" className="ctx-mono" value={value} placeholder="owner/repo 或 https://github.com/owner/repo"
37
+ onChange={(e) => onChange(e.target.value)} />
38
+ </div>
39
+ </>
40
+ );
41
+ }
42
+
43
+ // Guided `gh auth login` device flow: one click starts it, the one-time code +
44
+ // verification URL appear right here (no terminal scrollback), and we poll
45
+ // until GitHub confirms. The session lives server-side, so closing the modal
46
+ // mid-login and reopening resumes the same code.
47
+ export function GitHubConnect({ onConnected }: { onConnected: () => void }) {
48
+ const [login, setLogin] = useState<GhLoginT | null>(null);
49
+ const [busy, setBusy] = useState(false);
50
+ const [copied, setCopied] = useState(false);
51
+ const done = useRef(false);
52
+
53
+ // Rejoin a login that's already underway (e.g. the modal was closed mid-flow).
54
+ useEffect(() => {
55
+ jget<GhLoginT>("/api/github/login").then((l) => { if (l.status !== "idle") setLogin(l); }).catch(() => {});
56
+ }, []);
57
+
58
+ const start = async () => {
59
+ setBusy(true);
60
+ try {
61
+ setLogin(await jsend<GhLoginT>("/api/github/login", "POST"));
62
+ } catch (e) {
63
+ setLogin({ status: "error", code: null, url: null, user: null, error: e instanceof Error ? e.message : String(e) });
64
+ } finally {
65
+ setBusy(false);
66
+ }
67
+ };
68
+ const cancel = async () => {
69
+ await jsend("/api/github/login", "DELETE").catch(() => {});
70
+ setLogin(null);
71
+ };
72
+
73
+ // Re-check while gh waits for the user to authorize on github.com — for a
74
+ // bounded window, and again when the user comes back from github.com (useBoundedPoll).
75
+ useBoundedPoll(!!login && (login.status === "starting" || login.status === "awaiting"), () => {
76
+ jget<GhLoginT>("/api/github/login").then((l) => {
77
+ setLogin(l);
78
+ if (l.status === "success" && !done.current) { done.current = true; onConnected(); }
79
+ }).catch(() => {});
80
+ }, 2000, 10 * 60_000);
81
+
82
+ const copyCode = () => {
83
+ if (!login?.code) return;
84
+ navigator.clipboard?.writeText(login.code).then(() => {
85
+ setCopied(true);
86
+ setTimeout(() => setCopied(false), 1500);
87
+ }).catch(() => {});
88
+ };
89
+
90
+ if (!login || login.status === "idle") {
91
+ return (
92
+ <div className="field">
93
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 8 }}>
94
+ 连接一次 GitHub,就能从你的仓库列表中挑选 —— 包括私有仓库。登录状态保存在此工作区中,重启后依然有效。
95
+ </div>
96
+ <button className="btn btn-line" disabled={busy} onClick={start} style={{ alignSelf: "flex-start" }}>
97
+ {Icon.github()} {busy ? "启动中…" : "连接 GitHub"}
98
+ </button>
99
+ </div>
100
+ );
101
+ }
102
+ if (login.status === "starting") return <LoadNote style={{ padding: 0, marginBottom: 14 }}>正在启动 GitHub 登录…</LoadNote>;
103
+ if (login.status === "awaiting") {
104
+ return (
105
+ <div className="field" style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)", padding: "14px 16px" }}>
106
+ <div className="hlp" style={{ marginTop: 0 }}>在 GitHub 上输入这个一次性验证码以完成连接:</div>
107
+ <div style={{ display: "flex", alignItems: "center", gap: 12, margin: "10px 0" }}>
108
+ <button onClick={copyCode} title="点击复制"
109
+ style={{ fontFamily: "var(--mono)", fontSize: 24, fontWeight: 700, letterSpacing: ".12em", color: "var(--ink)", background: "none", border: "1px dashed var(--line-strong)", borderRadius: 8, padding: "6px 14px", cursor: "pointer" }}>
110
+ {login.code}
111
+ </button>
112
+ <span className="hlp" style={{ margin: 0 }}>{copied ? "已复制!" : "点击复制"}</span>
113
+ </div>
114
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
115
+ <a className="btn btn-accent" href={login.url ?? "https://github.com/login/device"} target="_blank" rel="noreferrer">
116
+ {Icon.github()} 打开 github.com/login/device
117
+ </a>
118
+ <button className="btn btn-ghost" onClick={cancel}>取消</button>
119
+ </div>
120
+ <div className="hlp" style={{ marginBottom: 0 }}>等待你在 github.com 上完成授权…</div>
121
+ </div>
122
+ );
123
+ }
124
+ if (login.status === "error") {
125
+ return (
126
+ <div className="field">
127
+ <div className="hlp" style={{ marginTop: 0, color: "var(--red)" }}>⚠ GitHub 登录失败:{login.error ?? "未知错误"}</div>
128
+ <button className="btn btn-line" onClick={start} style={{ alignSelf: "flex-start" }}>{Icon.restore()} 重试</button>
129
+ </div>
130
+ );
131
+ }
132
+ // success
133
+ return <div className="hlp" style={{ marginBottom: 14 }}>{Icon.check()} 已连接,账号 <strong>{login.user}</strong> —— 正在加载你的仓库…</div>;
134
+ }
135
+
136
+ // Searchable list of the user's repos (most recently pushed first).
137
+ export function GhRepoList({ selected, onPick }: { selected: string; onPick: (nameWithOwner: string) => void }) {
138
+ const [repos, setRepos] = useState<GhRepoT[] | null>(null);
139
+ const [error, setError] = useState<string | null>(null);
140
+ const [q, setQ] = useState("");
141
+ const load = useCallback(() => {
142
+ setError(null);
143
+ setRepos(null);
144
+ jget<GhRepoT[]>("/api/github/repos").then(setRepos).catch((e) => setError(e instanceof Error ? e.message : String(e)));
145
+ }, []);
146
+ useEffect(() => { load(); }, [load]);
147
+
148
+ if (error) return <ErrNote style={{ marginBottom: 14 }} onRetry={load}>无法加载你的仓库:{error}</ErrNote>;
149
+ if (!repos) {
150
+ return (
151
+ <div className="field">
152
+ <div className="lab">{Icon.github()} 你的仓库</div>
153
+ <div style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)" }} aria-hidden>
154
+ {[46, 60, 38, 52].map((w, i) => (
155
+ <div key={i} className="skel-lrow">
156
+ <Skel w={14} h={14} r={4} />
157
+ <Skel w={`${w}%`} h={11} />
158
+ </div>
159
+ ))}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+ const filtered = repos.filter((r) => r.nameWithOwner.toLowerCase().includes(q.trim().toLowerCase()));
165
+ return (
166
+ <div className="field">
167
+ <div className="lab">{Icon.github()} 你的仓库</div>
168
+ <input type="text" value={q} placeholder="搜索仓库…" onChange={(e) => setQ(e.target.value)} style={{ marginBottom: 8 }} />
169
+ <div style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)", maxHeight: 220, overflowY: "auto" }}>
170
+ {filtered.length === 0 && <div className="hlp" style={{ padding: "12px 14px", margin: 0 }}>没有匹配的仓库。</div>}
171
+ {filtered.map((r) => (
172
+ <button key={r.nameWithOwner} onClick={() => onPick(r.nameWithOwner)} title={r.nameWithOwner}
173
+ style={{ display: "flex", alignItems: "center", gap: 9, width: "100%", textAlign: "left", padding: "8px 13px", borderBottom: "1px solid var(--line)", color: "var(--ink)", fontSize: 13, background: selected === r.nameWithOwner ? "var(--accent-soft, rgba(194,96,60,.12))" : "none" }}>
174
+ <span style={{ color: selected === r.nameWithOwner ? "var(--accent)" : "var(--ink-4)", display: "inline-flex" }}>
175
+ {selected === r.nameWithOwner ? Icon.check() : Icon.git()}
176
+ </span>
177
+ <span style={{ fontFamily: "var(--mono)", fontSize: 12.5, whiteSpace: "nowrap" }}>{r.nameWithOwner}</span>
178
+ {r.isPrivate && <span style={{ color: "var(--ink-4)", display: "inline-flex" }} title="私有">{Icon.lock()}</span>}
179
+ <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--ink-3)", fontSize: 12 }}>{r.description}</span>
180
+ </button>
181
+ ))}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ // Settings → GitHub: connection status, the same guided connect flow, and
188
+ // disconnect. Useful for checking the login survived a workspace restart.
189
+ export function GitHubSettings() {
190
+ const [status, setStatus] = useState<GhStatusT | null>(null);
191
+ const [err, setErr] = useState<string | null>(null);
192
+ const refresh = useCallback(() => {
193
+ jget<GhStatusT>("/api/github/status")
194
+ .then(setStatus)
195
+ .catch(() => setStatus({ installed: false, authenticated: false, login: null }));
196
+ }, []);
197
+ useEffect(() => { refresh(); }, [refresh]);
198
+
199
+ const disconnect = async () => {
200
+ setErr(null);
201
+ try {
202
+ await jsend("/api/github/logout", "POST");
203
+ refresh();
204
+ } catch (e) {
205
+ setErr(e instanceof Error ? e.message : String(e));
206
+ }
207
+ };
208
+
209
+ if (!status) return <LoadNote style={{ padding: 0 }}>正在检查 GitHub 连接…</LoadNote>;
210
+ if (!status.installed) {
211
+ return <div className="hlp">此工作区没有可用的 GitHub CLI(<code>gh</code>),因此这里无需连接。创建项目时仍可通过地址克隆公开仓库。</div>;
212
+ }
213
+ return (
214
+ <div className="field">
215
+ <div className="lab">{Icon.github()} GitHub 账号</div>
216
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 12 }}>
217
+ 用于在创建项目时列出并克隆你的仓库(包括私有仓库),以及在任务和终端中执行 <code>git push/pull</code>。登录状态保存在此工作区中,重启后依然有效。
218
+ </div>
219
+ {status.authenticated ? (
220
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
221
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 7, color: "var(--ink)" }}>
222
+ {Icon.check()} 已连接,账号 <strong>{status.login ?? "未知"}</strong>
223
+ </span>
224
+ <button className="btn btn-line btn-sm" onClick={disconnect}>{Icon.x()} 断开连接</button>
225
+ </div>
226
+ ) : (
227
+ <GitHubConnect onConnected={refresh} />
228
+ )}
229
+ {err && <ErrNote style={{ marginTop: 10 }}>{err}</ErrNote>}
230
+ </div>
231
+ );
232
+ }
@@ -0,0 +1,88 @@
1
+ /* Orchestrator — icon set (ported from the design's icons.js) */
2
+ import type { SVGProps } from "react";
3
+
4
+ type P = SVGProps<SVGSVGElement>;
5
+
6
+ function S(children: React.ReactNode, props?: P) {
7
+ return (
8
+ <svg
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth={1.7}
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ width="1em"
16
+ height="1em"
17
+ style={{ flex: "none" }}
18
+ {...props}
19
+ >
20
+ {children}
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ export const Icon = {
26
+ plus: (p?: P) => S(<><line x1={12} y1={5} x2={12} y2={19} /><line x1={5} y1={12} x2={19} y2={12} /></>, p),
27
+ list: (p?: P) => S(<><line x1={9} y1={6} x2={20} y2={6} /><line x1={9} y1={12} x2={20} y2={12} /><line x1={9} y1={18} x2={20} y2={18} /><circle cx={5} cy={6} r={0.5} /><circle cx={5} cy={12} r={0.5} /><circle cx={5} cy={18} r={0.5} /></>, p),
28
+ board: (p?: P) => S(<><rect x={3} y={4} width={5} height={16} rx={1.5} /><rect x={10} y={4} width={5} height={11} rx={1.5} /><rect x={17} y={4} width={4} height={8} rx={1.5} /></>, p),
29
+ chart: (p?: P) => S(<><line x1={5} y1={20} x2={5} y2={13} /><line x1={12} y1={20} x2={12} y2={5} /><line x1={19} y1={20} x2={19} y2={9} /></>, p),
30
+ folder: (p?: P) => S(<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />, p),
31
+ gear: (p?: P) => S(<><circle cx={12} cy={12} r={3} /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></>, p),
32
+ send: (p?: P) => S(<><path d="M7 11l5-5 5 5" /><line x1={12} y1={6} x2={12} y2={18} /></>, p),
33
+ spark: (p?: P) => S(<path d="M12 3l1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" />, p),
34
+ clock: (p?: P) => S(<><circle cx={12} cy={12} r={9} /><path d="M12 7v5l3 2" /></>, p),
35
+ clear: (p?: P) => S(<><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v4h4" /></>, p),
36
+ dots: (p?: P) => S(<><circle cx={5} cy={12} r={1} /><circle cx={12} cy={12} r={1} /><circle cx={19} cy={12} r={1} /></>, p),
37
+ chevDown: (p?: P) => S(<path d="M6 9l6 6 6-6" />, p),
38
+ chevUp: (p?: P) => S(<path d="M6 15l6-6 6 6" />, p),
39
+ chevRight: (p?: P) => S(<path d="M9 6l6 6-6 6" />, p),
40
+ toBottom: (p?: P) => S(<><path d="M7 7l5 5 5-5" /><line x1={5} y1={17} x2={19} y2={17} /></>, p),
41
+ x: (p?: P) => S(<><line x1={6} y1={6} x2={18} y2={18} /><line x1={6} y1={18} x2={18} y2={6} /></>, p),
42
+ clip: (p?: P) => S(<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />, p),
43
+ check: (p?: P) => S(<path d="M5 12l5 5 9-11" />, p),
44
+ lock: (p?: P) => S(<><rect x={4} y={11} width={16} height={9} rx={2} /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>, p),
45
+ edit: (p?: P) => S(<><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></>, p),
46
+ doc: (p?: P) => S(<><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></>, p),
47
+ github: (p?: P) => S(<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />, p),
48
+ git: (p?: P) => S(<><circle cx={6} cy={6} r={2} /><circle cx={6} cy={18} r={2} /><circle cx={18} cy={9} r={2} /><path d="M18 11a8 8 0 0 1-8 8" /><line x1={6} y1={8} x2={6} y2={16} /></>, p),
49
+ flag: (p?: P) => S(<path d="M5 21V4h12l-2 4 2 4H5" />, p),
50
+ bolt: (p?: P) => S(<path d="M13 2L4 14h7l-1 8 9-12h-7z" />, p),
51
+ moon: (p?: P) => S(<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />, p),
52
+ sun: (p?: P) => S(<><circle cx={12} cy={12} r={4} /><line x1={12} y1={2} x2={12} y2={5} /><line x1={12} y1={19} x2={12} y2={22} /><line x1={2} y1={12} x2={5} y2={12} /><line x1={19} y1={12} x2={22} y2={12} /><line x1={4.9} y1={4.9} x2={6.8} y2={6.8} /><line x1={17.2} y1={17.2} x2={19.1} y2={19.1} /><line x1={4.9} y1={19.1} x2={6.8} y2={17.2} /><line x1={17.2} y1={6.8} x2={19.1} y2={4.9} /></>, p),
53
+ external: (p?: P) => S(<><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5" /></>, p),
54
+ copy: (p?: P) => S(<><rect x={9} y={9} width={12} height={12} rx={2} /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>, p),
55
+ play: (p?: P) => S(<path d="M7 5l11 7-11 7z" />, p),
56
+ stop: (p?: P) => S(<rect x={6} y={6} width={12} height={12} rx={2} />, p),
57
+ sliders: (p?: P) => S(<><line x1={4} y1={21} x2={4} y2={14} /><line x1={4} y1={10} x2={4} y2={3} /><line x1={12} y1={21} x2={12} y2={12} /><line x1={12} y1={8} x2={12} y2={3} /><line x1={20} y1={21} x2={20} y2={16} /><line x1={20} y1={12} x2={20} y2={3} /><line x1={1} y1={14} x2={7} y2={14} /><line x1={9} y1={8} x2={15} y2={8} /><line x1={17} y1={16} x2={23} y2={16} /></>, p),
58
+ search: (p?: P) => S(<><circle cx={11} cy={11} r={7} /><line x1={21} y1={21} x2={16.65} y2={16.65} /></>, p),
59
+ terminal: (p?: P) => S(<><rect x={3} y={4} width={18} height={16} rx={2} /><path d="M7 9l3 3-3 3" /><line x1={13} y1={15} x2={17} y2={15} /></>, p),
60
+ archive: (p?: P) => S(<><rect x={3} y={4} width={18} height={4} rx={1} /><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" /><line x1={10} y1={12} x2={14} y2={12} /></>, p),
61
+ restore: (p?: P) => S(<><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v4h4" /><path d="M12 8v4l3 2" /></>, p),
62
+ download: (p?: P) => S(<><path d="M12 4v11" /><path d="M7 11l5 5 5-5" /><line x1={5} y1={20} x2={19} y2={20} /></>, p),
63
+ };
64
+
65
+ // ---- agent brand marks ----
66
+ // Logos, not line icons: filled paths in `currentColor` (the stroked `S()` set
67
+ // above would smear these at avatar size). Keyed by agent driver id so any UI
68
+ // that knows a task's `agent` can stamp the right mark; unknown/legacy ids get
69
+ // no entry and callers fall back to a generic glyph.
70
+
71
+ function Brand(children: React.ReactNode, props?: P) {
72
+ return (
73
+ <svg viewBox="0 0 24 24" fill="currentColor" stroke="none" width="1em" height="1em" style={{ flex: "none" }} aria-hidden {...props}>
74
+ {children}
75
+ </svg>
76
+ );
77
+ }
78
+
79
+ export const AgentMark: Record<string, (p?: P) => React.ReactElement> = {
80
+ claude: (p?: P) => Brand(
81
+ <path d="M4.709 15.955l4.72-2.647.079-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 0 1-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.984-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.003 1.81 2.508 2.33.127.578-.322.455-.34-.049-2.204-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />,
82
+ p,
83
+ ),
84
+ codex: (p?: P) => Brand(
85
+ <path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.911 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.182a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.998-2.9 6.056 6.056 0 0 0-.748-7.073zm-9.022 12.608a4.476 4.476 0 0 1-2.876-1.04l.142-.081 4.778-2.758a.795.795 0 0 0 .393-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.495 4.494zM3.6 18.304a4.471 4.471 0 0 1-.535-3.014l.142.085 4.783 2.758a.771.771 0 0 0 .78 0l5.843-3.368v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.499 4.499 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.677l5.814 3.354-2.02 1.169a.076.076 0 0 1-.071 0l-4.83-2.787A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.856L13.104 8.364 15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.024l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.499 4.499 0 0 1 6.681 4.66zM8.307 12.863l-2.02-1.164a.08.08 0 0 1-.038-.057V6.074a4.499 4.499 0 0 1 7.376-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.682zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />,
86
+ p,
87
+ ),
88
+ };
@@ -0,0 +1,323 @@
1
+ "use client";
2
+
3
+ import { notifyCallback } from "../../core/callbacks";
4
+
5
+ // Every live stream the UI watches (task transcript, lifecycle events, browser
6
+ // status, services) rides ONE WebSocket per tab — server.js /live, bridged to the
7
+ // same SSE routes as before (lib/liveBridge.js). Plain EventSources each pinned
8
+ // one of the browser's 6 HTTP/1.1 connections per node, so a few open tabs
9
+ // starved every ordinary request: sends, the "needs you" list and the browser
10
+ // panel spun forever. WebSockets use their own pool.
11
+ //
12
+ // openLive() keeps EventSource's contract for its callers: onOpen fires on every
13
+ // (re)connect — each one re-snapshots — and onMessage gets each event's data.
14
+ // If the socket can't be opened at all (a proxy without WebSocket support, the
15
+ // node restarting while the tab loaded), the tab falls back to one EventSource
16
+ // per stream — the old behaviour — and keeps retrying the socket to move back.
17
+
18
+ export interface LiveHandlers {
19
+ /** Upstream HTTP status, or 0 for a normal/network stream end. */
20
+ onEnd?: (code: number) => void;
21
+ /** Transport disruption; built-in reconnect/fallback remains active. */
22
+ onError?: (error: Error) => void;
23
+ onMessage: (data: string) => void;
24
+ onOpen?: () => void;
25
+ }
26
+ export interface LiveSub {
27
+ close(): void;
28
+ }
29
+
30
+ interface Sub {
31
+ id: number;
32
+ url: string;
33
+ h: LiveHandlers;
34
+ es?: EventSource;
35
+ retry?: ReturnType<typeof setTimeout>;
36
+ fails: number;
37
+ /** Gave up retrying after MAX_STREAM_RETRIES; the next wake (online / visible / focus) restarts it. */
38
+ stalled?: boolean;
39
+ }
40
+
41
+ // The server sends a keepalive every 20 s; a socket silent for longer than this
42
+ // is dead (laptop sleep, dropped tunnel) even if the browser hasn't noticed.
43
+ const SILENCE_MS = 65_000;
44
+ const CONNECT_TIMEOUT_MS = 10_000;
45
+ const BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
46
+ /** While on the EventSource fallback, how often to try the socket again. */
47
+ const PROBE_MS = 30_000;
48
+ // No unbounded retry loops: every automatic retry has a budget. Once it is spent
49
+ // the transport stops and waits for an event — back online, the tab visible
50
+ // again, the window focused — which resets the budget and reconnects at once.
51
+ /** Socket reconnects in a row before stopping (≈1 minute with BACKOFF_MS). */
52
+ const MAX_RECONNECTS = 10;
53
+ /** Probes from the EventSource fallback back to the socket before stopping (≈5 minutes). */
54
+ const MAX_PROBES = 10;
55
+ /** Per-stream resubscribes / EventSource errors in a row before that stream stops. */
56
+ const MAX_STREAM_RETRIES = 5;
57
+ const MAX_EVENTSOURCE_ERRORS = 20;
58
+ const STOPPED = "实时事件连接多次失败,已停止自动重连;网络恢复、回到页面或切回窗口时会再连";
59
+ let probes = 0;
60
+
61
+ const subs = new Map<number, Sub>();
62
+ const observers = new Map<string, Set<(data: string) => void>>();
63
+ /** Observe an existing route without opening a connection or server subscription. */
64
+ export function observeLiveMessages(path: string, listener: (data: string) => void): () => void {
65
+ const key = path.split("?")[0];
66
+ const listeners = observers.get(key) ?? new Set(); observers.set(key, listeners); listeners.add(listener);
67
+ return () => { listeners.delete(listener); if (!listeners.size) observers.delete(key); };
68
+ }
69
+ function deliver(s: Sub, data: string) {
70
+ if (subs.get(s.id) !== s) return;
71
+ for (const listener of [...observers.get(s.url.split("?")[0]) ?? []]) notifyCallback(listener, data);
72
+ if (subs.get(s.id) === s) s.h.onMessage(data);
73
+ }
74
+ let nextId = 1;
75
+ let mode: "ws" | "sse" | null = null;
76
+ let ws: WebSocket | null = null;
77
+ let wsOpen = false;
78
+ let everOpened = false;
79
+ let failsBeforeOpen = 0;
80
+ let attempt = 0;
81
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
82
+ let watchdog: ReturnType<typeof setInterval> | undefined;
83
+ let lastFrame = 0;
84
+ let listening = false;
85
+ let probeTimer: ReturnType<typeof setTimeout> | undefined;
86
+ let cancelProbe: (() => void) | undefined;
87
+ let unlisten: (() => void) | undefined;
88
+
89
+ function currentMode(): "ws" | "sse" {
90
+ if (mode) return mode;
91
+ mode = typeof WebSocket === "undefined" ? "sse" : "ws";
92
+ return mode;
93
+ }
94
+
95
+ function listen() {
96
+ if (listening || typeof window === "undefined") return;
97
+ listening = true;
98
+ // Back from the background / back online / window focused: don't wait out the
99
+ // backoff, and restart whatever stopped after spending its retry budget.
100
+ const wake = () => {
101
+ if (subs.size === 0) return;
102
+ if (mode === "sse") {
103
+ probes = 0;
104
+ if (!probeTimer && !cancelProbe) probe();
105
+ for (const s of subs.values()) if (s.stalled) { s.stalled = false; s.fails = 0; openEventSource(s); }
106
+ return;
107
+ }
108
+ if (mode !== "ws") return;
109
+ attempt = 0;
110
+ if (!ws) connect();
111
+ else if (wsOpen && Date.now() - lastFrame > SILENCE_MS) drop(ws);
112
+ else if (wsOpen) for (const s of subs.values()) if (s.stalled) { s.stalled = false; s.fails = 0; sendSub(s); }
113
+ };
114
+ const visible = () => { if (document.visibilityState === "visible") wake(); };
115
+ document.addEventListener("visibilitychange", visible);
116
+ window.addEventListener("online", wake);
117
+ window.addEventListener("focus", wake);
118
+ unlisten = () => { document.removeEventListener("visibilitychange", visible); window.removeEventListener("online", wake); window.removeEventListener("focus", wake); listening = false; };
119
+ }
120
+
121
+ function sendSub(s: Sub) {
122
+ if (ws && wsOpen) ws.send(JSON.stringify({ t: "sub", s: s.id, u: s.url }));
123
+ }
124
+
125
+ function openEventSource(s: Sub) {
126
+ if (subs.get(s.id) !== s) return;
127
+ s.es?.close();
128
+ const es = new EventSource(s.url);
129
+ const active = () => subs.get(s.id) === s && s.es === es;
130
+ es.onopen = () => { if (!active()) return; if (s.retry) { clearTimeout(s.retry); s.retry = undefined; } s.fails = 0; s.h.onOpen?.(); };
131
+ es.onmessage = (event) => { if (active()) deliver(s, event.data); };
132
+ es.onerror = () => {
133
+ if (!active()) return;
134
+ s.fails++;
135
+ // EventSource retries a CONNECTING stream by itself, forever: close it once
136
+ // the budget is spent and wait for a wake instead.
137
+ if (s.fails >= MAX_EVENTSOURCE_ERRORS || (es.readyState === 2 && s.fails >= MAX_STREAM_RETRIES)) {
138
+ es.close(); s.es = undefined; s.stalled = true;
139
+ if (s.retry) { clearTimeout(s.retry); s.retry = undefined; }
140
+ notifyCallback(s.h.onError, new Error(STOPPED));
141
+ return;
142
+ }
143
+ notifyCallback(s.h.onError, new Error("实时事件连接中断,正在等待恢复"));
144
+ // EventSource hides HTTP status. CONNECTING retries itself; CLOSED needs
145
+ // a bounded manual retry so renewed auth can recover without a reload.
146
+ if (!active() || es.readyState !== 2) return;
147
+ es.close();
148
+ if (s.retry) clearTimeout(s.retry);
149
+ s.retry = setTimeout(() => { s.retry = undefined; if (active()) openEventSource(s); }, 30_000);
150
+ };
151
+ s.es = es;
152
+ }
153
+
154
+ function liveUrl(): string {
155
+ return `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/live`;
156
+ }
157
+
158
+ function fallBackToEventSource() {
159
+ console.warn("[live] WebSocket unavailable — falling back to EventSource");
160
+ mode = "sse";
161
+ for (const s of subs.values()) openEventSource(s);
162
+ scheduleProbe();
163
+ }
164
+
165
+ // The fallback is a stopgap, not a verdict: its EventSources pin the very HTTP
166
+ // connections /live exists to keep free, and the usual cause (the node was
167
+ // restarting while this tab loaded) is gone a minute later. Keep trying the
168
+ // socket and move the streams back onto it as soon as it opens.
169
+ function scheduleProbe() {
170
+ if (probeTimer || subs.size === 0 || mode !== "sse" || probes >= MAX_PROBES) return;
171
+ probes++;
172
+ probeTimer = setTimeout(probe, PROBE_MS);
173
+ }
174
+
175
+ function probe() {
176
+ probeTimer = undefined;
177
+ if (mode !== "sse") return;
178
+ if (subs.size === 0) {
179
+ // Nothing to move: let the next openLive try the socket first.
180
+ mode = "ws";
181
+ failsBeforeOpen = 0;
182
+ return;
183
+ }
184
+ let sock: WebSocket;
185
+ try { sock = new WebSocket(liveUrl()); } catch { scheduleProbe(); return; }
186
+ const settle = () => { clearTimeout(timer); sock.onopen = sock.onclose = sock.onerror = null; cancelProbe = undefined; };
187
+ cancelProbe = () => { settle(); try { sock.close(); } catch { /* gone */ } };
188
+ const timer = setTimeout(() => { settle(); try { sock.close(); } catch { /* gone */ } scheduleProbe(); }, CONNECT_TIMEOUT_MS);
189
+ sock.onopen = () => {
190
+ settle();
191
+ sock.close();
192
+ if (mode !== "sse") return;
193
+ mode = "ws";
194
+ failsBeforeOpen = 0;
195
+ probes = 0;
196
+ for (const s of subs.values()) { s.es?.close(); s.es = undefined; s.stalled = false; s.fails = 0; }
197
+ // Each stream re-snapshots on the socket, so the hand-over loses nothing.
198
+ connect();
199
+ };
200
+ sock.onclose = () => { settle(); scheduleProbe(); };
201
+ sock.onerror = () => { /* onclose follows */ };
202
+ }
203
+
204
+ function scheduleReconnect() {
205
+ if (reconnectTimer || subs.size === 0) return;
206
+ if (attempt >= MAX_RECONNECTS) {
207
+ for (const s of [...subs.values()]) notifyCallback(s.h.onError, new Error(STOPPED));
208
+ return;
209
+ }
210
+ const delay = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
211
+ attempt++;
212
+ reconnectTimer = setTimeout(() => { reconnectTimer = undefined; connect(); }, delay);
213
+ }
214
+
215
+ // Treat a socket as closed right away (its onclose can take minutes on a dead
216
+ // TCP connection), then reconnect.
217
+ function drop(sock: WebSocket) {
218
+ if (ws !== sock) return;
219
+ ws = null;
220
+ wsOpen = false;
221
+ if (watchdog) { clearInterval(watchdog); watchdog = undefined; }
222
+ sock.onopen = sock.onmessage = sock.onclose = sock.onerror = null;
223
+ try { sock.close(); } catch { /* already closed */ }
224
+ for (const s of subs.values()) { if (s.retry) { clearTimeout(s.retry); s.retry = undefined; } }
225
+ for (const s of [...subs.values()]) notifyCallback(s.h.onError, new Error("实时事件连接已断开,正在重连"));
226
+ if (subs.size === 0) return;
227
+ if (!everOpened && ++failsBeforeOpen >= 3) { fallBackToEventSource(); return; }
228
+ scheduleReconnect();
229
+ }
230
+
231
+ function resubscribeLater(s: Sub, code: number) {
232
+ // A renewed login must recover on the existing socket. Retry expired auth
233
+ // slowly; permanent missing/invalid resources wait for a new subscription.
234
+ const auth = code === 401 || code === 403;
235
+ if (!auth && code !== 0 && code !== 408 && code !== 429 && code < 500) return;
236
+ if (s.fails >= MAX_STREAM_RETRIES) { s.stalled = true; notifyCallback(s.h.onError, new Error(STOPPED)); return; }
237
+ const delay = auth || code === 429 ? 30_000 : BACKOFF_MS[Math.min(s.fails, BACKOFF_MS.length - 1)] * 2;
238
+ s.fails++;
239
+ if (s.retry) clearTimeout(s.retry);
240
+ s.retry = setTimeout(() => { s.retry = undefined; if (subs.get(s.id) === s) sendSub(s); }, delay);
241
+ }
242
+
243
+ function onFrame(text: string) {
244
+ lastFrame = Date.now();
245
+ if (text.charCodeAt(0) === 123 /* { */) {
246
+ let msg: { t?: string; s?: number; code?: number };
247
+ try { msg = JSON.parse(text); } catch { return; }
248
+ const s = msg.s !== undefined ? subs.get(msg.s) : undefined;
249
+ if (msg.t === "open" && s) { s.fails = 0; s.h.onOpen?.(); }
250
+ else if (msg.t === "end" && s) {
251
+ notifyCallback(s.h.onEnd, msg.code ?? 0);
252
+ if (subs.get(s.id) === s) resubscribeLater(s, msg.code ?? 0);
253
+ }
254
+ return;
255
+ }
256
+ const nl = text.indexOf("\n");
257
+ if (nl <= 0) return;
258
+ const s = subs.get(Number(text.slice(0, nl)));
259
+ if (s) deliver(s, text.slice(nl + 1));
260
+ }
261
+
262
+ function connect() {
263
+ if (ws || currentMode() !== "ws" || subs.size === 0) return;
264
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = undefined; }
265
+ listen();
266
+ let sock: WebSocket;
267
+ try {
268
+ sock = new WebSocket(liveUrl());
269
+ } catch {
270
+ fallBackToEventSource();
271
+ return;
272
+ }
273
+ ws = sock;
274
+ // A proxy that takes the connection but never answers the upgrade would
275
+ // otherwise leave the tab without live updates indefinitely.
276
+ const handshake = setTimeout(() => { if (ws === sock && !wsOpen) drop(sock); }, CONNECT_TIMEOUT_MS);
277
+ sock.onopen = () => {
278
+ clearTimeout(handshake);
279
+ wsOpen = true;
280
+ everOpened = true;
281
+ attempt = 0;
282
+ lastFrame = Date.now();
283
+ for (const s of subs.values()) { s.fails = 0; sendSub(s); }
284
+ watchdog = setInterval(() => { if (Date.now() - lastFrame > SILENCE_MS) drop(sock); }, 10_000);
285
+ };
286
+ sock.onmessage = (e) => onFrame(String(e.data));
287
+ sock.onclose = () => drop(sock);
288
+ sock.onerror = () => { /* onclose follows */ };
289
+ }
290
+
291
+ /**
292
+ * Watch one live stream (an SSE route URL). Mirrors EventSource: onOpen on each
293
+ * (re)connect, onMessage per event. close() stops it.
294
+ */
295
+ export function openLive(url: string, h: LiveHandlers): LiveSub {
296
+ const s: Sub = { id: nextId++, url, h, fails: 0 };
297
+ subs.set(s.id, s);
298
+ if (currentMode() === "sse") openEventSource(s);
299
+ else if (wsOpen) sendSub(s);
300
+ else connect();
301
+ return {
302
+ close() {
303
+ if (subs.get(s.id) !== s) return;
304
+ subs.delete(s.id);
305
+ if (s.retry) clearTimeout(s.retry);
306
+ s.es?.close();
307
+ if (ws && wsOpen) ws.send(JSON.stringify({ t: "unsub", s: s.id }));
308
+ if (subs.size === 0) {
309
+ // Standalone embeds may mount/unmount repeatedly. Release the shared
310
+ // transport when its last consumer leaves, including fallback probes.
311
+ if (reconnectTimer) clearTimeout(reconnectTimer);
312
+ if (probeTimer) clearTimeout(probeTimer);
313
+ if (watchdog) clearInterval(watchdog);
314
+ reconnectTimer = probeTimer = watchdog = undefined;
315
+ cancelProbe?.();
316
+ const socket = ws; ws = null; wsOpen = false;
317
+ if (socket) { socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null; try { socket.close(); } catch { /* already closed */ } }
318
+ unlisten?.(); unlisten = undefined;
319
+ mode = null; everOpened = false; failsBeforeOpen = attempt = probes = 0;
320
+ }
321
+ },
322
+ };
323
+ }