bilisum 1.13.2 → 1.13.3

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 (125) hide show
  1. package/README.md +4 -4
  2. package/bin/bilisum.js +157 -76
  3. package/package.json +7 -2
  4. package/runtime/VERSION +1 -0
  5. package/runtime/apps/service/pyproject.toml +29 -0
  6. package/runtime/apps/service/src/video_sum_service/__init__.py +1 -0
  7. package/runtime/apps/service/src/video_sum_service/__main__.py +5 -0
  8. package/runtime/apps/service/src/video_sum_service/app.py +480 -0
  9. package/runtime/apps/service/src/video_sum_service/context.py +24 -0
  10. package/runtime/apps/service/src/video_sum_service/integrations.py +264 -0
  11. package/runtime/apps/service/src/video_sum_service/knowledge/__init__.py +5 -0
  12. package/runtime/apps/service/src/video_sum_service/knowledge/index_service.py +408 -0
  13. package/runtime/apps/service/src/video_sum_service/knowledge/local_llm.py +319 -0
  14. package/runtime/apps/service/src/video_sum_service/knowledge/rag_service.py +492 -0
  15. package/runtime/apps/service/src/video_sum_service/knowledge/tag_service.py +242 -0
  16. package/runtime/apps/service/src/video_sum_service/main.py +31 -0
  17. package/runtime/apps/service/src/video_sum_service/repository.py +942 -0
  18. package/runtime/apps/service/src/video_sum_service/routers/__init__.py +1 -0
  19. package/runtime/apps/service/src/video_sum_service/routers/knowledge.py +272 -0
  20. package/runtime/apps/service/src/video_sum_service/routers/system.py +280 -0
  21. package/runtime/apps/service/src/video_sum_service/routers/tasks.py +287 -0
  22. package/runtime/apps/service/src/video_sum_service/routers/videos.py +766 -0
  23. package/runtime/apps/service/src/video_sum_service/runtime_support.py +1007 -0
  24. package/runtime/apps/service/src/video_sum_service/schemas.py +418 -0
  25. package/runtime/apps/service/src/video_sum_service/settings_manager.py +130 -0
  26. package/runtime/apps/service/src/video_sum_service/task_artifacts.py +102 -0
  27. package/runtime/apps/service/src/video_sum_service/task_exports.py +108 -0
  28. package/runtime/apps/service/src/video_sum_service/transcribe_worker.py +9 -0
  29. package/runtime/apps/service/src/video_sum_service/video_assets.py +623 -0
  30. package/runtime/apps/service/src/video_sum_service/worker.py +381 -0
  31. package/runtime/apps/web/static/apple-touch-icon.png +0 -0
  32. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  33. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  34. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  35. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  36. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  37. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  38. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  39. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  40. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  41. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  42. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  43. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  44. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  45. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  46. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  47. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  48. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  49. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  50. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  51. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  52. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  53. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  54. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  55. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  56. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  57. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  58. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  59. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  60. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  61. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  62. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  63. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  64. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  65. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  66. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  67. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  68. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  69. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  70. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  71. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  72. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  73. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  74. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  75. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  76. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  77. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  78. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  79. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  80. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  81. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  82. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  83. package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  84. package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  85. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  86. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  87. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  88. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  89. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  90. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  91. package/runtime/apps/web/static/assets/icons/icon-180.png +0 -0
  92. package/runtime/apps/web/static/assets/icons/icon-512.png +0 -0
  93. package/runtime/apps/web/static/assets/icons/icon.svg +17 -0
  94. package/runtime/apps/web/static/assets/index-Cj5PVjQg.js +388 -0
  95. package/runtime/apps/web/static/assets/index-DU2t4_7s.css +1 -0
  96. package/runtime/apps/web/static/favicon-32x32.png +0 -0
  97. package/runtime/apps/web/static/favicon.ico +0 -0
  98. package/runtime/apps/web/static/favicon.svg +17 -0
  99. package/runtime/apps/web/static/index.html +19 -0
  100. package/runtime/apps/web/static/js/api.js +123 -0
  101. package/runtime/apps/web/static/js/main.js +932 -0
  102. package/runtime/apps/web/static/js/state.js +28 -0
  103. package/runtime/apps/web/static/js/utils.js +81 -0
  104. package/runtime/apps/web/static/js/views/home.js +604 -0
  105. package/runtime/apps/web/static/js/views/settings.js +477 -0
  106. package/runtime/apps/web/static/styles.css +2206 -0
  107. package/runtime/packages/core/pyproject.toml +18 -0
  108. package/runtime/packages/core/src/video_sum_core/__init__.py +1 -0
  109. package/runtime/packages/core/src/video_sum_core/errors.py +22 -0
  110. package/runtime/packages/core/src/video_sum_core/markdown_exports.py +154 -0
  111. package/runtime/packages/core/src/video_sum_core/models/__init__.py +1 -0
  112. package/runtime/packages/core/src/video_sum_core/models/tasks.py +70 -0
  113. package/runtime/packages/core/src/video_sum_core/pipeline/__init__.py +1 -0
  114. package/runtime/packages/core/src/video_sum_core/pipeline/base.py +46 -0
  115. package/runtime/packages/core/src/video_sum_core/pipeline/real.py +3467 -0
  116. package/runtime/packages/core/src/video_sum_core/transcribe_subprocess.py +173 -0
  117. package/runtime/packages/core/src/video_sum_core/utils.py +127 -0
  118. package/runtime/packages/infra/pyproject.toml +16 -0
  119. package/runtime/packages/infra/src/video_sum_infra/__init__.py +1 -0
  120. package/runtime/packages/infra/src/video_sum_infra/app.py +39 -0
  121. package/runtime/packages/infra/src/video_sum_infra/config.py +393 -0
  122. package/runtime/packages/infra/src/video_sum_infra/db.py +34 -0
  123. package/runtime/packages/infra/src/video_sum_infra/logging.py +54 -0
  124. package/runtime/packages/infra/src/video_sum_infra/paths.py +6 -0
  125. package/runtime/packages/infra/src/video_sum_infra/runtime.py +485 -0
