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,604 @@
1
+ import { escapeHtml, formatDateTime, formatDuration, formatTaskDuration, formatTokenCount, taskStatusLabel } from "../utils.js";
2
+
3
+ function getStatusTone(message) {
4
+ if (/失败|错误|不可用|未就绪|无效|已取消|关闭/i.test(message)) {
5
+ return "error";
6
+ }
7
+ if (/完成|成功|已保存|已刷新|已开始|已删除|已更新|已复制|已请求/i.test(message)) {
8
+ return "success";
9
+ }
10
+ return "info";
11
+ }
12
+
13
+ function renderStatusNotice(message, className, statusKey) {
14
+ if (!message) {
15
+ return "";
16
+ }
17
+ const tone = getStatusTone(message);
18
+ return `
19
+ <div class="${className} tone-${tone}">
20
+ <span class="${className}-copy">${escapeHtml(message)}</span>
21
+ <button class="${className}-close" type="button" data-dismiss-status="${statusKey}" aria-label="关闭提示">
22
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
23
+ <path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
24
+ </svg>
25
+ </button>
26
+ </div>
27
+ `;
28
+ }
29
+
30
+ export function renderHomeView(state) {
31
+ return state.page === "video-detail" ? renderVideoDetailPage(state) : renderLibraryPage(state);
32
+ }
33
+
34
+ export function renderHomeRegions(state) {
35
+ return state.page === "video-detail" ? renderVideoDetailRegions(state) : renderLibraryRegions(state);
36
+ }
37
+
38
+ export function buildTaskProgressView(events) {
39
+ const displayEvents = getDisplayProgressEvents(events);
40
+ const stages = [
41
+ { key: "queued", label: "排队中" },
42
+ { key: "preparing", label: "准备" },
43
+ { key: "probing", label: "探测" },
44
+ { key: "downloading", label: "下载" },
45
+ { key: "transcribing", label: "转写" },
46
+ { key: "summarizing", label: "摘要" },
47
+ { key: "exporting", label: "导出" },
48
+ { key: "completed", label: "完成" },
49
+ ];
50
+
51
+ const stageMap = new Map();
52
+ let currentStageIndex = -1;
53
+ let currentEvent = null;
54
+ let failedEvent = null;
55
+
56
+ for (const event of events) {
57
+ if (event.stage === "failed") {
58
+ failedEvent = event;
59
+ continue;
60
+ }
61
+ stageMap.set(event.stage, event);
62
+ const stageIndex = stages.findIndex((stage) => stage.key === event.stage);
63
+ if (stageIndex >= currentStageIndex) {
64
+ currentStageIndex = stageIndex;
65
+ currentEvent = event;
66
+ }
67
+ }
68
+
69
+ const hasError = Boolean(failedEvent);
70
+ const isCompleted = stageMap.has("completed");
71
+ const completedCount = isCompleted ? stages.length : Math.max(currentStageIndex + 1, 0);
72
+ const headlineEvent = hasError ? failedEvent : currentEvent;
73
+ const fallbackProgress = Math.min((completedCount / stages.length) * 100, 100);
74
+ const headlineProgress = Number(headlineEvent?.progress);
75
+ const progress =
76
+ isCompleted ? 100 :
77
+ Number.isFinite(headlineProgress) ? Math.max(0, Math.min(100, Math.round(headlineProgress))) :
78
+ fallbackProgress;
79
+
80
+ return {
81
+ stages,
82
+ currentEvent,
83
+ failedEvent,
84
+ hasError,
85
+ isCompleted,
86
+ progress,
87
+ headlineEvent,
88
+ title: headlineEvent ? `当前阶段:${getStageLabel(headlineEvent.stage, stages)}` : "阶段详情",
89
+ subtitle: displayEvents.length ? `${displayEvents.length} 条进度记录` : "任务开始后会在这里显示阶段记录",
90
+ };
91
+ }
92
+
93
+ export function renderTaskProgressEvents(events) {
94
+ const view = buildTaskProgressView(events);
95
+ const displayEvents = getDisplayProgressEvents(events);
96
+ return displayEvents.length
97
+ ? displayEvents.map((event) => renderStageEventCard(event, view.stages, view.currentEvent, view.failedEvent)).join("")
98
+ : `<div class="empty-placeholder">暂无进度记录</div>`;
99
+ }
100
+
101
+ function getDisplayProgressEvents(events) {
102
+ const displayEvents = [];
103
+ const mergedStageIndexes = new Map();
104
+ const mergeableStages = new Set(["downloading", "transcribing"]);
105
+
106
+ for (const event of events) {
107
+ if (!mergeableStages.has(event.stage)) {
108
+ displayEvents.push(event);
109
+ continue;
110
+ }
111
+
112
+ const mergedIndex = mergedStageIndexes.get(event.stage);
113
+ if (mergedIndex == null) {
114
+ displayEvents.push(event);
115
+ mergedStageIndexes.set(event.stage, displayEvents.length - 1);
116
+ continue;
117
+ }
118
+
119
+ displayEvents[mergedIndex] = event;
120
+ }
121
+
122
+ return displayEvents;
123
+ }
124
+
125
+ function renderLibraryPage(state) {
126
+ const regions = renderLibraryRegions(state);
127
+ return `
128
+ <section class="library-page">
129
+ <section class="library-topbar">
130
+ <div id="library-intake-region">${regions.intake}</div>
131
+ <div id="library-summary-region">${regions.summary}</div>
132
+ </section>
133
+
134
+ <div id="library-grid-region">${regions.grid}</div>
135
+ </section>
136
+ `;
137
+ }
138
+
139
+ function renderLibraryRegions(state) {
140
+ const videos = (state.videos || []).filter((video) => {
141
+ const keyword = state.librarySearch.trim().toLowerCase();
142
+ if (!keyword) return true;
143
+ return video.title.toLowerCase().includes(keyword) || video.source_url.toLowerCase().includes(keyword);
144
+ });
145
+
146
+ return {
147
+ intake: `
148
+ <article class="grid-card library-intake-card">
149
+ <div class="panel-header">
150
+ <h2>
151
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
152
+ <circle cx="12" cy="12" r="10"></circle>
153
+ <line x1="12" y1="8" x2="12" y2="16"></line>
154
+ <line x1="12" y1="12" x2="16" y2="12"></line>
155
+ </svg>
156
+ 开始总结
157
+ </h2>
158
+ <p>输入 Bilibili / YouTube 视频链接,或直接填写 BV 号后,系统会抓取封面和标题,并立即开始本地总结。</p>
159
+ </div>
160
+ <form id="probe-form" class="task-form">
161
+ <label class="input-row">
162
+ <span class="input-label">视频链接</span>
163
+ <input id="probe-url-input" type="text" class="input-field" placeholder="https://www.bilibili.com/video/...、https://www.youtube.com/watch?v=... 或 BV1xx411c7mD" required autocomplete="off" />
164
+ </label>
165
+ <div class="hero-actions">
166
+ <button class="primary-button" type="submit">
167
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
168
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
169
+ </svg>
170
+ 开始总结
171
+ </button>
172
+ </div>
173
+ ${renderStatusNotice(state.submitStatus, "submit-status", "submitStatus")}
174
+ </form>
175
+ ${state.probePreview ? renderProbePreview(state.probePreview) : ""}
176
+ </article>
177
+ `,
178
+ summary: `
179
+ <article class="grid-card library-summary-card">
180
+ <div class="panel-header">
181
+ <h2>
182
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
183
+ <path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
184
+ </svg>
185
+ 视频库概览
186
+ </h2>
187
+ <p>封面、本地缓存和任务结果统一管理</p>
188
+ </div>
189
+ <div class="library-summary-grid">
190
+ <div class="summary-metric">
191
+ <strong>${state.videos.length}</strong>
192
+ <span>视频总数</span>
193
+ </div>
194
+ <div class="summary-metric">
195
+ <strong>${state.videos.filter((item) => item.latest_status === "completed").length}</strong>
196
+ <span>已完成</span>
197
+ </div>
198
+ <div class="summary-metric">
199
+ <strong>${state.videos.filter((item) => item.latest_status === "running").length}</strong>
200
+ <span>处理中</span>
201
+ </div>
202
+ <div class="summary-metric">
203
+ <strong>${state.videos.filter((item) => item.has_result).length}</strong>
204
+ <span>有摘要结果</span>
205
+ </div>
206
+ </div>
207
+ </article>
208
+ `,
209
+ grid: `
210
+ <section class="grid-card library-grid-card">
211
+ <div class="card-header">
212
+ <div>
213
+ <h2 style="font-size: 16px; margin-bottom: 4px;">视频库</h2>
214
+ <p style="font-size: 13px; color: var(--text-secondary); margin: 0;">${state.videos.length} 个视频资产,点击卡片打开详情</p>
215
+ </div>
216
+ <div class="library-toolbar">
217
+ <input id="library-search" class="input-field" type="search" value="${escapeHtml(state.librarySearch)}" placeholder="搜索标题或链接..." aria-label="搜索视频" style="min-width: 280px;" />
218
+ </div>
219
+ </div>
220
+ <div id="library-list-region" class="video-grid">
221
+ ${videos.length ? videos.map((video) => renderVideoCard(video)).join("") : renderEmptyLibrary()}
222
+ </div>
223
+ </section>
224
+ `,
225
+ list: `${videos.length ? videos.map((video) => renderVideoCard(video)).join("") : renderEmptyLibrary()}`,
226
+ };
227
+ }
228
+
229
+ function renderVideoDetailPage(state) {
230
+ const video = state.selectedVideoDetail;
231
+ if (!video) {
232
+ return `
233
+ <article class="grid-card video-detail-empty">
234
+ <div class="empty-detail">
235
+ <svg class="empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
236
+ <circle cx="12" cy="12" r="10"></circle>
237
+ <line x1="12" y1="8" x2="12" y2="12"></line>
238
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
239
+ </svg>
240
+ <h3>没有找到这个视频</h3>
241
+ <p>它可能已被删除,或者本地服务还没有同步到最新数据。</p>
242
+ <button class="secondary-button" data-action="back-library" style="margin-top: 16px;">返回视频库</button>
243
+ </div>
244
+ </article>
245
+ `;
246
+ }
247
+
248
+ const regions = renderVideoDetailRegions(state);
249
+ return `
250
+ <section class="video-detail-page">
251
+ <div class="detail-page-toolbar">
252
+ <button class="secondary-button" data-action="back-library">
253
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
254
+ <line x1="19" y1="12" x2="5" y2="12"></line>
255
+ <polyline points="12 19 5 12 12 5"></polyline>
256
+ </svg>
257
+ 返回视频库
258
+ </button>
259
+ </div>
260
+
261
+ <div id="video-detail-hero-region">${regions.hero}</div>
262
+
263
+ <section class="video-detail-main">
264
+ <div id="video-detail-result-region">${regions.result}</div>
265
+
266
+ <aside class="video-detail-sidebar">
267
+ <div id="video-detail-progress-region">${regions.progress}</div>
268
+ <div id="video-detail-history-region">${regions.history}</div>
269
+ </aside>
270
+ </section>
271
+ </section>
272
+ `;
273
+ }
274
+
275
+ function renderVideoDetailRegions(state) {
276
+ const video = state.selectedVideoDetail;
277
+ const latestTask = state.selectedTaskDetail;
278
+ const events = state.selectedTaskEvents || [];
279
+
280
+ return {
281
+ hero: `
282
+ <article class="video-detail-hero">
283
+ <a class="video-detail-cover" href="${escapeHtml(video.source_url)}" target="_blank" rel="noreferrer" aria-label="打开视频原链接:${escapeHtml(video.title)}">
284
+ ${video.cover_url ? `<img src="${escapeHtml(video.cover_url)}" alt="${escapeHtml(video.title)}" loading="lazy" />` : `<div class="video-card-placeholder">VIDEO</div>`}
285
+ </a>
286
+ <div class="video-detail-copy">
287
+ <div class="hero-chip-row">
288
+ <span class="mini-chip ${video.latest_status === 'completed' ? 'success' : video.latest_status === 'running' ? 'running' : ''}">${escapeHtml(taskStatusLabel(video.latest_status))}</span>
289
+ <span class="mini-chip">${escapeHtml(formatDuration(video.duration))}</span>
290
+ <span class="mini-chip">${escapeHtml(formatDateTime(video.updated_at))}</span>
291
+ </div>
292
+ <h1 class="video-detail-title">${escapeHtml(video.title)}</h1>
293
+ <div class="detail-hero-actions">
294
+ <button class="primary-button" data-action="start-task" data-video-id="${video.video_id}">
295
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
296
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
297
+ </svg>
298
+ ${video.latest_task_id ? "重新生成摘要" : "生成摘要"}
299
+ </button>
300
+ <button class="secondary-button" data-action="refresh-video" data-video-id="${video.video_id}" data-video-url="${escapeHtml(video.source_url)}">
301
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
302
+ <polyline points="23 4 23 10 17 10"></polyline>
303
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
304
+ </svg>
305
+ 刷新视频信息
306
+ </button>
307
+ ${latestTask ? `<button class="secondary-button" data-action="delete-task" data-task-id="${latestTask.task_id}">
308
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
309
+ <polyline points="3 6 5 6 21 6"></polyline>
310
+ <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
311
+ </svg>
312
+ 删除本次任务
313
+ </button>` : ""}
314
+ <button class="secondary-button danger-outline" data-action="delete-video" data-video-id="${video.video_id}">
315
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
316
+ <polyline points="3 6 5 6 21 6"></polyline>
317
+ <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
318
+ </svg>
319
+ 从视频库删除
320
+ </button>
321
+ </div>
322
+ </div>
323
+ </article>
324
+ `,
325
+ result: `
326
+ <section class="video-detail-primary">
327
+ <article class="grid-card detail-section-card">
328
+ <div class="panel-header">
329
+ <h2>
330
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
331
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"></path>
332
+ <polyline points="14 2 14 8 20 8"></polyline>
333
+ <line x1="16" y1="13" x2="8" y2="13"></line>
334
+ <line x1="16" y1="17" x2="8" y2="17"></line>
335
+ </svg>
336
+ 摘要结果
337
+ </h2>
338
+ <p>当前视频的最新结果</p>
339
+ </div>
340
+ ${video.latest_result ? renderResultPanels(video.latest_result) : `<div class="empty-placeholder">当前还没有可展示的摘要结果。</div>`}
341
+ </article>
342
+ </section>
343
+ `,
344
+ progress: `
345
+ <article class="grid-card detail-side-card">
346
+ <div class="panel-header">
347
+ <h2>
348
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
349
+ <circle cx="12" cy="12" r="10"></circle>
350
+ <polyline points="12 6 12 12 16 14"></polyline>
351
+ </svg>
352
+ 处理进度
353
+ </h2>
354
+ <p>${latestTask ? `当前任务 ${escapeHtml(latestTask.task_id.slice(0, 8))}` : "尚未开始处理"}</p>
355
+ </div>
356
+ ${latestTask ? renderTaskProgressSimple(events) : `<div class="empty-placeholder">点击"开始总结"后,这里会展示处理进度。</div>`}
357
+ </article>
358
+ `,
359
+ history: `
360
+ <article class="grid-card detail-side-card">
361
+ <div class="panel-header">
362
+ <h2>
363
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
364
+ <circle cx="12" cy="12" r="10"></circle>
365
+ <polyline points="12 6 12 12 16 14"></polyline>
366
+ </svg>
367
+ 任务历史
368
+ </h2>
369
+ <p>${state.selectedVideoTasks.length} 条任务记录</p>
370
+ </div>
371
+ <div class="task-history-list">
372
+ ${state.selectedVideoTasks.length ? state.selectedVideoTasks.map((task) => renderTaskHistoryItemSimple(task, state.selectedTaskId)).join("") : `<div class="empty-placeholder">暂无历史任务</div>`}
373
+ </div>
374
+ </article>
375
+ `,
376
+ };
377
+ }
378
+
379
+ function renderProbePreview(preview) {
380
+ return `
381
+ <article class="probe-preview">
382
+ ${preview.cover_url ? `<img src="${escapeHtml(preview.cover_url)}" alt="${escapeHtml(preview.title)}" loading="lazy" />` : `<div class="video-card-placeholder">VIDEO</div>`}
383
+ <div class="probe-preview-copy">
384
+ <strong>${escapeHtml(preview.title)}</strong>
385
+ <span>${escapeHtml(formatDuration(preview.duration))}</span>
386
+ </div>
387
+ </article>
388
+ `;
389
+ }
390
+
391
+ function renderVideoCard(video) {
392
+ const badgeClass =
393
+ video.latest_status === "completed"
394
+ ? "status-success"
395
+ : video.latest_status === "running"
396
+ ? "status-running"
397
+ : video.latest_status === "failed"
398
+ ? "status-failed"
399
+ : "status-pending";
400
+
401
+ // 截断过长的时间显示
402
+ const timeStr = formatDateTime(video.updated_at);
403
+ const shortTime = timeStr.length > 16 ? timeStr.slice(0, 16) + "..." : timeStr;
404
+
405
+ return `
406
+ <article class="video-card" data-video-id="${video.video_id}" role="button" tabindex="0" aria-label="打开视频详情:${escapeHtml(video.title)}">
407
+ <div class="video-card-cover">
408
+ ${video.cover_url ? `<img src="${escapeHtml(video.cover_url)}" alt="${escapeHtml(video.title)}" loading="lazy" />` : `<div class="video-card-placeholder">VIDEO</div>`}
409
+ <span class="video-duration">${escapeHtml(formatDuration(video.duration))}</span>
410
+ </div>
411
+ <div class="video-card-body">
412
+ <h3 title="${escapeHtml(video.title)}">${escapeHtml(video.title)}</h3>
413
+ <div class="video-card-meta">
414
+ <span class="task-status ${badgeClass}">${escapeHtml(taskStatusLabel(video.latest_status))}</span>
415
+ <span style="color: var(--text-tertiary);" title="${escapeHtml(timeStr)}">${escapeHtml(shortTime)}</span>
416
+ </div>
417
+ </div>
418
+ </article>
419
+ `;
420
+ }
421
+
422
+ function renderTaskProgressSimple(events) {
423
+ const view = buildTaskProgressView(events);
424
+ const fillClass = view.hasError ? "error" : view.isCompleted ? "success" : "";
425
+
426
+ return `
427
+ <div class="task-progress-simple">
428
+ <div class="progress-bar-wrapper">
429
+ <div id="task-progress-bar" class="progress-bar-simple" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(view.progress)}" aria-label="任务进度">
430
+ <div id="task-progress-fill" class="progress-fill-simple ${fillClass}" style="width: ${view.progress}%"></div>
431
+ </div>
432
+ <div class="progress-info-simple">
433
+ <span id="task-progress-percent" class="progress-percent-simple">${Math.round(view.progress)}%</span>
434
+ <span id="task-progress-status" class="progress-status-simple">${view.headlineEvent ? escapeHtml(view.headlineEvent.message) : "等待开始..."}</span>
435
+ </div>
436
+ </div>
437
+
438
+ <details class="progress-stage-card">
439
+ <summary>
440
+ <div>
441
+ <strong id="task-progress-title">${escapeHtml(view.title)}</strong>
442
+ <span id="task-progress-subtitle">${escapeHtml(view.subtitle)}</span>
443
+ </div>
444
+ <span class="progress-stage-toggle">展开详细</span>
445
+ </summary>
446
+ <div id="task-progress-events" class="progress-stage-list">
447
+ ${renderTaskProgressEvents(events)}
448
+ </div>
449
+ </details>
450
+ </div>
451
+ `;
452
+ }
453
+
454
+ function renderResultPanels(result) {
455
+ return `
456
+ <div class="detail-result-stack">
457
+ <section class="grid-card result-card">
458
+ <div class="card-header">
459
+ <h3>摘要概览</h3>
460
+ </div>
461
+ <div class="timeline ${result.overview ? "" : "empty-state"}">
462
+ ${result.overview ? `<p>${escapeHtml(result.overview)}</p>` : `<div class="empty-placeholder">暂无摘要概览</div>`}
463
+ </div>
464
+ </section>
465
+ <section class="grid-card result-card">
466
+ <div class="card-header">
467
+ <h3>关键要点</h3>
468
+ <span class="result-count">${result.key_points?.length || 0} 条</span>
469
+ </div>
470
+ <ul class="bullet-list ${result.key_points?.length ? "" : "empty-state"}">
471
+ ${result.key_points?.length ? result.key_points.map((item) => `<li>${escapeHtml(item)}</li>`).join("") : `<div class="empty-placeholder">暂无关键要点</div>`}
472
+ </ul>
473
+ </section>
474
+ <section class="grid-card result-card">
475
+ <div class="card-header">
476
+ <h3>时间轴</h3>
477
+ <span class="result-count">${result.timeline?.length || 0} 段</span>
478
+ </div>
479
+ <div class="timeline ${result.timeline?.length ? "" : "empty-state"}">
480
+ ${result.timeline?.length ? result.timeline.map((item, index) => `
481
+ <article class="timeline-item">
482
+ <div class="timeline-marker" aria-hidden="true">${index + 1}</div>
483
+ <div class="timeline-content">
484
+ <h4>${escapeHtml(item.title || "章节")}</h4>
485
+ <div class="timeline-meta">
486
+ <span class="timeline-time">${escapeHtml(formatDuration(item.start))}</span>
487
+ </div>
488
+ <p>${escapeHtml(item.summary || "")}</p>
489
+ </div>
490
+ </article>
491
+ `).join("") : `<div class="empty-placeholder">暂无时间轴</div>`}
492
+ </div>
493
+ </section>
494
+ <section class="grid-card transcript-card">
495
+ <div class="card-header">
496
+ <h3>转写全文</h3>
497
+ </div>
498
+ <pre class="transcript ${result.transcript_text ? "" : "empty-state"}">${escapeHtml(result.transcript_text || "暂无转写全文")}</pre>
499
+ </section>
500
+ </div>
501
+ `;
502
+ }
503
+
504
+ function renderTaskHistoryItemSimple(task, selectedTaskId) {
505
+ const active = task.task_id === selectedTaskId;
506
+ const statusClass = task.status === "completed" ? "status-success" :
507
+ task.status === "failed" ? "status-failed" :
508
+ task.status === "running" ? "status-running" : "status-pending";
509
+
510
+ return `
511
+ <details class="task-history-item ${active ? "active" : ""}" ${active ? "open" : ""}>
512
+ <summary class="task-history-summary" data-action="select-task" data-task-id="${task.task_id}">
513
+ <div class="task-history-main">
514
+ <span class="task-history-status ${statusClass}">${escapeHtml(taskStatusLabel(task.status))}</span>
515
+ <span class="task-history-time">${escapeHtml(formatDateTime(task.created_at))}</span>
516
+ </div>
517
+ <div class="task-history-meta">
518
+ ${active ? `<span class="task-history-item-current">当前查看</span>` : ""}
519
+ <span class="task-history-id">${escapeHtml(task.task_id.slice(0, 8))}</span>
520
+ <svg class="expand-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
521
+ <polyline points="6 9 12 15 18 9"></polyline>
522
+ </svg>
523
+ </div>
524
+ </summary>
525
+ <div class="task-history-details">
526
+ <div class="task-history-info">
527
+ <div class="info-row">
528
+ <span class="info-label">任务 ID</span>
529
+ <span class="info-value code">${escapeHtml(task.task_id)}</span>
530
+ </div>
531
+ <div class="info-row">
532
+ <span class="info-label">创建时间</span>
533
+ <span class="info-value">${escapeHtml(formatDateTime(task.created_at))}</span>
534
+ </div>
535
+ <div class="info-row">
536
+ <span class="info-label">更新时间</span>
537
+ <span class="info-value">${escapeHtml(formatDateTime(task.updated_at))}</span>
538
+ </div>
539
+ <div class="info-row">
540
+ <span class="info-label">LLM Token</span>
541
+ <span class="info-value">${escapeHtml(formatTokenCount(task.llm_total_tokens))}</span>
542
+ </div>
543
+ <div class="info-row">
544
+ <span class="info-label">任务耗时</span>
545
+ <span class="info-value">${escapeHtml(formatTaskDuration(task.task_duration_seconds))}</span>
546
+ </div>
547
+ ${task.error_message ? `
548
+ <div class="info-row error">
549
+ <span class="info-label">错误信息</span>
550
+ <span class="info-value">${escapeHtml(task.error_message)}</span>
551
+ </div>
552
+ ` : ""}
553
+ </div>
554
+ <div class="task-history-actions">
555
+ <button class="tertiary-button danger" data-action="delete-task" data-task-id="${task.task_id}">
556
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
557
+ <polyline points="3 6 5 6 21 6"></polyline>
558
+ <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
559
+ </svg>
560
+ 删除
561
+ </button>
562
+ </div>
563
+ </div>
564
+ </details>
565
+ `;
566
+ }
567
+
568
+ function getStageLabel(stageKey, stages) {
569
+ if (stageKey === "failed") return "失败";
570
+ return stages.find((stage) => stage.key === stageKey)?.label || stageKey || "未知阶段";
571
+ }
572
+
573
+ function renderStageEventCard(event, stages, currentEvent, failedEvent) {
574
+ const isFailed = event.stage === "failed";
575
+ const isCurrent = currentEvent?.event_id === event.event_id;
576
+ const statusClass = isFailed ? "error" : isCurrent ? "active" : "completed";
577
+
578
+ return `
579
+ <article class="progress-event-card ${statusClass}">
580
+ <div class="progress-event-index">${escapeHtml(getStageLabel(event.stage, stages))}</div>
581
+ <div class="progress-event-copy">
582
+ <div class="progress-event-topline">
583
+ <strong>${escapeHtml(event.message || getStageLabel(event.stage, stages))}</strong>
584
+ <span>${escapeHtml(formatDateTime(event.created_at))}</span>
585
+ </div>
586
+ ${event.progress != null ? `<div class="progress-event-meta">阶段进度 ${escapeHtml(String(Math.round(Number(event.progress))))}%</div>` : ""}
587
+ ${failedEvent?.event_id === event.event_id ? `<div class="progress-event-meta error-text">任务执行中断</div>` : ""}
588
+ </div>
589
+ </article>
590
+ `;
591
+ }
592
+
593
+ function renderEmptyLibrary() {
594
+ return `
595
+ <div class="empty-placeholder">
596
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom: 12px; opacity: 0.5;" aria-hidden="true">
597
+ <circle cx="12" cy="12" r="10"></circle>
598
+ <line x1="12" y1="8" x2="12" y2="12"></line>
599
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
600
+ </svg>
601
+ <p>还没有视频,先输入一个链接开始总结。</p>
602
+ </div>
603
+ `;
604
+ }