netizen-cli 0.10.0__py3-none-any.whl
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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2898 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const state = {
|
|
4
|
+
tab: "projects",
|
|
5
|
+
projects: null,
|
|
6
|
+
sessions: null,
|
|
7
|
+
sides: null,
|
|
8
|
+
updates: null,
|
|
9
|
+
schedules: null,
|
|
10
|
+
defaults: null,
|
|
11
|
+
projectCursor: null,
|
|
12
|
+
sideCursor: null,
|
|
13
|
+
sessionPage: {
|
|
14
|
+
cursor: null,
|
|
15
|
+
nextCursor: null,
|
|
16
|
+
previousCursors: [],
|
|
17
|
+
number: 1,
|
|
18
|
+
query: null,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const statusNode = document.querySelector("#status");
|
|
23
|
+
|
|
24
|
+
function setStatus(message, isError = false) {
|
|
25
|
+
statusNode.textContent = message || "";
|
|
26
|
+
statusNode.classList.toggle("error", isError);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function api(path, options = {}) {
|
|
30
|
+
const response = await fetch(path, { cache: "no-store", ...options });
|
|
31
|
+
if (response.status === 401) {
|
|
32
|
+
window.location.assign("/login");
|
|
33
|
+
const error = new Error("登录已失效,请重新登录。");
|
|
34
|
+
error.status = 401;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
if (response.status === 204) return null;
|
|
38
|
+
const data = await response.json();
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const error = new Error(data.message || `请求失败 (${response.status})`);
|
|
41
|
+
error.status = response.status;
|
|
42
|
+
error.code = data.code;
|
|
43
|
+
error.choices = data.choices;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const updateTerminalPhases = new Set([
|
|
50
|
+
"succeeded", "failed", "rolled_back", "requires_action", "recovery_required", "recovered",
|
|
51
|
+
]);
|
|
52
|
+
const updatePhaseLabels = {
|
|
53
|
+
accepted: "升级已受理",
|
|
54
|
+
downloading: "正在下载更新",
|
|
55
|
+
preparing: "正在准备并验证新版本",
|
|
56
|
+
installing: "正在安装",
|
|
57
|
+
restarting: "正在重启",
|
|
58
|
+
succeeded: "升级成功",
|
|
59
|
+
failed: "升级失败",
|
|
60
|
+
rolled_back: "升级失败,已回滚",
|
|
61
|
+
requires_action: "需要处理后重试",
|
|
62
|
+
recovery_required: "升级结果未确认,需要修复",
|
|
63
|
+
recovered: "已通过显式维护恢复",
|
|
64
|
+
};
|
|
65
|
+
const updateCodeMessages = {
|
|
66
|
+
restart_failed: "服务未能确认重启完成,请检查服务日志并按部署说明恢复。",
|
|
67
|
+
download_failed: "下载失败,请检查网络后重新检查更新。",
|
|
68
|
+
installer_invalid: "安装器校验失败,请重新检查更新。",
|
|
69
|
+
preparation_failed: "新版本准备失败,旧版本未切换。请通过官方安装器重试以查看具体原因。",
|
|
70
|
+
configuration_required: "升级需要补全配置或凭据,请按部署文档完成配置后重试。",
|
|
71
|
+
permissions_required: "升级需要处理飞书应用权限,请按部署文档完成授权后重试。",
|
|
72
|
+
activation_failed: "新版本未能完成启动,请查看回滚结果,检查服务日志并用官方安装器恢复。",
|
|
73
|
+
rollback_incomplete: "回滚未完成,请使用官方安装器修复,当前结果不能确认为成功。",
|
|
74
|
+
installer_failed: "安装器未成功完成,请通过官方安装器重试以查看具体原因。",
|
|
75
|
+
worker_interrupted: "维护进程已中断,请按部署说明检查并恢复。",
|
|
76
|
+
lock_busy: "已有维护操作正在执行,请稍后刷新维护状态。",
|
|
77
|
+
operation_invalid: "维护记录无法验证,请按部署说明检查并修复。",
|
|
78
|
+
dispatch_failed: "未能启动维护进程,请检查服务管理器后刷新维护状态。",
|
|
79
|
+
dispatch_unknown: "维护进程的启动结果未确认,请按部署说明检查并修复。",
|
|
80
|
+
worker_lost: "维护进程未能完成,请按部署说明检查并恢复。",
|
|
81
|
+
previous_release_changed: "当前安装版本已变化,请刷新维护状态。",
|
|
82
|
+
profile_failed: "无法读取账户运行环境,请检查登录 Shell 配置后重试。",
|
|
83
|
+
manual_recovery: "显式维护已恢复服务,请以当前版本为准;原操作不标记为成功。",
|
|
84
|
+
service_ready: "当前服务已就绪,可继续维护;原重启操作不标记为成功。",
|
|
85
|
+
};
|
|
86
|
+
const updatePollLimitMs = 5 * 60 * 1000;
|
|
87
|
+
let updatePollTimer = null;
|
|
88
|
+
let updatePollStarted = null;
|
|
89
|
+
let updatePollDelay = 2000;
|
|
90
|
+
let updatePollExpired = false;
|
|
91
|
+
let updateSubmitting = false;
|
|
92
|
+
let updateExpectedTarget = null;
|
|
93
|
+
let updatePriorOperationId = null;
|
|
94
|
+
let updateDisconnected = false;
|
|
95
|
+
let updateReading = false;
|
|
96
|
+
|
|
97
|
+
function sameRestartTarget(left, right) {
|
|
98
|
+
return left?.resource === "instance-restart" && typeof left.targetId === "string"
|
|
99
|
+
&& left.targetId === right?.installationId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function updateNeedsPolling() {
|
|
103
|
+
const operation = state.updates?.operation;
|
|
104
|
+
return updateExpectedTarget != null
|
|
105
|
+
|| (operation != null && !updateTerminalPhases.has(operation.phase));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderUpdates() {
|
|
109
|
+
const data = state.updates;
|
|
110
|
+
if (!data) return;
|
|
111
|
+
document.querySelector("#update-current").textContent = data.current.version;
|
|
112
|
+
document.querySelector("#update-source").textContent = {
|
|
113
|
+
python: "Python 环境安装",
|
|
114
|
+
}[data.current.source] || "安装来源未知";
|
|
115
|
+
document.querySelector("#update-message").textContent = data.checkingError
|
|
116
|
+
|| data.message || "程序更新请在对应 Python 环境运行 netizen update。";
|
|
117
|
+
const operation = data.operation;
|
|
118
|
+
const restarting = updateExpectedTarget != null || operation?.kind === "restart";
|
|
119
|
+
const operationName = restarting ? "重启" : "升级";
|
|
120
|
+
let phase = operation
|
|
121
|
+
? (updatePhaseLabels[operation.phase] || "维护结果未确认").replace("升级", operationName)
|
|
122
|
+
: "尚未执行维护操作";
|
|
123
|
+
let detail = operation
|
|
124
|
+
? `${restarting ? "保持版本" : "目标版本"} ${operation.target.version}。${
|
|
125
|
+
updateCodeMessages[operation.code] || ""}`
|
|
126
|
+
: "重启开始后,关闭页面不会取消操作。";
|
|
127
|
+
if (operation?.kind === "restart" && operation.phase === "recovered"
|
|
128
|
+
&& operation.code === "service_ready") {
|
|
129
|
+
phase = "服务已恢复";
|
|
130
|
+
}
|
|
131
|
+
if (operation?.phase === "succeeded") {
|
|
132
|
+
detail += restarting ? " 服务管理器已完成重启,服务就绪已确认。" : " 安装器已确认升级完成。";
|
|
133
|
+
}
|
|
134
|
+
if (updateExpectedTarget) {
|
|
135
|
+
phase = updateSubmitting ? `正在提交${operationName}` : `${operationName}提交结果尚未确认`;
|
|
136
|
+
detail = "正在查询服务端记录,请勿重复提交重启。";
|
|
137
|
+
}
|
|
138
|
+
if (updateDisconnected) {
|
|
139
|
+
phase = `连接暂时中断,正在查询${operationName}结果`;
|
|
140
|
+
detail = "服务可能正在重启;重新连接后将读取维护记录。";
|
|
141
|
+
}
|
|
142
|
+
if (updatePollExpired) {
|
|
143
|
+
phase = `${operationName}结果尚未确认`;
|
|
144
|
+
detail = "自动查询已停止;请点击“刷新维护状态”手动检查。请勿据此认定操作失败或重复提交。";
|
|
145
|
+
}
|
|
146
|
+
document.querySelector("#update-phase").textContent = phase;
|
|
147
|
+
document.querySelector("#update-detail").textContent = detail;
|
|
148
|
+
document.querySelector("#service-restart").disabled = updateSubmitting || updateReading
|
|
149
|
+
|| !data.restartSupported || !data.restartAvailable || !data.actions?.restart
|
|
150
|
+
|| updateNeedsPolling() || updatePollExpired || updateDisconnected;
|
|
151
|
+
document.querySelector("#restart-message").textContent = !data.restartSupported
|
|
152
|
+
? "当前安装不支持从管理页重启,请按部署说明管理服务。"
|
|
153
|
+
: !data.restartAvailable && !updateNeedsPolling()
|
|
154
|
+
? "当前暂不可重启,请查看维护状态或部署说明。" : "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function updateApi(path, options = {}) {
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
160
|
+
try {
|
|
161
|
+
return await api(path, { ...options, signal: controller.signal });
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timeout);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function acceptUpdateStatus(data) {
|
|
168
|
+
state.updates = data;
|
|
169
|
+
if (data.operation?.kind === "restart"
|
|
170
|
+
&& sameRestartTarget(updateExpectedTarget, data.operation?.target)
|
|
171
|
+
&& data.operation.operationId !== updatePriorOperationId) {
|
|
172
|
+
updateExpectedTarget = null;
|
|
173
|
+
}
|
|
174
|
+
updateDisconnected = false;
|
|
175
|
+
updatePollDelay = 2000;
|
|
176
|
+
renderUpdates();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stopUpdatePolling() {
|
|
180
|
+
clearTimeout(updatePollTimer);
|
|
181
|
+
updatePollTimer = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scheduleUpdatePoll() {
|
|
185
|
+
stopUpdatePolling();
|
|
186
|
+
if (state.tab !== "updates" || updatePollExpired || !updateNeedsPolling()) return;
|
|
187
|
+
if (updatePollStarted == null) updatePollStarted = Date.now();
|
|
188
|
+
if (Date.now() - updatePollStarted >= updatePollLimitMs) {
|
|
189
|
+
updatePollExpired = true;
|
|
190
|
+
renderUpdates();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
updatePollTimer = setTimeout(pollUpdateStatus, updatePollDelay);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function pollUpdateStatus() {
|
|
197
|
+
updatePollTimer = null;
|
|
198
|
+
if (state.tab !== "updates") return;
|
|
199
|
+
if (updateReading) {
|
|
200
|
+
scheduleUpdatePoll();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
updateReading = true;
|
|
204
|
+
try {
|
|
205
|
+
acceptUpdateStatus(await updateApi("/api/v1/updates"));
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error.status === 401) return;
|
|
208
|
+
updateDisconnected = true;
|
|
209
|
+
updatePollDelay = Math.min(updatePollDelay * 2, 15000);
|
|
210
|
+
} finally {
|
|
211
|
+
updateReading = false;
|
|
212
|
+
renderUpdates();
|
|
213
|
+
}
|
|
214
|
+
scheduleUpdatePoll();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function loadUpdates() {
|
|
218
|
+
if (updateReading) return;
|
|
219
|
+
stopUpdatePolling();
|
|
220
|
+
updatePollStarted = Date.now();
|
|
221
|
+
updatePollExpired = false;
|
|
222
|
+
updateReading = true;
|
|
223
|
+
renderUpdates();
|
|
224
|
+
try {
|
|
225
|
+
acceptUpdateStatus(await updateApi("/api/v1/updates"));
|
|
226
|
+
} finally {
|
|
227
|
+
updateReading = false;
|
|
228
|
+
renderUpdates();
|
|
229
|
+
scheduleUpdatePoll();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function restartService() {
|
|
234
|
+
if (updateSubmitting || updateReading || updateNeedsPolling()
|
|
235
|
+
|| updatePollExpired || updateDisconnected) return;
|
|
236
|
+
const envelope = state.updates?.actions?.restart;
|
|
237
|
+
if (!envelope || !state.updates.restartSupported || !state.updates.restartAvailable) return;
|
|
238
|
+
if (!window.confirm(
|
|
239
|
+
"确认重启服务?将使用绑定环境中当前安装的程序,不更新软件包。\n\n"
|
|
240
|
+
+ `实例:${document.querySelector("#instance-root")?.textContent || "当前实例"}\n\n`
|
|
241
|
+
+ "重启会中断正在执行的任务、暂停 Goal,并结束临时 Side 会话。"
|
|
242
|
+
+ "重启后不会自动续跑,管理页需要重新登录。",
|
|
243
|
+
)) return;
|
|
244
|
+
updateSubmitting = true;
|
|
245
|
+
updateExpectedTarget = envelope.target;
|
|
246
|
+
updatePriorOperationId = state.updates.operation?.operationId || null;
|
|
247
|
+
updatePollStarted = Date.now();
|
|
248
|
+
state.updates.actions.restart = null;
|
|
249
|
+
renderUpdates();
|
|
250
|
+
let sessionExpired = false;
|
|
251
|
+
try {
|
|
252
|
+
const result = await updateApi("/api/v1/updates/restart", {
|
|
253
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
254
|
+
body: JSON.stringify(actionPayload(envelope)),
|
|
255
|
+
});
|
|
256
|
+
acceptUpdateStatus({ ...state.updates, operation: result.operation });
|
|
257
|
+
const instanceLabel = document.querySelector("#instance-root")?.textContent || "当前实例";
|
|
258
|
+
setStatus(`重启已受理,正在查询服务重启结果。重启后需要重新登录。 实例:${instanceLabel}`);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error.status === 401) {
|
|
261
|
+
sessionExpired = true;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (error.status >= 400 && error.status < 500) {
|
|
265
|
+
updateExpectedTarget = null;
|
|
266
|
+
setStatus(`${error.message} 请刷新维护状态。`, true);
|
|
267
|
+
} else {
|
|
268
|
+
setStatus("重启提交结果未确认,正在查询服务端记录;请勿重复提交。", true);
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
updateSubmitting = false;
|
|
272
|
+
renderUpdates();
|
|
273
|
+
if (!sessionExpired) scheduleUpdatePoll();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function actionPayload(envelope, extra = {}) {
|
|
278
|
+
return {
|
|
279
|
+
csrfToken: envelope.csrfToken,
|
|
280
|
+
actionToken: envelope.actionToken,
|
|
281
|
+
target: envelope.target,
|
|
282
|
+
...extra,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function mutate(path, envelope, extra = {}) {
|
|
287
|
+
setStatus("正在执行…");
|
|
288
|
+
try {
|
|
289
|
+
const result = await api(path, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "Content-Type": "application/json" },
|
|
292
|
+
body: JSON.stringify(actionPayload(envelope, extra)),
|
|
293
|
+
});
|
|
294
|
+
const refreshed = await refresh(state.tab);
|
|
295
|
+
if (refreshed) {
|
|
296
|
+
setStatus(result?.message || "操作完成,已按服务端事实刷新。");
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
const message = error.message;
|
|
301
|
+
await refresh(state.tab);
|
|
302
|
+
setStatus(message, true);
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function cell(row, text, className = "") {
|
|
308
|
+
const td = document.createElement("td");
|
|
309
|
+
const node = document.createElement("span");
|
|
310
|
+
node.textContent = text == null || text === "" ? "—" : String(text);
|
|
311
|
+
if (className) node.className = className;
|
|
312
|
+
td.append(node);
|
|
313
|
+
row.append(td);
|
|
314
|
+
return td;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function actionButton(label, handler, danger = false) {
|
|
318
|
+
const button = document.createElement("button");
|
|
319
|
+
button.type = "button";
|
|
320
|
+
button.className = danger ? "action danger" : "action";
|
|
321
|
+
button.textContent = label;
|
|
322
|
+
button.addEventListener("click", handler);
|
|
323
|
+
return button;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function actionsCell(row) {
|
|
327
|
+
const td = document.createElement("td");
|
|
328
|
+
const wrap = document.createElement("div");
|
|
329
|
+
wrap.className = "actions";
|
|
330
|
+
td.append(wrap);
|
|
331
|
+
row.append(td);
|
|
332
|
+
return wrap;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function confirmMaterializedDelete(session) {
|
|
336
|
+
const name = session.nativeTitle || "未命名 Session";
|
|
337
|
+
return window.confirm(
|
|
338
|
+
`确认永久删除这个会话?\n\n会话:${name}\nScope:${session.scopeKey}\nShort ID:${session.shortId}`
|
|
339
|
+
+ "\n\n原生 Thread、spawned descendants、Codex App/CLI 历史和本地 Binding 都会永久消失,无法恢复。",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function badge(value, active = false, warning = false) {
|
|
344
|
+
const node = document.createElement("span");
|
|
345
|
+
node.className = `badge${active ? " on" : ""}${warning ? " warn" : ""}`;
|
|
346
|
+
node.textContent = value;
|
|
347
|
+
return node;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const pendingProjectDeletes = new Set();
|
|
351
|
+
|
|
352
|
+
function showProjectDeleteResult(message, details = [], isError = false) {
|
|
353
|
+
const panel = document.querySelector("#project-delete-result");
|
|
354
|
+
panel.hidden = false;
|
|
355
|
+
panel.classList.toggle("error", isError);
|
|
356
|
+
document.querySelector("#project-delete-message").textContent = message;
|
|
357
|
+
const remaining = document.querySelector("#project-delete-remaining");
|
|
358
|
+
remaining.replaceChildren();
|
|
359
|
+
for (const detail of details) {
|
|
360
|
+
const item = document.createElement("li");
|
|
361
|
+
item.textContent = detail;
|
|
362
|
+
remaining.append(item);
|
|
363
|
+
}
|
|
364
|
+
remaining.hidden = details.length === 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function confirmProjectDelete(preview) {
|
|
368
|
+
return window.confirm(
|
|
369
|
+
`删除 Project「${preview.project.alias}」及关联 Sessions?`
|
|
370
|
+
+ `\n\n关联 Sessions:${preview.sessionCount}(Lazy ${preview.lazySessionCount},已创建 Thread ${preview.materializedSessionCount})`
|
|
371
|
+
+ "\n范围包含所有归档会话,不受 Sessions 页面筛选影响。"
|
|
372
|
+
+ `\n关联 Side:${preview.sideCount},将结束并移除。`
|
|
373
|
+
+ `\n关联定时计划:${preview.scheduledPlanCount || 0};正在交接或未决的定时执行:${preview.scheduledRunCount || 0}。`
|
|
374
|
+
+ "\n定时计划将被删除;后续会话清理失败也不会恢复计划。"
|
|
375
|
+
+ "\n\n原生会话、派生子会话、Codex App/CLI 历史和本地会话登记将永久删除,无法恢复。"
|
|
376
|
+
+ "\n全部会话删除确认成功后,才会删除 Project 登记;部分失败时会保留 Project 和剩余会话。"
|
|
377
|
+
+ `\n\n磁盘代码目录保留:${preview.project.cwd}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function deleteProject(project) {
|
|
382
|
+
const envelope = project.actions.previewDelete;
|
|
383
|
+
if (!envelope || pendingProjectDeletes.has(project.alias)) return;
|
|
384
|
+
pendingProjectDeletes.add(project.alias);
|
|
385
|
+
project.actions.previewDelete = null;
|
|
386
|
+
for (const row of document.querySelectorAll("#projects-body tr")) {
|
|
387
|
+
if (row.dataset.projectAlias !== project.alias) continue;
|
|
388
|
+
for (const button of row.querySelectorAll("button")) button.disabled = true;
|
|
389
|
+
}
|
|
390
|
+
let submitted = false;
|
|
391
|
+
let message = `正在读取 Project「${project.alias}」的删除范围…`;
|
|
392
|
+
let details = [];
|
|
393
|
+
let isError = false;
|
|
394
|
+
showProjectDeleteResult(message);
|
|
395
|
+
setStatus(message);
|
|
396
|
+
try {
|
|
397
|
+
const preview = await api("/api/v1/projects/delete-preview", {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: { "Content-Type": "application/json" },
|
|
400
|
+
body: JSON.stringify(actionPayload(envelope)),
|
|
401
|
+
});
|
|
402
|
+
if (!confirmProjectDelete(preview)) {
|
|
403
|
+
message = `已取消删除 Project「${project.alias}」,未提交删除。`;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
submitted = true;
|
|
407
|
+
showProjectDeleteResult(`正在删除 Project「${project.alias}」及关联 Sessions,请等待服务端确认…`);
|
|
408
|
+
const result = await api("/api/v1/projects/delete", {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "Content-Type": "application/json" },
|
|
411
|
+
body: JSON.stringify(actionPayload(preview.actions.delete)),
|
|
412
|
+
});
|
|
413
|
+
if (result.deleted === true) {
|
|
414
|
+
message = `Project「${result.projectAlias}」及关联 Sessions 已删除,已删除 ${result.deletedSessionCount} 个 Session。磁盘代码目录保留。`;
|
|
415
|
+
} else {
|
|
416
|
+
isError = true;
|
|
417
|
+
message = `Project「${result.projectAlias}」仍保留,删除尚未全部完成。`
|
|
418
|
+
+ `\n已删除 ${result.deletedSessionCount} 个 Session;剩余 ${result.remainingSessionCount} 个 Session、${result.remainingSideCount} 个 Side。`
|
|
419
|
+
+ (result.message ? `\n${result.message}` : "");
|
|
420
|
+
details = (result.remainingSessions || []).map((session) =>
|
|
421
|
+
`Session ${session.shortId} · Scope ${session.scopeKey} · Binding ${session.bindingId}`);
|
|
422
|
+
if (result.failedBindingId) details.push(`未确认删除的 Binding:${result.failedBindingId}`);
|
|
423
|
+
if (result.failedSideId) details.push(`未确认收尾的 Side:${result.failedSideId}`);
|
|
424
|
+
}
|
|
425
|
+
if (result.deletedPlanCount) {
|
|
426
|
+
message += `\n已删除 ${result.deletedPlanCount} 个定时计划;后续会话清理失败也不会恢复计划。`;
|
|
427
|
+
}
|
|
428
|
+
if (result.remainingScheduledRunCount) {
|
|
429
|
+
details.push(`仍有 ${result.remainingScheduledRunCount} 条定时执行的交接或清理未确认。`);
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
isError = true;
|
|
434
|
+
if (submitted && (error.status == null || error.status >= 500)) {
|
|
435
|
+
message = `Project「${project.alias}」的删除结果未确认,服务端可能仍在处理。请手动刷新 Projects 查看事实;不要重复提交删除。`;
|
|
436
|
+
} else {
|
|
437
|
+
message = submitted
|
|
438
|
+
? `Project「${project.alias}」的删除请求未完成。${error.message}`
|
|
439
|
+
: `无法读取 Project「${project.alias}」的删除范围,未提交删除。${error.status ? error.message : "请检查连接后刷新 Projects。"}`;
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
pendingProjectDeletes.delete(project.alias);
|
|
443
|
+
const refreshed = await refresh("projects");
|
|
444
|
+
if (!refreshed) message += "\nProjects 刷新失败,请手动刷新查看最新状态。";
|
|
445
|
+
showProjectDeleteResult(message, details, isError);
|
|
446
|
+
setStatus(message, isError);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function loadProjects(cursor = null) {
|
|
451
|
+
const query = new URLSearchParams({ pageSize: "25" });
|
|
452
|
+
if (cursor) query.set("cursor", cursor);
|
|
453
|
+
const data = await api(`/api/v1/projects?${query}`);
|
|
454
|
+
state.projects = data;
|
|
455
|
+
state.projectCursor = data.nextCursor;
|
|
456
|
+
const body = document.querySelector("#projects-body");
|
|
457
|
+
body.replaceChildren();
|
|
458
|
+
for (const project of data.items) {
|
|
459
|
+
const row = document.createElement("tr");
|
|
460
|
+
row.dataset.projectAlias = project.alias;
|
|
461
|
+
cell(row, project.alias);
|
|
462
|
+
cell(row, project.cwd, "id");
|
|
463
|
+
const status = document.createElement("td");
|
|
464
|
+
status.append(badge(
|
|
465
|
+
project.deleting ? "正在删除" : project.enabled ? "Enabled" : "Disabled",
|
|
466
|
+
!project.deleting && project.enabled,
|
|
467
|
+
project.deleting,
|
|
468
|
+
));
|
|
469
|
+
row.append(status);
|
|
470
|
+
cell(row, `${project.bindingCount}(Lazy ${project.lazyBindingCount})`);
|
|
471
|
+
cell(row, project.archivedBindingCount == null
|
|
472
|
+
? "未确认"
|
|
473
|
+
: project.unconfirmedBindingCount > 0
|
|
474
|
+
? `已确认 ${project.archivedBindingCount};另有 ${project.unconfirmedBindingCount} 个会话状态未确认`
|
|
475
|
+
: project.archivedBindingCount);
|
|
476
|
+
cell(row, project.lastActivatedAt);
|
|
477
|
+
const actions = actionsCell(row);
|
|
478
|
+
if (project.actions.setEnabled) {
|
|
479
|
+
actions.append(actionButton(
|
|
480
|
+
project.enabled ? "停用" : "启用",
|
|
481
|
+
() => mutate("/api/v1/projects/set-enabled", project.actions.setEnabled, { enabled: !project.enabled }),
|
|
482
|
+
project.enabled,
|
|
483
|
+
));
|
|
484
|
+
}
|
|
485
|
+
if (project.actions.previewDelete) {
|
|
486
|
+
actions.append(actionButton(
|
|
487
|
+
"删除 Project 及关联 Sessions", () => deleteProject(project), true,
|
|
488
|
+
));
|
|
489
|
+
}
|
|
490
|
+
if (project.deleting || pendingProjectDeletes.has(project.alias)) {
|
|
491
|
+
for (const button of actions.querySelectorAll("button")) button.disabled = true;
|
|
492
|
+
}
|
|
493
|
+
body.append(row);
|
|
494
|
+
}
|
|
495
|
+
document.querySelector("#projects-next").hidden = !data.nextCursor;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const timeRangeControllers = new WeakMap();
|
|
499
|
+
const sessionMultiFilters = new Map();
|
|
500
|
+
let openSessionMultiFilter = null;
|
|
501
|
+
|
|
502
|
+
const sessionFilterOptions = {
|
|
503
|
+
project: [],
|
|
504
|
+
scopeKind: [["direct", "单聊"], ["group", "群聊"], ["topic", "话题"]],
|
|
505
|
+
inventoryState: [["active", "Active"], ["lazy", "Lazy"], ["unknown", "状态未确认"], ["archived", "Archived"], ["missing", "Missing"]],
|
|
506
|
+
current: [["true", "当前"], ["false", "非当前"]],
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
function initializeSessionMultiFilter(root) {
|
|
510
|
+
const name = root.dataset.sessionMulti;
|
|
511
|
+
const legend = root.querySelector("legend");
|
|
512
|
+
legend.id = `session-${name}-label`;
|
|
513
|
+
const trigger = document.createElement("button");
|
|
514
|
+
trigger.type = "button";
|
|
515
|
+
trigger.className = "multi-filter-trigger";
|
|
516
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
517
|
+
const summary = document.createElement("span");
|
|
518
|
+
summary.className = "multi-filter-summary";
|
|
519
|
+
const chevron = document.createElement("span");
|
|
520
|
+
chevron.textContent = "⌄";
|
|
521
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
522
|
+
trigger.append(summary, chevron);
|
|
523
|
+
const popover = document.createElement("div");
|
|
524
|
+
popover.className = "multi-filter-popover";
|
|
525
|
+
popover.id = `session-${name}-options`;
|
|
526
|
+
popover.hidden = true;
|
|
527
|
+
popover.setAttribute("role", "group");
|
|
528
|
+
popover.setAttribute("aria-labelledby", legend.id);
|
|
529
|
+
trigger.setAttribute("aria-controls", popover.id);
|
|
530
|
+
const search = document.createElement("input");
|
|
531
|
+
search.type = "search";
|
|
532
|
+
search.className = "multi-filter-search";
|
|
533
|
+
search.placeholder = "搜索 Project";
|
|
534
|
+
search.setAttribute("aria-label", "搜索 Project");
|
|
535
|
+
if (name === "project") popover.append(search);
|
|
536
|
+
const clear = document.createElement("button");
|
|
537
|
+
clear.type = "button";
|
|
538
|
+
clear.className = "multi-filter-clear";
|
|
539
|
+
clear.textContent = "清除筛选(全部)";
|
|
540
|
+
const choices = document.createElement("div");
|
|
541
|
+
choices.className = "multi-filter-options";
|
|
542
|
+
const empty = document.createElement("p");
|
|
543
|
+
empty.className = "multi-filter-empty";
|
|
544
|
+
empty.textContent = "没有匹配的 Project";
|
|
545
|
+
empty.hidden = true;
|
|
546
|
+
popover.append(clear, choices, empty);
|
|
547
|
+
root.append(trigger, popover);
|
|
548
|
+
let options = sessionFilterOptions[name];
|
|
549
|
+
let selected = new Set(name === "inventoryState" ? ["active", "lazy", "unknown"] : []);
|
|
550
|
+
|
|
551
|
+
function renderSummary() {
|
|
552
|
+
const labels = options.filter(([value]) => selected.has(value)).map(([, label]) => label);
|
|
553
|
+
summary.textContent = selected.size === 0 ? "全部" : labels.join("、");
|
|
554
|
+
trigger.title = summary.textContent;
|
|
555
|
+
trigger.setAttribute("aria-label", `${legend.textContent}:${summary.textContent}`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function filterChoices() {
|
|
559
|
+
const term = search.value.trim().toLocaleLowerCase();
|
|
560
|
+
let visible = 0;
|
|
561
|
+
for (const label of choices.children) {
|
|
562
|
+
label.hidden = !label.textContent.toLocaleLowerCase().includes(term);
|
|
563
|
+
if (!label.hidden) visible += 1;
|
|
564
|
+
}
|
|
565
|
+
empty.hidden = visible !== 0;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderChoices() {
|
|
569
|
+
choices.replaceChildren();
|
|
570
|
+
for (const [value, labelText] of options) {
|
|
571
|
+
const label = document.createElement("label");
|
|
572
|
+
label.className = "multi-filter-option";
|
|
573
|
+
const input = document.createElement("input");
|
|
574
|
+
input.type = "checkbox";
|
|
575
|
+
input.value = value;
|
|
576
|
+
input.checked = selected.has(value);
|
|
577
|
+
input.addEventListener("change", () => {
|
|
578
|
+
if (input.checked) selected.add(value);
|
|
579
|
+
else selected.delete(value);
|
|
580
|
+
renderSummary();
|
|
581
|
+
});
|
|
582
|
+
label.append(input, document.createTextNode(labelText));
|
|
583
|
+
choices.append(label);
|
|
584
|
+
}
|
|
585
|
+
filterChoices();
|
|
586
|
+
renderSummary();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function close({ restoreFocus = false } = {}) {
|
|
590
|
+
popover.hidden = true;
|
|
591
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
592
|
+
if (openSessionMultiFilter === controller) openSessionMultiFilter = null;
|
|
593
|
+
if (restoreFocus) trigger.focus();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function visibleInputs() {
|
|
597
|
+
return [...choices.children].filter((label) => !label.hidden)
|
|
598
|
+
.map((label) => label.querySelector("input"));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function open() {
|
|
602
|
+
openSessionMultiFilter?.close();
|
|
603
|
+
openTimeRangeFilter?.cancel({ restoreFocus: false });
|
|
604
|
+
openSessionMultiFilter = controller;
|
|
605
|
+
search.value = "";
|
|
606
|
+
filterChoices();
|
|
607
|
+
popover.hidden = false;
|
|
608
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
609
|
+
// Keep the dropdown inside the viewport even in the last grid column.
|
|
610
|
+
const bounds = root.getBoundingClientRect();
|
|
611
|
+
const width = popover.getBoundingClientRect().width;
|
|
612
|
+
popover.style.left = `${Math.min(0, window.innerWidth - 20 - bounds.left - width)}px`;
|
|
613
|
+
(name === "project" ? search : visibleInputs()[0] || clear).focus();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const controller = {
|
|
617
|
+
close,
|
|
618
|
+
values: () => [...selected],
|
|
619
|
+
reset() {
|
|
620
|
+
selected = new Set(name === "inventoryState" ? ["active", "lazy", "unknown"] : []);
|
|
621
|
+
search.value = "";
|
|
622
|
+
close();
|
|
623
|
+
renderChoices();
|
|
624
|
+
},
|
|
625
|
+
setOptions(nextOptions) {
|
|
626
|
+
// A selection remains visible if its Project was removed in another view.
|
|
627
|
+
const known = new Set(nextOptions.map(([value]) => value));
|
|
628
|
+
options = [...nextOptions, ...[...selected].filter((value) => !known.has(value))
|
|
629
|
+
.map((value) => [value, `${value}(已不可用)`])];
|
|
630
|
+
renderChoices();
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
trigger.addEventListener("click", () => {
|
|
634
|
+
if (popover.hidden) open();
|
|
635
|
+
else close({ restoreFocus: true });
|
|
636
|
+
});
|
|
637
|
+
trigger.addEventListener("keydown", (event) => {
|
|
638
|
+
if (event.key === "ArrowDown") {
|
|
639
|
+
event.preventDefault();
|
|
640
|
+
open();
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
search.addEventListener("input", filterChoices);
|
|
644
|
+
clear.addEventListener("click", () => {
|
|
645
|
+
selected.clear();
|
|
646
|
+
renderChoices();
|
|
647
|
+
});
|
|
648
|
+
root.addEventListener("keydown", (event) => {
|
|
649
|
+
if (popover.hidden) return;
|
|
650
|
+
if (event.key === "Escape") {
|
|
651
|
+
event.preventDefault();
|
|
652
|
+
close({ restoreFocus: true });
|
|
653
|
+
} else if (event.key === "Enter" && event.target === search) {
|
|
654
|
+
event.preventDefault();
|
|
655
|
+
} else if (event.target !== trigger && ["ArrowDown", "ArrowUp"].includes(event.key)) {
|
|
656
|
+
const inputs = visibleInputs();
|
|
657
|
+
if (!inputs.length) return;
|
|
658
|
+
event.preventDefault();
|
|
659
|
+
const index = inputs.indexOf(document.activeElement);
|
|
660
|
+
const next = index < 0 ? 0 : (index + (event.key === "ArrowDown" ? 1 : -1) + inputs.length) % inputs.length;
|
|
661
|
+
inputs[next].focus();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
// Label clicks briefly blur to no target before focusing their checkbox.
|
|
665
|
+
// Close only when focus actually enters another part of the page.
|
|
666
|
+
document.addEventListener("focusin", (event) => {
|
|
667
|
+
if (!root.contains(event.target)) close();
|
|
668
|
+
});
|
|
669
|
+
document.addEventListener("pointerdown", (event) => {
|
|
670
|
+
if (!root.contains(event.target)) close();
|
|
671
|
+
});
|
|
672
|
+
renderChoices();
|
|
673
|
+
return controller;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
for (const root of document.querySelectorAll("[data-session-multi]")) {
|
|
677
|
+
sessionMultiFilters.set(root.dataset.sessionMulti, initializeSessionMultiFilter(root));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function queryProjectOptions() {
|
|
681
|
+
const options = [];
|
|
682
|
+
const seenCursors = new Set();
|
|
683
|
+
let cursor = null;
|
|
684
|
+
do {
|
|
685
|
+
const query = new URLSearchParams({ pageSize: "50" });
|
|
686
|
+
if (cursor) query.set("cursor", cursor);
|
|
687
|
+
const data = await api(`/api/v1/projects/options?${query}`);
|
|
688
|
+
options.push(...data.items);
|
|
689
|
+
cursor = data.nextCursor;
|
|
690
|
+
if (cursor && seenCursors.has(cursor)) throw new Error("Project 列表分页异常,请刷新重试。");
|
|
691
|
+
if (cursor) seenCursors.add(cursor);
|
|
692
|
+
} while (cursor);
|
|
693
|
+
return options;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function loadSessionProjectOptions() {
|
|
697
|
+
const projects = await queryProjectOptions();
|
|
698
|
+
sessionMultiFilters.get("project").setOptions(projects.map((project) => [
|
|
699
|
+
project.alias, project.enabled ? project.alias : `${project.alias}(已停用)`,
|
|
700
|
+
]));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function formQuery(form, defaultPageSize = "25", refreshRelativeTime = true) {
|
|
704
|
+
for (const root of form.querySelectorAll("[data-time-range]")) {
|
|
705
|
+
if (refreshRelativeTime) timeRangeControllers.get(root)?.prepareQuery();
|
|
706
|
+
}
|
|
707
|
+
const query = new URLSearchParams();
|
|
708
|
+
for (const [key, value] of new FormData(form)) {
|
|
709
|
+
if (String(value)) query.append(key, String(value));
|
|
710
|
+
}
|
|
711
|
+
for (const root of form.querySelectorAll("[data-session-multi]")) {
|
|
712
|
+
const name = root.dataset.sessionMulti;
|
|
713
|
+
const values = sessionMultiFilters.get(name).values();
|
|
714
|
+
for (const value of values) query.append(name, value);
|
|
715
|
+
if (name === "inventoryState" && values.length === 0) query.set(name, "all");
|
|
716
|
+
}
|
|
717
|
+
if (!query.has("pageSize")) query.set("pageSize", defaultPageSize);
|
|
718
|
+
return query;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const timeRangeTemplate = document.querySelector("#time-range-template");
|
|
722
|
+
const timeRangeMobile = window.matchMedia("(max-width: 650px)");
|
|
723
|
+
const timeRangeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "浏览器本地时区";
|
|
724
|
+
let openTimeRangeFilter = null;
|
|
725
|
+
let timeRangeModalOwner = null;
|
|
726
|
+
let timeRangeModalIsolation = [];
|
|
727
|
+
|
|
728
|
+
const timeRangePresetLabels = {
|
|
729
|
+
all: "不限时间",
|
|
730
|
+
today: "今天",
|
|
731
|
+
yesterday: "昨天",
|
|
732
|
+
"last-24-hours": "最近 24 小时",
|
|
733
|
+
"last-7-days": "最近 7 天",
|
|
734
|
+
"last-30-days": "最近 30 天",
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
function twoDigits(value) {
|
|
738
|
+
return String(value).padStart(2, "0");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function localMinuteValue(value) {
|
|
742
|
+
return [
|
|
743
|
+
String(value.getFullYear()).padStart(4, "0"),
|
|
744
|
+
"-",
|
|
745
|
+
twoDigits(value.getMonth() + 1),
|
|
746
|
+
"-",
|
|
747
|
+
twoDigits(value.getDate()),
|
|
748
|
+
"T",
|
|
749
|
+
twoDigits(value.getHours()),
|
|
750
|
+
":",
|
|
751
|
+
twoDigits(value.getMinutes()),
|
|
752
|
+
].join("");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function formatLocalMinute(value) {
|
|
756
|
+
return localMinuteValue(value).replace("T", " ");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function compactLocalMinute(value) {
|
|
760
|
+
return `${twoDigits(value.getMonth() + 1)}-${twoDigits(value.getDate())} ${twoDigits(value.getHours())}:${twoDigits(value.getMinutes())}`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function utcOffsetLabel(value) {
|
|
764
|
+
const total = -value.getTimezoneOffset();
|
|
765
|
+
const sign = total >= 0 ? "+" : "-";
|
|
766
|
+
const absolute = Math.abs(total);
|
|
767
|
+
return `UTC${sign}${twoDigits(Math.floor(absolute / 60))}:${twoDigits(absolute % 60)}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function canonicalUtc(value) {
|
|
771
|
+
return `${value.toISOString().slice(0, -1)}000+00:00`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function sameLocalMinute(value, parts) {
|
|
775
|
+
return value.getFullYear() === parts.year
|
|
776
|
+
&& value.getMonth() === parts.month - 1
|
|
777
|
+
&& value.getDate() === parts.day
|
|
778
|
+
&& value.getHours() === parts.hour
|
|
779
|
+
&& value.getMinutes() === parts.minute;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function parseLocalMinute(raw) {
|
|
783
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(raw);
|
|
784
|
+
if (!match) return { date: null, error: "请选择有效的本地日期和时间。" };
|
|
785
|
+
const parts = {
|
|
786
|
+
year: Number(match[1]),
|
|
787
|
+
month: Number(match[2]),
|
|
788
|
+
day: Number(match[3]),
|
|
789
|
+
hour: Number(match[4]),
|
|
790
|
+
minute: Number(match[5]),
|
|
791
|
+
};
|
|
792
|
+
const value = new Date(0);
|
|
793
|
+
value.setFullYear(parts.year, parts.month - 1, parts.day);
|
|
794
|
+
value.setHours(parts.hour, parts.minute, 0, 0);
|
|
795
|
+
if (!sameLocalMinute(value, parts)) {
|
|
796
|
+
return { date: null, error: "该本地时间不存在,可能处于夏令时切换。" };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const currentOffset = value.getTimezoneOffset();
|
|
800
|
+
const nearbyOffsets = new Set([
|
|
801
|
+
new Date(value.getTime() - 24 * 60 * 60 * 1000).getTimezoneOffset(),
|
|
802
|
+
new Date(value.getTime() + 24 * 60 * 60 * 1000).getTimezoneOffset(),
|
|
803
|
+
]);
|
|
804
|
+
for (const candidateOffset of nearbyOffsets) {
|
|
805
|
+
if (candidateOffset === currentOffset) continue;
|
|
806
|
+
const alternative = new Date(
|
|
807
|
+
value.getTime() + (candidateOffset - currentOffset) * 60 * 1000,
|
|
808
|
+
);
|
|
809
|
+
if (sameLocalMinute(alternative, parts)) {
|
|
810
|
+
return { date: null, error: "该本地时间因夏令时切换而重复,请选择其他时间。" };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return { date: value, error: "" };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function zoneDescription(values = []) {
|
|
817
|
+
const dates = values.length ? values : [new Date()];
|
|
818
|
+
const offsets = [...new Set(dates.map(utcOffsetLabel))];
|
|
819
|
+
return `时区:${timeRangeZone} (${offsets.join(" → ")})`;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function setTimeRangeModalIsolation(controller, popover, active) {
|
|
823
|
+
if (!active && timeRangeModalOwner !== controller) return;
|
|
824
|
+
for (const node of timeRangeModalIsolation) node.removeAttribute("inert");
|
|
825
|
+
timeRangeModalIsolation = [];
|
|
826
|
+
timeRangeModalOwner = active ? controller : null;
|
|
827
|
+
if (active) {
|
|
828
|
+
let branch = popover;
|
|
829
|
+
for (let parent = popover.parentElement; parent; parent = parent.parentElement) {
|
|
830
|
+
for (const sibling of parent.children) {
|
|
831
|
+
if (sibling === branch || sibling.matches("[data-time-range-backdrop]")) {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (!sibling.hasAttribute("inert")) {
|
|
835
|
+
sibling.setAttribute("inert", "");
|
|
836
|
+
timeRangeModalIsolation.push(sibling);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
branch = parent;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
document.body.classList.toggle(
|
|
843
|
+
"time-range-modal-open",
|
|
844
|
+
timeRangeModalOwner !== null,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function allTimeRange() {
|
|
849
|
+
return {
|
|
850
|
+
kind: "all",
|
|
851
|
+
from: "",
|
|
852
|
+
before: "",
|
|
853
|
+
startValue: "",
|
|
854
|
+
endValue: "",
|
|
855
|
+
summary: "全部时间",
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function presetTimeRange(kind, now = new Date()) {
|
|
860
|
+
if (kind === "all") return allTimeRange();
|
|
861
|
+
let from;
|
|
862
|
+
let before;
|
|
863
|
+
if (kind === "today") {
|
|
864
|
+
from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
865
|
+
before = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
866
|
+
} else if (kind === "yesterday") {
|
|
867
|
+
from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
|
868
|
+
before = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
869
|
+
} else {
|
|
870
|
+
const durationDays = {
|
|
871
|
+
"last-24-hours": 1,
|
|
872
|
+
"last-7-days": 7,
|
|
873
|
+
"last-30-days": 30,
|
|
874
|
+
}[kind];
|
|
875
|
+
if (!durationDays) throw new Error("未知时间范围预设");
|
|
876
|
+
before = new Date(now.getTime());
|
|
877
|
+
from = new Date(now.getTime() - durationDays * 24 * 60 * 60 * 1000);
|
|
878
|
+
}
|
|
879
|
+
const label = timeRangePresetLabels[kind];
|
|
880
|
+
const summary = kind.startsWith("last-")
|
|
881
|
+
? `${label} · 至 ${compactLocalMinute(before)} · ${utcOffsetLabel(before)}`
|
|
882
|
+
: `${label} · ${utcOffsetLabel(from)}`;
|
|
883
|
+
return {
|
|
884
|
+
kind,
|
|
885
|
+
from: canonicalUtc(from),
|
|
886
|
+
before: canonicalUtc(before),
|
|
887
|
+
startValue: localMinuteValue(from),
|
|
888
|
+
endValue: localMinuteValue(new Date(before.getTime() - 60 * 1000)),
|
|
889
|
+
summary,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function customTimeRange(startValue, endValue) {
|
|
894
|
+
if (!startValue || !endValue) {
|
|
895
|
+
return {
|
|
896
|
+
range: null,
|
|
897
|
+
error: "请选择开始时间和结束时间。",
|
|
898
|
+
invalid: startValue ? "end" : "start",
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
const parsedStart = parseLocalMinute(startValue);
|
|
902
|
+
if (!parsedStart.date) {
|
|
903
|
+
return { range: null, error: `开始时间:${parsedStart.error}`, invalid: "start" };
|
|
904
|
+
}
|
|
905
|
+
const parsedEnd = parseLocalMinute(endValue);
|
|
906
|
+
if (!parsedEnd.date) {
|
|
907
|
+
return { range: null, error: `结束时间:${parsedEnd.error}`, invalid: "end" };
|
|
908
|
+
}
|
|
909
|
+
if (parsedEnd.date < parsedStart.date) {
|
|
910
|
+
return { range: null, error: "结束时间不能早于开始时间。", invalid: "end" };
|
|
911
|
+
}
|
|
912
|
+
const before = new Date(parsedEnd.date.getTime() + 60 * 1000);
|
|
913
|
+
return {
|
|
914
|
+
range: {
|
|
915
|
+
kind: "custom",
|
|
916
|
+
from: canonicalUtc(parsedStart.date),
|
|
917
|
+
before: canonicalUtc(before),
|
|
918
|
+
startValue,
|
|
919
|
+
endValue,
|
|
920
|
+
summary: `${formatLocalMinute(parsedStart.date)} — ${formatLocalMinute(parsedEnd.date)} · ${utcOffsetLabel(parsedStart.date)}`,
|
|
921
|
+
},
|
|
922
|
+
error: "",
|
|
923
|
+
invalid: null,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function initializeTimeRangeFilter(root) {
|
|
928
|
+
if (!(timeRangeTemplate instanceof HTMLTemplateElement)) {
|
|
929
|
+
throw new Error("时间范围模板不可用");
|
|
930
|
+
}
|
|
931
|
+
root.append(timeRangeTemplate.content.cloneNode(true));
|
|
932
|
+
const identity = root.dataset.timeRangeId;
|
|
933
|
+
const fromNode = root.querySelector("[data-time-range-from]");
|
|
934
|
+
const beforeNode = root.querySelector("[data-time-range-before]");
|
|
935
|
+
const trigger = root.querySelector("[data-time-range-trigger]");
|
|
936
|
+
const summary = root.querySelector("[data-time-range-summary]");
|
|
937
|
+
const quickClear = root.querySelector("[data-time-range-quick-clear]");
|
|
938
|
+
const backdrop = root.querySelector("[data-time-range-backdrop]");
|
|
939
|
+
const popover = root.querySelector("[data-time-range-popover]");
|
|
940
|
+
const title = root.querySelector("[data-time-range-dialog-title]");
|
|
941
|
+
const start = root.querySelector("[data-time-range-start]");
|
|
942
|
+
const end = root.querySelector("[data-time-range-end]");
|
|
943
|
+
const help = root.querySelector("[data-time-range-help]");
|
|
944
|
+
const zone = root.querySelector("[data-time-range-zone]");
|
|
945
|
+
const error = root.querySelector("[data-time-range-error]");
|
|
946
|
+
const done = root.querySelector("[data-time-range-done]");
|
|
947
|
+
const cancel = root.querySelector("[data-time-range-cancel]");
|
|
948
|
+
const clear = root.querySelector("[data-time-range-clear]");
|
|
949
|
+
const presetButtons = [...root.querySelectorAll("[data-time-range-preset]")];
|
|
950
|
+
|
|
951
|
+
popover.id = `${identity}-popover`;
|
|
952
|
+
title.id = `${identity}-title`;
|
|
953
|
+
help.id = `${identity}-help`;
|
|
954
|
+
zone.id = `${identity}-zone`;
|
|
955
|
+
error.id = `${identity}-error`;
|
|
956
|
+
start.id = `${identity}-start`;
|
|
957
|
+
end.id = `${identity}-end`;
|
|
958
|
+
trigger.setAttribute("aria-controls", popover.id);
|
|
959
|
+
popover.setAttribute("aria-labelledby", title.id);
|
|
960
|
+
start.setAttribute("aria-describedby", `${help.id} ${zone.id} ${error.id}`);
|
|
961
|
+
end.setAttribute("aria-describedby", `${help.id} ${zone.id} ${error.id}`);
|
|
962
|
+
|
|
963
|
+
let applied = allTimeRange();
|
|
964
|
+
let draft = allTimeRange();
|
|
965
|
+
|
|
966
|
+
function setInputValidity(invalid) {
|
|
967
|
+
for (const [node, name] of [[start, "start"], [end, "end"]]) {
|
|
968
|
+
if (invalid === name) node.setAttribute("aria-invalid", "true");
|
|
969
|
+
else node.removeAttribute("aria-invalid");
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function renderApplied() {
|
|
974
|
+
fromNode.value = applied.from;
|
|
975
|
+
beforeNode.value = applied.before;
|
|
976
|
+
summary.textContent = applied.summary;
|
|
977
|
+
trigger.classList.toggle("has-value", applied.kind !== "all");
|
|
978
|
+
trigger.setAttribute("aria-label", `创建时间:${applied.summary}`);
|
|
979
|
+
quickClear.hidden = applied.kind === "all";
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function renderDraft() {
|
|
983
|
+
start.value = draft.startValue;
|
|
984
|
+
end.value = draft.endValue;
|
|
985
|
+
error.textContent = "";
|
|
986
|
+
setInputValidity(null);
|
|
987
|
+
done.disabled = false;
|
|
988
|
+
for (const button of presetButtons) {
|
|
989
|
+
button.setAttribute(
|
|
990
|
+
"aria-pressed",
|
|
991
|
+
String(button.dataset.timeRangePreset === draft.kind),
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
const parsedDates = [start.value, end.value]
|
|
995
|
+
.map(parseLocalMinute)
|
|
996
|
+
.filter((item) => item.date)
|
|
997
|
+
.map((item) => item.date);
|
|
998
|
+
zone.textContent = zoneDescription(parsedDates);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function validateDraft() {
|
|
1002
|
+
const result = customTimeRange(start.value, end.value);
|
|
1003
|
+
draft = result.range || {
|
|
1004
|
+
kind: "custom",
|
|
1005
|
+
from: "",
|
|
1006
|
+
before: "",
|
|
1007
|
+
startValue: start.value,
|
|
1008
|
+
endValue: end.value,
|
|
1009
|
+
summary: "自定义范围",
|
|
1010
|
+
};
|
|
1011
|
+
error.textContent = result.error;
|
|
1012
|
+
setInputValidity(result.invalid);
|
|
1013
|
+
done.disabled = !result.range;
|
|
1014
|
+
for (const button of presetButtons) button.setAttribute("aria-pressed", "false");
|
|
1015
|
+
const parsedDates = [start.value, end.value]
|
|
1016
|
+
.map(parseLocalMinute)
|
|
1017
|
+
.filter((item) => item.date)
|
|
1018
|
+
.map((item) => item.date);
|
|
1019
|
+
zone.textContent = zoneDescription(parsedDates);
|
|
1020
|
+
return Boolean(result.range);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function syncPresentationMode() {
|
|
1024
|
+
const mobile = timeRangeMobile.matches && !popover.hidden;
|
|
1025
|
+
backdrop.hidden = !mobile;
|
|
1026
|
+
if (mobile) {
|
|
1027
|
+
popover.setAttribute("aria-modal", "true");
|
|
1028
|
+
popover.style.removeProperty("left");
|
|
1029
|
+
popover.style.removeProperty("right");
|
|
1030
|
+
} else {
|
|
1031
|
+
popover.removeAttribute("aria-modal");
|
|
1032
|
+
if (!popover.hidden) {
|
|
1033
|
+
const margin = 24;
|
|
1034
|
+
const width = Math.min(620, window.innerWidth - margin * 2);
|
|
1035
|
+
const rootBounds = root.getBoundingClientRect();
|
|
1036
|
+
const viewportLeft = Math.min(
|
|
1037
|
+
Math.max(rootBounds.left, margin),
|
|
1038
|
+
window.innerWidth - margin - width,
|
|
1039
|
+
);
|
|
1040
|
+
popover.style.left = `${viewportLeft - rootBounds.left}px`;
|
|
1041
|
+
popover.style.right = "auto";
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
setTimeRangeModalIsolation(controller, popover, mobile);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function closePopover({ restoreFocus = true } = {}) {
|
|
1048
|
+
popover.hidden = true;
|
|
1049
|
+
backdrop.hidden = true;
|
|
1050
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
1051
|
+
popover.removeAttribute("aria-modal");
|
|
1052
|
+
setTimeRangeModalIsolation(controller, popover, false);
|
|
1053
|
+
if (openTimeRangeFilter === controller) openTimeRangeFilter = null;
|
|
1054
|
+
if (restoreFocus) trigger.focus();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function cancelDraft(options) {
|
|
1058
|
+
draft = { ...applied };
|
|
1059
|
+
closePopover(options);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function prepareQuery() {
|
|
1063
|
+
if (applied.kind === "all" || applied.kind === "custom") return;
|
|
1064
|
+
applied = presetTimeRange(applied.kind);
|
|
1065
|
+
renderApplied();
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function openPopover() {
|
|
1069
|
+
openSessionMultiFilter?.close();
|
|
1070
|
+
if (openTimeRangeFilter && openTimeRangeFilter !== controller) {
|
|
1071
|
+
openTimeRangeFilter.cancel({ restoreFocus: false });
|
|
1072
|
+
}
|
|
1073
|
+
openTimeRangeFilter = controller;
|
|
1074
|
+
draft = { ...applied };
|
|
1075
|
+
renderDraft();
|
|
1076
|
+
popover.hidden = false;
|
|
1077
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
1078
|
+
syncPresentationMode();
|
|
1079
|
+
const selected = presetButtons.find(
|
|
1080
|
+
(button) => button.dataset.timeRangePreset === draft.kind,
|
|
1081
|
+
);
|
|
1082
|
+
(selected || start).focus();
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const controller = {
|
|
1086
|
+
cancel: cancelDraft,
|
|
1087
|
+
prepareQuery,
|
|
1088
|
+
reset() {
|
|
1089
|
+
applied = allTimeRange();
|
|
1090
|
+
draft = allTimeRange();
|
|
1091
|
+
closePopover({ restoreFocus: false });
|
|
1092
|
+
renderApplied();
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
trigger.addEventListener("click", () => {
|
|
1097
|
+
if (popover.hidden) openPopover();
|
|
1098
|
+
else cancelDraft();
|
|
1099
|
+
});
|
|
1100
|
+
quickClear.addEventListener("click", () => {
|
|
1101
|
+
applied = allTimeRange();
|
|
1102
|
+
draft = allTimeRange();
|
|
1103
|
+
renderApplied();
|
|
1104
|
+
if (!popover.hidden) renderDraft();
|
|
1105
|
+
trigger.focus();
|
|
1106
|
+
});
|
|
1107
|
+
for (const button of presetButtons) {
|
|
1108
|
+
button.addEventListener("click", () => {
|
|
1109
|
+
draft = presetTimeRange(button.dataset.timeRangePreset);
|
|
1110
|
+
renderDraft();
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
start.addEventListener("input", validateDraft);
|
|
1114
|
+
end.addEventListener("input", validateDraft);
|
|
1115
|
+
clear.addEventListener("click", () => {
|
|
1116
|
+
draft = allTimeRange();
|
|
1117
|
+
renderDraft();
|
|
1118
|
+
});
|
|
1119
|
+
cancel.addEventListener("click", () => cancelDraft());
|
|
1120
|
+
done.addEventListener("click", () => {
|
|
1121
|
+
if (draft.kind === "custom" && !validateDraft()) {
|
|
1122
|
+
(start.getAttribute("aria-invalid") === "true" ? start : end).focus();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
applied = { ...draft };
|
|
1126
|
+
renderApplied();
|
|
1127
|
+
closePopover();
|
|
1128
|
+
});
|
|
1129
|
+
backdrop.addEventListener("click", () => cancelDraft());
|
|
1130
|
+
popover.addEventListener("keydown", (event) => {
|
|
1131
|
+
if (event.key === "Escape") {
|
|
1132
|
+
event.preventDefault();
|
|
1133
|
+
cancelDraft();
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (event.key === "Enter" && (event.target === start || event.target === end)) {
|
|
1137
|
+
event.preventDefault();
|
|
1138
|
+
if (!done.disabled) done.click();
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (event.key !== "Tab") return;
|
|
1142
|
+
const focusable = [...popover.querySelectorAll("button:not(:disabled), input:not(:disabled)")]
|
|
1143
|
+
.filter((node) => !node.hidden);
|
|
1144
|
+
if (!focusable.length) return;
|
|
1145
|
+
const first = focusable[0];
|
|
1146
|
+
const last = focusable[focusable.length - 1];
|
|
1147
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
1148
|
+
event.preventDefault();
|
|
1149
|
+
last.focus();
|
|
1150
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
1151
|
+
event.preventDefault();
|
|
1152
|
+
first.focus();
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
document.addEventListener("pointerdown", (event) => {
|
|
1156
|
+
if (!popover.hidden && !root.contains(event.target)) {
|
|
1157
|
+
cancelDraft({ restoreFocus: false });
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
timeRangeMobile.addEventListener("change", syncPresentationMode);
|
|
1161
|
+
window.addEventListener("resize", syncPresentationMode);
|
|
1162
|
+
renderApplied();
|
|
1163
|
+
zone.textContent = zoneDescription();
|
|
1164
|
+
return controller;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
for (const root of document.querySelectorAll("[data-time-range]")) {
|
|
1168
|
+
timeRangeControllers.set(root, initializeTimeRangeFilter(root));
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function runtimeLabel(runtime) {
|
|
1172
|
+
if (runtime.primaryStatusResolution === "archived") return "—";
|
|
1173
|
+
if (runtime.primaryStatus != null) {
|
|
1174
|
+
return runtime.primaryStatusResolution === "unavailable"
|
|
1175
|
+
|| runtime.primaryStatusResolution === "deferred"
|
|
1176
|
+
? `${runtime.primaryStatus}(待确认)`
|
|
1177
|
+
: runtime.primaryStatus;
|
|
1178
|
+
}
|
|
1179
|
+
return runtime.primaryStatusResolution === "deferred"
|
|
1180
|
+
? "状态待确认"
|
|
1181
|
+
: "状态暂不可用";
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function updateRuntimeCell(target, runtime) {
|
|
1185
|
+
const archived = runtime.primaryStatusResolution === "archived";
|
|
1186
|
+
const label = runtimeLabel(runtime);
|
|
1187
|
+
const title = archived ? "已归档,不查询运行态" : "";
|
|
1188
|
+
const subscriptionLabel = !archived && runtime.subscriptionState
|
|
1189
|
+
? `订阅:${runtime.subscriptionState}` : "";
|
|
1190
|
+
const previous = target.querySelector(".runtime-primary");
|
|
1191
|
+
if (
|
|
1192
|
+
previous?.textContent === label
|
|
1193
|
+
&& previous.title === title
|
|
1194
|
+
&& (target.querySelector(".runtime-subscription")?.textContent || "") === subscriptionLabel
|
|
1195
|
+
) return;
|
|
1196
|
+
const primary = document.createElement("span");
|
|
1197
|
+
primary.className = "runtime-primary";
|
|
1198
|
+
primary.textContent = label;
|
|
1199
|
+
primary.title = title;
|
|
1200
|
+
target.replaceChildren(primary);
|
|
1201
|
+
if (subscriptionLabel) {
|
|
1202
|
+
const subscription = document.createElement("small");
|
|
1203
|
+
subscription.className = "runtime-subscription";
|
|
1204
|
+
subscription.textContent = subscriptionLabel;
|
|
1205
|
+
target.append(document.createElement("br"), subscription);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function chatModeLabel(mode, scopeKind = null) {
|
|
1210
|
+
const known = { p2p: "单聊", group: "群聊", topic: "话题群" }[mode];
|
|
1211
|
+
if (known) return known;
|
|
1212
|
+
if (scopeKind === "direct") return "单聊";
|
|
1213
|
+
if (scopeKind === "group") return "群聊";
|
|
1214
|
+
return "飞书会话";
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function pointerStateLabel(value) {
|
|
1218
|
+
return {
|
|
1219
|
+
current: "当前",
|
|
1220
|
+
inactive: "非当前",
|
|
1221
|
+
}[value] || value;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function catalogStateLabel(value) {
|
|
1225
|
+
return {
|
|
1226
|
+
active: "Active",
|
|
1227
|
+
archived: "已归档",
|
|
1228
|
+
lazy: "Lazy",
|
|
1229
|
+
unknown: "状态未确认",
|
|
1230
|
+
missing: "原生会话缺失",
|
|
1231
|
+
}[value] || value;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function shortIdentity(value) {
|
|
1235
|
+
const text = String(value || "");
|
|
1236
|
+
return text.length > 18 ? `${text.slice(0, 12)}…` : text;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function locationFallback(session) {
|
|
1240
|
+
return `${chatModeLabel(session.chatMode, session.scopeKind)} · ${shortIdentity(session.chatId)}`;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function sessionLocationCell(row, session) {
|
|
1244
|
+
const td = document.createElement("td");
|
|
1245
|
+
const link = document.createElement("a");
|
|
1246
|
+
link.className = "chat-link";
|
|
1247
|
+
link.href = session.topicOpenUrl || session.chatOpenUrl;
|
|
1248
|
+
link.target = "_blank";
|
|
1249
|
+
link.rel = "noopener noreferrer";
|
|
1250
|
+
link.textContent = session.chatLabelResolved
|
|
1251
|
+
? session.chatLabel
|
|
1252
|
+
: locationFallback(session);
|
|
1253
|
+
link.title = session.topicOpenUrl
|
|
1254
|
+
? "打开飞书话题"
|
|
1255
|
+
: "打开飞书会话";
|
|
1256
|
+
const mode = document.createElement("div");
|
|
1257
|
+
mode.className = "meta-line";
|
|
1258
|
+
mode.append(badge(chatModeLabel(session.chatMode, session.scopeKind)));
|
|
1259
|
+
const chatId = document.createElement("span");
|
|
1260
|
+
chatId.className = "id";
|
|
1261
|
+
chatId.textContent = session.chatId;
|
|
1262
|
+
mode.append(chatId);
|
|
1263
|
+
td.append(link, mode);
|
|
1264
|
+
if (session.topicId) {
|
|
1265
|
+
const topicId = document.createElement("div");
|
|
1266
|
+
topicId.className = "id";
|
|
1267
|
+
topicId.textContent = `Topic ${session.topicId}`;
|
|
1268
|
+
td.append(topicId);
|
|
1269
|
+
}
|
|
1270
|
+
row.append(td);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function sessionIdentityCell(row, session) {
|
|
1274
|
+
const td = document.createElement("td");
|
|
1275
|
+
const title = document.createElement("strong");
|
|
1276
|
+
title.textContent = session.nativeTitle
|
|
1277
|
+
|| (session.catalogState === "lazy" ? "Lazy Session" : "未命名 Session");
|
|
1278
|
+
td.append(title);
|
|
1279
|
+
if (session.nativePreview) {
|
|
1280
|
+
const preview = document.createElement("div");
|
|
1281
|
+
preview.className = "preview";
|
|
1282
|
+
preview.textContent = session.nativePreview;
|
|
1283
|
+
td.append(preview);
|
|
1284
|
+
}
|
|
1285
|
+
const bindingId = document.createElement("div");
|
|
1286
|
+
bindingId.className = "id";
|
|
1287
|
+
bindingId.textContent = `Binding ${session.shortId} · ${session.bindingId}`;
|
|
1288
|
+
td.append(bindingId);
|
|
1289
|
+
if (session.nativeThreadId) {
|
|
1290
|
+
const threadId = document.createElement("div");
|
|
1291
|
+
threadId.className = "id";
|
|
1292
|
+
threadId.textContent = `Thread ${session.nativeThreadId}`;
|
|
1293
|
+
td.append(threadId);
|
|
1294
|
+
}
|
|
1295
|
+
row.append(td);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function sessionUpdatedAtLabel(session) {
|
|
1299
|
+
if (session.catalogState === "lazy") return "尚未开始";
|
|
1300
|
+
if (!Number.isSafeInteger(session.updatedAt) || session.updatedAt < 0) return "—";
|
|
1301
|
+
const date = new Date(session.updatedAt * 1000);
|
|
1302
|
+
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("zh-CN", { hour12: false });
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function renderSessionPagination() {
|
|
1306
|
+
const page = state.sessionPage;
|
|
1307
|
+
document.querySelector("#sessions-previous").disabled = page.previousCursors.length === 0;
|
|
1308
|
+
document.querySelector("#sessions-next").disabled = !page.nextCursor;
|
|
1309
|
+
const count = state.sessions?.items?.length || 0;
|
|
1310
|
+
document.querySelector("#sessions-page").textContent = `第 ${page.number} 页 · ${count} 条`;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function resetSessionPagination() {
|
|
1314
|
+
state.sessionPage = {
|
|
1315
|
+
cursor: null,
|
|
1316
|
+
nextCursor: null,
|
|
1317
|
+
previousCursors: [],
|
|
1318
|
+
number: 1,
|
|
1319
|
+
query: null,
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function resetSessionFilters() {
|
|
1324
|
+
const form = document.querySelector("#session-filter");
|
|
1325
|
+
form.reset();
|
|
1326
|
+
for (const controller of sessionMultiFilters.values()) controller.reset();
|
|
1327
|
+
for (const root of form.querySelectorAll("[data-time-range]")) {
|
|
1328
|
+
timeRangeControllers.get(root)?.reset();
|
|
1329
|
+
}
|
|
1330
|
+
resetSessionPagination();
|
|
1331
|
+
return refresh("sessions");
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
async function loadSessions(cursor = state.sessionPage.cursor) {
|
|
1335
|
+
const query = state.sessionPage.query == null
|
|
1336
|
+
? formQuery(document.querySelector("#session-filter"), "20")
|
|
1337
|
+
: new URLSearchParams(state.sessionPage.query);
|
|
1338
|
+
const appliedQuery = query.toString();
|
|
1339
|
+
await loadSessionProjectOptions();
|
|
1340
|
+
if (cursor) query.set("cursor", cursor);
|
|
1341
|
+
const data = await api(`/api/v1/sessions?${query}`);
|
|
1342
|
+
state.sessions = data;
|
|
1343
|
+
state.sessionPage.cursor = cursor;
|
|
1344
|
+
state.sessionPage.nextCursor = data.nextCursor;
|
|
1345
|
+
state.sessionPage.query = appliedQuery;
|
|
1346
|
+
document.querySelector("#sessions-catalog-notice").hidden = data.catalogAvailable !== false
|
|
1347
|
+
&& !data.items.some((session) => session.catalogState === "unknown");
|
|
1348
|
+
const body = document.querySelector("#sessions-body");
|
|
1349
|
+
body.replaceChildren();
|
|
1350
|
+
for (const session of data.items) {
|
|
1351
|
+
const row = document.createElement("tr");
|
|
1352
|
+
row.dataset.bindingId = session.bindingId;
|
|
1353
|
+
sessionLocationCell(row, session);
|
|
1354
|
+
const type = document.createElement("td");
|
|
1355
|
+
type.append(badge(session.sessionType === "topic" ? "话题" : "消息"));
|
|
1356
|
+
row.append(type);
|
|
1357
|
+
sessionIdentityCell(row, session);
|
|
1358
|
+
const sessionState = document.createElement("td");
|
|
1359
|
+
sessionState.append(badge(
|
|
1360
|
+
pointerStateLabel(session.pointerState),
|
|
1361
|
+
session.pointerState === "current",
|
|
1362
|
+
));
|
|
1363
|
+
sessionState.append(document.createTextNode(" "), badge(
|
|
1364
|
+
catalogStateLabel(session.catalogState),
|
|
1365
|
+
session.catalogState === "active",
|
|
1366
|
+
session.catalogState === "missing",
|
|
1367
|
+
));
|
|
1368
|
+
row.append(sessionState);
|
|
1369
|
+
const runtime = document.createElement("td");
|
|
1370
|
+
runtime.className = "runtime-state";
|
|
1371
|
+
updateRuntimeCell(runtime, session.runtime);
|
|
1372
|
+
row.append(runtime);
|
|
1373
|
+
cell(row, session.projectAlias);
|
|
1374
|
+
const settings = session.turnSettings;
|
|
1375
|
+
const model = settings ? `${settings.modelId} / ${settings.effortId} / ${settings.serviceTierId}` : "继承 Codex";
|
|
1376
|
+
cell(row, `${session.messageContextMode} · ${model}`);
|
|
1377
|
+
cell(row, sessionUpdatedAtLabel(session));
|
|
1378
|
+
const actions = actionsCell(row);
|
|
1379
|
+
wireSessionActions(actions, session);
|
|
1380
|
+
body.append(row);
|
|
1381
|
+
}
|
|
1382
|
+
renderSessionPagination();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function wireSessionActions(actions, session) {
|
|
1386
|
+
const a = session.actions;
|
|
1387
|
+
if (a.createLazy) actions.append(actionButton("新建 Lazy", async () => {
|
|
1388
|
+
if (!state.projects) await loadProjects();
|
|
1389
|
+
const alias = window.prompt("Project alias");
|
|
1390
|
+
if (!alias) return;
|
|
1391
|
+
const project = state.projects.items.find((item) => item.alias === alias && item.enabled);
|
|
1392
|
+
if (!project) return setStatus("未找到当前页中已启用的 Project,请先刷新 Projects。", true);
|
|
1393
|
+
const activate = window.confirm("是否立即设为当前会话?");
|
|
1394
|
+
await mutate("/api/v1/sessions/create-lazy", a.createLazy, {
|
|
1395
|
+
projectAlias: alias,
|
|
1396
|
+
projectRevision: project.revision,
|
|
1397
|
+
activate,
|
|
1398
|
+
turnSettings: null,
|
|
1399
|
+
});
|
|
1400
|
+
}));
|
|
1401
|
+
if (a.activate) actions.append(actionButton("设为当前", () => mutate("/api/v1/sessions/activate", a.activate)));
|
|
1402
|
+
if (a.configure) actions.append(actionButton("配置", async () => {
|
|
1403
|
+
const clear = window.confirm("确定使用默认配置?取消后可输入精确 Model / Effort / Speed ID。");
|
|
1404
|
+
let turnSettings = null;
|
|
1405
|
+
if (!clear) {
|
|
1406
|
+
const modelId = window.prompt("Model ID", session.turnSettings?.modelId || "");
|
|
1407
|
+
const effortId = window.prompt("Effort ID", session.turnSettings?.effortId || "");
|
|
1408
|
+
const serviceTierId = window.prompt("Speed / Service Tier ID", session.turnSettings?.serviceTierId || "");
|
|
1409
|
+
if (!modelId || !effortId || !serviceTierId) return;
|
|
1410
|
+
turnSettings = { modelId, effortId, serviceTierId };
|
|
1411
|
+
}
|
|
1412
|
+
await mutate("/api/v1/sessions/configure", a.configure, { turnSettings });
|
|
1413
|
+
}));
|
|
1414
|
+
if (a.rename) actions.append(actionButton("重命名", async () => {
|
|
1415
|
+
const name = window.prompt("新的原生会话名称");
|
|
1416
|
+
if (name) await mutate("/api/v1/sessions/rename", a.rename, { name });
|
|
1417
|
+
}));
|
|
1418
|
+
if (a.archive) actions.append(actionButton("归档", () => window.confirm("确认归档?") && mutate("/api/v1/sessions/archive", a.archive), true));
|
|
1419
|
+
if (a.unarchive) actions.append(actionButton("恢复", () => mutate("/api/v1/sessions/unarchive", a.unarchive, { actionKind: a.unarchive.actionKind })));
|
|
1420
|
+
if (a.unarchiveCurrent) actions.append(actionButton("恢复并设为当前", () => mutate("/api/v1/sessions/unarchive", a.unarchiveCurrent, { actionKind: a.unarchiveCurrent.actionKind })));
|
|
1421
|
+
if (a.deleteLazy) actions.append(actionButton("删除 Lazy", () => window.confirm("永久删除这个 Lazy Binding?") && mutate("/api/v1/sessions/delete-lazy", a.deleteLazy), true));
|
|
1422
|
+
if (a.deleteMaterialized) actions.append(actionButton("删除", () => confirmMaterializedDelete(session) && mutate("/api/v1/sessions/delete-materialized", a.deleteMaterialized), true));
|
|
1423
|
+
if (a.stop) actions.append(actionButton("停止", () => window.confirm("停止这个 exact 会话的当前运行?") && mutate("/api/v1/sessions/stop", a.stop), true));
|
|
1424
|
+
if (a.release) actions.append(actionButton("释放订阅", () => window.confirm("释放本进程订阅?历史不会删除。") && mutate("/api/v1/sessions/release", a.release)));
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
async function loadSides(cursor = null) {
|
|
1428
|
+
const query = formQuery(
|
|
1429
|
+
document.querySelector("#side-filter"),
|
|
1430
|
+
"25",
|
|
1431
|
+
cursor === null,
|
|
1432
|
+
);
|
|
1433
|
+
if (cursor) query.set("cursor", cursor);
|
|
1434
|
+
const data = await api(`/api/v1/side-topics?${query}`);
|
|
1435
|
+
state.sides = data;
|
|
1436
|
+
state.sideCursor = data.nextCursor;
|
|
1437
|
+
const body = document.querySelector("#sides-body");
|
|
1438
|
+
body.replaceChildren();
|
|
1439
|
+
for (const side of data.items) {
|
|
1440
|
+
const row = document.createElement("tr");
|
|
1441
|
+
row.dataset.sideId = side.sideId;
|
|
1442
|
+
cell(row, side.sideId, "id");
|
|
1443
|
+
cell(row, `${side.parentBindingId} · ${side.projectAlias || "unknown"}`, "id");
|
|
1444
|
+
cell(row, `${side.chatLabel}${side.topicId ? ` · ${side.topicId}` : ""}`);
|
|
1445
|
+
const stateCell = document.createElement("td");
|
|
1446
|
+
stateCell.append(badge(side.state, side.state === "open", side.state === "failed"));
|
|
1447
|
+
row.append(stateCell);
|
|
1448
|
+
const runtime = cell(row, sideRuntimeLabel(side.runtime));
|
|
1449
|
+
runtime.className = "runtime-state";
|
|
1450
|
+
const actions = actionsCell(row);
|
|
1451
|
+
if (side.actions.close) actions.append(actionButton("结束 Side", () => window.confirm("确认结束这个 exact Side?") && mutate("/api/v1/side-topics/close", side.actions.close), true));
|
|
1452
|
+
body.append(row);
|
|
1453
|
+
}
|
|
1454
|
+
document.querySelector("#sides-next").hidden = !data.nextCursor;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function moveSessionPage(direction) {
|
|
1458
|
+
const page = state.sessionPage;
|
|
1459
|
+
const previousState = {
|
|
1460
|
+
cursor: page.cursor,
|
|
1461
|
+
nextCursor: page.nextCursor,
|
|
1462
|
+
previousCursors: [...page.previousCursors],
|
|
1463
|
+
number: page.number,
|
|
1464
|
+
query: page.query,
|
|
1465
|
+
};
|
|
1466
|
+
if (direction === "next") {
|
|
1467
|
+
if (!page.nextCursor) return;
|
|
1468
|
+
page.previousCursors.push(page.cursor);
|
|
1469
|
+
page.cursor = page.nextCursor;
|
|
1470
|
+
page.number += 1;
|
|
1471
|
+
} else {
|
|
1472
|
+
if (!page.previousCursors.length) return;
|
|
1473
|
+
page.cursor = page.previousCursors.pop();
|
|
1474
|
+
page.number -= 1;
|
|
1475
|
+
}
|
|
1476
|
+
if (!await refresh("sessions")) {
|
|
1477
|
+
state.sessionPage = previousState;
|
|
1478
|
+
renderSessionPagination();
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function sideRuntimeLabel(runtime) {
|
|
1483
|
+
return runtime
|
|
1484
|
+
? `${runtime.state}${runtime.turnState ? ` · ${runtime.turnState}` : ""}`
|
|
1485
|
+
: "无进程内 Session";
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function rowByIdentity(selector, key, value) {
|
|
1489
|
+
for (const row of document.querySelector(selector).rows) {
|
|
1490
|
+
if (row.dataset[key] === value) return row;
|
|
1491
|
+
}
|
|
1492
|
+
return null;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
let defaultsEditor = null;
|
|
1496
|
+
let defaultsProjects = [];
|
|
1497
|
+
let defaultsOffset = 0;
|
|
1498
|
+
let defaultsTab = "chat";
|
|
1499
|
+
let defaultsListSerial = 0;
|
|
1500
|
+
let defaultsBusy = false;
|
|
1501
|
+
let defaultsNeedsRefresh = false;
|
|
1502
|
+
const defaultsInput = (name) => document.querySelector(`#defaults-${name}`);
|
|
1503
|
+
|
|
1504
|
+
function selectDefaultTab(kind) {
|
|
1505
|
+
if (defaultsBusy) return;
|
|
1506
|
+
defaultsTab = kind;
|
|
1507
|
+
for (const candidate of ["chat", "group_name"]) {
|
|
1508
|
+
const selected = candidate === kind;
|
|
1509
|
+
defaultsInput(`tab-${candidate}`).setAttribute("aria-selected", String(selected));
|
|
1510
|
+
defaultsInput(`tab-${candidate}`).tabIndex = selected ? 0 : -1;
|
|
1511
|
+
defaultsInput(`panel-${candidate}`).hidden = !selected;
|
|
1512
|
+
}
|
|
1513
|
+
defaultsInput("new").textContent = kind === "chat" ? "添加聊天配置" : "添加群名规则";
|
|
1514
|
+
renderDefaultControls();
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function renderDefaultControls() {
|
|
1518
|
+
defaultsInput("new").disabled = defaultsBusy || defaultsNeedsRefresh || !state.defaults?.[defaultsTab];
|
|
1519
|
+
for (const kind of ["chat", "group_name"]) defaultsInput(`tab-${kind}`).disabled = defaultsBusy;
|
|
1520
|
+
defaultsInput("previous").disabled = defaultsBusy || defaultsOffset === 0;
|
|
1521
|
+
defaultsInput("next").disabled = defaultsBusy || !state.defaults?.chat.has_more;
|
|
1522
|
+
defaultsInput("cancel").disabled = defaultsBusy;
|
|
1523
|
+
defaultsInput("close").disabled = defaultsBusy;
|
|
1524
|
+
for (const button of document.querySelectorAll("[data-default-action]")) {
|
|
1525
|
+
button.disabled = defaultsBusy || defaultsNeedsRefresh || button.dataset.unavailable === "true";
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function renderDefaultSettings() {
|
|
1530
|
+
const editor = defaultsEditor;
|
|
1531
|
+
renderDefaultControls();
|
|
1532
|
+
defaultsInput("fields").disabled = defaultsBusy || Boolean(editor?.loading);
|
|
1533
|
+
if (!editor) return;
|
|
1534
|
+
const settings = editor.settings;
|
|
1535
|
+
const turn = settings.turn_settings;
|
|
1536
|
+
const model = editor.models.find((item) => item.id === turn?.model_id);
|
|
1537
|
+
scheduleSelectOptions(defaultsInput("model"), [["", "继承 Codex"],
|
|
1538
|
+
...editor.models.map((item) => [item.id, item.display_name])], turn?.model_id);
|
|
1539
|
+
scheduleSelectOptions(defaultsInput("effort"), (model?.efforts || []).map((item) => [item.id, item.id]), turn?.effort_id);
|
|
1540
|
+
scheduleSelectOptions(defaultsInput("tier"), (model?.service_tiers || []).map((item) => [item.id, item.name || item.id]), turn?.service_tier_id);
|
|
1541
|
+
defaultsInput("effort").disabled = !model;
|
|
1542
|
+
defaultsInput("tier").disabled = !model;
|
|
1543
|
+
defaultsInput("context").value = settings.message_context_mode;
|
|
1544
|
+
for (const option of defaultsInput("context").querySelectorAll("option")) {
|
|
1545
|
+
option.disabled = option.value === "catch-up" && editor.contextAvailable === false;
|
|
1546
|
+
}
|
|
1547
|
+
defaultsInput("reactions").checked = settings.reaction_pulse_enabled;
|
|
1548
|
+
defaultsInput("progress").checked = settings.progress_card_enabled;
|
|
1549
|
+
defaultsInput("completion-mention").checked = settings.completion_mention_enabled;
|
|
1550
|
+
const notes = [editor.loading ? "正在读取配置…" : "", editor.sourceNote, editor.error, editor.catalogError];
|
|
1551
|
+
if (turn && !model) notes.push("已保存的模型当前不可用,选择保持不变;可显式选择其他模型。");
|
|
1552
|
+
if (editor.contextAvailable === false) notes.push("单聊不支持补齐未读上下文,请使用当前消息。");
|
|
1553
|
+
defaultsInput("note").textContent = notes.filter(Boolean).join(" ");
|
|
1554
|
+
defaultsInput("note").classList.toggle("error", Boolean(editor.error));
|
|
1555
|
+
defaultsInput("save").disabled = defaultsBusy || editor.loading || !editor.action;
|
|
1556
|
+
defaultsInput("save").textContent = defaultsBusy ? "保存中…" : "保存";
|
|
1557
|
+
defaultsInput("editor").setAttribute("aria-busy", String(defaultsBusy || editor.loading));
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function renderDefaultProject(project) {
|
|
1561
|
+
scheduleSelectOptions(defaultsInput("project"), [["", "选择 Project"], ...defaultsProjects.map((item) => [
|
|
1562
|
+
item.alias, item.enabled ? item.alias : `${item.alias}(已停用)`,
|
|
1563
|
+
])], project);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async function loadDefaultOptions(resolveChat = false) {
|
|
1567
|
+
const editor = defaultsEditor;
|
|
1568
|
+
if (!editor) return;
|
|
1569
|
+
const serial = ++editor.serial;
|
|
1570
|
+
const chatId = editor.kind === "chat" ? defaultsInput("chat").value.trim() : "";
|
|
1571
|
+
editor.loading = true;
|
|
1572
|
+
editor.error = "";
|
|
1573
|
+
renderDefaultSettings();
|
|
1574
|
+
try {
|
|
1575
|
+
let view = null;
|
|
1576
|
+
if (resolveChat && chatId) {
|
|
1577
|
+
view = await api(`/api/v1/defaults?${new URLSearchParams({ mode: "view", chat_id: chatId })}`);
|
|
1578
|
+
if (defaultsEditor !== editor || serial !== editor.serial) return;
|
|
1579
|
+
editor.record = view.exact;
|
|
1580
|
+
editor.action = view.exact?.actions.save || view.actions.create;
|
|
1581
|
+
const effective = view.exact || view.effective;
|
|
1582
|
+
if (effective) {
|
|
1583
|
+
editor.settings = structuredClone(effective.session_settings);
|
|
1584
|
+
renderDefaultProject(effective.project);
|
|
1585
|
+
editor.touched = true;
|
|
1586
|
+
} else {
|
|
1587
|
+
editor.settings = defaultScheduleSessionSettings();
|
|
1588
|
+
renderDefaultProject("");
|
|
1589
|
+
editor.touched = false;
|
|
1590
|
+
}
|
|
1591
|
+
editor.sourceNote = view.exact ? "修改本聊天的精确配置。"
|
|
1592
|
+
: view.effective ? "当前使用群名规则;保存后成为本聊天的精确配置。" : "本聊天尚未配置默认会话。";
|
|
1593
|
+
if (view.match_error) editor.sourceNote += ` ${view.match_error}`;
|
|
1594
|
+
}
|
|
1595
|
+
const query = new URLSearchParams({ mode: "options" });
|
|
1596
|
+
if (chatId) query.set("chat_id", chatId);
|
|
1597
|
+
const data = await api(`/api/v1/defaults?${query}`);
|
|
1598
|
+
if (defaultsEditor !== editor || serial !== editor.serial) return;
|
|
1599
|
+
editor.models = data.models;
|
|
1600
|
+
editor.contextAvailable = data.context_mode_available;
|
|
1601
|
+
editor.catalogError = typeof data.model_catalog_error === "string"
|
|
1602
|
+
? data.model_catalog_error : data.model_catalog_error?.message || "";
|
|
1603
|
+
if (!editor.record && !editor.touched && !view?.effective) editor.settings = structuredClone(data.session_settings);
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
if (defaultsEditor !== editor || serial !== editor.serial) return;
|
|
1606
|
+
editor.error = `${error.message} 请重新打开表单后操作。`;
|
|
1607
|
+
if (resolveChat) editor.action = null;
|
|
1608
|
+
} finally {
|
|
1609
|
+
if (defaultsEditor === editor && serial === editor.serial) {
|
|
1610
|
+
editor.loading = false;
|
|
1611
|
+
renderDefaultSettings();
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function openDefaultEditor(kind, record = null) {
|
|
1617
|
+
if (defaultsBusy || defaultsNeedsRefresh || !state.defaults) return;
|
|
1618
|
+
defaultsEditor = { kind, record, action: record ? record.actions.save : state.defaults[kind].actions.create,
|
|
1619
|
+
returnFocus: document.activeElement,
|
|
1620
|
+
settings: structuredClone(record?.session_settings || defaultScheduleSessionSettings()),
|
|
1621
|
+
models: [], serial: 0, loading: true, touched: false, contextAvailable: null,
|
|
1622
|
+
sourceNote: record ? "修改已保存的配置,只影响之后创建的会话。" : "保存后,在没有当前会话的位置收到消息时生效。",
|
|
1623
|
+
error: "", catalogError: "" };
|
|
1624
|
+
defaultsInput("editor").hidden = false;
|
|
1625
|
+
defaultsInput("editor-title").textContent = `${record ? "编辑" : "添加"}${kind === "chat" ? "聊天配置" : "群名规则"}`;
|
|
1626
|
+
defaultsInput("target-help").textContent = kind === "chat"
|
|
1627
|
+
? "支持单聊和群聊,优先于群名规则。删除后可能重新命中群名规则。"
|
|
1628
|
+
: "群名包含关键词即匹配,英文忽略大小写。新规则追加到末尾,保存后可在列表中调整优先级。";
|
|
1629
|
+
defaultsInput("chat-field").hidden = kind !== "chat";
|
|
1630
|
+
defaultsInput("keyword-field").hidden = kind !== "group_name";
|
|
1631
|
+
defaultsInput("chat").value = record?.chat_id || "";
|
|
1632
|
+
defaultsInput("chat").disabled = kind !== "chat" || Boolean(record);
|
|
1633
|
+
defaultsInput("chat").required = kind === "chat";
|
|
1634
|
+
defaultsInput("keyword").value = record?.keyword || "";
|
|
1635
|
+
defaultsInput("keyword").disabled = kind !== "group_name";
|
|
1636
|
+
defaultsInput("keyword").required = kind === "group_name";
|
|
1637
|
+
renderDefaultProject(record?.project || "");
|
|
1638
|
+
renderDefaultSettings();
|
|
1639
|
+
if (!defaultsInput("drawer").open) defaultsInput("drawer").showModal();
|
|
1640
|
+
document.body.classList.toggle("defaults-drawer-open", true);
|
|
1641
|
+
defaultsInput("close").focus();
|
|
1642
|
+
const editor = defaultsEditor;
|
|
1643
|
+
loadDefaultOptions().then(() => {
|
|
1644
|
+
if (defaultsEditor === editor && document.activeElement === defaultsInput("close")) {
|
|
1645
|
+
defaultsInput(record ? "project" : kind === "chat" ? "chat" : "keyword").focus();
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function closeDefaultEditor() {
|
|
1651
|
+
if (defaultsBusy) return;
|
|
1652
|
+
defaultsEditor = null;
|
|
1653
|
+
defaultsInput("editor").hidden = true;
|
|
1654
|
+
defaultsInput("drawer").close();
|
|
1655
|
+
document.body.classList.toggle("defaults-drawer-open", false);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function changeDefaultSettings(field) {
|
|
1659
|
+
const editor = defaultsEditor;
|
|
1660
|
+
if (!editor) return;
|
|
1661
|
+
editor.touched = true;
|
|
1662
|
+
const settings = editor.settings;
|
|
1663
|
+
if (field === "model") {
|
|
1664
|
+
const selected = editor.models.find((item) => item.id === defaultsInput("model").value);
|
|
1665
|
+
if (!defaultsInput("model").value) settings.turn_settings = null;
|
|
1666
|
+
else if (selected) settings.turn_settings = { model_id: selected.id,
|
|
1667
|
+
effort_id: selected.default_effort_id, service_tier_id: selected.default_service_tier_id };
|
|
1668
|
+
} else if (field === "effort" && settings.turn_settings) settings.turn_settings.effort_id = defaultsInput("effort").value;
|
|
1669
|
+
else if (field === "tier" && settings.turn_settings) settings.turn_settings.service_tier_id = defaultsInput("tier").value;
|
|
1670
|
+
else if (field === "context") settings.message_context_mode = defaultsInput("context").value;
|
|
1671
|
+
else if (field === "reactions") settings.reaction_pulse_enabled = defaultsInput("reactions").checked;
|
|
1672
|
+
else if (field === "progress") settings.progress_card_enabled = defaultsInput("progress").checked;
|
|
1673
|
+
else if (field === "completion-mention") settings.completion_mention_enabled = defaultsInput("completion-mention").checked;
|
|
1674
|
+
renderDefaultSettings();
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
async function defaultMutation(mode, action, extra = {}) {
|
|
1678
|
+
if (defaultsBusy || !action) return false;
|
|
1679
|
+
const returnFocus = defaultsEditor?.returnFocus || document.activeElement;
|
|
1680
|
+
defaultsListSerial += 1;
|
|
1681
|
+
defaultsBusy = true;
|
|
1682
|
+
renderDefaultSettings();
|
|
1683
|
+
try {
|
|
1684
|
+
await api(`/api/v1/defaults/${mode}`, { method: "POST", headers: { "Content-Type": "application/json" },
|
|
1685
|
+
body: JSON.stringify(actionPayload(action, extra)) });
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
setStatus(`${error.message} 请刷新列表,核查后重新操作。`, true);
|
|
1688
|
+
if (defaultsEditor) {
|
|
1689
|
+
defaultsEditor.action = null;
|
|
1690
|
+
defaultsEditor.error = `${error.message} 请重新打开表单后操作。`;
|
|
1691
|
+
}
|
|
1692
|
+
return false;
|
|
1693
|
+
} finally {
|
|
1694
|
+
defaultsListSerial += 1;
|
|
1695
|
+
defaultsNeedsRefresh = true;
|
|
1696
|
+
defaultsBusy = false;
|
|
1697
|
+
renderDefaultSettings();
|
|
1698
|
+
}
|
|
1699
|
+
closeDefaultEditor();
|
|
1700
|
+
try {
|
|
1701
|
+
await loadDefaults(defaultsOffset, returnFocus);
|
|
1702
|
+
setStatus(mode === "delete" ? "默认会话配置已删除。" : mode === "reorder" ? "群名规则优先级已更新。" : "默认会话配置已保存。");
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
setStatus(`操作已完成,但列表刷新失败:${error.message} 请手动刷新。`, true);
|
|
1705
|
+
}
|
|
1706
|
+
return true;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
async function saveDefault(event) {
|
|
1710
|
+
event.preventDefault();
|
|
1711
|
+
const editor = defaultsEditor;
|
|
1712
|
+
if (!editor || editor.loading || !editor.action || defaultsBusy) return;
|
|
1713
|
+
const definition = { kind: editor.kind, project: defaultsInput("project").value,
|
|
1714
|
+
session_settings: structuredClone(editor.settings) };
|
|
1715
|
+
if (editor.kind === "chat") definition.chat_id = defaultsInput("chat").value.trim();
|
|
1716
|
+
else definition.keyword = defaultsInput("keyword").value.trim();
|
|
1717
|
+
const action = editor.action;
|
|
1718
|
+
editor.action = null;
|
|
1719
|
+
await defaultMutation("save", action, { definition });
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function deleteDefault(record) {
|
|
1723
|
+
if (defaultsBusy || defaultsNeedsRefresh || !window.confirm(record.kind === "chat"
|
|
1724
|
+
? `删除 ${record.chat_id} 的精确配置?删除后可能重新命中群名规则,已有会话不受影响。`
|
|
1725
|
+
: `删除群名规则“${record.keyword}”?已有会话不受影响。`)) return;
|
|
1726
|
+
const action = record.actions.delete;
|
|
1727
|
+
record.actions.delete = null;
|
|
1728
|
+
await defaultMutation("delete", action);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async function moveDefaultRule(index, delta) {
|
|
1732
|
+
const page = state.defaults?.group_name;
|
|
1733
|
+
if (!page || page.has_more || defaultsBusy || defaultsNeedsRefresh) return;
|
|
1734
|
+
const ids = page.items.map((rule) => rule.id);
|
|
1735
|
+
const other = index + delta;
|
|
1736
|
+
if (other < 0 || other >= ids.length) return;
|
|
1737
|
+
[ids[index], ids[other]] = [ids[other], ids[index]];
|
|
1738
|
+
const action = page.actions.reorder;
|
|
1739
|
+
page.actions.reorder = null;
|
|
1740
|
+
await defaultMutation("reorder", action, { rule_ids: ids });
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function defaultListButton(label, callback, { id = "", disabled = false, primary = false, danger = false } = {}) {
|
|
1744
|
+
const button = document.createElement("button");
|
|
1745
|
+
button.type = "button";
|
|
1746
|
+
button.className = `action${primary ? "" : " secondary"}${danger ? " danger" : ""}`;
|
|
1747
|
+
button.textContent = label;
|
|
1748
|
+
button.setAttribute("data-default-action", label);
|
|
1749
|
+
button.dataset.defaultId = id;
|
|
1750
|
+
button.dataset.unavailable = String(disabled);
|
|
1751
|
+
button.disabled = defaultsBusy || disabled;
|
|
1752
|
+
button.addEventListener("click", callback);
|
|
1753
|
+
return button;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function defaultChatCell(row, rule) {
|
|
1757
|
+
const td = document.createElement("td");
|
|
1758
|
+
const chat = rule.chat;
|
|
1759
|
+
const title = document.createElement(chat?.chatOpenUrl ? "a" : "strong");
|
|
1760
|
+
title.textContent = chat?.chatLabelResolved ? chat.chatLabel : rule.chat_id;
|
|
1761
|
+
if (chat?.chatOpenUrl) {
|
|
1762
|
+
title.className = "chat-link";
|
|
1763
|
+
title.href = chat.chatOpenUrl;
|
|
1764
|
+
title.target = "_blank";
|
|
1765
|
+
title.rel = "noopener noreferrer";
|
|
1766
|
+
}
|
|
1767
|
+
td.append(title);
|
|
1768
|
+
if (chat?.chatLabelResolved) {
|
|
1769
|
+
const id = document.createElement("div");
|
|
1770
|
+
id.className = "id";
|
|
1771
|
+
id.textContent = rule.chat_id;
|
|
1772
|
+
td.append(id);
|
|
1773
|
+
}
|
|
1774
|
+
row.append(td);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function defaultSettingsCell(row, settings) {
|
|
1778
|
+
const td = document.createElement("td");
|
|
1779
|
+
const model = settings.turn_settings;
|
|
1780
|
+
const title = document.createElement("div");
|
|
1781
|
+
title.textContent = model ? `${model.model_id} / ${model.effort_id} / ${model.service_tier_id}` : "继承 Codex";
|
|
1782
|
+
const detail = document.createElement("div");
|
|
1783
|
+
detail.className = "default-settings-detail";
|
|
1784
|
+
detail.textContent = scheduleSessionSummary(settings).split(" · ").slice(1).join(" · ");
|
|
1785
|
+
td.append(title, detail);
|
|
1786
|
+
row.append(td);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
async function loadDefaults(offset = defaultsOffset, returnFocus = null) {
|
|
1790
|
+
const serial = ++defaultsListSerial;
|
|
1791
|
+
const [chat, group, projects] = await Promise.all([
|
|
1792
|
+
api(`/api/v1/defaults?${new URLSearchParams({ kind: "chat", offset: String(offset), limit: "50" })}`),
|
|
1793
|
+
api("/api/v1/defaults?kind=group_name&limit=200"), queryProjectOptions(),
|
|
1794
|
+
]);
|
|
1795
|
+
if (serial !== defaultsListSerial) return;
|
|
1796
|
+
if (offset > 0 && !chat.items.length) return loadDefaults(Math.max(0, offset - 50), returnFocus);
|
|
1797
|
+
const focused = returnFocus || document.activeElement;
|
|
1798
|
+
const restoreFocus = !document.activeElement || document.activeElement === focused || document.activeElement === document.body;
|
|
1799
|
+
defaultsProjects = projects;
|
|
1800
|
+
defaultsOffset = offset;
|
|
1801
|
+
defaultsNeedsRefresh = false;
|
|
1802
|
+
state.defaults = { chat, group_name: group };
|
|
1803
|
+
for (const [kind, page, body] of [["chat", chat, defaultsInput("chat-body")], ["group_name", group, defaultsInput("rules-body")]]) {
|
|
1804
|
+
body.replaceChildren();
|
|
1805
|
+
page.items.forEach((rule, index) => {
|
|
1806
|
+
const row = document.createElement("tr");
|
|
1807
|
+
if (kind === "group_name") {
|
|
1808
|
+
cell(row, String(index + 1));
|
|
1809
|
+
cell(row, rule.keyword);
|
|
1810
|
+
} else defaultChatCell(row, rule);
|
|
1811
|
+
cell(row, rule.project);
|
|
1812
|
+
defaultSettingsCell(row, rule.session_settings);
|
|
1813
|
+
const actions = document.createElement("td");
|
|
1814
|
+
const buttons = document.createElement("div");
|
|
1815
|
+
buttons.className = "actions";
|
|
1816
|
+
buttons.append(
|
|
1817
|
+
defaultListButton("编辑", () => openDefaultEditor(kind, rule), { id: rule.id, disabled: !rule.actions.save }),
|
|
1818
|
+
defaultListButton("删除", () => deleteDefault(rule), { id: rule.id, disabled: !rule.actions.delete, danger: true }),
|
|
1819
|
+
);
|
|
1820
|
+
if (kind === "group_name") {
|
|
1821
|
+
buttons.append(
|
|
1822
|
+
defaultListButton("上移", () => moveDefaultRule(index, -1), { id: rule.id, disabled: index === 0 || page.has_more || !page.actions.reorder }),
|
|
1823
|
+
defaultListButton("下移", () => moveDefaultRule(index, 1), { id: rule.id, disabled: index === page.items.length - 1 || page.has_more || !page.actions.reorder }),
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
actions.append(buttons);
|
|
1827
|
+
row.append(actions);
|
|
1828
|
+
body.append(row);
|
|
1829
|
+
});
|
|
1830
|
+
if (!page.items.length) {
|
|
1831
|
+
const row = document.createElement("tr");
|
|
1832
|
+
const empty = cell(row, "", "defaults-empty");
|
|
1833
|
+
empty.colSpan = kind === "chat" ? 4 : 5;
|
|
1834
|
+
const message = document.createElement("p");
|
|
1835
|
+
message.textContent = kind === "chat" ? "还没有聊天配置,为一个单聊或群聊设置会话默认值。" : "还没有群名规则,通过关键词为匹配的群聊设置会话默认值。";
|
|
1836
|
+
empty.append(message, defaultListButton(kind === "chat" ? "添加聊天配置" : "添加群名规则",
|
|
1837
|
+
() => openDefaultEditor(kind), { primary: true }));
|
|
1838
|
+
body.append(row);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
defaultsInput("pagination").hidden = offset === 0 && !chat.has_more;
|
|
1842
|
+
defaultsInput("page").textContent = `第 ${Math.floor(offset / 50) + 1} 页`;
|
|
1843
|
+
renderDefaultControls();
|
|
1844
|
+
if (restoreFocus && focused === defaultsInput("new")) {
|
|
1845
|
+
focused.focus({ preventScroll: true });
|
|
1846
|
+
} else if (restoreFocus && focused?.dataset.defaultAction) {
|
|
1847
|
+
const replacement = Array.from(defaultsInput(`panel-${defaultsTab}`).querySelectorAll("[data-default-action]"))
|
|
1848
|
+
.find((button) => button.dataset.defaultId === focused.dataset.defaultId
|
|
1849
|
+
&& button.dataset.defaultAction === focused.dataset.defaultAction && !button.disabled);
|
|
1850
|
+
(replacement || defaultsInput("new")).focus({ preventScroll: true });
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function scheduleDate(value) {
|
|
1855
|
+
if (value == null) return "—";
|
|
1856
|
+
if (typeof value === "number") return new Date(value * 1000).toISOString();
|
|
1857
|
+
return String(value);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function scheduleRuleLabel(rule) {
|
|
1861
|
+
const kinds = { once: "一次性", daily: "每天", weekly: "每周", interval: "固定间隔" };
|
|
1862
|
+
const days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
|
|
1863
|
+
const when = rule.kind === "interval" ? `每 ${rule.every_minutes} 分钟`
|
|
1864
|
+
: rule.kind === "weekly" ? `${(rule.weekdays || []).map((day) => days[day]).join("、")} ${rule.at}`
|
|
1865
|
+
: rule.kind === "once" ? String(rule.at).replace("T", " ") : rule.at;
|
|
1866
|
+
const labels = [`${kinds[rule.kind] || rule.kind} · ${when} · ${rule.timezone}`];
|
|
1867
|
+
if (rule.end_at) labels.push(`截止 ${scheduleLocalTime(rule.end_at)}(含)`);
|
|
1868
|
+
return labels.join(" · ");
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function scheduleLocalTime(value) {
|
|
1872
|
+
return value ? String(value).replace("T", " ").replace(/(\d{2}:\d{2}):00(?=[+-]\d{2}:\d{2}$)/, "$1") : "—";
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function schedulePlanState(plan) {
|
|
1876
|
+
return `启停:${plan.enabled ? "已启用" : "已暂停"}\n结束:${plan.lifecycle.ended ? "已结束" : "未结束"}`;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function scheduleRunStatus(status) {
|
|
1880
|
+
return { completed: "已完成", failed: "失败", interrupted: "已停止", inProgress: "执行中",
|
|
1881
|
+
starting: "启动中", unknown: "结果待确认", unavailable: "结果暂不可用",
|
|
1882
|
+
not_started: "尚未执行", expired: "已过期,未执行", missed: "已错过",
|
|
1883
|
+
skipped_busy: "上次仍在执行,本次跳过", blocked_unknown: "上次结果待确认,本次跳过",
|
|
1884
|
+
project_disabled: "Project 已停用,本次未执行", project_unavailable: "Project 不可用,本次未执行",
|
|
1885
|
+
released: "调度已收尾", recovery_no_start: "服务恢复,本次未启动",
|
|
1886
|
+
publishing_unknown: "话题投递待确认", deleted: "执行会话已删除",
|
|
1887
|
+
initial_start_rejected: "启动被拒绝", publishing_failed: "话题发布失败",
|
|
1888
|
+
scope_conflict: "话题已有会话,未启动", dispatch_rejected: "未启动",
|
|
1889
|
+
input_started: "已启动新一轮", input_steered: "已追加当前任务", input_unknown: "输入接收情况待确认",
|
|
1890
|
+
input_rejected: "输入被拒绝", anchor_failed: "触发消息发送失败", anchor_unknown: "触发消息发送结果待确认",
|
|
1891
|
+
target_inactive: "原会话已切走或归档,本次未执行", target_missing: "原会话不可用",
|
|
1892
|
+
target_mismatch: "原会话位置不匹配" }[status] || "结果暂不可用";
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function scheduleTriggerSource(source) {
|
|
1896
|
+
return source === "manual" ? "手动触发" : source === "scheduled" ? "定时触发" : "";
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function scheduleExecutionLabel(execution) {
|
|
1900
|
+
const label = scheduleRunStatus(execution.status);
|
|
1901
|
+
if (execution.kind === "none") return label;
|
|
1902
|
+
if (execution.status === "deleted") return label;
|
|
1903
|
+
if (execution.kind === "current") return `${execution.is_last ? "最后一次" : "本次"}${label}`;
|
|
1904
|
+
if (["skipped_busy", "blocked_unknown", "project_disabled", "project_unavailable"].includes(execution.status)) return label;
|
|
1905
|
+
return `上次${label}`;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function scheduleBlockedLabel(plan) {
|
|
1909
|
+
return { blocked_unknown: "执行结果待确认", project_disabled: "Project 已停用",
|
|
1910
|
+
project_unavailable: "Project 不可用", target_inactive: "原会话已切走或归档,自动暂停",
|
|
1911
|
+
target_missing: "原会话不可用", target_mismatch: "原会话位置不匹配" }[plan.blocked_reason] || "";
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function scheduleTargetLabel(plan) {
|
|
1915
|
+
return plan.target_kind === "binding" ? `原会话 · ${plan.target_label || plan.target_binding_id}` : "每次新建独立话题";
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function scheduleNote(parent, value) {
|
|
1919
|
+
const note = document.createElement("span");
|
|
1920
|
+
note.className = "schedule-note";
|
|
1921
|
+
note.textContent = value;
|
|
1922
|
+
parent.append(note);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
let scheduleEditor = null;
|
|
1926
|
+
let scheduleEditorSerial = 0;
|
|
1927
|
+
let schedulePrepared = null;
|
|
1928
|
+
let schedulePreviewSerial = 0;
|
|
1929
|
+
let scheduleDetailSerial = 0;
|
|
1930
|
+
let scheduleDetailId = null;
|
|
1931
|
+
let scheduleRunsCursor = null;
|
|
1932
|
+
let scheduleListCursor = null;
|
|
1933
|
+
let scheduleListQuery = null;
|
|
1934
|
+
let scheduleProjects = [];
|
|
1935
|
+
const pendingScheduleMutations = new Set();
|
|
1936
|
+
|
|
1937
|
+
function scheduleInput(id) { return document.querySelector(`#schedule-${id}`); }
|
|
1938
|
+
|
|
1939
|
+
function renderScheduleTarget() {
|
|
1940
|
+
const editor = scheduleEditor;
|
|
1941
|
+
if (!editor) return;
|
|
1942
|
+
const binding = scheduleInput("target-kind").value === "binding";
|
|
1943
|
+
scheduleInput("target-kind").disabled = Boolean(editor.plan);
|
|
1944
|
+
scheduleInput("binding-field").hidden = !binding;
|
|
1945
|
+
scheduleInput("binding").disabled = !binding || Boolean(editor.plan);
|
|
1946
|
+
scheduleInput("binding").required = binding;
|
|
1947
|
+
scheduleInput("binding-query-field").hidden = !binding || Boolean(editor.plan);
|
|
1948
|
+
scheduleInput("binding-query").disabled = !binding || Boolean(editor.plan);
|
|
1949
|
+
scheduleInput("target-note").hidden = !binding;
|
|
1950
|
+
scheduleInput("target-options-note").hidden = !binding || Boolean(editor.plan);
|
|
1951
|
+
scheduleInput("target-options-note").textContent = editor.bindingOptionsError
|
|
1952
|
+
|| (editor.bindingsTruncated ? "仅显示前 100 个匹配会话,请输入更具体的条件查找。" : "选择目标会话;查找条件修改后离开输入框即可刷新。");
|
|
1953
|
+
for (const field of ["project", "chat"]) {
|
|
1954
|
+
scheduleInput(`${field}-field`).hidden = binding;
|
|
1955
|
+
scheduleInput(field).disabled = binding;
|
|
1956
|
+
scheduleInput(field).required = !binding;
|
|
1957
|
+
}
|
|
1958
|
+
scheduleInput("session-settings").hidden = binding;
|
|
1959
|
+
const selected = editor.plan?.target_binding_id || scheduleInput("binding").value;
|
|
1960
|
+
const targets = editor.bindingTargets;
|
|
1961
|
+
const choices = [["", "选择原会话"], ...targets.map((item) => [item.id,
|
|
1962
|
+
`${item.label} · ${item.project_alias}${item.available ? "" : "(自动暂停)"}`])];
|
|
1963
|
+
if (selected && !targets.some((item) => item.id === selected)) {
|
|
1964
|
+
choices.push([selected, editor.plan?.target_label || `已选择的原会话 · ${selected.slice(0, 8)}`]);
|
|
1965
|
+
}
|
|
1966
|
+
scheduleSelectOptions(scheduleInput("binding"), choices, selected);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function renderScheduleProjects() {
|
|
1970
|
+
scheduleSelectOptions(scheduleInput("filter-project"), [["", "全部 Project"],
|
|
1971
|
+
...scheduleProjects.map((project) => [project.alias,
|
|
1972
|
+
project.enabled ? project.alias : `${project.alias}(已停用)`])], scheduleInput("filter-project").value);
|
|
1973
|
+
if (!scheduleEditor) return;
|
|
1974
|
+
const selected = scheduleInput("project").value || scheduleEditor.plan?.project_alias || "";
|
|
1975
|
+
const available = scheduleProjects.filter((project) => project.enabled || project.alias === selected);
|
|
1976
|
+
scheduleSelectOptions(scheduleInput("project"), [["", "选择 Project"],
|
|
1977
|
+
...available.map((project) => [project.alias,
|
|
1978
|
+
project.enabled ? project.alias : `${project.alias}(已停用)`])], selected);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function closeScheduleEditor({ saved = false } = {}) {
|
|
1982
|
+
if (scheduleInput("fields").disabled && !saved) return;
|
|
1983
|
+
scheduleEditorSerial += 1;
|
|
1984
|
+
scheduleEditor = null;
|
|
1985
|
+
invalidateSchedulePreview();
|
|
1986
|
+
scheduleInput("editor").hidden = true;
|
|
1987
|
+
scheduleInput("drawer").close();
|
|
1988
|
+
document.body.classList.toggle("schedule-drawer-open", false);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function scheduleRuleFingerprint() {
|
|
1992
|
+
return JSON.stringify({ rule: readScheduleRule(),
|
|
1993
|
+
utc_offset: scheduleInput("kind").value === "once" ? scheduleInput("offset").value : "",
|
|
1994
|
+
end_utc_offset: scheduleInput("kind").value !== "once" ? scheduleInput("end-offset").value : "" });
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function resetScheduleOffset() {
|
|
1998
|
+
scheduleInput("offset-field").hidden = true;
|
|
1999
|
+
scheduleInput("offset").replaceChildren();
|
|
2000
|
+
scheduleInput("offset").value = "";
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function resetScheduleEndOffset() {
|
|
2004
|
+
scheduleInput("end-offset-field").hidden = true;
|
|
2005
|
+
scheduleInput("end-offset").replaceChildren();
|
|
2006
|
+
scheduleInput("end-offset").value = "";
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function defaultScheduleSessionSettings() {
|
|
2010
|
+
return { turn_settings: null, reaction_pulse_enabled: false, progress_card_enabled: true, completion_mention_enabled: true,
|
|
2011
|
+
message_context_mode: "current-only" };
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function scheduleSessionSummary(settings) {
|
|
2015
|
+
const model = settings.turn_settings;
|
|
2016
|
+
const labels = [model ? `${model.model_id} / ${model.effort_id} / ${model.service_tier_id}` : "继承 Codex",
|
|
2017
|
+
settings.message_context_mode === "catch-up" ? "补齐未读上下文" : "当前消息"];
|
|
2018
|
+
if (settings.reaction_pulse_enabled) labels.push("表情反馈");
|
|
2019
|
+
if (settings.progress_card_enabled) labels.push("进度卡片");
|
|
2020
|
+
if (settings.completion_mention_enabled) labels.push("结束时 @ 提醒");
|
|
2021
|
+
return labels.join(" · ");
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function scheduleSelectOptions(node, choices, selected) {
|
|
2025
|
+
node.replaceChildren();
|
|
2026
|
+
for (const [value, label] of choices) {
|
|
2027
|
+
const option = document.createElement("option");
|
|
2028
|
+
option.value = value;
|
|
2029
|
+
option.textContent = label;
|
|
2030
|
+
node.append(option);
|
|
2031
|
+
}
|
|
2032
|
+
if (selected && !choices.some(([value]) => value === selected)) {
|
|
2033
|
+
const option = document.createElement("option");
|
|
2034
|
+
option.value = selected;
|
|
2035
|
+
option.textContent = `${selected}(保留已存设置,当前目录不可用)`;
|
|
2036
|
+
node.append(option);
|
|
2037
|
+
}
|
|
2038
|
+
node.value = selected || "";
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function renderScheduleSessionSettings() {
|
|
2042
|
+
const editor = scheduleEditor;
|
|
2043
|
+
if (!editor) return;
|
|
2044
|
+
const settings = editor.sessionDraft;
|
|
2045
|
+
const current = settings.turn_settings;
|
|
2046
|
+
const model = editor.models.find((item) => item.id === current?.model_id);
|
|
2047
|
+
scheduleSelectOptions(scheduleInput("model"), [["", "继承 Codex"],
|
|
2048
|
+
...editor.models.map((item) => [item.id, item.display_name])], current?.model_id);
|
|
2049
|
+
scheduleSelectOptions(scheduleInput("effort"), (model?.efforts || []).map((item) => [item.id, item.id]), current?.effort_id);
|
|
2050
|
+
scheduleSelectOptions(scheduleInput("tier"), (model?.service_tiers || []).map((item) => [item.id, item.name || item.id]), current?.service_tier_id);
|
|
2051
|
+
scheduleInput("effort").disabled = !model;
|
|
2052
|
+
scheduleInput("tier").disabled = !model;
|
|
2053
|
+
scheduleInput("context").value = settings.message_context_mode;
|
|
2054
|
+
for (const option of scheduleInput("context").querySelectorAll("option")) {
|
|
2055
|
+
option.disabled = option.value === "catch-up" && editor.contextAvailable === false;
|
|
2056
|
+
}
|
|
2057
|
+
scheduleInput("reactions").checked = settings.reaction_pulse_enabled;
|
|
2058
|
+
scheduleInput("progress").checked = settings.progress_card_enabled;
|
|
2059
|
+
scheduleInput("completion-mention").checked = settings.completion_mention_enabled;
|
|
2060
|
+
scheduleInput("session-summary").textContent = scheduleSessionSummary(settings);
|
|
2061
|
+
const notes = [];
|
|
2062
|
+
if (editor.catalogMessage) notes.push(editor.catalogMessage);
|
|
2063
|
+
if (current && !model && !editor.catalogMessage) notes.push("已保存的模型当前不可用,原配置保持不变。可调整其他配置,或显式选择新模型。");
|
|
2064
|
+
if (editor.contextAvailable === false) notes.push("私聊目标不支持补齐未读上下文;请选择当前消息后保存。");
|
|
2065
|
+
scheduleInput("session-note").textContent = notes.join(" ");
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
async function loadScheduleSessionOptions() {
|
|
2069
|
+
const editor = scheduleEditor;
|
|
2070
|
+
if (!editor) return;
|
|
2071
|
+
const chatId = scheduleInput("chat").value.trim();
|
|
2072
|
+
const serial = ++editor.optionsSerial;
|
|
2073
|
+
const query = new URLSearchParams({ mode: "options" });
|
|
2074
|
+
if (chatId && scheduleInput("target-kind").value !== "binding") query.set("chat_id", chatId);
|
|
2075
|
+
if (scheduleInput("target-kind").value === "binding" && scheduleInput("binding-query").value.trim()) {
|
|
2076
|
+
query.set("binding_query", scheduleInput("binding-query").value.trim());
|
|
2077
|
+
}
|
|
2078
|
+
try {
|
|
2079
|
+
const data = await api(`/api/v1/schedules?${query}`);
|
|
2080
|
+
if (scheduleEditor !== editor || serial !== editor.optionsSerial) return;
|
|
2081
|
+
editor.models = data.models;
|
|
2082
|
+
editor.bindingTargets = data.binding_targets || [];
|
|
2083
|
+
editor.bindingsTruncated = Boolean(data.bindings_truncated);
|
|
2084
|
+
editor.bindingOptionsError = data.binding_options_error?.message || "";
|
|
2085
|
+
editor.contextAvailable = data.context_mode_available;
|
|
2086
|
+
editor.catalogMessage = data.model_catalog_error
|
|
2087
|
+
? `${data.model_catalog_error.message} 已有配置保持不变,仍可调整其他项目。` : "";
|
|
2088
|
+
if (!editor.plan && !editor.settingsTouched) {
|
|
2089
|
+
if (JSON.stringify(editor.sessionDraft) !== JSON.stringify(data.session_settings)) invalidateSchedulePreview();
|
|
2090
|
+
editor.sessionBase = structuredClone(data.session_settings);
|
|
2091
|
+
editor.sessionDraft = structuredClone(data.session_settings);
|
|
2092
|
+
}
|
|
2093
|
+
renderScheduleSessionSettings();
|
|
2094
|
+
renderScheduleTarget();
|
|
2095
|
+
} catch (error) {
|
|
2096
|
+
if (scheduleEditor !== editor || serial !== editor.optionsSerial) return;
|
|
2097
|
+
editor.models = [];
|
|
2098
|
+
editor.contextAvailable = null;
|
|
2099
|
+
editor.catalogMessage = `${error.message} 可选配置暂未刷新,已有选择保持不变。`;
|
|
2100
|
+
editor.bindingOptionsError = editor.catalogMessage;
|
|
2101
|
+
renderScheduleSessionSettings();
|
|
2102
|
+
renderScheduleTarget();
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function changeScheduleSessionSettings(field) {
|
|
2107
|
+
const editor = scheduleEditor;
|
|
2108
|
+
if (!editor) return;
|
|
2109
|
+
const settings = editor.sessionDraft;
|
|
2110
|
+
editor.settingsTouched = true;
|
|
2111
|
+
if (field === "model") {
|
|
2112
|
+
const id = scheduleInput("model").value;
|
|
2113
|
+
const selected = editor.models.find((item) => item.id === id);
|
|
2114
|
+
if (!id) settings.turn_settings = null;
|
|
2115
|
+
else if (selected) settings.turn_settings = { model_id: selected.id,
|
|
2116
|
+
effort_id: selected.default_effort_id, service_tier_id: selected.default_service_tier_id };
|
|
2117
|
+
else if (id === editor.sessionBase.turn_settings?.model_id) settings.turn_settings = structuredClone(editor.sessionBase.turn_settings);
|
|
2118
|
+
} else if (field === "effort" && settings.turn_settings) {
|
|
2119
|
+
settings.turn_settings.effort_id = scheduleInput("effort").value;
|
|
2120
|
+
} else if (field === "tier" && settings.turn_settings) {
|
|
2121
|
+
settings.turn_settings.service_tier_id = scheduleInput("tier").value;
|
|
2122
|
+
} else if (field === "context") settings.message_context_mode = scheduleInput("context").value;
|
|
2123
|
+
else if (field === "reactions") settings.reaction_pulse_enabled = scheduleInput("reactions").checked;
|
|
2124
|
+
else if (field === "progress") settings.progress_card_enabled = scheduleInput("progress").checked;
|
|
2125
|
+
else if (field === "completion-mention") settings.completion_mention_enabled = scheduleInput("completion-mention").checked;
|
|
2126
|
+
invalidateSchedulePreview();
|
|
2127
|
+
renderScheduleSessionSettings();
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function scheduleSessionPatch() {
|
|
2131
|
+
const patch = {};
|
|
2132
|
+
if (!scheduleEditor) return patch;
|
|
2133
|
+
if (scheduleInput("target-kind").value === "binding") return patch;
|
|
2134
|
+
if (!scheduleEditor.plan) return structuredClone(scheduleEditor.sessionDraft);
|
|
2135
|
+
for (const [key, value] of Object.entries(scheduleEditor.sessionDraft)) {
|
|
2136
|
+
if (JSON.stringify(value) !== JSON.stringify(scheduleEditor.sessionBase[key])) patch[key] = value;
|
|
2137
|
+
}
|
|
2138
|
+
return patch;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function readScheduleRule() {
|
|
2142
|
+
const kind = scheduleInput("kind").value;
|
|
2143
|
+
const rule = { kind, timezone: scheduleInput("timezone").value.trim() };
|
|
2144
|
+
if (kind === "once") rule.at = scheduleInput("at").value.trim();
|
|
2145
|
+
if (kind === "daily" || kind === "weekly") rule.at = scheduleInput("time").value;
|
|
2146
|
+
if (kind === "weekly") rule.weekdays = Array.from(scheduleInput("weekdays").querySelectorAll("input"))
|
|
2147
|
+
.filter((input) => input.checked).map((input) => Number(input.value));
|
|
2148
|
+
if (kind === "interval") {
|
|
2149
|
+
rule.every_minutes = Number(scheduleInput("every").value);
|
|
2150
|
+
const previous = scheduleEditor?.plan?.schedule;
|
|
2151
|
+
if (previous?.kind === "interval") {
|
|
2152
|
+
rule.anchor = previous.anchor;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
if (kind !== "once") {
|
|
2156
|
+
const endAt = scheduleInput("end-at").value.trim();
|
|
2157
|
+
if (endAt) rule.end_at = endAt;
|
|
2158
|
+
}
|
|
2159
|
+
return rule;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function invalidateSchedulePreview() {
|
|
2163
|
+
schedulePrepared = null;
|
|
2164
|
+
schedulePreviewSerial += 1;
|
|
2165
|
+
scheduleInput("save").disabled = true;
|
|
2166
|
+
scheduleInput("preview-times").replaceChildren();
|
|
2167
|
+
scheduleInput("preview-message").textContent = "保存前请预览触发时间。";
|
|
2168
|
+
for (const node of document.querySelectorAll("[data-schedule-rule]")) {
|
|
2169
|
+
node.hidden = !node.dataset.scheduleRule.split(" ").includes(scheduleInput("kind").value);
|
|
2170
|
+
}
|
|
2171
|
+
scheduleInput("end-condition").disabled = scheduleInput("kind").value === "once";
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function renderSchedulePreview(selector, preview) {
|
|
2175
|
+
const list = document.querySelector(selector);
|
|
2176
|
+
list.replaceChildren();
|
|
2177
|
+
for (const item of preview || []) {
|
|
2178
|
+
const li = document.createElement("li");
|
|
2179
|
+
li.textContent = `${item.local}(UTC ${item.utc})`;
|
|
2180
|
+
list.append(li);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
async function previewSchedule() {
|
|
2185
|
+
const serial = ++schedulePreviewSerial;
|
|
2186
|
+
schedulePrepared = null;
|
|
2187
|
+
scheduleInput("save").disabled = true;
|
|
2188
|
+
scheduleInput("preview-message").textContent = "正在计算触发时间…";
|
|
2189
|
+
scheduleInput("preview-message").classList.toggle("error", false);
|
|
2190
|
+
try {
|
|
2191
|
+
const rule = readScheduleRule();
|
|
2192
|
+
const fingerprint = scheduleRuleFingerprint();
|
|
2193
|
+
const query = new URLSearchParams({ mode: "preview" });
|
|
2194
|
+
if (rule.kind === "once") {
|
|
2195
|
+
query.set("local_at", rule.at);
|
|
2196
|
+
delete rule.at;
|
|
2197
|
+
if (scheduleInput("offset").value) query.set("utc_offset", scheduleInput("offset").value);
|
|
2198
|
+
}
|
|
2199
|
+
if (rule.kind !== "once" && rule.end_at) {
|
|
2200
|
+
query.set("local_end_at", rule.end_at);
|
|
2201
|
+
delete rule.end_at;
|
|
2202
|
+
if (scheduleInput("end-offset").value) query.set("end_utc_offset", scheduleInput("end-offset").value);
|
|
2203
|
+
}
|
|
2204
|
+
query.set("schedule", JSON.stringify(rule));
|
|
2205
|
+
if (scheduleEditor?.plan) query.set("plan_id", scheduleEditor.plan.id);
|
|
2206
|
+
if (scheduleInput("target-kind").value === "binding") {
|
|
2207
|
+
query.set("target_kind", "binding");
|
|
2208
|
+
query.set("target_binding_id", scheduleInput("binding").value);
|
|
2209
|
+
} else {
|
|
2210
|
+
const chatId = scheduleInput("chat").value.trim();
|
|
2211
|
+
if (chatId) query.set("chat_id", chatId);
|
|
2212
|
+
}
|
|
2213
|
+
const settings = scheduleSessionPatch();
|
|
2214
|
+
if (Object.keys(settings).length) query.set("session_settings", JSON.stringify(settings));
|
|
2215
|
+
const data = await api(`/api/v1/schedules?${query}`);
|
|
2216
|
+
if (serial !== schedulePreviewSerial || fingerprint !== scheduleRuleFingerprint()) return;
|
|
2217
|
+
schedulePrepared = { fingerprint, schedule: data.schedule, hasFuture: Boolean(data.preview.length) };
|
|
2218
|
+
renderSchedulePreview("#schedule-preview-times", data.preview);
|
|
2219
|
+
scheduleInput("preview-message").textContent = data.preview.length
|
|
2220
|
+
? `预计触发时间 · ${data.schedule.timezone}`
|
|
2221
|
+
: scheduleEditor?.plan ? "没有后续触发时间。仍可保存计划修改;已认领的执行可以继续。" : "没有后续触发时间,请调整规则。";
|
|
2222
|
+
scheduleInput("save").disabled = (!data.preview.length && !scheduleEditor?.plan) || !scheduleEditor?.action;
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
if (serial !== schedulePreviewSerial) return;
|
|
2225
|
+
if (["ambiguous_local_time", "ambiguous_end_time"].includes(error.code) && Array.isArray(error.choices)) {
|
|
2226
|
+
const field = error.code === "ambiguous_end_time" ? "end-offset" : "offset";
|
|
2227
|
+
scheduleSelectOptions(scheduleInput(field), [["", field === "end-offset" ? "选择截止时刻" : "选择触发时刻"],
|
|
2228
|
+
...error.choices.map((choice, index) => [choice.utc_offset,
|
|
2229
|
+
`第 ${index + 1} 次 · UTC${choice.utc_offset}`])], "");
|
|
2230
|
+
scheduleInput(`${field}-field`).hidden = false;
|
|
2231
|
+
}
|
|
2232
|
+
scheduleInput("preview-message").textContent = error.message;
|
|
2233
|
+
scheduleInput("preview-message").classList.toggle("error", true);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function openScheduleEditor(plan = null) {
|
|
2238
|
+
if (scheduleInput("fields").disabled) return;
|
|
2239
|
+
scheduleEditorSerial += 1;
|
|
2240
|
+
const action = plan ? plan.actions.update : state.schedules?.actions.create;
|
|
2241
|
+
if (!action) return;
|
|
2242
|
+
const settings = plan?.session_settings || defaultScheduleSessionSettings();
|
|
2243
|
+
scheduleEditor = { plan, action, sessionBase: structuredClone(settings), sessionDraft: structuredClone(settings),
|
|
2244
|
+
models: [], bindingTargets: [], contextAvailable: null, optionsSerial: 0, settingsTouched: false, catalogMessage: "正在读取模型目录…" };
|
|
2245
|
+
const rule = plan?.schedule || { kind: "daily", at: "09:00", timezone: state.schedules.default_timezone || "" };
|
|
2246
|
+
scheduleInput("editor").hidden = false;
|
|
2247
|
+
scheduleInput("editor-title").textContent = plan ? `编辑计划 · ${plan.name}` : "创建计划";
|
|
2248
|
+
scheduleInput("name").value = plan?.name || "";
|
|
2249
|
+
scheduleInput("target-kind").value = plan?.target_kind || "new_topic";
|
|
2250
|
+
scheduleInput("binding").value = plan?.target_binding_id || "";
|
|
2251
|
+
scheduleInput("binding-query").value = "";
|
|
2252
|
+
scheduleInput("project").value = plan?.project_alias || "";
|
|
2253
|
+
renderScheduleProjects();
|
|
2254
|
+
scheduleInput("chat").value = plan?.chat_id || "";
|
|
2255
|
+
scheduleInput("instructions").value = plan?.instructions || "";
|
|
2256
|
+
scheduleInput("enabled").checked = plan?.enabled ?? true;
|
|
2257
|
+
scheduleInput("timezone").value = rule.timezone;
|
|
2258
|
+
scheduleInput("kind").value = rule.kind;
|
|
2259
|
+
scheduleInput("time").value = ["daily", "weekly"].includes(rule.kind) ? rule.at : "09:00";
|
|
2260
|
+
scheduleInput("at").value = plan?.once_local_at || "";
|
|
2261
|
+
resetScheduleOffset();
|
|
2262
|
+
scheduleInput("end-at").value = plan?.end_local_at || "";
|
|
2263
|
+
resetScheduleEndOffset();
|
|
2264
|
+
scheduleInput("every").value = String(rule.every_minutes || 60);
|
|
2265
|
+
for (const input of scheduleInput("weekdays").querySelectorAll("input")) {
|
|
2266
|
+
input.checked = (rule.weekdays || []).includes(Number(input.value));
|
|
2267
|
+
}
|
|
2268
|
+
invalidateSchedulePreview();
|
|
2269
|
+
scheduleInput("session-settings").open = true;
|
|
2270
|
+
renderScheduleSessionSettings();
|
|
2271
|
+
renderScheduleTarget();
|
|
2272
|
+
scheduleInput("preview-message").classList.toggle("error", false);
|
|
2273
|
+
scheduleInput("close").disabled = false;
|
|
2274
|
+
if (!scheduleInput("drawer").open) scheduleInput("drawer").showModal();
|
|
2275
|
+
document.body.classList.toggle("schedule-drawer-open", true);
|
|
2276
|
+
scheduleInput("name").focus();
|
|
2277
|
+
scheduleInput("editor").querySelector(".schedule-editor-body").scrollTop = 0;
|
|
2278
|
+
return loadScheduleSessionOptions();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
async function editSchedule(plan) {
|
|
2282
|
+
const serial = ++scheduleEditorSerial;
|
|
2283
|
+
try {
|
|
2284
|
+
const data = await api(`/api/v1/schedules?${new URLSearchParams({ mode: "view", plan_id: plan.id })}`);
|
|
2285
|
+
if (serial !== scheduleEditorSerial) return;
|
|
2286
|
+
await openScheduleEditor(data.plan);
|
|
2287
|
+
} catch (error) { setStatus(error.message, true); }
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async function saveSchedule(event) {
|
|
2291
|
+
event.preventDefault();
|
|
2292
|
+
if (!scheduleEditor?.action || !schedulePrepared
|
|
2293
|
+
|| schedulePrepared.fingerprint !== scheduleRuleFingerprint()) return;
|
|
2294
|
+
const editor = scheduleEditor;
|
|
2295
|
+
scheduleEditorSerial += 1;
|
|
2296
|
+
const action = editor.action;
|
|
2297
|
+
const hasFuture = schedulePrepared.hasFuture;
|
|
2298
|
+
const definition = {
|
|
2299
|
+
name: scheduleInput("name").value.trim(),
|
|
2300
|
+
instructions: scheduleInput("instructions").value.trim(),
|
|
2301
|
+
schedule: schedulePrepared.schedule,
|
|
2302
|
+
enabled: scheduleInput("enabled").checked,
|
|
2303
|
+
};
|
|
2304
|
+
if (scheduleInput("target-kind").value === "binding") {
|
|
2305
|
+
if (!editor.plan) {
|
|
2306
|
+
definition.target_kind = "binding";
|
|
2307
|
+
definition.target_binding_id = scheduleInput("binding").value;
|
|
2308
|
+
}
|
|
2309
|
+
} else {
|
|
2310
|
+
definition.project = scheduleInput("project").value.trim();
|
|
2311
|
+
definition.chat_id = scheduleInput("chat").value.trim();
|
|
2312
|
+
}
|
|
2313
|
+
const settings = scheduleSessionPatch();
|
|
2314
|
+
if (Object.keys(settings).length) definition.session_settings = settings;
|
|
2315
|
+
scheduleEditor.action = null;
|
|
2316
|
+
scheduleInput("fields").disabled = true;
|
|
2317
|
+
scheduleInput("close").disabled = true;
|
|
2318
|
+
let saved = false;
|
|
2319
|
+
try {
|
|
2320
|
+
await api(`/api/v1/schedules/${editor.plan ? "update" : "create"}`, {
|
|
2321
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
2322
|
+
body: JSON.stringify(actionPayload(action, { definition })),
|
|
2323
|
+
});
|
|
2324
|
+
saved = true;
|
|
2325
|
+
closeScheduleEditor({ saved: true });
|
|
2326
|
+
} catch (error) {
|
|
2327
|
+
scheduleInput("preview-message").textContent = `${error.message} 请刷新列表并重新打开编辑,核查后再操作。`;
|
|
2328
|
+
scheduleInput("preview-message").classList.toggle("error", true);
|
|
2329
|
+
setStatus(error.message, true);
|
|
2330
|
+
await refresh("schedules");
|
|
2331
|
+
setStatus(error.message, true);
|
|
2332
|
+
} finally {
|
|
2333
|
+
scheduleInput("fields").disabled = false;
|
|
2334
|
+
scheduleInput("close").disabled = false;
|
|
2335
|
+
scheduleInput("save").disabled = true;
|
|
2336
|
+
}
|
|
2337
|
+
if (saved) {
|
|
2338
|
+
try {
|
|
2339
|
+
await loadSchedules(scheduleListCursor, scheduleListQuery);
|
|
2340
|
+
const trigger = Array.from(document.querySelectorAll("[data-schedule-edit]"))
|
|
2341
|
+
.find((button) => button.dataset.scheduleEdit === editor.plan?.id);
|
|
2342
|
+
(trigger || scheduleInput("new")).focus();
|
|
2343
|
+
setStatus(hasFuture ? "已保存计划,将按时向所选目标提交任务。"
|
|
2344
|
+
: "已保存计划。当前没有后续触发,已认领的执行可以继续。");
|
|
2345
|
+
} catch (error) {
|
|
2346
|
+
scheduleInput("new").focus();
|
|
2347
|
+
setStatus(`计划已保存,但列表刷新失败:${error.message} 请刷新列表查看。`, true);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
async function changeSchedule(plan, mode) {
|
|
2353
|
+
if (pendingScheduleMutations.has(plan.id)) return;
|
|
2354
|
+
if (mode === "delete" && !window.confirm(
|
|
2355
|
+
`删除计划「${plan.name}」?\n\n后续不再触发。已认领的执行可以继续,历史话题和普通会话保留。`,
|
|
2356
|
+
)) return;
|
|
2357
|
+
const action = plan.actions[mode];
|
|
2358
|
+
if (!action) return;
|
|
2359
|
+
pendingScheduleMutations.add(plan.id);
|
|
2360
|
+
plan.actions[mode] = null;
|
|
2361
|
+
const definition = mode === "update" ? { definition: { enabled: !plan.enabled } } : {};
|
|
2362
|
+
try {
|
|
2363
|
+
await api(`/api/v1/schedules/${mode}`, {
|
|
2364
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
2365
|
+
body: JSON.stringify(actionPayload(action, definition)),
|
|
2366
|
+
});
|
|
2367
|
+
if (scheduleDetailId === plan.id) {
|
|
2368
|
+
scheduleDetailSerial += 1;
|
|
2369
|
+
scheduleDetailId = null;
|
|
2370
|
+
scheduleInput("detail").hidden = true;
|
|
2371
|
+
}
|
|
2372
|
+
pendingScheduleMutations.delete(plan.id);
|
|
2373
|
+
await loadSchedules();
|
|
2374
|
+
setStatus(mode === "delete" ? "计划已删除,历史会话保留。" : plan.enabled ? "计划已暂停。" : "计划已启用。");
|
|
2375
|
+
} catch (error) {
|
|
2376
|
+
pendingScheduleMutations.delete(plan.id);
|
|
2377
|
+
await refresh("schedules");
|
|
2378
|
+
setStatus(`${error.message} 请先核查最新状态。`, true);
|
|
2379
|
+
} finally { pendingScheduleMutations.delete(plan.id); }
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
async function runSchedule(plan, button) {
|
|
2383
|
+
const action = plan.actions.run_now;
|
|
2384
|
+
if (!action || pendingScheduleMutations.has(plan.id)) return;
|
|
2385
|
+
pendingScheduleMutations.add(plan.id);
|
|
2386
|
+
plan.actions.run_now = null;
|
|
2387
|
+
if (button) button.disabled = true;
|
|
2388
|
+
scheduleInput("run-receipt").hidden = true;
|
|
2389
|
+
let receipt;
|
|
2390
|
+
try {
|
|
2391
|
+
receipt = await api("/api/v1/schedules/run-now", {
|
|
2392
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
2393
|
+
body: JSON.stringify(actionPayload(action)),
|
|
2394
|
+
});
|
|
2395
|
+
} catch (error) {
|
|
2396
|
+
setStatus(`${error.message} 请先查看最近记录核查触发结果,再决定是否重新运行。`, true);
|
|
2397
|
+
return;
|
|
2398
|
+
} finally { pendingScheduleMutations.delete(plan.id); }
|
|
2399
|
+
|
|
2400
|
+
const message = `已受理「${plan.name}」的手动运行,原定时安排保持不变。`;
|
|
2401
|
+
const container = scheduleInput("run-receipt");
|
|
2402
|
+
container.replaceChildren();
|
|
2403
|
+
container.textContent = `${message} 执行 ID:${receipt.run_id}。`;
|
|
2404
|
+
if (receipt.run?.feishu_url) {
|
|
2405
|
+
const link = document.createElement("a");
|
|
2406
|
+
link.href = receipt.run.feishu_url;
|
|
2407
|
+
link.target = "_blank";
|
|
2408
|
+
link.rel = "noopener noreferrer";
|
|
2409
|
+
link.textContent = "打开执行话题";
|
|
2410
|
+
container.append(link);
|
|
2411
|
+
}
|
|
2412
|
+
container.append(actionButton("查看最近记录", () => showSchedule(plan.id)));
|
|
2413
|
+
container.hidden = false;
|
|
2414
|
+
setStatus(message);
|
|
2415
|
+
try {
|
|
2416
|
+
await loadSchedules(scheduleListCursor, scheduleListQuery);
|
|
2417
|
+
if (scheduleDetailId === plan.id) await showSchedule(plan.id);
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
setStatus(`${message} 列表刷新失败:${error.message} 请手动刷新查看执行进展。`);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
async function loadSchedules(cursor = null, appliedQuery = cursor ? scheduleListQuery : null) {
|
|
2424
|
+
const query = new URLSearchParams(appliedQuery || "");
|
|
2425
|
+
if (appliedQuery === null) {
|
|
2426
|
+
for (const [name, value] of new FormData(scheduleInput("filter"))) {
|
|
2427
|
+
if (String(value).trim()) query.set(name, String(value).trim());
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
const queryIdentity = query.toString();
|
|
2431
|
+
if (cursor) query.set("cursor", cursor);
|
|
2432
|
+
const [data, projects] = await Promise.all([
|
|
2433
|
+
api(`/api/v1/schedules?${query}`), queryProjectOptions(),
|
|
2434
|
+
]);
|
|
2435
|
+
state.schedules = data;
|
|
2436
|
+
scheduleProjects = projects;
|
|
2437
|
+
scheduleListCursor = cursor;
|
|
2438
|
+
scheduleListQuery = queryIdentity;
|
|
2439
|
+
renderScheduleProjects();
|
|
2440
|
+
const body = document.querySelector("#schedules-body");
|
|
2441
|
+
body.replaceChildren();
|
|
2442
|
+
for (const plan of data.plans) {
|
|
2443
|
+
const row = document.createElement("tr");
|
|
2444
|
+
cell(row, plan.name);
|
|
2445
|
+
cell(row, plan.project_alias);
|
|
2446
|
+
const target = document.createElement("td");
|
|
2447
|
+
target.className = "schedule-target";
|
|
2448
|
+
const link = document.createElement("a");
|
|
2449
|
+
link.className = "chat-link";
|
|
2450
|
+
link.href = plan.chat.chatOpenUrl;
|
|
2451
|
+
link.target = "_blank";
|
|
2452
|
+
link.rel = "noopener noreferrer";
|
|
2453
|
+
link.textContent = plan.chat.chatLabelResolved && plan.chat.chatLabel?.trim() && plan.chat.chatLabel !== "?"
|
|
2454
|
+
? plan.chat.chatLabel : "会话名称暂不可用";
|
|
2455
|
+
link.title = `打开飞书会话 · ${plan.chat_id}`;
|
|
2456
|
+
target.append(link);
|
|
2457
|
+
scheduleNote(target, scheduleTargetLabel(plan));
|
|
2458
|
+
row.append(target);
|
|
2459
|
+
const timing = document.createElement("td");
|
|
2460
|
+
timing.className = "schedule-time";
|
|
2461
|
+
timing.textContent = scheduleRuleLabel(plan.schedule);
|
|
2462
|
+
scheduleNote(timing, !plan.lifecycle.has_trigger ? "无后续触发"
|
|
2463
|
+
: plan.next_due_local ? `下次:${scheduleLocalTime(plan.next_due_local)}`
|
|
2464
|
+
: plan.enabled ? "下次:等待触发" : "下次:已暂停");
|
|
2465
|
+
row.append(timing);
|
|
2466
|
+
const planState = document.createElement("td");
|
|
2467
|
+
planState.className = "schedule-state";
|
|
2468
|
+
planState.textContent = schedulePlanState(plan);
|
|
2469
|
+
const blocked = scheduleBlockedLabel(plan);
|
|
2470
|
+
if (blocked) scheduleNote(planState, blocked);
|
|
2471
|
+
row.append(planState);
|
|
2472
|
+
const execution = document.createElement("td");
|
|
2473
|
+
execution.className = "schedule-execution";
|
|
2474
|
+
execution.textContent = scheduleExecutionLabel(plan.execution);
|
|
2475
|
+
const source = scheduleTriggerSource(plan.execution.trigger_source);
|
|
2476
|
+
if (source) scheduleNote(execution, source);
|
|
2477
|
+
if (plan.execution.due_local) scheduleNote(execution, scheduleLocalTime(plan.execution.due_local));
|
|
2478
|
+
row.append(execution);
|
|
2479
|
+
const actions = actionsCell(row);
|
|
2480
|
+
actions.append(actionButton("详情 / 最近记录", () => showSchedule(plan.id)));
|
|
2481
|
+
const edit = actionButton("编辑", () => editSchedule(plan));
|
|
2482
|
+
edit.setAttribute("data-schedule-edit", plan.id);
|
|
2483
|
+
actions.append(edit);
|
|
2484
|
+
const run = actionButton("立即运行", () => runSchedule(plan, run));
|
|
2485
|
+
run.disabled = !plan.actions.run_now;
|
|
2486
|
+
run.title = scheduleBlockedLabel(plan) || (plan.inflight ? "本次执行尚未结束" : "按已保存的指令和配置运行一次,原定时安排保持不变");
|
|
2487
|
+
actions.append(run);
|
|
2488
|
+
if (plan.lifecycle.has_trigger) {
|
|
2489
|
+
actions.append(actionButton(plan.enabled ? "暂停" : "启用", () => changeSchedule(plan, "update")));
|
|
2490
|
+
}
|
|
2491
|
+
actions.append(actionButton("删除", () => changeSchedule(plan, "delete"), true));
|
|
2492
|
+
for (const button of actions.querySelectorAll("button")) button.disabled ||= pendingScheduleMutations.has(plan.id);
|
|
2493
|
+
body.append(row);
|
|
2494
|
+
}
|
|
2495
|
+
if (!data.plans.length) {
|
|
2496
|
+
const row = document.createElement("tr");
|
|
2497
|
+
const empty = document.createElement("td");
|
|
2498
|
+
empty.colSpan = 7;
|
|
2499
|
+
empty.textContent = "没有符合筛选条件的计划。可调整结束状态查看历史计划,或创建新计划。";
|
|
2500
|
+
row.append(empty);
|
|
2501
|
+
body.append(row);
|
|
2502
|
+
}
|
|
2503
|
+
document.querySelector("#schedules-next").hidden = !data.next_cursor;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
async function showSchedule(planId) {
|
|
2507
|
+
const serial = ++scheduleDetailSerial;
|
|
2508
|
+
scheduleDetailId = planId;
|
|
2509
|
+
try {
|
|
2510
|
+
const data = await api(`/api/v1/schedules?${new URLSearchParams({ mode: "view", plan_id: planId })}`);
|
|
2511
|
+
if (serial !== scheduleDetailSerial) return;
|
|
2512
|
+
scheduleInput("detail").hidden = false;
|
|
2513
|
+
scheduleInput("detail-title").textContent = `${data.plan.name} · ${data.plan.id} · 修订 ${data.plan.revision}`;
|
|
2514
|
+
scheduleInput("detail-state").textContent = `${schedulePlanState(data.plan).replace("\n", " · ")} · ${scheduleExecutionLabel(data.plan.execution)}`
|
|
2515
|
+
+ (scheduleTriggerSource(data.plan.execution.trigger_source) ? ` · ${scheduleTriggerSource(data.plan.execution.trigger_source)}` : "")
|
|
2516
|
+
+ (scheduleBlockedLabel(data.plan) ? ` · ${scheduleBlockedLabel(data.plan)}` : "")
|
|
2517
|
+
+ ` · 目标会话 ID:${data.plan.chat_id}`
|
|
2518
|
+
+ (data.inflight ? "。编辑、暂停或删除不会取消本次执行。" : "");
|
|
2519
|
+
scheduleInput("detail-instructions").textContent = data.plan.instructions;
|
|
2520
|
+
scheduleInput("detail-settings").textContent = data.plan.target_kind === "binding"
|
|
2521
|
+
? `${scheduleTargetLabel(data.plan)} · 沿用原会话当前配置;系统输入不新增 @ 通知。`
|
|
2522
|
+
: scheduleSessionSummary(data.plan.session_settings || defaultScheduleSessionSettings());
|
|
2523
|
+
scheduleInput("detail-rule").textContent = scheduleRuleLabel(data.plan.schedule);
|
|
2524
|
+
renderSchedulePreview("#schedule-detail-preview", data.preview);
|
|
2525
|
+
await loadScheduleRuns();
|
|
2526
|
+
} catch (error) { if (serial === scheduleDetailSerial) setStatus(error.message, true); }
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
async function loadScheduleRuns(cursor = null) {
|
|
2530
|
+
const planId = scheduleDetailId;
|
|
2531
|
+
const query = new URLSearchParams({ mode: "runs", plan_id: planId });
|
|
2532
|
+
if (cursor) query.set("cursor", cursor);
|
|
2533
|
+
try {
|
|
2534
|
+
const data = await api(`/api/v1/schedules?${query}`);
|
|
2535
|
+
if (planId !== scheduleDetailId) return;
|
|
2536
|
+
const body = scheduleInput("runs-body");
|
|
2537
|
+
body.replaceChildren();
|
|
2538
|
+
for (const run of data.runs) {
|
|
2539
|
+
const row = document.createElement("tr");
|
|
2540
|
+
cell(row, scheduleDate(run.due_at));
|
|
2541
|
+
cell(row, scheduleTriggerSource(run.trigger_source));
|
|
2542
|
+
cell(row, scheduleRunStatus(run.status || run.stage));
|
|
2543
|
+
const links = actionsCell(row);
|
|
2544
|
+
if (run.feishu_url) {
|
|
2545
|
+
const link = document.createElement("a");
|
|
2546
|
+
link.href = run.feishu_url;
|
|
2547
|
+
link.target = "_blank";
|
|
2548
|
+
link.rel = "noopener noreferrer";
|
|
2549
|
+
link.textContent = run.target_kind === "binding" ? "打开触发消息" : "打开飞书话题";
|
|
2550
|
+
links.append(link);
|
|
2551
|
+
}
|
|
2552
|
+
if (run.binding_id && !run.binding_removed) {
|
|
2553
|
+
const link = document.createElement("a");
|
|
2554
|
+
link.href = `/?${new URLSearchParams({ binding_id: run.binding_id })}`;
|
|
2555
|
+
link.textContent = "打开 Session";
|
|
2556
|
+
links.append(link);
|
|
2557
|
+
}
|
|
2558
|
+
body.append(row);
|
|
2559
|
+
}
|
|
2560
|
+
scheduleRunsCursor = data.next_cursor;
|
|
2561
|
+
scheduleInput("runs-next").hidden = !scheduleRunsCursor;
|
|
2562
|
+
} catch (error) { setStatus(error.message, true); }
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// Page-owned Session objects bound retry state to the current inventory. A
|
|
2566
|
+
// refresh (including unarchive) gets new objects and an immediate fresh read.
|
|
2567
|
+
const runtimeResolutionRetries = new WeakMap();
|
|
2568
|
+
|
|
2569
|
+
function recordRuntimeResolutionFailure(session, activityRevision) {
|
|
2570
|
+
const previous = runtimeResolutionRetries.get(session);
|
|
2571
|
+
const failures = previous?.activityRevision === activityRevision
|
|
2572
|
+
? Math.min(previous.failures + 1, 4) : 1;
|
|
2573
|
+
runtimeResolutionRetries.set(session, {
|
|
2574
|
+
activityRevision,
|
|
2575
|
+
failures,
|
|
2576
|
+
nextAt: performance.now() + Math.min(10000 * 2 ** (failures - 1), 60000),
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
function mergeDeferredBindingRuntime(incoming, previous) {
|
|
2581
|
+
const needsResolution = !previous
|
|
2582
|
+
|| incoming.activityRevision !== previous.activityRevision
|
|
2583
|
+
|| previous.primaryStatusResolution === "deferred"
|
|
2584
|
+
|| previous.primaryStatusResolution === "unavailable";
|
|
2585
|
+
if (!needsResolution) {
|
|
2586
|
+
return {
|
|
2587
|
+
runtime: {
|
|
2588
|
+
...incoming,
|
|
2589
|
+
primaryStatus: previous.primaryStatus,
|
|
2590
|
+
primaryStatusResolution: previous.primaryStatusResolution,
|
|
2591
|
+
},
|
|
2592
|
+
needsResolution: false,
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
return {
|
|
2596
|
+
runtime: {
|
|
2597
|
+
...incoming,
|
|
2598
|
+
// Commit a new revision only after its exact projection succeeds. This
|
|
2599
|
+
// leaves a failed/timeout follow-up eligible for the next bounded poll.
|
|
2600
|
+
activityRevision: previous?.activityRevision ?? incoming.activityRevision,
|
|
2601
|
+
primaryStatus: previous?.primaryStatus ?? null,
|
|
2602
|
+
// A background retry must not turn an unavailable result into a
|
|
2603
|
+
// visible loading state on every poll. A changed known value remains
|
|
2604
|
+
// explicitly unconfirmed until its exact projection succeeds.
|
|
2605
|
+
primaryStatusResolution: previous?.primaryStatusResolution === "unavailable"
|
|
2606
|
+
? "unavailable" : "deferred",
|
|
2607
|
+
},
|
|
2608
|
+
needsResolution: true,
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
function applyRuntimeSnapshots(payload, resolveChanges = true) {
|
|
2613
|
+
const bindings = new Map(payload.bindings.map((item) => [item.bindingId, item]));
|
|
2614
|
+
const changedBindings = [];
|
|
2615
|
+
for (const session of state.sessions?.items || []) {
|
|
2616
|
+
let incoming = bindings.get(session.bindingId);
|
|
2617
|
+
if (!incoming) continue;
|
|
2618
|
+
// The inventory already confirmed archival. Keep local activity visible
|
|
2619
|
+
// until it clears, then stop resolving/polling this row without inventing
|
|
2620
|
+
// idle or reading a historical Goal.
|
|
2621
|
+
if (session.catalogState === "archived" && incoming.primaryStatusResolution !== "local") {
|
|
2622
|
+
incoming = { ...incoming, primaryStatus: null, primaryStatusResolution: "archived" };
|
|
2623
|
+
}
|
|
2624
|
+
const previous = session.runtime;
|
|
2625
|
+
let runtime = incoming;
|
|
2626
|
+
if (incoming.primaryStatusResolution === "deferred") {
|
|
2627
|
+
const merged = mergeDeferredBindingRuntime(incoming, previous);
|
|
2628
|
+
runtime = merged.runtime;
|
|
2629
|
+
let retry = runtimeResolutionRetries.get(session);
|
|
2630
|
+
if (retry && retry.activityRevision !== incoming.activityRevision) {
|
|
2631
|
+
runtimeResolutionRetries.delete(session);
|
|
2632
|
+
retry = null;
|
|
2633
|
+
}
|
|
2634
|
+
if (resolveChanges && merged.needsResolution && (!retry || performance.now() >= retry.nextAt)) {
|
|
2635
|
+
changedBindings.push(session.bindingId);
|
|
2636
|
+
}
|
|
2637
|
+
} else if (incoming.primaryStatusResolution === "unavailable") {
|
|
2638
|
+
runtime = {
|
|
2639
|
+
...incoming,
|
|
2640
|
+
activityRevision: previous?.activityRevision ?? incoming.activityRevision,
|
|
2641
|
+
primaryStatus: previous?.primaryStatus ?? null,
|
|
2642
|
+
};
|
|
2643
|
+
recordRuntimeResolutionFailure(session, incoming.activityRevision);
|
|
2644
|
+
} else {
|
|
2645
|
+
runtimeResolutionRetries.delete(session);
|
|
2646
|
+
}
|
|
2647
|
+
session.runtime = runtime;
|
|
2648
|
+
const row = rowByIdentity("#sessions-body", "bindingId", session.bindingId);
|
|
2649
|
+
const node = row?.querySelector(".runtime-state");
|
|
2650
|
+
if (node) updateRuntimeCell(node, runtime);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
const sides = new Map(payload.sides.map((item) => [item.sideId, item]));
|
|
2654
|
+
const missing = new Set(payload.missingSideIds);
|
|
2655
|
+
for (const side of state.sides?.items || []) {
|
|
2656
|
+
if (!sides.has(side.sideId) && !missing.has(side.sideId)) continue;
|
|
2657
|
+
side.runtime = sides.get(side.sideId) || null;
|
|
2658
|
+
const row = rowByIdentity("#sides-body", "sideId", side.sideId);
|
|
2659
|
+
const node = row?.querySelector(".runtime-state span");
|
|
2660
|
+
if (node) node.textContent = sideRuntimeLabel(side.runtime);
|
|
2661
|
+
}
|
|
2662
|
+
return changedBindings;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
async function refresh(tab, cursor = undefined) {
|
|
2666
|
+
setStatus("正在读取服务端事实…");
|
|
2667
|
+
try {
|
|
2668
|
+
if (tab === "projects") await loadProjects(cursor || null);
|
|
2669
|
+
if (tab === "sessions") {
|
|
2670
|
+
if (cursor === undefined) await loadSessions();
|
|
2671
|
+
else await loadSessions(cursor);
|
|
2672
|
+
}
|
|
2673
|
+
if (tab === "side-topics") await loadSides(cursor || null);
|
|
2674
|
+
if (tab === "updates") await loadUpdates();
|
|
2675
|
+
if (tab === "schedules") await loadSchedules(cursor || null);
|
|
2676
|
+
if (tab === "defaults") await loadDefaults();
|
|
2677
|
+
setStatus("已更新。");
|
|
2678
|
+
return true;
|
|
2679
|
+
} catch (error) {
|
|
2680
|
+
setStatus(error.message, true);
|
|
2681
|
+
return false;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
function selectTab(name) {
|
|
2686
|
+
state.tab = name;
|
|
2687
|
+
stopUpdatePolling();
|
|
2688
|
+
// Only a navigation preference survives re-login; operation facts always
|
|
2689
|
+
// come from the server, and no credential or session token is stored here.
|
|
2690
|
+
try {
|
|
2691
|
+
if (name === "updates") sessionStorage.setItem("netizen-admin-updates-view", "1");
|
|
2692
|
+
else sessionStorage.removeItem("netizen-admin-updates-view");
|
|
2693
|
+
} catch (_error) { /* Storage may be disabled by browser policy. */ }
|
|
2694
|
+
for (const item of document.querySelectorAll(".tab")) {
|
|
2695
|
+
const active = item.dataset.tab === name;
|
|
2696
|
+
item.classList.toggle("active", active);
|
|
2697
|
+
item.setAttribute("aria-selected", String(active));
|
|
2698
|
+
}
|
|
2699
|
+
for (const panel of document.querySelectorAll(".panel")) {
|
|
2700
|
+
const active = panel.id === name;
|
|
2701
|
+
panel.hidden = !active;
|
|
2702
|
+
panel.classList.toggle("active", active);
|
|
2703
|
+
}
|
|
2704
|
+
refresh(name);
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
for (const tab of document.querySelectorAll(".tab")) {
|
|
2708
|
+
tab.addEventListener("click", () => selectTab(tab.dataset.tab));
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
for (const button of document.querySelectorAll("[data-refresh]")) {
|
|
2712
|
+
button.addEventListener("click", () => refresh(button.dataset.refresh));
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
document.querySelector("#register-project").addEventListener("submit", async (event) => {
|
|
2716
|
+
event.preventDefault();
|
|
2717
|
+
const formElement = event.currentTarget;
|
|
2718
|
+
const form = new FormData(formElement);
|
|
2719
|
+
await mutate("/api/v1/projects/register", state.projects.actions.register, { alias: form.get("alias"), path: form.get("path") });
|
|
2720
|
+
formElement.reset();
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
document.querySelector("#create-project").addEventListener("submit", async (event) => {
|
|
2724
|
+
event.preventDefault();
|
|
2725
|
+
const formElement = event.currentTarget;
|
|
2726
|
+
const form = new FormData(formElement);
|
|
2727
|
+
await mutate("/api/v1/projects/create-directory", state.projects.actions.createDirectory, { alias: form.get("alias"), path: form.get("path") || null });
|
|
2728
|
+
formElement.reset();
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
document.querySelector("#session-filter").addEventListener("submit", (event) => {
|
|
2732
|
+
event.preventDefault();
|
|
2733
|
+
resetSessionPagination();
|
|
2734
|
+
refresh("sessions");
|
|
2735
|
+
});
|
|
2736
|
+
document.querySelector("#session-filter-reset").addEventListener("click", resetSessionFilters);
|
|
2737
|
+
document.querySelector("#session-page-size").addEventListener("change", () => {
|
|
2738
|
+
resetSessionPagination();
|
|
2739
|
+
refresh("sessions");
|
|
2740
|
+
});
|
|
2741
|
+
document.querySelector("#side-filter").addEventListener("submit", (event) => { event.preventDefault(); refresh("side-topics"); });
|
|
2742
|
+
document.querySelector("#projects-next").addEventListener("click", () => refresh("projects", state.projectCursor));
|
|
2743
|
+
document.querySelector("#sessions-previous").addEventListener("click", () => moveSessionPage("previous"));
|
|
2744
|
+
document.querySelector("#sessions-next").addEventListener("click", () => moveSessionPage("next"));
|
|
2745
|
+
document.querySelector("#sides-next").addEventListener("click", () => refresh("side-topics", state.sideCursor));
|
|
2746
|
+
defaultsInput("new").addEventListener("click", () => openDefaultEditor(defaultsTab));
|
|
2747
|
+
for (const kind of ["chat", "group_name"]) {
|
|
2748
|
+
defaultsInput(`tab-${kind}`).addEventListener("click", () => selectDefaultTab(kind));
|
|
2749
|
+
defaultsInput(`tab-${kind}`).addEventListener("keydown", (event) => {
|
|
2750
|
+
if (defaultsBusy || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
2751
|
+
event.preventDefault();
|
|
2752
|
+
const target = event.key === "Home" ? "chat" : event.key === "End" ? "group_name" : kind === "chat" ? "group_name" : "chat";
|
|
2753
|
+
selectDefaultTab(target);
|
|
2754
|
+
defaultsInput(`tab-${target}`).focus();
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
defaultsInput("cancel").addEventListener("click", closeDefaultEditor);
|
|
2758
|
+
defaultsInput("close").addEventListener("click", closeDefaultEditor);
|
|
2759
|
+
defaultsInput("drawer").addEventListener("cancel", (event) => {
|
|
2760
|
+
event.preventDefault();
|
|
2761
|
+
closeDefaultEditor();
|
|
2762
|
+
});
|
|
2763
|
+
defaultsInput("editor").addEventListener("submit", saveDefault);
|
|
2764
|
+
defaultsInput("chat").addEventListener("change", () => loadDefaultOptions(true));
|
|
2765
|
+
for (const field of ["model", "effort", "tier", "context", "reactions", "progress", "completion-mention"]) {
|
|
2766
|
+
defaultsInput(field).addEventListener("change", () => changeDefaultSettings(field));
|
|
2767
|
+
}
|
|
2768
|
+
for (const [name, delta] of [["previous", -50], ["next", 50]]) {
|
|
2769
|
+
defaultsInput(name).addEventListener("click", async () => {
|
|
2770
|
+
try { await loadDefaults(Math.max(0, defaultsOffset + delta)); } catch (error) { setStatus(error.message, true); }
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
scheduleInput("filter").addEventListener("submit", (event) => { event.preventDefault(); refresh("schedules"); });
|
|
2774
|
+
scheduleInput("new").addEventListener("click", () => openScheduleEditor());
|
|
2775
|
+
scheduleInput("preview").addEventListener("click", previewSchedule);
|
|
2776
|
+
scheduleInput("editor").addEventListener("input", invalidateSchedulePreview);
|
|
2777
|
+
scheduleInput("kind").addEventListener("change", invalidateSchedulePreview);
|
|
2778
|
+
scheduleInput("editor").addEventListener("submit", saveSchedule);
|
|
2779
|
+
scheduleInput("chat").addEventListener("change", loadScheduleSessionOptions);
|
|
2780
|
+
scheduleInput("target-kind").addEventListener("change", () => {
|
|
2781
|
+
invalidateSchedulePreview();
|
|
2782
|
+
renderScheduleTarget();
|
|
2783
|
+
loadScheduleSessionOptions();
|
|
2784
|
+
});
|
|
2785
|
+
scheduleInput("binding").addEventListener("change", invalidateSchedulePreview);
|
|
2786
|
+
scheduleInput("binding-query").addEventListener("change", loadScheduleSessionOptions);
|
|
2787
|
+
for (const field of ["model", "effort", "tier", "context", "reactions", "progress", "completion-mention"]) {
|
|
2788
|
+
scheduleInput(field).addEventListener("change", () => changeScheduleSessionSettings(field));
|
|
2789
|
+
}
|
|
2790
|
+
scheduleInput("cancel").addEventListener("click", () => closeScheduleEditor());
|
|
2791
|
+
scheduleInput("close").addEventListener("click", () => closeScheduleEditor());
|
|
2792
|
+
scheduleInput("drawer").addEventListener("cancel", (event) => {
|
|
2793
|
+
event.preventDefault();
|
|
2794
|
+
closeScheduleEditor();
|
|
2795
|
+
});
|
|
2796
|
+
for (const field of ["at", "timezone", "kind"]) {
|
|
2797
|
+
scheduleInput(field).addEventListener("input", resetScheduleOffset);
|
|
2798
|
+
}
|
|
2799
|
+
for (const field of ["end-at", "timezone", "kind"]) {
|
|
2800
|
+
scheduleInput(field).addEventListener("input", resetScheduleEndOffset);
|
|
2801
|
+
}
|
|
2802
|
+
document.querySelector("#schedules-next").addEventListener("click", () => refresh("schedules", state.schedules.next_cursor));
|
|
2803
|
+
scheduleInput("runs-next").addEventListener("click", () => loadScheduleRuns(scheduleRunsCursor));
|
|
2804
|
+
document.querySelector("#service-restart").addEventListener("click", restartService);
|
|
2805
|
+
document.querySelector("#logout").addEventListener("click", async () => { await api("/logout", { method: "POST" }); window.location.assign("/login"); });
|
|
2806
|
+
|
|
2807
|
+
function chunkValues(values, size = 50) {
|
|
2808
|
+
const chunks = [];
|
|
2809
|
+
for (let index = 0; index < values.length; index += size) {
|
|
2810
|
+
chunks.push(values.slice(index, index + size));
|
|
2811
|
+
}
|
|
2812
|
+
return chunks;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
async function fetchRuntimeSnapshots(bindingIds, sideIds, resolvePrimary = false) {
|
|
2816
|
+
const paths = [];
|
|
2817
|
+
for (const ids of chunkValues(bindingIds)) {
|
|
2818
|
+
const query = new URLSearchParams({ bindingIds: ids.join(",") });
|
|
2819
|
+
if (resolvePrimary) query.set("resolvePrimary", "true");
|
|
2820
|
+
paths.push(`/api/v1/runtime-snapshots?${query}`);
|
|
2821
|
+
}
|
|
2822
|
+
for (const ids of chunkValues(sideIds)) {
|
|
2823
|
+
const query = new URLSearchParams({ sideIds: ids.join(",") });
|
|
2824
|
+
paths.push(`/api/v1/runtime-snapshots?${query}`);
|
|
2825
|
+
}
|
|
2826
|
+
const payloads = [];
|
|
2827
|
+
if (resolvePrimary) {
|
|
2828
|
+
for (const path of paths) payloads.push(await api(path));
|
|
2829
|
+
} else {
|
|
2830
|
+
payloads.push(...await Promise.all(paths.map((path) => api(path))));
|
|
2831
|
+
}
|
|
2832
|
+
return {
|
|
2833
|
+
bindings: payloads.flatMap((payload) => payload.bindings),
|
|
2834
|
+
sides: payloads.flatMap((payload) => payload.sides),
|
|
2835
|
+
missingSideIds: payloads.flatMap((payload) => payload.missingSideIds),
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
let runtimePollInFlight = false;
|
|
2840
|
+
|
|
2841
|
+
setInterval(async () => {
|
|
2842
|
+
if (
|
|
2843
|
+
runtimePollInFlight
|
|
2844
|
+
|| document.hidden
|
|
2845
|
+
|| (state.tab !== "sessions" && state.tab !== "side-topics")
|
|
2846
|
+
) return;
|
|
2847
|
+
const tab = state.tab;
|
|
2848
|
+
const page = tab === "sessions" ? state.sessions : state.sides;
|
|
2849
|
+
const isCurrentPage = () => state.tab === tab
|
|
2850
|
+
&& (tab === "sessions" ? state.sessions : state.sides) === page;
|
|
2851
|
+
const bindingIds = state.tab === "sessions"
|
|
2852
|
+
? state.sessions?.items?.filter((item) => item.runtime.primaryStatusResolution !== "archived")
|
|
2853
|
+
.map((item) => item.bindingId) || []
|
|
2854
|
+
: [];
|
|
2855
|
+
const sideIds = state.tab === "side-topics"
|
|
2856
|
+
? state.sides?.items?.map((item) => item.sideId) || []
|
|
2857
|
+
: [];
|
|
2858
|
+
if (!bindingIds.length && !sideIds.length) return;
|
|
2859
|
+
runtimePollInFlight = true;
|
|
2860
|
+
let resolving = [];
|
|
2861
|
+
try {
|
|
2862
|
+
const snapshots = await fetchRuntimeSnapshots(bindingIds, sideIds);
|
|
2863
|
+
if (!isCurrentPage() || document.hidden) return;
|
|
2864
|
+
const changedBindings = applyRuntimeSnapshots(snapshots);
|
|
2865
|
+
if (changedBindings.length) {
|
|
2866
|
+
const changed = new Set(changedBindings);
|
|
2867
|
+
resolving = snapshots.bindings.filter((item) => changed.has(item.bindingId));
|
|
2868
|
+
const resolved = await fetchRuntimeSnapshots(changedBindings, [], true);
|
|
2869
|
+
if (!isCurrentPage()) return;
|
|
2870
|
+
applyRuntimeSnapshots(resolved, false);
|
|
2871
|
+
}
|
|
2872
|
+
} catch (error) {
|
|
2873
|
+
if (!isCurrentPage()) return;
|
|
2874
|
+
if (resolving.length) {
|
|
2875
|
+
applyRuntimeSnapshots({
|
|
2876
|
+
bindings: resolving.map((item) => ({
|
|
2877
|
+
...item, primaryStatus: null, primaryStatusResolution: "unavailable",
|
|
2878
|
+
})),
|
|
2879
|
+
sides: [], missingSideIds: [],
|
|
2880
|
+
}, false);
|
|
2881
|
+
}
|
|
2882
|
+
setStatus(error.message, true);
|
|
2883
|
+
} finally {
|
|
2884
|
+
runtimePollInFlight = false;
|
|
2885
|
+
}
|
|
2886
|
+
}, 5000);
|
|
2887
|
+
|
|
2888
|
+
let initialTab = "projects";
|
|
2889
|
+
try {
|
|
2890
|
+
if (sessionStorage.getItem("netizen-admin-updates-view") === "1") initialTab = "updates";
|
|
2891
|
+
} catch (_error) { /* The update page remains available without storage. */ }
|
|
2892
|
+
const linkedBindingId = new URLSearchParams(window.location.search).get("binding_id");
|
|
2893
|
+
if (linkedBindingId) {
|
|
2894
|
+
resetSessionPagination();
|
|
2895
|
+
state.sessionPage.query = new URLSearchParams({ identity: linkedBindingId, pageSize: "20" }).toString();
|
|
2896
|
+
initialTab = "sessions";
|
|
2897
|
+
}
|
|
2898
|
+
selectTab(initialTab);
|