@@ -0,0 +1,932 @@
1
+ import { api } from "./api.js";
2
+ import { state } from "./state.js";
3
+ import { parseAppLocation, routeMeta } from "./utils.js";
4
+ import { buildTaskProgressView, renderHomeRegions, renderHomeView, renderTaskProgressEvents } from "./views/home.js";
5
+ import { renderSettingsView } from "./views/settings.js";
6
+
7
+ const elements = {
8
+ nav: document.getElementById("nav"),
9
+ pageEyebrow: document.getElementById("page-eyebrow"),
10
+ pageTitle: document.getElementById("page-title"),
11
+ serviceBadge: document.getElementById("service-badge"),
12
+ liveStatus: document.getElementById("live-status"),
13
+ viewRoot: document.getElementById("view-root"),
14
+ };
15
+ let renderedViewKey = "";
16
+ const transientStatusTimers = new Map();
17
+
18
+ bootstrap().catch((error) => {
19
+ elements.viewRoot.innerHTML = `<div class="grid-card empty-state">
20
+ <div class="empty-detail">
21
+ <svg class="empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
22
+ <circle cx="12" cy="12" r="10"></circle>
23
+ <line x1="12" y1="8" x2="12" y2="12"></line>
24
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
25
+ </svg>
26
+ <h3>页面初始化失败</h3>
27
+ <p>${escapeHtml(error.message || "未知错误")}</p>
28
+ </div>
29
+ </div>`;
30
+ });
31
+
32
+ async function bootstrap() {
33
+ syncRouteFromLocation();
34
+ bindNavigation();
35
+ bindViewEvents();
36
+ window.addEventListener("popstate", async () => {
37
+ syncRouteFromLocation();
38
+ await refreshApp({ fullView: true });
39
+ });
40
+ await refreshApp({ fullView: true });
41
+ startPolling();
42
+ }
43
+
44
+ function syncRouteFromLocation() {
45
+ const route = parseAppLocation(window.location.pathname);
46
+ state.route = route.route;
47
+ state.page = route.page;
48
+ state.selectedVideoId = route.videoId || state.selectedVideoId;
49
+ }
50
+
51
+ function navigateTo(path) {
52
+ if (window.location.pathname === path) return;
53
+ window.history.pushState({}, "", path);
54
+ syncRouteFromLocation();
55
+ }
56
+
57
+ function inferTransientStatusTone(message) {
58
+ if (/失败|错误|不可用|未就绪|无效|已取消|关闭/i.test(message)) {
59
+ return "error";
60
+ }
61
+ if (/完成|成功|已保存|已刷新|已开始|已删除|已更新|已复制|已请求/i.test(message)) {
62
+ return "success";
63
+ }
64
+ return "info";
65
+ }
66
+
67
+ function resolveTransientStatusDuration(message, tone) {
68
+ if (!message) {
69
+ return null;
70
+ }
71
+ if (/^正在|检测到.+请先|请先选择/i.test(message)) {
72
+ return null;
73
+ }
74
+ return tone === "error" ? 6500 : 4800;
75
+ }
76
+
77
+ function clearTransientStatus(key, { shouldRender = true } = {}) {
78
+ const timer = transientStatusTimers.get(key);
79
+ if (timer) {
80
+ window.clearTimeout(timer);
81
+ transientStatusTimers.delete(key);
82
+ }
83
+ if (!state[key]) {
84
+ return;
85
+ }
86
+ state[key] = "";
87
+ if (shouldRender) {
88
+ render({ fullView: true });
89
+ }
90
+ }
91
+
92
+ function setTransientStatus(key, message, { shouldRender = true } = {}) {
93
+ const nextMessage = String(message || "");
94
+ const previousMessage = String(state[key] || "");
95
+ const timer = transientStatusTimers.get(key);
96
+ if (timer) {
97
+ window.clearTimeout(timer);
98
+ transientStatusTimers.delete(key);
99
+ }
100
+
101
+ state[key] = nextMessage;
102
+
103
+ if (nextMessage) {
104
+ const tone = inferTransientStatusTone(nextMessage);
105
+ const duration = resolveTransientStatusDuration(nextMessage, tone);
106
+ if (duration != null) {
107
+ transientStatusTimers.set(key, window.setTimeout(() => {
108
+ if (state[key] === nextMessage) {
109
+ state[key] = "";
110
+ transientStatusTimers.delete(key);
111
+ render({ fullView: true });
112
+ }
113
+ }, duration));
114
+ }
115
+ }
116
+
117
+ if (shouldRender && previousMessage !== nextMessage) {
118
+ render({ fullView: true });
119
+ } else if (shouldRender) {
120
+ render({ fullView: true });
121
+ }
122
+ }
123
+
124
+ function bindNavigation() {
125
+ elements.nav.addEventListener("click", async (event) => {
126
+ const target = event.target.closest("[data-route]");
127
+ if (!target) return;
128
+ if (target.dataset.route === "settings") {
129
+ navigateTo("/settings");
130
+ state.route = "settings";
131
+ state.page = "settings";
132
+ } else {
133
+ navigateTo("/");
134
+ state.route = "library";
135
+ state.page = "library";
136
+ }
137
+ render({ fullView: true });
138
+ });
139
+ }
140
+
141
+ function bindViewEvents() {
142
+ elements.viewRoot.addEventListener("submit", (event) => {
143
+ const form = event.target;
144
+ if (!(form instanceof HTMLFormElement)) return;
145
+ if (form.id === "probe-form") {
146
+ event.preventDefault();
147
+ handleProbeSubmit(event).catch(console.error);
148
+ return;
149
+ }
150
+ if (form.id === "settings-form") {
151
+ event.preventDefault();
152
+ handleSettingsSubmit(event).catch(console.error);
153
+ }
154
+ });
155
+
156
+ elements.viewRoot.addEventListener("input", async (event) => {
157
+ const target = event.target;
158
+ if (!(target instanceof HTMLElement)) return;
159
+ if (target.id === "library-search") {
160
+ state.librarySearch = target.value;
161
+ render({ regions: ["list"] });
162
+ }
163
+ });
164
+
165
+ elements.viewRoot.addEventListener("click", async (event) => {
166
+ const target = event.target instanceof HTMLElement ? event.target : null;
167
+ if (!target) return;
168
+
169
+ const dismissButton = target.closest("[data-dismiss-status]");
170
+ if (dismissButton instanceof HTMLElement) {
171
+ event.preventDefault();
172
+ event.stopPropagation();
173
+ clearTransientStatus(dismissButton.dataset.dismissStatus || "");
174
+ return;
175
+ }
176
+
177
+ const actionNode = target.closest("[data-action]");
178
+ if (actionNode instanceof HTMLElement) {
179
+ event.stopPropagation();
180
+ await handleVideoAction(actionNode.dataset.action, actionNode.dataset);
181
+ return;
182
+ }
183
+
184
+ if (target.closest("#refresh-env")) {
185
+ await handleRefreshEnvironment();
186
+ return;
187
+ }
188
+
189
+ if (target.closest("#refresh-logs")) {
190
+ await handleRefreshLogs();
191
+ return;
192
+ }
193
+
194
+ if (target.closest("#shutdown-service")) {
195
+ await handleShutdownService();
196
+ return;
197
+ }
198
+
199
+ if (target.closest("#install-cuda")) {
200
+ await handleInstallCuda();
201
+ return;
202
+ }
203
+
204
+ if (target.closest("#install-local-asr")) {
205
+ await handleInstallLocalAsr();
206
+ return;
207
+ }
208
+
209
+ const videoCard = target.closest("[data-video-id]");
210
+ if (videoCard instanceof HTMLElement) {
211
+ await handleVideoAction("open-video", { videoId: videoCard.dataset.videoId });
212
+ }
213
+ });
214
+
215
+ // 支持键盘导航视频卡片
216
+ elements.viewRoot.addEventListener("keydown", async (event) => {
217
+ if (event.key === "Enter" || event.key === " ") {
218
+ const videoCard = event.target.closest("[data-video-id]");
219
+ if (videoCard instanceof HTMLElement) {
220
+ event.preventDefault();
221
+ await handleVideoAction("open-video", { videoId: videoCard.dataset.videoId });
222
+ }
223
+ }
224
+ });
225
+ }
226
+
227
+ async function refreshApp({ fullView = false } = {}) {
228
+ const prevState = getRenderState();
229
+ try {
230
+ const [health, systemInfo, settings, videos, environment] = await Promise.all([
231
+ api.getHealth(),
232
+ api.getSystemInfo(),
233
+ api.getSettings(),
234
+ api.listVideos(),
235
+ api.getEnvironment(),
236
+ ]);
237
+ state.serviceOnline = health.status === "ok";
238
+ state.systemInfo = systemInfo;
239
+ state.settings = settings;
240
+ state.environment = environment;
241
+ state.videos = videos;
242
+ state.logPath = systemInfo?.service?.log_file || state.logPath;
243
+
244
+ if (!state.selectedVideoId && videos.length) {
245
+ state.selectedVideoId = videos[0].video_id;
246
+ }
247
+ if (state.selectedVideoId) {
248
+ await loadSelectedVideo({ preserveRender: true });
249
+ }
250
+
251
+ const nextState = getRenderState();
252
+ const changedRegions = getChangedRegions(prevState, nextState);
253
+ if (fullView || hasShellChanges(prevState, nextState) || changedRegions.length) {
254
+ render({ fullView, regions: changedRegions });
255
+ }
256
+ } catch (error) {
257
+ state.serviceOnline = false;
258
+ render({ errorMessage: error.message || "服务不可用" });
259
+ }
260
+ }
261
+
262
+ async function loadSelectedVideo({ preserveRender = false } = {}) {
263
+ if (!state.selectedVideoId) return;
264
+ const [video, tasks] = await Promise.all([
265
+ api.getVideo(state.selectedVideoId),
266
+ api.getVideoTasks(state.selectedVideoId),
267
+ ]);
268
+ state.selectedVideoDetail = video;
269
+ state.selectedVideoTasks = tasks;
270
+ const nextTaskId =
271
+ state.selectedTaskId && tasks.some((task) => task.task_id === state.selectedTaskId)
272
+ ? state.selectedTaskId
273
+ : tasks[0]?.task_id ?? null;
274
+ state.selectedTaskId = nextTaskId;
275
+ if (nextTaskId) {
276
+ const [detail, events] = await Promise.all([
277
+ api.getTaskResult(nextTaskId),
278
+ api.getTaskEvents(nextTaskId),
279
+ ]);
280
+ state.selectedTaskDetail = detail;
281
+ state.selectedTaskEvents = events;
282
+ state.eventSourceAfter = events.length ? events[events.length - 1].created_at : null;
283
+ syncTaskEventStream();
284
+ } else {
285
+ state.selectedTaskDetail = null;
286
+ state.selectedTaskEvents = [];
287
+ closeTaskEventStream();
288
+ }
289
+ if (!preserveRender) {
290
+ render({ fullView: true });
291
+ }
292
+ }
293
+
294
+ async function handleProbeSubmit(event) {
295
+ event.preventDefault();
296
+ const url = document.getElementById("probe-url-input")?.value.trim();
297
+ if (!url) {
298
+ setTransientStatus("submitStatus", "请输入 Bilibili / YouTube 视频链接,或直接输入 BV 号");
299
+ return;
300
+ }
301
+ setTransientStatus("submitStatus", "正在抓取视频信息并准备开始总结...");
302
+ try {
303
+ let response = await api.probeVideo({ url, force_refresh: false });
304
+ if (response.requires_selection && Array.isArray(response.pages) && response.pages.length > 0) {
305
+ const pageChoices = response.pages.map((item) => `P${item.page}: ${item.title}`).join("\n");
306
+ const selected = window.prompt(`检测到多 P 视频,请输入要解析的 P 编号:\n${pageChoices}`, String(response.pages[0].page));
307
+ if (!selected) {
308
+ setTransientStatus("submitStatus", "已取消解析");
309
+ return;
310
+ }
311
+ const selectedPage = response.pages.find((item) => String(item.page) === selected.trim());
312
+ if (!selectedPage) {
313
+ setTransientStatus("submitStatus", "输入的 P 编号无效");
314
+ return;
315
+ }
316
+ setTransientStatus("submitStatus", `已选择 P${selectedPage.page},正在创建任务...`);
317
+ state.probePreview = response.video;
318
+ state.selectedVideoId = response.video.video_id;
319
+ navigateTo(`/videos/${response.video.video_id}`);
320
+ const created = await api.createVideoTask(response.video.video_id, { page_number: selectedPage.page });
321
+ state.selectedTaskId = created.task_id;
322
+ setTransientStatus("submitStatus", `P${selectedPage.page} 已开始生成摘要`, { shouldRender: false });
323
+ await refreshApp({ fullView: true });
324
+ return;
325
+ }
326
+ state.probePreview = response.video;
327
+ state.selectedVideoId = response.video.video_id;
328
+ navigateTo(`/videos/${response.video.video_id}`);
329
+ const created = await api.createVideoTask(response.video.video_id);
330
+ state.selectedTaskId = created.task_id;
331
+ setTransientStatus("submitStatus", response.cached ? "已从视频库读取并开始总结" : "视频已加入本地库并开始总结", { shouldRender: false });
332
+ await refreshApp({ fullView: true });
333
+ } catch (error) {
334
+ setTransientStatus("submitStatus", error.message || "开始总结失败");
335
+ }
336
+ }
337
+
338
+ async function handleVideoAction(action, dataset) {
339
+ if (action === "open-video" && dataset.videoId) {
340
+ state.selectedVideoId = dataset.videoId;
341
+ navigateTo(`/videos/${dataset.videoId}`);
342
+ await loadSelectedVideo({ preserveRender: false });
343
+ return;
344
+ }
345
+
346
+ if (action === "back-library") {
347
+ navigateTo("/");
348
+ state.page = "library";
349
+ render({ fullView: true });
350
+ return;
351
+ }
352
+
353
+ if (action === "start-task" && dataset.videoId) {
354
+ setTransientStatus("submitStatus", "正在创建处理任务...");
355
+ try {
356
+ const created = await api.createVideoTask(dataset.videoId);
357
+ state.selectedTaskId = created.task_id;
358
+ await refreshApp({ fullView: true });
359
+ } catch (error) {
360
+ setTransientStatus("submitStatus", error.message || "创建任务失败");
361
+ }
362
+ return;
363
+ }
364
+
365
+ if (action === "refresh-video" && dataset.videoUrl) {
366
+ setTransientStatus("submitStatus", "正在重新获取视频信息...");
367
+ try {
368
+ const response = await api.refreshVideo(dataset.videoUrl);
369
+ if (response?.video?.video_id) {
370
+ state.selectedVideoId = response.video.video_id;
371
+ }
372
+ setTransientStatus("submitStatus", "视频信息已更新", { shouldRender: false });
373
+ await refreshApp({ fullView: state.page === "video-detail" });
374
+ } catch (error) {
375
+ setTransientStatus("submitStatus", error.message || "重新获取视频信息失败");
376
+ }
377
+ return;
378
+ }
379
+
380
+ if (action === "delete-video" && dataset.videoId) {
381
+ if (!confirm("确定要删除这个视频吗?这将同时删除所有相关任务。")) {
382
+ return;
383
+ }
384
+ try {
385
+ await api.deleteVideo(dataset.videoId);
386
+ if (state.selectedVideoId === dataset.videoId) {
387
+ state.selectedVideoId = null;
388
+ state.selectedTaskId = null;
389
+ state.selectedTaskDetail = null;
390
+ state.selectedTaskEvents = [];
391
+ state.selectedVideoDetail = null;
392
+ state.selectedVideoTasks = [];
393
+ }
394
+ if (state.page === "video-detail" && dataset.videoId) {
395
+ navigateTo("/");
396
+ state.page = "library";
397
+ }
398
+ setTransientStatus("submitStatus", "视频已删除", { shouldRender: false });
399
+ await refreshApp({ fullView: true });
400
+ } catch (error) {
401
+ setTransientStatus("submitStatus", error.message || "删除视频失败");
402
+ }
403
+ return;
404
+ }
405
+
406
+ if (action === "delete-task" && dataset.taskId) {
407
+ if (!confirm("确定要删除这个任务吗?")) {
408
+ return;
409
+ }
410
+ try {
411
+ await api.deleteTask(dataset.taskId);
412
+ if (state.selectedTaskId === dataset.taskId) {
413
+ state.selectedTaskId = null;
414
+ }
415
+ await refreshApp({ fullView: true });
416
+ } catch (error) {
417
+ setTransientStatus("submitStatus", error.message || "删除任务失败");
418
+ }
419
+ return;
420
+ }
421
+
422
+ if (action === "select-task" && dataset.taskId) {
423
+ state.selectedTaskId = dataset.taskId;
424
+ await loadSelectedVideo({ preserveRender: false });
425
+ }
426
+ }
427
+
428
+ async function handleSettingsSubmit(event) {
429
+ event.preventDefault();
430
+ const readValue = (id, fallback = "") => {
431
+ const element = document.getElementById(id);
432
+ if (!element) return fallback;
433
+ return "value" in element ? element.value : fallback;
434
+ };
435
+ const readTrimmedValue = (id, fallback = "") => String(readValue(id, fallback)).trim();
436
+ const readNumberValue = (id, fallback = 0) => {
437
+ const parsed = Number(readValue(id, fallback));
438
+ return Number.isFinite(parsed) ? parsed : fallback;
439
+ };
440
+ const readChecked = (id, fallback = false) => {
441
+ const element = document.getElementById(id);
442
+ if (!element) return fallback;
443
+ return "checked" in element ? element.checked : fallback;
444
+ };
445
+ const current = state.settings || {};
446
+ const payload = {
447
+ host: readTrimmedValue("host", current.host || ""),
448
+ port: readNumberValue("port", current.port || 3838),
449
+ data_dir: readTrimmedValue("data_dir", current.data_dir || ""),
450
+ cache_dir: readTrimmedValue("cache_dir", current.cache_dir || ""),
451
+ tasks_dir: readTrimmedValue("tasks_dir", current.tasks_dir || ""),
452
+ database_url: readTrimmedValue("database_url", current.database_url || ""),
453
+ transcription_provider: readValue("transcription_provider", current.transcription_provider || "siliconflow"),
454
+ whisper_model: readTrimmedValue("whisper_model", current.whisper_model || ""),
455
+ whisper_device: readTrimmedValue("whisper_device", current.whisper_device || ""),
456
+ whisper_compute_type: readTrimmedValue("whisper_compute_type", current.whisper_compute_type || ""),
457
+ device_preference: readValue("device_preference", current.device_preference || "cpu"),
458
+ compute_type: readValue("compute_type", current.compute_type || "int8"),
459
+ model_mode: readValue("model_mode", current.model_mode || "fixed"),
460
+ fixed_model: readValue("fixed_model", current.fixed_model || "tiny"),
461
+ siliconflow_asr_base_url: readTrimmedValue("siliconflow_asr_base_url", current.siliconflow_asr_base_url || ""),
462
+ siliconflow_asr_model: readTrimmedValue("siliconflow_asr_model", current.siliconflow_asr_model || "TeleAI/TeleSpeechASR"),
463
+ siliconflow_asr_api_key: readValue("siliconflow_asr_api_key", current.siliconflow_asr_api_key || ""),
464
+ cuda_variant: readValue("cuda_variant", current.cuda_variant || "cu128"),
465
+ runtime_channel: readValue("runtime_channel", current.runtime_channel || "base"),
466
+ output_dir: readTrimmedValue("output_dir", current.output_dir || ""),
467
+ preserve_temp_audio: readChecked("preserve_temp_audio", Boolean(current.preserve_temp_audio)),
468
+ enable_cache: readChecked("enable_cache", Boolean(current.enable_cache)),
469
+ language: readTrimmedValue("language", current.language || "zh"),
470
+ summary_mode: readValue("summary_mode", current.summary_mode || "llm"),
471
+ llm_enabled: readChecked("llm_enabled", Boolean(current.llm_enabled)),
472
+ llm_provider: readTrimmedValue("llm_provider", current.llm_provider || ""),
473
+ llm_base_url: readTrimmedValue("llm_base_url", current.llm_base_url || ""),
474
+ llm_model: readTrimmedValue("llm_model", current.llm_model || ""),
475
+ llm_api_key: readValue("llm_api_key", current.llm_api_key || ""),
476
+ summary_system_prompt: readValue("summary_system_prompt", current.summary_system_prompt || ""),
477
+ summary_user_prompt_template: readValue(
478
+ "summary_user_prompt_template",
479
+ current.summary_user_prompt_template || "",
480
+ ),
481
+ summary_chunk_target_chars: Number(readValue("summary_chunk_target_chars", current.summary_chunk_target_chars || 2200)),
482
+ summary_chunk_overlap_segments: Number(readValue("summary_chunk_overlap_segments", current.summary_chunk_overlap_segments || 2)),
483
+ summary_chunk_concurrency: Number(readValue("summary_chunk_concurrency", current.summary_chunk_concurrency || 2)),
484
+ summary_chunk_retry_count: Number(readValue("summary_chunk_retry_count", current.summary_chunk_retry_count || 2)),
485
+ };
486
+ setTransientStatus("settingsSaveStatus", "正在保存设置...");
487
+ try {
488
+ const response = await api.updateSettings(payload);
489
+ state.settings = response.settings;
490
+ setTransientStatus("settingsSaveStatus", response.message || "设置已保存", { shouldRender: false });
491
+ await refreshApp({ fullView: true });
492
+ } catch (error) {
493
+ setTransientStatus("settingsSaveStatus", error.message || "设置保存失败");
494
+ }
495
+ }
496
+
497
+ async function handleRefreshEnvironment() {
498
+ setTransientStatus("cudaActionStatus", "正在检测本机环境...");
499
+ try {
500
+ const runtimeChannel = document.getElementById("runtime_channel")?.value || state.settings?.runtime_channel || "base";
501
+ state.environment = await api.getEnvironment({ runtimeChannel, refresh: true });
502
+ setTransientStatus("cudaActionStatus", "环境检测完成", { shouldRender: false });
503
+ } catch (error) {
504
+ setTransientStatus("cudaActionStatus", error.message || "环境检测失败", { shouldRender: false });
505
+ }
506
+ render({ fullView: true });
507
+ }
508
+
509
+ async function handleRefreshLogs() {
510
+ setTransientStatus("serviceActionStatus", "正在读取后端日志...");
511
+ try {
512
+ const response = await api.getSystemLogs(200);
513
+ state.logOutput = response.content || "";
514
+ state.logPath = response.path || "";
515
+ setTransientStatus("serviceActionStatus", "日志已刷新", { shouldRender: false });
516
+ } catch (error) {
517
+ setTransientStatus("serviceActionStatus", error.message || "读取日志失败", { shouldRender: false });
518
+ }
519
+ render({ fullView: true });
520
+ }
521
+
522
+ async function handleInstallCuda() {
523
+ setTransientStatus("cudaActionStatus", "正在安装 CUDA 支持,这可能需要几分钟...", { shouldRender: false });
524
+ state.cudaInstallOutput = "";
525
+ render({ fullView: true });
526
+ try {
527
+ const response = await api.installCuda({
528
+ cuda_variant: document.getElementById("cuda_variant")?.value || "cu128",
529
+ });
530
+ state.cudaInstallOutput = response.stdoutTail || "";
531
+ if (state.settings) {
532
+ state.settings = {
533
+ ...state.settings,
534
+ cuda_variant: response.cudaVariant,
535
+ runtime_channel: response.runtimeChannel || state.settings.runtime_channel,
536
+ };
537
+ }
538
+ state.environment = await api.getEnvironment({
539
+ runtimeChannel: response.runtimeChannel || state.settings?.runtime_channel || "base",
540
+ refresh: true,
541
+ });
542
+ setTransientStatus(
543
+ "cudaActionStatus",
544
+ response.restartRequired
545
+ ? "CUDA 安装完成,请重启应用后切换到新的 GPU 运行时"
546
+ : "CUDA 安装完成",
547
+ { shouldRender: false },
548
+ );
549
+ } catch (error) {
550
+ setTransientStatus("cudaActionStatus", error.message || "CUDA 安装失败", { shouldRender: false });
551
+ }
552
+ render({ fullView: true });
553
+ }
554
+
555
+ async function handleInstallLocalAsr() {
556
+ setTransientStatus("localAsrActionStatus", "正在安装本地语音识别环境...", { shouldRender: false });
557
+ try {
558
+ const response = await api.installLocalAsr({});
559
+ state.localAsrInstallOutput = response.stdoutTail || "";
560
+ if (response.environment) {
561
+ state.environment = response.environment;
562
+ } else {
563
+ state.environment = await api.getEnvironment({
564
+ runtimeChannel: state.settings?.runtime_channel || "base",
565
+ refresh: true,
566
+ });
567
+ }
568
+ if (response.installed && state.settings) {
569
+ try {
570
+ const settingsResponse = await api.updateSettings({
571
+ ...state.settings,
572
+ transcription_provider: "local",
573
+ });
574
+ state.settings = settingsResponse.settings;
575
+ setTransientStatus("settingsSaveStatus", settingsResponse.message || "已切换为本地 ASR", { shouldRender: false });
576
+ } catch (error) {
577
+ setTransientStatus(
578
+ "localAsrActionStatus",
579
+ `本地 ASR 已安装,但切换默认转写方式失败:${error.message || "保存设置失败"}`,
580
+ { shouldRender: false },
581
+ );
582
+ render({ fullView: true });
583
+ return;
584
+ }
585
+ }
586
+ setTransientStatus("localAsrActionStatus", response.installed ? "本地语音识别环境已安装" : "本地语音识别环境安装失败", { shouldRender: false });
587
+ } catch (error) {
588
+ setTransientStatus("localAsrActionStatus", error.message || "本地语音识别环境安装失败", { shouldRender: false });
589
+ }
590
+ render({ fullView: true });
591
+ }
592
+
593
+ async function handleShutdownService() {
594
+ setTransientStatus("serviceActionStatus", "正在关闭后端服务...");
595
+ try {
596
+ const response = await api.shutdownService();
597
+ setTransientStatus("serviceActionStatus", response.message || "服务正在关闭", { shouldRender: false });
598
+ state.serviceOnline = false;
599
+ if (state.pollTimer) {
600
+ clearInterval(state.pollTimer);
601
+ state.pollTimer = null;
602
+ }
603
+ } catch (error) {
604
+ setTransientStatus("serviceActionStatus", error.message || "关闭服务失败", { shouldRender: false });
605
+ }
606
+ render({ fullView: true });
607
+ }
608
+
609
+ function render({ errorMessage = "", fullView = false, regions = [] } = {}) {
610
+ const meta = routeMeta(state.route);
611
+ elements.pageEyebrow.textContent = meta.eyebrow;
612
+ elements.pageTitle.textContent =
613
+ state.page === "video-detail" && state.selectedVideoDetail
614
+ ? state.selectedVideoDetail.title
615
+ : meta.title;
616
+ elements.serviceBadge.textContent = state.serviceOnline ? "服务在线" : "服务离线";
617
+ elements.serviceBadge.className = `service-badge ${state.serviceOnline ? "service-online" : "service-offline"}`;
618
+ elements.liveStatus.innerHTML = renderLiveStatus(errorMessage);
619
+ highlightNav();
620
+
621
+ const nextViewKey = getViewKey();
622
+ if (fullView || renderedViewKey !== nextViewKey || state.route === "settings") {
623
+ if (state.route === "settings") {
624
+ elements.viewRoot.innerHTML = renderSettingsView(state);
625
+ } else {
626
+ elements.viewRoot.innerHTML = renderHomeView(state);
627
+ }
628
+ renderedViewKey = nextViewKey;
629
+ return;
630
+ }
631
+
632
+ if (state.route !== "library") return;
633
+
634
+ const regionHtml = renderHomeRegions(state);
635
+ for (const region of regions) {
636
+ if (region === "progress" && patchTaskProgressNodes()) {
637
+ continue;
638
+ }
639
+ const targetId = getRegionTargetId(region);
640
+ const html = regionHtml[region];
641
+ if (!targetId || html == null) continue;
642
+ const node = document.getElementById(targetId);
643
+ if (node) {
644
+ node.innerHTML = html;
645
+ }
646
+ }
647
+ }
648
+
649
+ function patchTaskProgressNodes() {
650
+ if (state.route !== "library" || state.page !== "video-detail" || !state.selectedTaskDetail) {
651
+ return false;
652
+ }
653
+
654
+ const bar = document.getElementById("task-progress-bar");
655
+ const fill = document.getElementById("task-progress-fill");
656
+ const percent = document.getElementById("task-progress-percent");
657
+ const status = document.getElementById("task-progress-status");
658
+ const title = document.getElementById("task-progress-title");
659
+ const subtitle = document.getElementById("task-progress-subtitle");
660
+ const events = document.getElementById("task-progress-events");
661
+
662
+ if (!bar || !fill || !percent || !status || !title || !subtitle || !events) {
663
+ return false;
664
+ }
665
+
666
+ const view = buildTaskProgressView(state.selectedTaskEvents || []);
667
+ const fillClass = view.hasError ? "error" : view.isCompleted ? "success" : "";
668
+
669
+ bar.setAttribute("aria-valuenow", String(Math.round(view.progress)));
670
+ fill.className = `progress-fill-simple ${fillClass}`.trim();
671
+ fill.style.width = `${view.progress}%`;
672
+ percent.textContent = `${Math.round(view.progress)}%`;
673
+ status.textContent = view.headlineEvent?.message || "等待开始...";
674
+ title.textContent = view.title;
675
+ subtitle.textContent = view.subtitle;
676
+ events.innerHTML = renderTaskProgressEvents(state.selectedTaskEvents || []);
677
+ return true;
678
+ }
679
+
680
+ function getViewKey() {
681
+ return state.route === "library" && state.page === "video-detail"
682
+ ? `${state.route}:${state.page}:${state.selectedVideoId || ""}`
683
+ : `${state.route}:${state.page}`;
684
+ }
685
+
686
+ function getRegionTargetId(region) {
687
+ const map = {
688
+ intake: "library-intake-region",
689
+ summary: "library-summary-region",
690
+ grid: "library-grid-region",
691
+ list: "library-list-region",
692
+ hero: "video-detail-hero-region",
693
+ result: "video-detail-result-region",
694
+ progress: "video-detail-progress-region",
695
+ history: "video-detail-history-region",
696
+ };
697
+ return map[region] || "";
698
+ }
699
+
700
+ function getRenderState() {
701
+ return {
702
+ route: state.route,
703
+ page: state.page,
704
+ serviceOnline: state.serviceOnline,
705
+ videos: state.videos,
706
+ librarySearch: state.librarySearch,
707
+ selectedVideoId: state.selectedVideoId,
708
+ selectedVideoDetail: state.selectedVideoDetail,
709
+ selectedVideoTasks: state.selectedVideoTasks,
710
+ selectedTaskId: state.selectedTaskId,
711
+ selectedTaskDetail: state.selectedTaskDetail,
712
+ selectedTaskEvents: state.selectedTaskEvents,
713
+ settings: state.settings,
714
+ environment: state.environment,
715
+ submitStatus: state.submitStatus,
716
+ settingsSaveStatus: state.settingsSaveStatus,
717
+ probePreview: state.probePreview,
718
+ cudaActionStatus: state.cudaActionStatus,
719
+ cudaInstallOutput: state.cudaInstallOutput,
720
+ logOutput: state.logOutput,
721
+ logPath: state.logPath,
722
+ serviceActionStatus: state.serviceActionStatus,
723
+ };
724
+ }
725
+
726
+ function isEqual(a, b) {
727
+ return JSON.stringify(a) === JSON.stringify(b);
728
+ }
729
+
730
+ function hasShellChanges(prevState, nextState) {
731
+ return !isEqual(
732
+ {
733
+ route: prevState.route,
734
+ page: prevState.page,
735
+ serviceOnline: prevState.serviceOnline,
736
+ videos: prevState.videos,
737
+ selectedVideoDetail: prevState.selectedVideoDetail,
738
+ },
739
+ {
740
+ route: nextState.route,
741
+ page: nextState.page,
742
+ serviceOnline: nextState.serviceOnline,
743
+ videos: nextState.videos,
744
+ selectedVideoDetail: nextState.selectedVideoDetail,
745
+ },
746
+ );
747
+ }
748
+
749
+ function getChangedRegions(prevState, nextState) {
750
+ if (prevState.route !== nextState.route || prevState.page !== nextState.page) {
751
+ return [];
752
+ }
753
+
754
+ if (nextState.route !== "library") {
755
+ return [];
756
+ }
757
+
758
+ if (nextState.page === "library") {
759
+ const changed = [];
760
+ if (!isEqual(
761
+ { submitStatus: prevState.submitStatus, probePreview: prevState.probePreview },
762
+ { submitStatus: nextState.submitStatus, probePreview: nextState.probePreview },
763
+ )) {
764
+ changed.push("intake");
765
+ }
766
+ if (!isEqual(prevState.videos, nextState.videos)) {
767
+ changed.push("summary", "list");
768
+ }
769
+ if (prevState.librarySearch !== nextState.librarySearch) {
770
+ changed.push("list");
771
+ }
772
+ return [...new Set(changed)];
773
+ }
774
+
775
+ const changed = [];
776
+ if (!isEqual(
777
+ { selectedVideoDetail: prevState.selectedVideoDetail, selectedTaskDetail: prevState.selectedTaskDetail },
778
+ { selectedVideoDetail: nextState.selectedVideoDetail, selectedTaskDetail: nextState.selectedTaskDetail },
779
+ )) {
780
+ changed.push("hero");
781
+ }
782
+ if (!isEqual(prevState.selectedVideoDetail?.latest_result, nextState.selectedVideoDetail?.latest_result)) {
783
+ changed.push("result");
784
+ }
785
+ if (!isEqual(
786
+ { selectedTaskDetail: prevState.selectedTaskDetail, selectedTaskEvents: prevState.selectedTaskEvents },
787
+ { selectedTaskDetail: nextState.selectedTaskDetail, selectedTaskEvents: nextState.selectedTaskEvents },
788
+ )) {
789
+ changed.push("progress");
790
+ }
791
+ if (!isEqual(
792
+ { selectedVideoTasks: prevState.selectedVideoTasks, selectedTaskId: prevState.selectedTaskId },
793
+ { selectedVideoTasks: nextState.selectedVideoTasks, selectedTaskId: nextState.selectedTaskId },
794
+ )) {
795
+ changed.push("history");
796
+ }
797
+ return [...new Set(changed)];
798
+ }
799
+
800
+ function renderLiveStatus(errorMessage) {
801
+ const latest = state.videos[0];
802
+ const latestTitle = latest ? latest.title : "暂无视频";
803
+ const latestStatus = latest?.latest_status || "未开始";
804
+ const statusColor = state.serviceOnline ? "var(--color-success)" : "var(--color-error)";
805
+ const statusText = state.serviceOnline ? "运行中" : "不可访问";
806
+
807
+ // 截断标题,最多显示20个字符
808
+ const truncatedTitle = latestTitle.length > 20 ? latestTitle.slice(0, 20) + "..." : latestTitle;
809
+
810
+ return `
811
+ <div class="status-card">
812
+ <strong>服务状态</strong>
813
+ <div class="status-caption" style="color: ${statusColor}; font-weight: 600;">${statusText}</div>
814
+ </div>
815
+ <div class="status-card">
816
+ <strong>视频数量</strong>
817
+ <div class="status-caption" style="font-size: 18px; font-weight: 700;">${state.videos.length}</div>
818
+ </div>
819
+ <div class="status-card">
820
+ <strong>最近视频</strong>
821
+ <div class="status-caption" title="${escapeHtml(latestTitle)}">${escapeHtml(truncatedTitle)}</div>
822
+ </div>
823
+ <div class="status-card">
824
+ <strong>最近状态</strong>
825
+ <div class="status-caption">${escapeHtml(latestStatus)}</div>
826
+ </div>
827
+ ${errorMessage ? `<div class="status-card" style="background: var(--bg-error); border: 1px solid var(--error);"><strong style="color: var(--error);">错误</strong><div class="status-caption">${escapeHtml(errorMessage)}</div></div>` : ""}
828
+ `;
829
+ }
830
+
831
+ function highlightNav() {
832
+ for (const item of elements.nav.querySelectorAll("[data-route]")) {
833
+ const isActive = item.dataset.route === state.route;
834
+ item.classList.toggle("active", isActive);
835
+ item.setAttribute("aria-selected", String(isActive));
836
+ }
837
+ }
838
+
839
+ function startPolling() {
840
+ if (state.pollTimer) clearInterval(state.pollTimer);
841
+ state.pollTimer = setInterval(async () => {
842
+ await refreshApp({ fullView: false });
843
+ }, 5000);
844
+ }
845
+
846
+ function syncTaskEventStream() {
847
+ const shouldStream =
848
+ state.route === "library" &&
849
+ state.selectedTaskDetail &&
850
+ !["completed", "failed", "cancelled"].includes(state.selectedTaskDetail.status);
851
+ if (!shouldStream) {
852
+ closeTaskEventStream();
853
+ return;
854
+ }
855
+ if (state.eventSource && state.eventSourceTaskId === state.selectedTaskId) return;
856
+ closeTaskEventStream();
857
+ const source = api.streamTaskEvents(state.selectedTaskId, state.eventSourceAfter);
858
+ state.eventSource = source;
859
+ state.eventSourceTaskId = state.selectedTaskId;
860
+ source.addEventListener("progress", (event) => {
861
+ const payload = JSON.parse(event.data);
862
+ const nextEvent = payload.event;
863
+ if (!state.selectedTaskEvents.some((item) => item.event_id === nextEvent.event_id)) {
864
+ state.selectedTaskEvents = [...state.selectedTaskEvents, nextEvent];
865
+ state.eventSourceAfter = nextEvent.created_at;
866
+ if (state.selectedTaskDetail) {
867
+ state.selectedTaskDetail = {
868
+ ...state.selectedTaskDetail,
869
+ status: payload.status,
870
+ updated_at: payload.updated_at,
871
+ result: payload.result === undefined ? state.selectedTaskDetail.result : payload.result,
872
+ llm_total_tokens: payload.result?.llm_total_tokens ?? state.selectedTaskDetail.llm_total_tokens,
873
+ };
874
+ }
875
+ if (state.selectedVideoDetail) {
876
+ state.selectedVideoDetail = {
877
+ ...state.selectedVideoDetail,
878
+ latest_status: payload.status,
879
+ updated_at: payload.updated_at,
880
+ latest_result: payload.result === undefined ? state.selectedVideoDetail.latest_result : payload.result,
881
+ };
882
+ }
883
+ state.selectedVideoTasks = state.selectedVideoTasks.map((task) =>
884
+ task.task_id === state.selectedTaskId
885
+ ? {
886
+ ...task,
887
+ status: payload.status,
888
+ updated_at: payload.updated_at,
889
+ llm_total_tokens: payload.result?.llm_total_tokens ?? task.llm_total_tokens,
890
+ }
891
+ : task,
892
+ );
893
+ state.videos = state.videos.map((video) =>
894
+ video.video_id === state.selectedVideoId
895
+ ? {
896
+ ...video,
897
+ latest_status: payload.status,
898
+ latest_stage: nextEvent.stage,
899
+ latest_task_id: state.selectedTaskId,
900
+ updated_at: payload.updated_at,
901
+ }
902
+ : video,
903
+ );
904
+ render({ regions: state.page === "video-detail" ? ["hero", "progress", "list", "summary"] : ["list", "summary"] });
905
+ }
906
+ if (["completed", "failed", "cancelled"].includes(payload.status)) {
907
+ closeTaskEventStream();
908
+ refreshApp({ fullView: false }).catch(() => {});
909
+ }
910
+ });
911
+ source.addEventListener("error", () => {
912
+ closeTaskEventStream();
913
+ });
914
+ }
915
+
916
+ function closeTaskEventStream() {
917
+ if (state.eventSource) state.eventSource.close();
918
+ state.eventSource = null;
919
+ state.eventSourceTaskId = null;
920
+ }
921
+
922
+ function snapshot() {
923
+ return JSON.stringify(getRenderState());
924
+ }
925
+
926
+ // 简单的 HTML 转义函数(用于错误消息)
927
+ function escapeHtml(str) {
928
+ if (typeof str !== "string") return str;
929
+ const div = document.createElement("div");
930
+ div.textContent = str;
931
+ return div.innerHTML;
932
+ }