dsh-session-title-summary 0.0.0-stage → 1.7.2
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.
- package/LICENSE +661 -0
- package/backfill.js +159 -0
- package/client.js +937 -0
- package/cordis.patch.yml +92 -0
- package/icon.svg +6 -0
- package/index.js +372 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/monitor.js +74 -0
- package/package.json +70 -4
- package/panel-api.js +40 -0
- package/pure.js +255 -0
- package/store.js +82 -0
- package/README.md +0 -3
package/client.js
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
// Browser half of @local/session-title-summary. Only React is a runtime import.
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: 'dsh-session-title-summary',
|
|
4
|
+
factory(require) {
|
|
5
|
+
const React = require('react');
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const { useCallback, useEffect, useRef, useState } = React;
|
|
8
|
+
const PANEL_ID = 'session-title-summary';
|
|
9
|
+
const NS = 'dsh-session-title-summary';
|
|
10
|
+
const POLL_MS = 2000;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
12
|
+
const ACTIVE_STATES = ['scanning', 'generating', 'preparing-model', 'calling-model', 'persisting', 'running'];
|
|
13
|
+
|
|
14
|
+
const DICTS = {
|
|
15
|
+
zh: {
|
|
16
|
+
'entry.label': '会话标题',
|
|
17
|
+
'panel.title': '会话摘要标题',
|
|
18
|
+
'panel.subtitle': 'MMDD | 分类 | 主题 · 自动命名与历史回填',
|
|
19
|
+
'panel.version': 'Host 版本',
|
|
20
|
+
'section.backfill': '扫描与处理',
|
|
21
|
+
'section.builtin': '内置分类',
|
|
22
|
+
'section.custom': '动态分类',
|
|
23
|
+
'section.history': '最近 10 次运行',
|
|
24
|
+
'section.records': '处理记录',
|
|
25
|
+
'section.current': '正在处理',
|
|
26
|
+
'connection.loading': '正在连接 Host,读取扫描状态…',
|
|
27
|
+
'connection.error': '状态不可用,不能确认当前是否空闲',
|
|
28
|
+
'connection.stale': '保留上次成功读取的数据;运行状态可能已变化。',
|
|
29
|
+
'connection.synced': '状态更新时间',
|
|
30
|
+
'connection.poll': '每 2 秒串行更新',
|
|
31
|
+
'action.scan': '重新扫描',
|
|
32
|
+
'action.pending': '正在提交扫描…',
|
|
33
|
+
'action.retry': '重试连接',
|
|
34
|
+
'action.refreshing': '正在刷新…',
|
|
35
|
+
'action.failure': '扫描请求失败',
|
|
36
|
+
'run.idle': '空闲',
|
|
37
|
+
'run.running': '正在扫描会话',
|
|
38
|
+
'run.unknown': '等待状态',
|
|
39
|
+
'run.error': '运行异常',
|
|
40
|
+
'run.last': '上次运行',
|
|
41
|
+
'run.never': '尚无运行记录',
|
|
42
|
+
'run.latest': '最新运行(自动跟随)',
|
|
43
|
+
'run.select': '选择运行',
|
|
44
|
+
'run.source': '触发来源',
|
|
45
|
+
'run.id': '运行 ID',
|
|
46
|
+
'run.started': '开始时间',
|
|
47
|
+
'run.finished': '结束时间',
|
|
48
|
+
'run.inProgress': '尚未结束',
|
|
49
|
+
'run.emptyHistory': '扫描后可查看每次运行的统计与处理记录。',
|
|
50
|
+
'run.selectHint': '选择历史运行,可查询当次记录;实时处理始终显示当前会话。',
|
|
51
|
+
'metric.discovered': '发现会话',
|
|
52
|
+
'metric.considered': '纳入检查',
|
|
53
|
+
'metric.scanned': '已扫描',
|
|
54
|
+
'metric.renamed': '已修改',
|
|
55
|
+
'metric.skipped': '已跳过',
|
|
56
|
+
'metric.failed': '失败',
|
|
57
|
+
'record.id': '会话 ID',
|
|
58
|
+
'record.fullId': '展开完整会话 ID',
|
|
59
|
+
'record.created': '会话创建',
|
|
60
|
+
'record.old': '原标题',
|
|
61
|
+
'record.new': '新标题',
|
|
62
|
+
'record.model': '实际调用模型',
|
|
63
|
+
'record.provider': '供应商',
|
|
64
|
+
'record.noModel': '未调用模型',
|
|
65
|
+
'record.method': '处理方式',
|
|
66
|
+
'record.reason': '原因 / 说明',
|
|
67
|
+
'record.noReason': 'Host 未提供原因',
|
|
68
|
+
'record.untitled': '无标题',
|
|
69
|
+
'record.noNewTitle': '尚无新标题',
|
|
70
|
+
'record.noCurrent': '等待 Host 报告正在处理的会话…',
|
|
71
|
+
'record.empty': '此运行尚无处理记录。',
|
|
72
|
+
'record.noMatch': '没有符合筛选条件的记录。',
|
|
73
|
+
'record.count': '匹配 / 总记录',
|
|
74
|
+
'filter.query': '搜索记录',
|
|
75
|
+
'filter.placeholder': '会话 ID、原标题、新标题、模型或原因',
|
|
76
|
+
'filter.status': '处理状态',
|
|
77
|
+
'filter.all': '全部状态',
|
|
78
|
+
'filter.clear': '清除筛选',
|
|
79
|
+
'table.status': '结果',
|
|
80
|
+
'table.session': '会话',
|
|
81
|
+
'table.old': '原标题',
|
|
82
|
+
'table.new': '新标题',
|
|
83
|
+
'table.model': '模型',
|
|
84
|
+
'table.method': '方式',
|
|
85
|
+
'table.reason': '原因',
|
|
86
|
+
'table.finished': '完成时间',
|
|
87
|
+
'table.expand': '展开或收起该行详情',
|
|
88
|
+
'tab.records': '处理记录',
|
|
89
|
+
'tab.config': '模型配置',
|
|
90
|
+
'section.config': '标题生成模型',
|
|
91
|
+
'config.hint': '未配置时:标题跟随每个会话自己的模型路由,无路由的会话用简短标题回退。配置后:所有标题生成(含历史回填)统一使用该固定模型。',
|
|
92
|
+
'config.current': '当前配置',
|
|
93
|
+
'config.none': '未配置(跟随会话模型)',
|
|
94
|
+
'config.provider': '供应商(provider)',
|
|
95
|
+
'config.model': '模型(model)',
|
|
96
|
+
'config.save': '保存配置',
|
|
97
|
+
'config.clear': '清除配置',
|
|
98
|
+
'config.saved': '已保存,之后所有标题生成使用该模型',
|
|
99
|
+
'config.cleared': '已清除,恢复跟随会话模型',
|
|
100
|
+
'config.pairRequired': '供应商与模型必须同时填写,或同时留空',
|
|
101
|
+
'config.seen': '最近实际调用过的模型(点击直接采用)',
|
|
102
|
+
'config.loadingModels': '正在从当前程序拉取模型供应商…',
|
|
103
|
+
'config.modelsError': '模型目录获取失败',
|
|
104
|
+
'config.pickProvider': '请选择供应商',
|
|
105
|
+
'config.pickModel': '请选择模型',
|
|
106
|
+
'config.pickProviderFirst': '请先选择供应商',
|
|
107
|
+
'config.providerFailed': '列表获取失败',
|
|
108
|
+
'config.modelNotInProvider': '该模型不在所选供应商的模型列表中,请重新选择',
|
|
109
|
+
'config.reload': '刷新模型列表',
|
|
110
|
+
'status.preparing-model': '准备模型请求',
|
|
111
|
+
'status.interrupted': '已中断(重启或卸载)',
|
|
112
|
+
'method.none': '未执行生成',
|
|
113
|
+
'reason.live-session': '会话已打开,避免干扰',
|
|
114
|
+
'reason.batch-limit': '超过本次批量上限',
|
|
115
|
+
'reason.unchanged': '标题未变化',
|
|
116
|
+
'reason.no-model-route': '无模型路由,使用简短标题回退',
|
|
117
|
+
'reason.no-title-accepted': '标题服务未接受新标题',
|
|
118
|
+
'reason.cancelled': '本次处理已取消',
|
|
119
|
+
'status.scanning': '扫描中',
|
|
120
|
+
'status.generating': '生成标题中',
|
|
121
|
+
'status.calling-model': '正在调用模型',
|
|
122
|
+
'status.persisting': '正在保存标题',
|
|
123
|
+
'status.running': '运行中',
|
|
124
|
+
'status.renamed': '已修改',
|
|
125
|
+
'status.skipped': '已跳过',
|
|
126
|
+
'status.failed': '失败',
|
|
127
|
+
'status.completed': '已完成',
|
|
128
|
+
'status.finished': '已完成',
|
|
129
|
+
'status.succeeded': '成功',
|
|
130
|
+
'status.idle': '空闲',
|
|
131
|
+
'status.unknown': '未提供状态',
|
|
132
|
+
'method.model': '模型生成',
|
|
133
|
+
'method.local': '本地处理',
|
|
134
|
+
'method.fallback': '回退生成',
|
|
135
|
+
'method.unknown': '未提供',
|
|
136
|
+
'source.activation': '插件激活',
|
|
137
|
+
'source.panel': '面板扫描',
|
|
138
|
+
'source.manual': '手动触发',
|
|
139
|
+
'source.startup': '启动回填',
|
|
140
|
+
'reason.events-below-minimum': '消息数不足',
|
|
141
|
+
'reason.valid-format': '已有合法格式',
|
|
142
|
+
'reason.user-pinned': '用户手动命名',
|
|
143
|
+
'reason.subagent': '子代理会话',
|
|
144
|
+
'reason.session-in-use': '会话正在使用',
|
|
145
|
+
'category.empty': '暂无动态分类',
|
|
146
|
+
'category.count': '次',
|
|
147
|
+
'error.timeout': '请求超时,请重试',
|
|
148
|
+
'error.invalid': 'Host 返回了无效状态',
|
|
149
|
+
'error.http': 'HTTP',
|
|
150
|
+
'error.notFound': '接口未找到,请确认 Host 路由与桌面适配已加载',
|
|
151
|
+
'error.auth': '认证失败,请重新连接当前应用',
|
|
152
|
+
'error.network': '网络请求失败',
|
|
153
|
+
'empty.value': '—'
|
|
154
|
+
},
|
|
155
|
+
en: {
|
|
156
|
+
'entry.label': 'Session Titles',
|
|
157
|
+
'panel.title': 'Session Summary Titles',
|
|
158
|
+
'panel.subtitle': 'MMDD | Category | Topic · automatic naming and history backfill',
|
|
159
|
+
'panel.version': 'Host version',
|
|
160
|
+
'section.backfill': 'Scan and processing',
|
|
161
|
+
'section.builtin': 'Built-in categories',
|
|
162
|
+
'section.custom': 'Dynamic categories',
|
|
163
|
+
'section.history': 'Last 10 runs',
|
|
164
|
+
'section.records': 'Processing records',
|
|
165
|
+
'section.current': 'Currently processing',
|
|
166
|
+
'connection.loading': 'Connecting to Host and reading scan status…',
|
|
167
|
+
'connection.error': 'Status unavailable; idle state cannot be confirmed',
|
|
168
|
+
'connection.stale': 'Keeping the last successful snapshot; the run state may have changed.',
|
|
169
|
+
'connection.synced': 'Status updated',
|
|
170
|
+
'connection.poll': 'Serial updates every 2 seconds',
|
|
171
|
+
'action.scan': 'Rescan sessions',
|
|
172
|
+
'action.pending': 'Submitting scan…',
|
|
173
|
+
'action.retry': 'Retry connection',
|
|
174
|
+
'action.refreshing': 'Refreshing…',
|
|
175
|
+
'action.failure': 'Scan request failed',
|
|
176
|
+
'run.idle': 'Idle',
|
|
177
|
+
'run.running': 'Scanning sessions',
|
|
178
|
+
'run.unknown': 'Waiting for status',
|
|
179
|
+
'run.error': 'Run error',
|
|
180
|
+
'run.last': 'Last run',
|
|
181
|
+
'run.never': 'No runs yet',
|
|
182
|
+
'run.latest': 'Latest run (follow live)',
|
|
183
|
+
'run.select': 'Select a run',
|
|
184
|
+
'run.source': 'Trigger',
|
|
185
|
+
'run.id': 'Run ID',
|
|
186
|
+
'run.started': 'Started',
|
|
187
|
+
'run.finished': 'Finished',
|
|
188
|
+
'run.inProgress': 'In progress',
|
|
189
|
+
'run.emptyHistory': 'After a scan, inspect each run’s counts and processing records here.',
|
|
190
|
+
'run.selectHint': 'Select a historical run to query its records. Live processing always shows the current session.',
|
|
191
|
+
'metric.discovered': 'Discovered',
|
|
192
|
+
'metric.considered': 'Considered',
|
|
193
|
+
'metric.scanned': 'Scanned',
|
|
194
|
+
'metric.renamed': 'Renamed',
|
|
195
|
+
'metric.skipped': 'Skipped',
|
|
196
|
+
'metric.failed': 'Failed',
|
|
197
|
+
'record.id': 'Session ID',
|
|
198
|
+
'record.fullId': 'Expand full session ID',
|
|
199
|
+
'record.created': 'Session created',
|
|
200
|
+
'record.old': 'Old title',
|
|
201
|
+
'record.new': 'New title',
|
|
202
|
+
'record.model': 'Actual model called',
|
|
203
|
+
'record.provider': 'Provider',
|
|
204
|
+
'record.noModel': 'No model called',
|
|
205
|
+
'record.method': 'Method',
|
|
206
|
+
'record.reason': 'Reason / detail',
|
|
207
|
+
'record.noReason': 'No reason supplied by Host',
|
|
208
|
+
'record.untitled': 'Untitled',
|
|
209
|
+
'record.noNewTitle': 'No new title yet',
|
|
210
|
+
'record.noCurrent': 'Waiting for Host to report the session being processed…',
|
|
211
|
+
'record.empty': 'This run has no processing records yet.',
|
|
212
|
+
'record.noMatch': 'No records match these filters.',
|
|
213
|
+
'record.count': 'Matching / total records',
|
|
214
|
+
'filter.query': 'Search records',
|
|
215
|
+
'filter.placeholder': 'Session ID, old/new title, model, or reason',
|
|
216
|
+
'filter.status': 'Processing status',
|
|
217
|
+
'filter.all': 'All statuses',
|
|
218
|
+
'filter.clear': 'Clear filters',
|
|
219
|
+
'table.status': 'Result',
|
|
220
|
+
'table.session': 'Session',
|
|
221
|
+
'table.old': 'Old title',
|
|
222
|
+
'table.new': 'New title',
|
|
223
|
+
'table.model': 'Model',
|
|
224
|
+
'table.method': 'Method',
|
|
225
|
+
'table.reason': 'Reason',
|
|
226
|
+
'table.finished': 'Finished',
|
|
227
|
+
'table.expand': 'Toggle row details',
|
|
228
|
+
'tab.records': 'Records',
|
|
229
|
+
'tab.config': 'Model',
|
|
230
|
+
'section.config': 'Title generation model',
|
|
231
|
+
'config.hint': 'Unset: each title follows its session own model route, with a short-title fallback when none exists. Set: every title generation (history backfill included) uses this fixed model.',
|
|
232
|
+
'config.current': 'Current',
|
|
233
|
+
'config.none': 'Not set (follow session model)',
|
|
234
|
+
'config.provider': 'Provider',
|
|
235
|
+
'config.model': 'Model',
|
|
236
|
+
'config.save': 'Save',
|
|
237
|
+
'config.clear': 'Clear',
|
|
238
|
+
'config.saved': 'Saved; every title now uses this model',
|
|
239
|
+
'config.cleared': 'Cleared; back to session models',
|
|
240
|
+
'config.pairRequired': 'Provider and model must be filled together, or left empty together',
|
|
241
|
+
'config.seen': 'Recently called models (click to adopt)',
|
|
242
|
+
'config.loadingModels': 'Loading model providers from this app…',
|
|
243
|
+
'config.modelsError': 'Failed to load the model catalog',
|
|
244
|
+
'config.pickProvider': 'Select a provider',
|
|
245
|
+
'config.pickModel': 'Select a model',
|
|
246
|
+
'config.pickProviderFirst': 'Select a provider first',
|
|
247
|
+
'config.providerFailed': 'list unavailable',
|
|
248
|
+
'config.modelNotInProvider': 'That model is not in the selected provider; choose again',
|
|
249
|
+
'config.reload': 'Reload models',
|
|
250
|
+
'status.preparing-model': 'Preparing model request',
|
|
251
|
+
'status.interrupted': 'Interrupted (restart or unload)',
|
|
252
|
+
'method.none': 'No generation',
|
|
253
|
+
'reason.live-session': 'Already open; left untouched',
|
|
254
|
+
'reason.batch-limit': 'Beyond this batch limit',
|
|
255
|
+
'reason.unchanged': 'Title unchanged',
|
|
256
|
+
'reason.no-model-route': 'No model route; short-title fallback',
|
|
257
|
+
'reason.no-title-accepted': 'No title accepted by title service',
|
|
258
|
+
'reason.cancelled': 'Processing cancelled',
|
|
259
|
+
'status.scanning': 'Scanning',
|
|
260
|
+
'status.generating': 'Generating title',
|
|
261
|
+
'status.calling-model': 'Calling model',
|
|
262
|
+
'status.persisting': 'Saving title',
|
|
263
|
+
'status.running': 'Running',
|
|
264
|
+
'status.renamed': 'Renamed',
|
|
265
|
+
'status.skipped': 'Skipped',
|
|
266
|
+
'status.failed': 'Failed',
|
|
267
|
+
'status.completed': 'Completed',
|
|
268
|
+
'status.finished': 'Completed',
|
|
269
|
+
'status.succeeded': 'Succeeded',
|
|
270
|
+
'status.idle': 'Idle',
|
|
271
|
+
'status.unknown': 'Status not supplied',
|
|
272
|
+
'method.model': 'Model generation',
|
|
273
|
+
'method.local': 'Local processing',
|
|
274
|
+
'method.fallback': 'Fallback generation',
|
|
275
|
+
'method.unknown': 'Not supplied',
|
|
276
|
+
'source.activation': 'Plugin activation',
|
|
277
|
+
'source.panel': 'Panel scan',
|
|
278
|
+
'source.manual': 'Manual',
|
|
279
|
+
'source.startup': 'Startup backfill',
|
|
280
|
+
'reason.events-below-minimum': 'Too few messages',
|
|
281
|
+
'reason.valid-format': 'Already in valid format',
|
|
282
|
+
'reason.user-pinned': 'User-pinned title',
|
|
283
|
+
'reason.subagent': 'Subagent session',
|
|
284
|
+
'reason.session-in-use': 'Session in use',
|
|
285
|
+
'category.empty': 'No dynamic categories yet',
|
|
286
|
+
'category.count': 'uses',
|
|
287
|
+
'error.timeout': 'Request timed out; please retry',
|
|
288
|
+
'error.invalid': 'Host returned an invalid status',
|
|
289
|
+
'error.http': 'HTTP',
|
|
290
|
+
'error.notFound': 'Endpoint not found; confirm the Host route and desktop adapter are loaded',
|
|
291
|
+
'error.auth': 'Authentication failed; reconnect the current application',
|
|
292
|
+
'error.network': 'Network request failed',
|
|
293
|
+
'empty.value': '—'
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Component-owned CSS is removed with its slot; all colors inherit theme tokens.
|
|
298
|
+
const CSS = `
|
|
299
|
+
.session-title-summary { box-sizing: border-box; flex: 1 1 auto; height: 100%; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; width: 100%; margin: 0 auto; padding: 1.5rem; color: var(--dsw-alias-label-primary); font-size: .8125rem; line-height: 1.5; }
|
|
300
|
+
.session-title-summary * { box-sizing: border-box; }
|
|
301
|
+
.session-title-summary h1 { font-size: 1.25rem; line-height: 1.4; margin: 0; }
|
|
302
|
+
.session-title-summary h2 { font-size: .9375rem; margin: 0 0 .75rem; }
|
|
303
|
+
.session-title-summary h3 { font-size: .8125rem; margin: 0 0 .5rem; }
|
|
304
|
+
.session-title-summary p { margin: .25rem 0; }
|
|
305
|
+
.session-title-summary .sts-muted { color: var(--dsw-alias-label-secondary); }
|
|
306
|
+
.session-title-summary .sts-header { margin-bottom: 1.5rem; }
|
|
307
|
+
.session-title-summary .sts-tabs { display: flex; gap: .5rem; border-bottom: 1px solid var(--dsw-alias-border-l2); margin-bottom: 1.25rem; }
|
|
308
|
+
.session-title-summary .sts-tabs button { border: 1px solid transparent; border-bottom: none; border-radius: .375rem .375rem 0 0; background: transparent; min-height: 2rem; }
|
|
309
|
+
.session-title-summary .sts-tabs button.sts-tab-active { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); font-weight: 600; }
|
|
310
|
+
.session-title-summary .sts-section { border-top: 1px solid var(--dsw-alias-border-l1); padding: 1.25rem 0; }
|
|
311
|
+
.session-title-summary .sts-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; margin-bottom: .75rem; }
|
|
312
|
+
.session-title-summary .sts-actions, .session-title-summary .sts-meta { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem 1rem; }
|
|
313
|
+
.session-title-summary button, .session-title-summary input, .session-title-summary select { font: inherit; color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2); border-radius: .375rem; background: var(--dsw-alias-bg-layer-1); min-height: 2.25rem; padding: .375rem .75rem; }
|
|
314
|
+
.session-title-summary button { cursor: pointer; }
|
|
315
|
+
.session-title-summary button:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
|
|
316
|
+
.session-title-summary button:disabled { opacity: .55; cursor: not-allowed; }
|
|
317
|
+
.session-title-summary button:focus-visible, .session-title-summary input:focus-visible, .session-title-summary select:focus-visible, .session-title-summary summary:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
|
|
318
|
+
.session-title-summary .sts-primary { border-color: var(--dsw-alias-brand-primary); font-weight: 600; }
|
|
319
|
+
.session-title-summary .sts-notice { padding: .75rem 1rem; margin: .75rem 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: .5rem; background: var(--dsw-alias-bg-layer-1); overflow-wrap: anywhere; }
|
|
320
|
+
.session-title-summary .sts-error { border-color: var(--dsw-alias-state-error-primary); }
|
|
321
|
+
.session-title-summary .sts-error-text { color: var(--dsw-alias-state-error-primary); }
|
|
322
|
+
.session-title-summary .sts-success { color: var(--dsw-alias-state-success-primary); }
|
|
323
|
+
.session-title-summary .sts-badge { display: inline-flex; align-items: center; padding: .125rem .5rem; border: 1px solid var(--dsw-alias-border-l1); border-radius: .25rem; background: var(--dsw-alias-bg-layer-2); font-size: .75rem; overflow-wrap: anywhere; }
|
|
324
|
+
.session-title-summary .sts-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: .75rem; margin: 1rem 0; }
|
|
325
|
+
.session-title-summary .sts-metric { border-left: 2px solid var(--dsw-alias-border-l2); padding-left: .75rem; }
|
|
326
|
+
.session-title-summary .sts-metric strong { display: block; font-size: 1.25rem; font-variant-numeric: tabular-nums; }
|
|
327
|
+
.session-title-summary .sts-metric span { color: var(--dsw-alias-label-secondary); font-size: .75rem; }
|
|
328
|
+
.session-title-summary .sts-current { padding: 1rem; margin-top: 1rem; border: 1px solid var(--dsw-alias-border-l2); border-radius: .5rem; background: var(--dsw-alias-bg-layer-1); }
|
|
329
|
+
.session-title-summary .sts-record { padding: 1rem 0; border-bottom: 1px solid var(--dsw-alias-border-l1); min-width: 0; }
|
|
330
|
+
.session-title-summary .sts-current .sts-record { border-bottom: 0; padding: 0; }
|
|
331
|
+
.session-title-summary .sts-record-top { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: .5rem; margin-bottom: .5rem; }
|
|
332
|
+
.session-title-summary .sts-id { min-width: 0; }
|
|
333
|
+
.session-title-summary summary { cursor: pointer; overflow-wrap: anywhere; }
|
|
334
|
+
.session-title-summary code { font-size: .75rem; overflow-wrap: anywhere; white-space: normal; }
|
|
335
|
+
.session-title-summary .sts-id code { display: block; padding-top: .25rem; user-select: all; }
|
|
336
|
+
.session-title-summary dl { display: grid; grid-template-columns: minmax(6rem, 8rem) minmax(0, 1fr); gap: .375rem .75rem; margin: .5rem 0 0; }
|
|
337
|
+
.session-title-summary dt { color: var(--dsw-alias-label-secondary); }
|
|
338
|
+
.session-title-summary dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
339
|
+
.session-title-summary .sts-filters { display: grid; grid-template-columns: minmax(0, 1fr) minmax(10rem, 14rem); gap: .75rem; margin: 1rem 0 .75rem; }
|
|
340
|
+
.session-title-summary label { display: flex; flex-direction: column; gap: .375rem; color: var(--dsw-alias-label-secondary); min-width: 0; }
|
|
341
|
+
.session-title-summary input, .session-title-summary select { width: 100%; min-width: 0; }
|
|
342
|
+
.session-title-summary .sts-run-select { max-width: 32rem; margin-top: .75rem; }
|
|
343
|
+
.session-title-summary .sts-history { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; padding: 0; margin: .75rem 0; list-style: none; }
|
|
344
|
+
.session-title-summary .sts-history button { text-align: left; width: 100%; height: 100%; }
|
|
345
|
+
.session-title-summary .sts-history button[aria-pressed="true"] { border-color: var(--dsw-alias-brand-primary); background: var(--dsw-alias-bg-layer-2); }
|
|
346
|
+
.session-title-summary .sts-history small { display: block; color: var(--dsw-alias-label-secondary); margin-top: .25rem; }
|
|
347
|
+
.session-title-summary .sts-categories { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; }
|
|
348
|
+
.session-title-summary .sts-chips { display: flex; flex-wrap: wrap; gap: .5rem; }
|
|
349
|
+
.session-title-summary .sts-category-row { display: flex; justify-content: space-between; gap: 1rem; padding: .375rem 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
350
|
+
.session-title-summary .sts-table-wrap { overflow-x: auto; margin-top: .75rem; border: 1px solid var(--dsw-alias-border-l1); border-radius: .5rem; }
|
|
351
|
+
.session-title-summary .sts-table { width: 100%; border-collapse: collapse; font-size: .75rem; }
|
|
352
|
+
.session-title-summary .sts-table th { position: sticky; top: 0; z-index: 1; text-align: left; font-weight: 600; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); padding: .5rem .625rem; border-bottom: 1px solid var(--dsw-alias-border-l2); white-space: nowrap; }
|
|
353
|
+
.session-title-summary .sts-table td { padding: .5rem .625rem; border-bottom: 1px solid var(--dsw-alias-border-l1); vertical-align: top; min-width: 0; }
|
|
354
|
+
.session-title-summary .sts-table tbody tr { cursor: pointer; }
|
|
355
|
+
.session-title-summary .sts-table tbody tr:hover td, .session-title-summary .sts-table tbody tr.sts-row-open td { background: var(--dsw-alias-bg-layer-2); }
|
|
356
|
+
.session-title-summary .sts-table tbody tr:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
|
|
357
|
+
.session-title-summary .sts-trunc { max-width: 20rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
358
|
+
.session-title-summary .sts-nowrap { white-space: nowrap; }
|
|
359
|
+
.session-title-summary .sts-detail-row td, .session-title-summary .sts-detail-row:hover td { background: var(--dsw-alias-bg-layer-1); cursor: default; border-bottom: 1px solid var(--dsw-alias-border-l2); }
|
|
360
|
+
@media (max-width: 48rem) { .session-title-summary { padding: 1rem; } .session-title-summary .sts-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } .session-title-summary .sts-history { grid-template-columns: minmax(0, 1fr); } }
|
|
361
|
+
@media (max-width: 30rem) { .session-title-summary .sts-filters, .session-title-summary .sts-categories { grid-template-columns: minmax(0, 1fr); } .session-title-summary dl { grid-template-columns: minmax(0, 1fr); gap: .125rem; } .session-title-summary dd { margin-bottom: .5rem; } }
|
|
362
|
+
`;
|
|
363
|
+
|
|
364
|
+
function translated(t, prefix, value, emptyKey = 'empty.value') {
|
|
365
|
+
if (!value) return t(emptyKey);
|
|
366
|
+
const key = `${prefix}.${value}`;
|
|
367
|
+
const text = t(key);
|
|
368
|
+
return text && text !== key ? text : String(value);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function formatTime(value, t) {
|
|
372
|
+
if (value == null || value === '') return t('empty.value');
|
|
373
|
+
const date = new Date(value);
|
|
374
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
375
|
+
return date.toLocaleString();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function errorMessage(error, t) {
|
|
379
|
+
if (!error) return '';
|
|
380
|
+
if (typeof error === 'string') return error;
|
|
381
|
+
if (error.code) {
|
|
382
|
+
const message = t(`error.${error.code}`);
|
|
383
|
+
return error.detail ? `${message}: ${error.detail}` : message;
|
|
384
|
+
}
|
|
385
|
+
return String(error.message || error.reason || JSON.stringify(error));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function endpoint(name) {
|
|
389
|
+
const relative = `api/session-title-summary/${name}`;
|
|
390
|
+
// Desktop fetch adapters require a path without a leading slash.
|
|
391
|
+
if (!/^https?:$/.test(window.location.protocol)) return relative;
|
|
392
|
+
return new URL(relative, document.baseURI).toString();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function request(name, options, signal) {
|
|
396
|
+
const response = await fetch(endpoint(name), {
|
|
397
|
+
...options,
|
|
398
|
+
credentials: 'same-origin',
|
|
399
|
+
headers: { accept: 'application/json', ...options?.headers },
|
|
400
|
+
cache: 'no-store',
|
|
401
|
+
signal
|
|
402
|
+
});
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
const error = new Error(`HTTP ${response.status}`);
|
|
405
|
+
error.code = response.status === 404 ? 'notFound'
|
|
406
|
+
: response.status === 401 || response.status === 403 ? 'auth' : 'http';
|
|
407
|
+
error.detail = `HTTP ${response.status}`;
|
|
408
|
+
// Preserve a Host-provided failure reason without displaying HTML error pages.
|
|
409
|
+
try {
|
|
410
|
+
const body = await response.json();
|
|
411
|
+
const detail = body?.error || body?.reason || body?.message;
|
|
412
|
+
if (detail) error.detail += ` · ${errorMessage(detail, (key) => key)}`;
|
|
413
|
+
} catch { /* the HTTP status is sufficient when no JSON detail exists */ }
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
const body = await response.json();
|
|
417
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
418
|
+
throw Object.assign(new Error('Invalid Host response'), { code: 'invalid' });
|
|
419
|
+
}
|
|
420
|
+
return body;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** One status request at a time; the next poll starts after it settles. */
|
|
424
|
+
function useStatus() {
|
|
425
|
+
const [data, setData] = useState(null);
|
|
426
|
+
const [loadError, setLoadError] = useState(null);
|
|
427
|
+
const [actionError, setActionError] = useState(null);
|
|
428
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
429
|
+
const [posting, setPosting] = useState(false);
|
|
430
|
+
const [syncedAt, setSyncedAt] = useState(null);
|
|
431
|
+
const latest = useRef(null);
|
|
432
|
+
const connectionError = useRef(null);
|
|
433
|
+
const refreshRef = useRef(null);
|
|
434
|
+
const postRef = useRef(null);
|
|
435
|
+
const postLock = useRef(false);
|
|
436
|
+
const mounted = useRef(false);
|
|
437
|
+
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
mounted.current = true;
|
|
440
|
+
let disposed = false;
|
|
441
|
+
let pollTimer;
|
|
442
|
+
let activeController;
|
|
443
|
+
let flight = null;
|
|
444
|
+
|
|
445
|
+
async function refresh(afterExisting = false) {
|
|
446
|
+
if (disposed) return;
|
|
447
|
+
if (flight) {
|
|
448
|
+
await flight;
|
|
449
|
+
return afterExisting && !disposed ? refresh(false) : undefined;
|
|
450
|
+
}
|
|
451
|
+
clearTimeout(pollTimer);
|
|
452
|
+
const controller = new AbortController();
|
|
453
|
+
activeController = controller;
|
|
454
|
+
let timedOut = false;
|
|
455
|
+
const timeout = setTimeout(() => {
|
|
456
|
+
timedOut = true;
|
|
457
|
+
controller.abort();
|
|
458
|
+
}, REQUEST_TIMEOUT_MS);
|
|
459
|
+
setRefreshing(true);
|
|
460
|
+
flight = (async () => {
|
|
461
|
+
try {
|
|
462
|
+
const snapshot = await request('status', {}, controller.signal);
|
|
463
|
+
if (typeof snapshot.running !== 'boolean') {
|
|
464
|
+
throw Object.assign(new Error('Missing running state'), { code: 'invalid' });
|
|
465
|
+
}
|
|
466
|
+
if (disposed) return;
|
|
467
|
+
latest.current = snapshot;
|
|
468
|
+
connectionError.current = null;
|
|
469
|
+
setData(snapshot);
|
|
470
|
+
setLoadError(null);
|
|
471
|
+
setSyncedAt(Date.now());
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (disposed) return;
|
|
474
|
+
const failure = timedOut ? { code: 'timeout' } : error;
|
|
475
|
+
connectionError.current = failure;
|
|
476
|
+
setLoadError(failure);
|
|
477
|
+
} finally {
|
|
478
|
+
clearTimeout(timeout);
|
|
479
|
+
if (!disposed) {
|
|
480
|
+
setRefreshing(false);
|
|
481
|
+
pollTimer = setTimeout(() => { void refresh(); }, POLL_MS);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
})();
|
|
485
|
+
await flight;
|
|
486
|
+
flight = null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
refreshRef.current = refresh;
|
|
490
|
+
void refresh();
|
|
491
|
+
return () => {
|
|
492
|
+
disposed = true;
|
|
493
|
+
mounted.current = false;
|
|
494
|
+
refreshRef.current = null;
|
|
495
|
+
clearTimeout(pollTimer);
|
|
496
|
+
activeController?.abort();
|
|
497
|
+
postRef.current?.abort();
|
|
498
|
+
};
|
|
499
|
+
}, []);
|
|
500
|
+
|
|
501
|
+
const refresh = useCallback(() => refreshRef.current?.(), []);
|
|
502
|
+
const scan = useCallback(async () => {
|
|
503
|
+
// A ref guards the same event loop tick, before React disables the button.
|
|
504
|
+
if (postLock.current || !latest.current || latest.current.running || connectionError.current) return;
|
|
505
|
+
postLock.current = true;
|
|
506
|
+
setPosting(true);
|
|
507
|
+
setActionError(null);
|
|
508
|
+
const controller = new AbortController();
|
|
509
|
+
postRef.current = controller;
|
|
510
|
+
let timedOut = false;
|
|
511
|
+
const timeout = setTimeout(() => {
|
|
512
|
+
timedOut = true;
|
|
513
|
+
controller.abort();
|
|
514
|
+
}, REQUEST_TIMEOUT_MS);
|
|
515
|
+
try {
|
|
516
|
+
await request('backfill', { method: 'POST', headers: { 'content-type': 'application/json' } }, controller.signal);
|
|
517
|
+
// Read after any older GET, never accept its pre-scan idle snapshot.
|
|
518
|
+
await refreshRef.current?.(true);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (mounted.current) {
|
|
521
|
+
setActionError(timedOut ? { code: 'timeout' } : error);
|
|
522
|
+
// A timed-out POST might still run on Host; re-read before allowing a retry.
|
|
523
|
+
await refreshRef.current?.(true);
|
|
524
|
+
}
|
|
525
|
+
} finally {
|
|
526
|
+
clearTimeout(timeout);
|
|
527
|
+
postLock.current = false;
|
|
528
|
+
postRef.current = null;
|
|
529
|
+
if (mounted.current) setPosting(false);
|
|
530
|
+
}
|
|
531
|
+
}, []);
|
|
532
|
+
|
|
533
|
+
return { data, loadError, actionError, refreshing, posting, syncedAt, refresh, scan };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function PanelIcon({ size }) {
|
|
537
|
+
return h('svg', {
|
|
538
|
+
viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
|
|
539
|
+
stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round',
|
|
540
|
+
strokeLinejoin: 'round', 'aria-hidden': true, 'data-dsh-panel-entry': PANEL_ID
|
|
541
|
+
},
|
|
542
|
+
h('path', { d: 'M2.5 4.5h7l4 3.5-4 3.5h-7z' }),
|
|
543
|
+
h('circle', { cx: 5.4, cy: 8, r: .9, fill: 'currentColor', stroke: 'none' })
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function StatusBadge({ status, t }) {
|
|
548
|
+
const tone = status === 'failed' ? ' sts-error-text' : status === 'renamed' ? ' sts-success' : '';
|
|
549
|
+
return h('span', { className: `sts-badge${tone}` }, translated(t, 'status', status, 'status.unknown'));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function Detail({ label, children, className }) {
|
|
553
|
+
return h(React.Fragment, null, h('dt', null, label), h('dd', { className }, children));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function SessionId({ id, t }) {
|
|
557
|
+
if (!id) return h('span', { className: 'sts-muted' }, t('empty.value'));
|
|
558
|
+
return h('details', { className: 'sts-id' },
|
|
559
|
+
h('summary', { title: t('record.fullId') }, `${t('record.id')}: …${String(id).slice(-12)}`),
|
|
560
|
+
h('code', null, String(id))
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function ModelValue({ record, t }) {
|
|
565
|
+
// Only actual telemetry counts. A session route or label alone is not a call.
|
|
566
|
+
if (!record.model) return h('span', { className: 'sts-muted' }, t('record.noModel'));
|
|
567
|
+
const raw = `${record.provider ? `${record.provider} / ` : ''}${record.model}`;
|
|
568
|
+
return h('span', null,
|
|
569
|
+
record.modelLabel ? `${record.modelLabel} · ` : '',
|
|
570
|
+
h('code', null, raw)
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function RecordCard({ record, t }) {
|
|
575
|
+
const reason = record.reason ? translated(t, 'reason', record.reason) : '';
|
|
576
|
+
return h('article', { className: 'sts-record' },
|
|
577
|
+
h('div', { className: 'sts-record-top' },
|
|
578
|
+
h(SessionId, { id: record.sessionId, t }),
|
|
579
|
+
h(StatusBadge, { status: record.status, t })
|
|
580
|
+
),
|
|
581
|
+
h('dl', null,
|
|
582
|
+
h(Detail, { label: t('record.old') }, record.oldTitle || t('record.untitled')),
|
|
583
|
+
h(Detail, { label: t('record.new'), className: record.status === 'renamed' ? 'sts-success' : '' }, record.newTitle || t('record.noNewTitle')),
|
|
584
|
+
h(Detail, { label: t('record.model') }, h(ModelValue, { record, t })),
|
|
585
|
+
h(Detail, { label: t('record.method') }, translated(t, 'method', record.method, 'method.unknown')),
|
|
586
|
+
(reason || record.status === 'failed') && h(Detail, { label: t('record.reason'), className: record.status === 'failed' ? 'sts-error-text' : '' }, reason || t('record.noReason')),
|
|
587
|
+
h(Detail, { label: t('record.created') }, formatTime(record.createdAt, t)),
|
|
588
|
+
h(Detail, { label: t('run.started') }, formatTime(record.startedAt, t)),
|
|
589
|
+
h(Detail, { label: t('run.finished') }, record.finishedAt == null && ACTIVE_STATES.includes(record.status) ? t('run.inProgress') : formatTime(record.finishedAt, t))
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function RecordsTable({ records, t }) {
|
|
595
|
+
const [expanded, setExpanded] = useState(() => new Set());
|
|
596
|
+
const toggle = (key) => setExpanded((prev) => {
|
|
597
|
+
const next = new Set(prev);
|
|
598
|
+
if (next.has(key)) next.delete(key); else next.add(key);
|
|
599
|
+
return next;
|
|
600
|
+
});
|
|
601
|
+
const columns = ['status', 'session', 'old', 'new', 'model', 'method', 'reason', 'finished'];
|
|
602
|
+
return h('div', { className: 'sts-table-wrap' },
|
|
603
|
+
h('table', { className: 'sts-table' },
|
|
604
|
+
h('thead', null, h('tr', null, ...columns.map((key) => h('th', { key, scope: 'col' }, t(`table.${key}`))))),
|
|
605
|
+
h('tbody', null, ...records.slice().reverse().map((record, index) => {
|
|
606
|
+
const key = `${record.sessionId || 'record'}-${record.startedAt || ''}-${index}`;
|
|
607
|
+
const open = expanded.has(key);
|
|
608
|
+
const reasonText = record.reason ? translated(t, 'reason', record.reason) : '';
|
|
609
|
+
const finished = record.finishedAt == null && ACTIVE_STATES.includes(record.status)
|
|
610
|
+
? t('run.inProgress') : formatTime(record.finishedAt, t);
|
|
611
|
+
const rowProps = {
|
|
612
|
+
className: open ? 'sts-row-open' : '',
|
|
613
|
+
tabIndex: 0,
|
|
614
|
+
'aria-expanded': open,
|
|
615
|
+
'aria-label': t('table.expand'),
|
|
616
|
+
onClick: () => toggle(key),
|
|
617
|
+
onKeyDown: (event) => {
|
|
618
|
+
if (event.key === 'Enter' || event.key === ' ' || event.code === 'Space') { event.preventDefault(); toggle(key); }
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
return h(React.Fragment, { key },
|
|
622
|
+
h('tr', rowProps,
|
|
623
|
+
h('td', null, h(StatusBadge, { status: record.status, t })),
|
|
624
|
+
h('td', { className: 'sts-nowrap', title: record.sessionId }, record.sessionId ? `…${String(record.sessionId).slice(-8)}` : t('empty.value')),
|
|
625
|
+
h('td', { className: 'sts-trunc', title: record.oldTitle }, record.oldTitle || h('span', { className: 'sts-muted' }, t('record.untitled'))),
|
|
626
|
+
h('td', { className: `sts-trunc${record.status === 'renamed' ? ' sts-success' : ''}`, title: record.newTitle }, record.newTitle || h('span', { className: 'sts-muted' }, t('record.noNewTitle'))),
|
|
627
|
+
h('td', { className: 'sts-trunc' }, h(ModelValue, { record, t })),
|
|
628
|
+
h('td', { className: 'sts-nowrap' }, translated(t, 'method', record.method, 'method.unknown')),
|
|
629
|
+
h('td', { className: `sts-trunc${record.status === 'failed' ? ' sts-error-text' : ''}`, title: reasonText }, reasonText || (record.status === 'failed' ? h('span', { className: 'sts-muted' }, t('record.noReason')) : '')),
|
|
630
|
+
h('td', { className: 'sts-nowrap' }, finished)
|
|
631
|
+
),
|
|
632
|
+
open && h('tr', { className: 'sts-detail-row' },
|
|
633
|
+
h('td', { colSpan: columns.length },
|
|
634
|
+
h('dl', null,
|
|
635
|
+
h(Detail, { label: t('record.id') }, h('code', null, record.sessionId || t('empty.value'))),
|
|
636
|
+
h(Detail, { label: t('record.created') }, formatTime(record.createdAt, t)),
|
|
637
|
+
h(Detail, { label: t('run.started') }, formatTime(record.startedAt, t)),
|
|
638
|
+
h(Detail, { label: t('run.finished') }, finished),
|
|
639
|
+
h(Detail, { label: t('record.reason') }, reasonText || t('record.noReason'))
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
);
|
|
644
|
+
}))
|
|
645
|
+
)
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function Metrics({ report, t }) {
|
|
650
|
+
return h('div', { className: 'sts-metrics' },
|
|
651
|
+
...['discovered', 'considered', 'scanned', 'renamed', 'skipped', 'failed'].map((key) =>
|
|
652
|
+
h('div', { className: 'sts-metric', key },
|
|
653
|
+
h('strong', { className: key === 'failed' && report?.[key] > 0 ? 'sts-error-text' : '' }, typeof report?.[key] === 'number' ? report[key] : t('empty.value')),
|
|
654
|
+
h('span', null, t(`metric.${key}`))
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function collectRuns(data) {
|
|
661
|
+
const reports = [data?.lastReport, ...(Array.isArray(data?.history) ? data.history : [])];
|
|
662
|
+
const unique = new Map();
|
|
663
|
+
for (const report of reports) {
|
|
664
|
+
if (!report || typeof report !== 'object') continue;
|
|
665
|
+
const id = report.runId || `legacy-${report.startedAt || report.finishedAt || 'latest'}`;
|
|
666
|
+
if (!unique.has(id)) unique.set(id, { ...report, runId: id });
|
|
667
|
+
}
|
|
668
|
+
return [...unique.values()].sort((a, b) => {
|
|
669
|
+
const timestamp = (run) => new Date(run.startedAt || run.finishedAt || 0).getTime() || 0;
|
|
670
|
+
return timestamp(b) - timestamp(a);
|
|
671
|
+
}).slice(0, 10);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function RunHistory({ runs, selectedId, onSelect, t }) {
|
|
675
|
+
if (runs.length === 0) return h('p', { className: 'sts-muted' }, t('run.emptyHistory'));
|
|
676
|
+
return h(React.Fragment, null,
|
|
677
|
+
h('p', { className: 'sts-muted' }, t('run.selectHint')),
|
|
678
|
+
h('label', { className: 'sts-run-select' }, t('run.select'),
|
|
679
|
+
h('select', { value: selectedId, onChange: (event) => onSelect(event.target.value) },
|
|
680
|
+
h('option', { value: '' }, t('run.latest')),
|
|
681
|
+
...runs.map((run) => h('option', { key: run.runId, value: run.runId },
|
|
682
|
+
`${formatTime(run.startedAt, t)} · ${translated(t, 'source', run.source)} · ${translated(t, 'status', run.state, 'status.unknown')}`
|
|
683
|
+
))
|
|
684
|
+
)
|
|
685
|
+
),
|
|
686
|
+
h('ol', { className: 'sts-history' }, ...runs.map((run, index) =>
|
|
687
|
+
h('li', { key: run.runId },
|
|
688
|
+
h('button', {
|
|
689
|
+
type: 'button', 'aria-pressed': selectedId ? selectedId === run.runId : index === 0,
|
|
690
|
+
onClick: () => onSelect(run.runId)
|
|
691
|
+
},
|
|
692
|
+
`${formatTime(run.startedAt, t)} · ${translated(t, 'status', run.state, 'status.unknown')}`,
|
|
693
|
+
h('small', null, `${translated(t, 'source', run.source)} · ${t('metric.scanned')} ${run.scanned ?? '—'} · ${t('metric.renamed')} ${run.renamed ?? '—'} · ${t('metric.failed')} ${run.failed ?? '—'}`)
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
))
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function Records({ report, t }) {
|
|
701
|
+
const [query, setQuery] = useState('');
|
|
702
|
+
const [status, setStatus] = useState('');
|
|
703
|
+
const records = Array.isArray(report?.records) ? report.records : [];
|
|
704
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
705
|
+
const statuses = [...new Set(records.map((record) => record.status).filter(Boolean))];
|
|
706
|
+
const filtered = records.filter((record) => {
|
|
707
|
+
if (status && record.status !== status) return false;
|
|
708
|
+
if (!needle) return true;
|
|
709
|
+
return [record.sessionId, record.oldTitle, record.newTitle, record.provider, record.model,
|
|
710
|
+
record.modelLabel, record.reason, record.method, translated(t, 'status', record.status),
|
|
711
|
+
translated(t, 'reason', record.reason), translated(t, 'method', record.method)]
|
|
712
|
+
.some((value) => value != null && String(value).toLocaleLowerCase().includes(needle));
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
return h('section', { className: 'sts-section' },
|
|
716
|
+
h('h2', null, t('section.records')),
|
|
717
|
+
report && h('dl', null,
|
|
718
|
+
h(Detail, { label: t('run.id') }, h('code', null, report.runId)),
|
|
719
|
+
h(Detail, { label: t('run.source') }, translated(t, 'source', report.source)),
|
|
720
|
+
h(Detail, { label: t('run.started') }, formatTime(report.startedAt, t)),
|
|
721
|
+
h(Detail, { label: t('run.finished') }, report.finishedAt == null && ACTIVE_STATES.includes(report.state) ? t('run.inProgress') : formatTime(report.finishedAt, t))
|
|
722
|
+
),
|
|
723
|
+
h(Metrics, { report, t }),
|
|
724
|
+
h('div', { className: 'sts-filters' },
|
|
725
|
+
h('label', null, t('filter.query'), h('input', {
|
|
726
|
+
type: 'search', value: query, placeholder: t('filter.placeholder'),
|
|
727
|
+
onChange: (event) => setQuery(event.target.value)
|
|
728
|
+
})),
|
|
729
|
+
h('label', null, t('filter.status'), h('select', {
|
|
730
|
+
value: status, onChange: (event) => setStatus(event.target.value)
|
|
731
|
+
}, h('option', { value: '' }, t('filter.all')),
|
|
732
|
+
...statuses.map((value) => h('option', { key: value, value }, translated(t, 'status', value)))
|
|
733
|
+
))
|
|
734
|
+
),
|
|
735
|
+
h('div', { className: 'sts-toolbar' },
|
|
736
|
+
h('p', { className: 'sts-muted', role: 'status' }, `${t('record.count')}: ${filtered.length} / ${records.length}`),
|
|
737
|
+
(query || status) && h('button', { type: 'button', onClick: () => { setQuery(''); setStatus(''); } }, t('filter.clear'))
|
|
738
|
+
),
|
|
739
|
+
records.length === 0 ? h('p', { className: 'sts-muted' }, t('record.empty'))
|
|
740
|
+
: filtered.length === 0 ? h('p', { className: 'sts-muted' }, t('record.noMatch'))
|
|
741
|
+
: h(RecordsTable, { records: filtered, t })
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function Categories({ data, t }) {
|
|
746
|
+
const builtin = Array.isArray(data.builtin) ? data.builtin : [];
|
|
747
|
+
const custom = Array.isArray(data.custom) ? data.custom : [];
|
|
748
|
+
return h('section', { className: 'sts-section sts-categories' },
|
|
749
|
+
h('div', null,
|
|
750
|
+
h('h2', null, t('section.builtin')),
|
|
751
|
+
h('div', { className: 'sts-chips' }, ...builtin.map((name) => h('span', { key: name, className: 'sts-badge' }, name)))
|
|
752
|
+
),
|
|
753
|
+
h('div', null,
|
|
754
|
+
h('h2', null, t('section.custom')),
|
|
755
|
+
custom.length === 0 ? h('p', { className: 'sts-muted' }, t('category.empty'))
|
|
756
|
+
: h('div', null, ...custom.map((entry) => h('div', { className: 'sts-category-row', key: entry.name },
|
|
757
|
+
h('span', null, entry.name),
|
|
758
|
+
h('span', { className: 'sts-muted' }, `${entry.usage_count ?? 0} ${t('category.count')}`)
|
|
759
|
+
)))
|
|
760
|
+
)
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function ConfigTab({ status, t }) {
|
|
765
|
+
const { data, refresh } = status;
|
|
766
|
+
const override = data?.routeOverride;
|
|
767
|
+
const [catalog, setCatalog] = useState(null);
|
|
768
|
+
const [catalogError, setCatalogError] = useState(null);
|
|
769
|
+
const [provider, setProvider] = useState(override?.provider ?? '');
|
|
770
|
+
const [model, setModel] = useState(override?.model ?? '');
|
|
771
|
+
const [busy, setBusy] = useState(false);
|
|
772
|
+
const [message, setMessage] = useState(null);
|
|
773
|
+
const [error, setError] = useState(null);
|
|
774
|
+
// The live provider/model catalog comes from the Host's own LLM
|
|
775
|
+
// registry — the same source the official model selector reads.
|
|
776
|
+
const loadCatalog = useCallback(async () => {
|
|
777
|
+
setCatalogError(null);
|
|
778
|
+
try { setCatalog(await request('models', {})); }
|
|
779
|
+
catch (e) { setCatalogError(String(e?.message ?? e)); }
|
|
780
|
+
}, []);
|
|
781
|
+
useEffect(() => { void loadCatalog(); }, [loadCatalog]);
|
|
782
|
+
const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
|
|
783
|
+
const providerGroup = groups.find((group) => group.id === provider) ?? null;
|
|
784
|
+
// Only models of the selected provider are ever offered; a stale
|
|
785
|
+
// saved model that the provider no longer lists is surfaced, not hidden.
|
|
786
|
+
const models = providerGroup?.models ?? [];
|
|
787
|
+
const modelKnown = models.some((entry) => entry.id === model);
|
|
788
|
+
const save = async (next) => {
|
|
789
|
+
setBusy(true); setError(null); setMessage(null);
|
|
790
|
+
try {
|
|
791
|
+
const body = next ? { provider: next.provider, model: next.model } : { provider: '', model: '' };
|
|
792
|
+
await request('config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
793
|
+
if (next) { setProvider(next.provider); setModel(next.model); } else { setProvider(''); setModel(''); }
|
|
794
|
+
setMessage(next ? t('config.saved') : t('config.cleared'));
|
|
795
|
+
await refresh();
|
|
796
|
+
} catch (e) { setError(String(e?.message ?? e)); }
|
|
797
|
+
finally { setBusy(false); }
|
|
798
|
+
};
|
|
799
|
+
const onPickProvider = (id) => {
|
|
800
|
+
setProvider(id);
|
|
801
|
+
// Switching providers invalidates any model choice from the old one.
|
|
802
|
+
setModel('');
|
|
803
|
+
setMessage(null);
|
|
804
|
+
};
|
|
805
|
+
const complete = provider !== '' && model !== '' && modelKnown;
|
|
806
|
+
return h('section', { className: 'sts-section' },
|
|
807
|
+
h('h2', null, t('section.config')),
|
|
808
|
+
h('div', { className: 'sts-notice' },
|
|
809
|
+
h('p', { className: 'sts-muted' }, t('config.hint'))
|
|
810
|
+
),
|
|
811
|
+
h('p', null, `${t('config.current')}: `, override
|
|
812
|
+
? h('code', null, `${override.provider} / ${override.model}`)
|
|
813
|
+
: h('span', { className: 'sts-muted' }, t('config.none'))),
|
|
814
|
+
catalogError && h('div', { className: 'sts-notice sts-error', role: 'alert' },
|
|
815
|
+
h('p', { className: 'sts-error-text' }, `${t('config.modelsError')}: ${catalogError}`),
|
|
816
|
+
h('button', { type: 'button', onClick: () => { void loadCatalog(); } }, t('action.retry'))
|
|
817
|
+
),
|
|
818
|
+
!catalog && !catalogError && h('p', { className: 'sts-muted', role: 'status' }, t('config.loadingModels')),
|
|
819
|
+
groups.length > 0 && h('div', { className: 'sts-filters' },
|
|
820
|
+
h('label', null, t('config.provider'),
|
|
821
|
+
h('select', { value: provider, onChange: (e) => onPickProvider(e.target.value), disabled: busy },
|
|
822
|
+
h('option', { value: '' }, t('config.pickProvider')),
|
|
823
|
+
...groups.map((group) => h('option', { key: group.id, value: group.id },
|
|
824
|
+
`${group.name}${group.models.length === 0 && group.error ? `(${t('config.providerFailed')})` : ''}`))
|
|
825
|
+
)),
|
|
826
|
+
h('label', null, t('config.model'),
|
|
827
|
+
h('select', { value: modelKnown ? model : '', onChange: (e) => setModel(e.target.value), disabled: busy || provider === '' },
|
|
828
|
+
h('option', { value: '' }, provider === '' ? t('config.pickProviderFirst') : t('config.pickModel')),
|
|
829
|
+
...models.map((entry) => h('option', { key: entry.id, value: entry.id }, entry.name === entry.id ? entry.id : `${entry.name} (${entry.id})`))
|
|
830
|
+
))
|
|
831
|
+
),
|
|
832
|
+
provider !== '' && model !== '' && !modelKnown && h('p', { className: 'sts-error-text', role: 'alert' }, t('config.modelNotInProvider')),
|
|
833
|
+
h('div', { className: 'sts-toolbar' },
|
|
834
|
+
h('div', { className: 'sts-actions' },
|
|
835
|
+
h('button', { className: 'sts-primary', type: 'button', disabled: busy || !complete, onClick: () => { void save({ provider, model }); } }, busy ? t('busy') : t('config.save')),
|
|
836
|
+
h('button', { type: 'button', disabled: busy || !override, onClick: () => { void save(null); } }, t('config.clear')),
|
|
837
|
+
catalog && h('button', { type: 'button', disabled: busy, onClick: () => { void loadCatalog(); } }, t('config.reload'))
|
|
838
|
+
)
|
|
839
|
+
),
|
|
840
|
+
message && h('p', { className: 'sts-success', role: 'status' }, message),
|
|
841
|
+
error && h('div', { className: 'sts-notice sts-error', role: 'alert' }, `${t('action.failure')}: ${error}`)
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function PanelPage({ t }) {
|
|
846
|
+
const status = useStatus();
|
|
847
|
+
const { data, loadError, actionError, refreshing, posting, syncedAt } = status;
|
|
848
|
+
const [tab, setTab] = useState('records');
|
|
849
|
+
const [selectedId, setSelectedId] = useState('');
|
|
850
|
+
const runs = collectRuns(data);
|
|
851
|
+
const report = runs.find((run) => run.runId === selectedId) || runs[0] || null;
|
|
852
|
+
const hostError = data?.error;
|
|
853
|
+
const isRunning = data?.running === true;
|
|
854
|
+
const unknown = !data || !!loadError;
|
|
855
|
+
const scanDisabled = unknown || isRunning || posting;
|
|
856
|
+
const heading = loadError ? t('connection.error') : !data ? t('run.unknown')
|
|
857
|
+
: posting ? t('action.pending') : isRunning ? t('run.running')
|
|
858
|
+
: hostError || actionError ? t('run.error') : t('run.idle');
|
|
859
|
+
|
|
860
|
+
useEffect(() => {
|
|
861
|
+
if (selectedId && !runs.some((run) => run.runId === selectedId)) setSelectedId('');
|
|
862
|
+
}, [selectedId, runs.map((run) => run.runId).join('|')]);
|
|
863
|
+
|
|
864
|
+
return h('div', { className: 'session-title-summary', 'data-dsh-panel': PANEL_ID },
|
|
865
|
+
h('style', null, CSS),
|
|
866
|
+
h('header', { className: 'sts-header' },
|
|
867
|
+
h('h1', null, t('panel.title')),
|
|
868
|
+
h('p', { className: 'sts-muted' }, t('panel.subtitle'))
|
|
869
|
+
),
|
|
870
|
+
h('div', { className: 'sts-tabs', role: 'tablist' },
|
|
871
|
+
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'records', className: tab === 'records' ? 'sts-tab-active' : '', onClick: () => setTab('records') }, t('tab.records')),
|
|
872
|
+
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'config', className: tab === 'config' ? 'sts-tab-active' : '', onClick: () => setTab('config') }, t('tab.config'))
|
|
873
|
+
),
|
|
874
|
+
tab === 'config' ? h(ConfigTab, { status, t }) : h(React.Fragment, null,
|
|
875
|
+
h('section', { className: 'sts-section', 'aria-busy': !data && refreshing },
|
|
876
|
+
h('h2', null, t('section.backfill')),
|
|
877
|
+
h('div', { className: 'sts-toolbar' },
|
|
878
|
+
h('strong', { role: 'status', 'aria-live': 'polite', className: loadError || hostError || actionError ? 'sts-error-text' : '' }, heading),
|
|
879
|
+
h('div', { className: 'sts-actions' },
|
|
880
|
+
h('button', { type: 'button', disabled: refreshing, onClick: () => { void status.refresh(); } }, refreshing ? t('action.refreshing') : t('action.retry')),
|
|
881
|
+
h('button', { className: 'sts-primary', type: 'button', disabled: scanDisabled, onClick: () => { void status.scan(); } }, posting ? t('action.pending') : t('action.scan'))
|
|
882
|
+
)
|
|
883
|
+
),
|
|
884
|
+
!data && !loadError && h('p', { role: 'status', className: 'sts-muted' }, t('connection.loading')),
|
|
885
|
+
loadError && h('div', { className: 'sts-notice sts-error', role: 'alert' },
|
|
886
|
+
h('p', { className: 'sts-error-text' }, errorMessage(loadError, t)),
|
|
887
|
+
data && h('p', { className: 'sts-muted' }, t('connection.stale'))
|
|
888
|
+
),
|
|
889
|
+
actionError && h('div', { className: 'sts-notice sts-error', role: 'alert' }, `${t('action.failure')}: ${errorMessage(actionError, t)}`),
|
|
890
|
+
hostError && h('div', { className: 'sts-notice sts-error', role: 'alert' }, `${t('run.error')}: ${errorMessage(hostError, t)}`),
|
|
891
|
+
h('div', { className: 'sts-meta sts-muted' },
|
|
892
|
+
h('span', null, t('connection.poll')),
|
|
893
|
+
syncedAt && h('span', null, `${t('connection.synced')}: ${formatTime(syncedAt, t)}`),
|
|
894
|
+
data?.version && h('span', null, `${t('panel.version')}: ${data.version}`)
|
|
895
|
+
),
|
|
896
|
+
data && h('p', { className: 'sts-muted' }, `${t('run.last')}: ${data.lastRunAt == null ? t('run.never') : formatTime(data.lastRunAt, t)}`),
|
|
897
|
+
data && h(Metrics, { report: data.lastReport, t }),
|
|
898
|
+
(data?.current || isRunning) && h('div', { className: 'sts-current' },
|
|
899
|
+
h('h3', null, t('section.current')),
|
|
900
|
+
data.current ? h(RecordCard, { record: data.current, t }) : h('p', { className: 'sts-muted', role: 'status' }, t('record.noCurrent'))
|
|
901
|
+
)
|
|
902
|
+
),
|
|
903
|
+
data && h('section', { className: 'sts-section' },
|
|
904
|
+
h('h2', null, t('section.history')),
|
|
905
|
+
h(RunHistory, { runs, selectedId, onSelect: setSelectedId, t })
|
|
906
|
+
),
|
|
907
|
+
data && h(Records, { report, t }),
|
|
908
|
+
data && h(Categories, { data, t })
|
|
909
|
+
)
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return {
|
|
914
|
+
inject: ['slots', 'locale'],
|
|
915
|
+
apply(ctx) {
|
|
916
|
+
let t = (key) => DICTS.zh[key] ?? key;
|
|
917
|
+
try {
|
|
918
|
+
ctx.effect(() => ctx.locale.register(NS, DICTS));
|
|
919
|
+
t = ctx.locale.bind(NS);
|
|
920
|
+
} catch { /* Keep the localized Chinese fallback if locale is unavailable. */ }
|
|
921
|
+
const slots = ctx.slots;
|
|
922
|
+
const disposers = [
|
|
923
|
+
slots.inject('sidebar.panellist', () => slots.register({
|
|
924
|
+
name: 'sidebar.panellist', id: PANEL_ID, order: 25,
|
|
925
|
+
label: () => t('entry.label')
|
|
926
|
+
}, PanelIcon)),
|
|
927
|
+
slots.inject('main', () => slots.register({
|
|
928
|
+
name: 'main', key: PANEL_ID
|
|
929
|
+
}, () => h(PanelPage, { t })))
|
|
930
|
+
];
|
|
931
|
+
ctx.effect(() => () => {
|
|
932
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
933
|
+
}, 'session-title-summary: panel slots');
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
});
|