@oldsuns/pi-switch 0.3.2 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -4
- package/bin/pi-switch.js +12 -1
- package/package.json +10 -4
- package/pi-switch-native.darwin-arm64.node +0 -0
- package/pi-switch-native.darwin-x64.node +0 -0
- package/pi-switch-native.linux-x64-gnu.node +0 -0
- package/pi-switch-native.linux-x64-musl.node +0 -0
- package/pi-switch-native.win32-x64-msvc.node +0 -0
- package/web/native-client.js +51 -0
- package/web/native-worker.js +17 -0
- package/web/public/app.js +990 -0
- package/web/public/appearance.js +24 -0
- package/web/public/dialogs.js +191 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/index.html +23 -0
- package/web/public/overview.js +83 -0
- package/web/public/profile-order.js +47 -0
- package/web/public/profiles.js +134 -0
- package/web/public/session-tree.js +120 -0
- package/web/public/sessions.js +164 -0
- package/web/public/settings.js +48 -0
- package/web/public/shell.js +48 -0
- package/web/public/styles.css +764 -0
- package/web/public/ui.js +135 -0
- package/web/server.js +137 -0
- package/web/start.js +63 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
import { h, t, icon, setLanguage, emptyState } from "./ui.js";
|
|
2
|
+
import { shell, pages } from "./shell.js";
|
|
3
|
+
import { overview } from "./overview.js";
|
|
4
|
+
import { profiles, selectedProvider, visibleProviders, visibleModels } from "./profiles.js";
|
|
5
|
+
import { orderSnapshot, reorderedVisibleIds } from "./profile-order.js";
|
|
6
|
+
import { sessions, messageReader } from "./sessions.js";
|
|
7
|
+
import { canFoldBranch, expandedMessagePath, reconcilePreview, sessionTree, visibleTreeNodes } from "./session-tree.js";
|
|
8
|
+
import { settings } from "./settings.js";
|
|
9
|
+
import * as dialogs from "./dialogs.js";
|
|
10
|
+
import { setTheme } from "./appearance.js";
|
|
11
|
+
|
|
12
|
+
const state = {
|
|
13
|
+
snapshot: null, page: "overview", providerId: null, providerQuery: "", providerFilter: "all", modelQuery: "", modelId: null,
|
|
14
|
+
providerSearchOpen: false, modelSearchOpen: false, sessionSearchOpen: false,
|
|
15
|
+
sessions: [], sessionsLoaded: false, sessionsLoading: false, sessionsError: null, sessionId: null, sessionQuery: "", namedOnly: false,
|
|
16
|
+
preview: null, previewLoading: false, previewError: null, previewMode: "tree", messageId: null, userOnly: false, folded: new Set(),
|
|
17
|
+
checks: null, busy: false, connected: false, navOpen: false, lastSaved: null,
|
|
18
|
+
};
|
|
19
|
+
const app = document.getElementById("app");
|
|
20
|
+
const dialog = document.getElementById("dialog");
|
|
21
|
+
let dialogContext = null;
|
|
22
|
+
let dialogVersion = 0;
|
|
23
|
+
let previewVersion = 0;
|
|
24
|
+
let sessionVersion = 0;
|
|
25
|
+
let returnFocus = null;
|
|
26
|
+
let returnFocusSelector = null;
|
|
27
|
+
let toastTimer;
|
|
28
|
+
let profileDrag = null;
|
|
29
|
+
const profileInsertionLine = document.createElement("div");
|
|
30
|
+
profileInsertionLine.className = "order-insertion-line";
|
|
31
|
+
profileInsertionLine.setAttribute("aria-hidden", "true");
|
|
32
|
+
const SEARCH_SCOPES = new Map(["provider", "model", "session"].map((scope) => [scope + "-search", scope]));
|
|
33
|
+
const BUSY_ACTIONS = new Set(["close-toast", "toggle-nav", "close-nav", "select-model", "skip-to-content", "theme"]);
|
|
34
|
+
const mobileViewport = matchMedia("(max-width: 760px)");
|
|
35
|
+
|
|
36
|
+
async function api(action, payload = {}) {
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetch("/api", {
|
|
40
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...payload }),
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
state.connected = false;
|
|
44
|
+
throw new Error(t("无法连接本地服务。请确认 pi-switch --web 仍在运行。", "Cannot connect to the local service. Check that pi-switch --web is still running."), { cause: error });
|
|
45
|
+
}
|
|
46
|
+
state.connected = true;
|
|
47
|
+
const result = await response.json();
|
|
48
|
+
if (!response.ok) throw new Error(result.error || t("请求失败", "Request failed"));
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function applySnapshot(snapshot) {
|
|
53
|
+
setLanguage(snapshot.language);
|
|
54
|
+
state.snapshot = orderSnapshot(snapshot);
|
|
55
|
+
if (!state.snapshot.providers.some((provider) => provider.id === state.providerId)) state.providerId = state.snapshot.providers[0]?.id ?? null;
|
|
56
|
+
if (!selectedProvider(state)?.models.some((model) => model.id === state.modelId)) state.modelId = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function render({ resetScroll = false, focusMain = false } = {}) {
|
|
60
|
+
if (!state.snapshot) return;
|
|
61
|
+
const main = document.getElementById("main");
|
|
62
|
+
const scrollTop = resetScroll ? 0 : main?.scrollTop ?? 0;
|
|
63
|
+
const sessionScroll = resetScroll ? [] : [...app.querySelectorAll("[data-session-scroll]")].map((element) => ({
|
|
64
|
+
name: element.dataset.sessionScroll, key: element.dataset.scrollKey ?? element.dataset.sessionId, top: element.scrollTop, left: element.scrollLeft,
|
|
65
|
+
}));
|
|
66
|
+
const active = document.activeElement;
|
|
67
|
+
const activeId = app.contains(active) ? active.id : null;
|
|
68
|
+
const activeSelector = app.contains(active) ? focusSelector(active) : null;
|
|
69
|
+
const selectionStart = activeId && "selectionStart" in active ? active.selectionStart : null;
|
|
70
|
+
const selectionEnd = activeId && "selectionEnd" in active ? active.selectionEnd : null;
|
|
71
|
+
const renderer = { overview, profiles, sessions, settings }[state.page];
|
|
72
|
+
app.innerHTML = shell(state, renderer(state));
|
|
73
|
+
if (state.busy) {
|
|
74
|
+
for (const element of app.querySelectorAll("button[data-action], button[data-order-handle], input[data-action], select[data-action]")) {
|
|
75
|
+
if (!BUSY_ACTIONS.has(element.dataset.action)) element.disabled = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
document.title = "pi-switch · " + t(pages[state.page].zh, pages[state.page].en);
|
|
79
|
+
document.getElementById("main").scrollTop = scrollTop;
|
|
80
|
+
for (const position of sessionScroll) {
|
|
81
|
+
const element = app.querySelector('[data-session-scroll="' + position.name + '"]');
|
|
82
|
+
if (element && (element.dataset.scrollKey ?? element.dataset.sessionId) === position.key) {
|
|
83
|
+
element.scrollTop = position.top;
|
|
84
|
+
element.scrollLeft = position.left;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (activeId) {
|
|
88
|
+
const next = document.getElementById(activeId);
|
|
89
|
+
next?.focus({ preventScroll: true });
|
|
90
|
+
if (selectionStart != null && next?.setSelectionRange) next.setSelectionRange(selectionStart, selectionEnd);
|
|
91
|
+
} else if (activeSelector) {
|
|
92
|
+
document.querySelector(activeSelector)?.focus({ preventScroll: true });
|
|
93
|
+
}
|
|
94
|
+
if (focusMain) document.getElementById("main").focus({ preventScroll: true });
|
|
95
|
+
prepareMarkdownLinks(app);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function prepareMarkdownLinks(root) {
|
|
99
|
+
for (const anchor of root.querySelectorAll(".markdown a")) {
|
|
100
|
+
anchor.target = "_blank";
|
|
101
|
+
anchor.rel = "noreferrer";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function route() {
|
|
106
|
+
const [page, query] = location.hash.slice(1).split("?");
|
|
107
|
+
const previousPage = state.page;
|
|
108
|
+
state.page = Object.hasOwn(pages, page) ? page : "overview";
|
|
109
|
+
state.navOpen = false;
|
|
110
|
+
const parameters = new URLSearchParams(query);
|
|
111
|
+
if (parameters.has("provider")) state.providerId = parameters.get("provider");
|
|
112
|
+
if (state.snapshot && !selectedProvider(state)) state.providerId = state.snapshot.providers[0]?.id ?? null;
|
|
113
|
+
const sessionId = state.page === "sessions" ? parameters.get("session") || null : null;
|
|
114
|
+
const sessionChanged = sessionId !== state.sessionId;
|
|
115
|
+
if (sessionChanged) {
|
|
116
|
+
state.sessionId = sessionId;
|
|
117
|
+
resetPreview();
|
|
118
|
+
}
|
|
119
|
+
render({ resetScroll: previousPage !== state.page, focusMain: previousPage !== state.page });
|
|
120
|
+
if (sessionId && state.sessionsLoaded && (sessionChanged || (!state.preview && !state.previewLoading && !state.previewError))) {
|
|
121
|
+
void loadPreview();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function navigate(page, key, value, { replace = false } = {}) {
|
|
126
|
+
const next = "#" + page + (key && value ? "?" + key + "=" + encodeURIComponent(value) : "");
|
|
127
|
+
if (replace) { history.replaceState(null, "", next); route(); }
|
|
128
|
+
else if (location.hash === next) route();
|
|
129
|
+
else location.hash = next;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function toast(message, error = false) {
|
|
133
|
+
clearTimeout(toastTimer);
|
|
134
|
+
document.getElementById("toast-region").innerHTML = `<div class="toast ${error ? "error" : ""}" ${error ? 'role="alert"' : ""}>${icon(error ? "warning" : "checkCircle")}<div class="toast-content">${h(message)}</div><button class="icon-button" data-action="close-toast" aria-label="${t("关闭提示", "Dismiss notification")}">${icon("close")}</button></div>`;
|
|
135
|
+
if (!error) toastTimer = setTimeout(() => { document.getElementById("toast-region").textContent = ""; }, 5000);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function showError(error) {
|
|
139
|
+
if (!dialog.open) { toast(error.message, true); render(); return; }
|
|
140
|
+
const target = dialog.querySelector("#dialog-error");
|
|
141
|
+
if (target) target.innerHTML = '<div class="banner error">' + icon("warning") + "<span>" + h(error.message) + "</span></div>";
|
|
142
|
+
if (error instanceof dialogs.FieldError) {
|
|
143
|
+
const field = dialog.querySelector('[name="' + error.field + '"]');
|
|
144
|
+
const details = field?.closest("details");
|
|
145
|
+
if (details) details.open = true;
|
|
146
|
+
field?.setAttribute("aria-invalid", "true");
|
|
147
|
+
const fieldError = dialog.querySelector('[data-field-error="' + error.field + '"]');
|
|
148
|
+
if (fieldError) fieldError.textContent = error.message;
|
|
149
|
+
field?.focus();
|
|
150
|
+
} else {
|
|
151
|
+
target?.scrollIntoView({ block: "nearest" });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function openDialog(content, context = null, preserveFocus = false) {
|
|
156
|
+
if (!dialog.open) {
|
|
157
|
+
returnFocus = document.activeElement;
|
|
158
|
+
returnFocusSelector = focusSelector(returnFocus);
|
|
159
|
+
}
|
|
160
|
+
let focusId;
|
|
161
|
+
let start;
|
|
162
|
+
if (preserveFocus) {
|
|
163
|
+
focusId = dialog.contains(document.activeElement) ? document.activeElement.id : null;
|
|
164
|
+
start = document.activeElement.selectionStart;
|
|
165
|
+
}
|
|
166
|
+
dialogVersion++;
|
|
167
|
+
dialogContext = context;
|
|
168
|
+
dialog.innerHTML = content;
|
|
169
|
+
if (!dialog.open) dialog.showModal();
|
|
170
|
+
const focus = focusId ? document.getElementById(focusId) : dialog.querySelector("[autofocus]");
|
|
171
|
+
focus?.focus({ preventScroll: true });
|
|
172
|
+
if (focusId && start != null) focus?.setSelectionRange(start, start);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function closeDialog() {
|
|
176
|
+
if (state.busy) return;
|
|
177
|
+
const onCancel = dialogContext?.onCancel;
|
|
178
|
+
dialogVersion++;
|
|
179
|
+
dialog.close();
|
|
180
|
+
onCancel?.();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
dialog.addEventListener("cancel", (event) => {
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
closeDialog();
|
|
186
|
+
});
|
|
187
|
+
dialog.addEventListener("close", () => {
|
|
188
|
+
dialogVersion++;
|
|
189
|
+
dialogContext = null;
|
|
190
|
+
const nextFocus = returnFocus?.isConnected ? returnFocus : returnFocusSelector && document.querySelector(returnFocusSelector);
|
|
191
|
+
if (nextFocus) nextFocus.focus({ preventScroll: true });
|
|
192
|
+
else document.getElementById("main")?.focus({ preventScroll: true });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
async function execute(task) {
|
|
196
|
+
if (state.busy) return;
|
|
197
|
+
const previousFocus = focusSelector(document.activeElement);
|
|
198
|
+
const row = document.activeElement.closest(".model-row,.provider-row,.provider-option,.session-option");
|
|
199
|
+
const rowFocus = focusSelector(row?.querySelector(".provider-option") ?? row);
|
|
200
|
+
state.busy = true;
|
|
201
|
+
const buttons = [...dialog.querySelectorAll("[data-submit]")];
|
|
202
|
+
const labels = buttons.map((button) => button.innerHTML);
|
|
203
|
+
for (const button of buttons) { button.disabled = true; button.innerHTML = '<span class="spinner"></span>' + t("处理中…", "Working…"); }
|
|
204
|
+
render();
|
|
205
|
+
let controls = [];
|
|
206
|
+
let outcome;
|
|
207
|
+
let failure;
|
|
208
|
+
try {
|
|
209
|
+
const operation = task();
|
|
210
|
+
controls = [...dialog.querySelectorAll("input,select,textarea")].map((element) => ({ element, disabled: element.disabled }));
|
|
211
|
+
controls.forEach(({ element }) => { element.disabled = true; });
|
|
212
|
+
outcome = await operation;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
failure = error;
|
|
215
|
+
} finally {
|
|
216
|
+
state.busy = false;
|
|
217
|
+
controls.forEach(({ element, disabled }) => { if (element.isConnected) element.disabled = disabled; });
|
|
218
|
+
buttons.forEach((button, index) => { if (button.isConnected) { button.disabled = false; button.innerHTML = labels[index]; } });
|
|
219
|
+
render();
|
|
220
|
+
if (!dialog.open) {
|
|
221
|
+
const preferred = previousFocus && document.querySelector(previousFocus);
|
|
222
|
+
const fallback = rowFocus && document.querySelector(rowFocus);
|
|
223
|
+
const target = preferred && !preferred.disabled ? preferred : fallback;
|
|
224
|
+
target?.focus({ preventScroll: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (failure) showError(failure);
|
|
228
|
+
return outcome;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function save(action, payload, message, options = {}) {
|
|
232
|
+
const result = await api(action, payload);
|
|
233
|
+
if (result.snapshot) {
|
|
234
|
+
applySnapshot(result.snapshot);
|
|
235
|
+
state.lastSaved = Date.now();
|
|
236
|
+
state.checks = null;
|
|
237
|
+
}
|
|
238
|
+
if (options.close !== false && dialog.open) dialog.close();
|
|
239
|
+
if (message) toast(message);
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function confirm(options, task, onCancel = null) {
|
|
244
|
+
openDialog(dialogs.confirmationDialog(options), { kind: "confirm", task, onCancel });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function refresh() {
|
|
248
|
+
const snapshot = await api("snapshot");
|
|
249
|
+
applySnapshot(snapshot);
|
|
250
|
+
render();
|
|
251
|
+
await loadSessions();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function loadSessions() {
|
|
255
|
+
const requestVersion = ++sessionVersion;
|
|
256
|
+
state.sessionsLoading = true;
|
|
257
|
+
state.sessionsError = null;
|
|
258
|
+
render();
|
|
259
|
+
try {
|
|
260
|
+
const result = await api("sessions.list");
|
|
261
|
+
if (requestVersion !== sessionVersion) return;
|
|
262
|
+
state.sessions = result.sessions;
|
|
263
|
+
if (state.sessionId && !state.sessions.some((session) => session.id === state.sessionId)) {
|
|
264
|
+
resetPreview();
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (requestVersion !== sessionVersion) return;
|
|
268
|
+
state.sessionsError = error.message;
|
|
269
|
+
} finally {
|
|
270
|
+
if (requestVersion === sessionVersion) {
|
|
271
|
+
state.sessionsLoaded = true;
|
|
272
|
+
state.sessionsLoading = false;
|
|
273
|
+
render();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (requestVersion === sessionVersion && !state.sessionsError && state.sessionId) {
|
|
277
|
+
await loadPreview();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resetPreview() {
|
|
282
|
+
++previewVersion;
|
|
283
|
+
state.preview = null;
|
|
284
|
+
state.previewLoading = false;
|
|
285
|
+
state.previewError = null;
|
|
286
|
+
state.messageId = null;
|
|
287
|
+
state.folded = new Set();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function loadPreview() {
|
|
291
|
+
if (!state.sessionId || !state.sessions.some((session) => session.id === state.sessionId)) return;
|
|
292
|
+
const requestVersion = ++previewVersion;
|
|
293
|
+
const id = state.sessionId;
|
|
294
|
+
const userOnly = state.userOnly;
|
|
295
|
+
state.previewLoading = true;
|
|
296
|
+
state.previewError = null;
|
|
297
|
+
render();
|
|
298
|
+
let result;
|
|
299
|
+
let failure;
|
|
300
|
+
try {
|
|
301
|
+
const preview = await api("sessions.preview", { id, userOnly });
|
|
302
|
+
if (requestVersion !== previewVersion || id !== state.sessionId || userOnly !== state.userOnly) return;
|
|
303
|
+
result = reconcilePreview(state, { ...preview, userOnly });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
failure = error;
|
|
306
|
+
}
|
|
307
|
+
if (requestVersion !== previewVersion || id !== state.sessionId || userOnly !== state.userOnly) return;
|
|
308
|
+
state.previewLoading = false;
|
|
309
|
+
const previousMessageId = state.messageId;
|
|
310
|
+
const restoreMessageFocus = Boolean(document.activeElement.closest(".message-node,.message-reader"));
|
|
311
|
+
if (failure) {
|
|
312
|
+
state.previewError = failure.message;
|
|
313
|
+
if (state.preview) state.userOnly = state.preview.userOnly;
|
|
314
|
+
} else {
|
|
315
|
+
Object.assign(state, result);
|
|
316
|
+
}
|
|
317
|
+
render();
|
|
318
|
+
if (!failure && previousMessageId !== state.messageId) revealMessage(restoreMessageFocus);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function loadDialog(title, action, renderer, payload = {}) {
|
|
322
|
+
openDialog(dialogs.loadingDialog(title), { kind: "loading" });
|
|
323
|
+
const version = dialogVersion;
|
|
324
|
+
try {
|
|
325
|
+
const result = await api(action, payload);
|
|
326
|
+
if (!dialog.open || dialogVersion !== version) return;
|
|
327
|
+
openDialog(renderer(result));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (dialog.open && dialogVersion === version) {
|
|
330
|
+
openDialog(dialogs.frame({ title, body: "" }));
|
|
331
|
+
showError(error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function editProvider(provider = null) {
|
|
337
|
+
openDialog(dialogs.providerDialog(state.snapshot, provider), { kind: "provider", provider });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function editModel(model = null, copy = false, provider = selectedProvider(state)) {
|
|
341
|
+
if (!provider) return;
|
|
342
|
+
let draft = model;
|
|
343
|
+
if (copy) {
|
|
344
|
+
let suffix = "-copy";
|
|
345
|
+
let index = 2;
|
|
346
|
+
while (provider.models.some((candidate) => candidate.id === model.id + suffix)) suffix = "-copy-" + index++;
|
|
347
|
+
draft = { ...model, id: model.id + suffix };
|
|
348
|
+
}
|
|
349
|
+
openDialog(dialogs.modelDialog(draft, copy, state.snapshot.apiTypes), {
|
|
350
|
+
kind: "model", providerId: provider.id, previousId: model && !copy ? model.id : null, sourceModelId: copy ? model.id : null,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function importModels(provider) {
|
|
355
|
+
openDialog(dialogs.loadingDialog(t("在线导入模型", "Import models"), t("正在获取服务商的模型列表…", "Fetching the provider's model list…")));
|
|
356
|
+
const version = dialogVersion;
|
|
357
|
+
try {
|
|
358
|
+
const result = await api("models.fetch", { providerId: provider.id });
|
|
359
|
+
if (!dialog.open || dialogVersion !== version) return;
|
|
360
|
+
const context = { kind: "models", providerId: provider.id, items: result.models, selected: new Set(result.models.filter((model) => !model.existing).map((model) => model.id)), query: "", updateExisting: false };
|
|
361
|
+
openDialog(dialogs.selectionDialog(context), context);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (dialog.open && version === dialogVersion) {
|
|
364
|
+
openDialog(dialogs.frame({ title: t("在线导入模型", "Import models"), body: "" }));
|
|
365
|
+
showError(error);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function importOpenCode() {
|
|
371
|
+
openDialog(dialogs.loadingDialog(t("从 OpenCode 导入", "Import from OpenCode")));
|
|
372
|
+
const version = dialogVersion;
|
|
373
|
+
try {
|
|
374
|
+
const result = await api("opencode.list");
|
|
375
|
+
if (!dialog.open || dialogVersion !== version) return;
|
|
376
|
+
const context = { kind: "opencode", path: result.path, items: result.providerIds.map((id) => ({ id })), selected: new Set(), query: "" };
|
|
377
|
+
openDialog(dialogs.selectionDialog(context), context);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (dialog.open && version === dialogVersion) {
|
|
380
|
+
openDialog(dialogs.frame({ title: t("从 OpenCode 导入", "Import from OpenCode"), body: "" }));
|
|
381
|
+
showError(error);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function showImportResult(result) {
|
|
387
|
+
applySnapshot(result.snapshot);
|
|
388
|
+
state.lastSaved = Date.now();
|
|
389
|
+
dialog.close();
|
|
390
|
+
const summary = result.summary;
|
|
391
|
+
const message = "added" in summary
|
|
392
|
+
? t("模型导入完成:新增 " + summary.added + ",更新 " + summary.updated + "。", "Models imported: " + summary.added + " added, " + summary.updated + " updated.")
|
|
393
|
+
: t("已导入 " + summary.providers + " 个 Provider、" + summary.models + " 个模型,并同步到 Pi。", "Imported " + summary.providers + " providers and " + summary.models + " models, synced to Pi.");
|
|
394
|
+
toast(message + (result.warning ? "\n" + result.warning : ""), Boolean(result.warning));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function finishModelImport(payload) {
|
|
398
|
+
const result = await api("models.import", payload);
|
|
399
|
+
if (result.requiresSelection) {
|
|
400
|
+
openDialog(dialogs.ambiguityDialog(result.ambiguities, result.warning), { kind: "ambiguities", action: "models.import", payload: { ...payload, selectionId: result.selectionId }, count: result.ambiguities.length });
|
|
401
|
+
} else {
|
|
402
|
+
showImportResult(result);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function submitImport() {
|
|
407
|
+
const context = dialogContext;
|
|
408
|
+
const ids = Array.from(context.selected);
|
|
409
|
+
if (!ids.length) throw new Error(t("请至少选择一项。", "Select at least one item."));
|
|
410
|
+
if (context.kind === "models") {
|
|
411
|
+
await finishModelImport({ providerId: context.providerId, ids, updateExisting: context.updateExisting });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const prepared = await api("opencode.prepare", { providerIds: ids });
|
|
415
|
+
if (prepared.ambiguities.length) {
|
|
416
|
+
openDialog(dialogs.ambiguityDialog(prepared.ambiguities), { kind: "ambiguities", action: "opencode.import", payload: { planId: prepared.planId }, count: prepared.ambiguities.length });
|
|
417
|
+
} else {
|
|
418
|
+
await applyOpenCodeImport({ planId: prepared.planId });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function applyOpenCodeImport(payload) {
|
|
423
|
+
const result = await api("opencode.import", { ...payload, candidateIndices: [] });
|
|
424
|
+
if (await confirmCredentialOverwrite(result, () => applyOpenCodeImport(payload))) return;
|
|
425
|
+
showImportResult(result);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/// Pi resolves auth.json before the provider documents, so replacing an entry
|
|
429
|
+
/// there discards a credential only Pi can restore by signing in again.
|
|
430
|
+
async function confirmCredentialOverwrite(result, retry) {
|
|
431
|
+
if (!result.requiresCredentialOverwrite) return false;
|
|
432
|
+
confirm({
|
|
433
|
+
title: t("覆盖已有的 Pi 凭据?", "Replace the existing Pi credential?"),
|
|
434
|
+
description: t("auth.json 里该 ID 已有 Pi 凭据(例如 OAuth 登录)。继续会丢弃它,Pi 需要重新登录才能恢复。", "auth.json already stores a Pi credential for this ID (an OAuth sign-in, for example). Continuing discards it, and Pi has to sign in again to restore it."),
|
|
435
|
+
label: t("覆盖", "Replace"),
|
|
436
|
+
danger: true,
|
|
437
|
+
}, retry);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function copyText(value) {
|
|
442
|
+
await navigator.clipboard.writeText(value);
|
|
443
|
+
toast(t("已复制到剪贴板", "Copied to clipboard"));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function selectedMessageElement() {
|
|
447
|
+
return state.messageId && app.querySelector('.message-node[data-message="' + CSS.escape(state.messageId) + '"]');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function revealMessage(focus = false) {
|
|
451
|
+
const element = selectedMessageElement();
|
|
452
|
+
if (focus) element?.focus({ preventScroll: true });
|
|
453
|
+
element?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function focusMessage(id, { focus = true } = {}) {
|
|
457
|
+
const next = app.querySelector('.message-node[data-message="' + CSS.escape(id) + '"]');
|
|
458
|
+
if (!next) return;
|
|
459
|
+
if (state.messageId !== id) {
|
|
460
|
+
const previous = selectedMessageElement();
|
|
461
|
+
const selectedAttribute = state.previewMode === "tree" ? "aria-selected" : "aria-current";
|
|
462
|
+
previous?.setAttribute(selectedAttribute, "false");
|
|
463
|
+
previous?.setAttribute("tabindex", "-1");
|
|
464
|
+
next.setAttribute(selectedAttribute, "true");
|
|
465
|
+
next.setAttribute("tabindex", "0");
|
|
466
|
+
state.messageId = id;
|
|
467
|
+
if (state.previewMode === "tree") {
|
|
468
|
+
document.getElementById("session-message-reader").outerHTML = messageReader(state);
|
|
469
|
+
prepareMarkdownLinks(document.getElementById("session-message-reader"));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
revealMessage(focus);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function setPreviewMode(mode) {
|
|
476
|
+
const restoreMessageFocus = Boolean(document.activeElement.closest(".message-node,.message-reader"));
|
|
477
|
+
state.previewMode = mode;
|
|
478
|
+
render();
|
|
479
|
+
revealMessage(restoreMessageFocus);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function toggleMessageBranch(id, focus = false) {
|
|
483
|
+
if (!state.preview || !canFoldBranch(sessionTree(state.preview).nodes.get(id))) return;
|
|
484
|
+
const folded = new Set(state.folded);
|
|
485
|
+
if (folded.has(id)) folded.delete(id); else folded.add(id);
|
|
486
|
+
const previousMessageId = state.messageId;
|
|
487
|
+
Object.assign(state, reconcilePreview({ ...state, folded }, state.preview));
|
|
488
|
+
render();
|
|
489
|
+
if (focus || state.messageId !== previousMessageId) revealMessage(focus);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function foldMessageTree(collapse) {
|
|
493
|
+
if (!state.preview) return;
|
|
494
|
+
const tree = sessionTree(state.preview);
|
|
495
|
+
const folded = collapse ? new Set(tree.ordered.filter(canFoldBranch).map((node) => node.id)) : new Set();
|
|
496
|
+
Object.assign(state, reconcilePreview({ ...state, folded }, state.preview));
|
|
497
|
+
render();
|
|
498
|
+
revealMessage(!collapse);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function locateActiveMessage() {
|
|
502
|
+
const id = state.preview?.activeMessageId;
|
|
503
|
+
if (!id) return;
|
|
504
|
+
state.folded = expandedMessagePath(state.preview, state.folded, id);
|
|
505
|
+
state.messageId = id;
|
|
506
|
+
render();
|
|
507
|
+
revealMessage();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function setSearch(scope, open) {
|
|
511
|
+
state[scope + "SearchOpen"] = open;
|
|
512
|
+
if (!open) state[scope + "Query"] = "";
|
|
513
|
+
render();
|
|
514
|
+
const target = open ? document.getElementById(scope + "-search") : app.querySelector('[data-action="toggle-search"][data-scope="' + scope + '"]');
|
|
515
|
+
target?.focus();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function profileCollection(scope, providerId) {
|
|
519
|
+
if (scope === "providers") return { items: state.snapshot.providers, visible: visibleProviders(state), ordering: state.snapshot.ordering.providers };
|
|
520
|
+
const provider = state.snapshot.providers.find((item) => item.id === providerId);
|
|
521
|
+
if (!provider) throw new Error(t("Provider 已不存在,请重新读取配置。", "The provider no longer exists. Reload the configuration."));
|
|
522
|
+
return { items: provider.models, visible: visibleModels({ ...state, providerId }), ordering: state.snapshot.ordering.models[providerId] };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function reorderProfile({ scope, providerId, itemId, insertionIndex }) {
|
|
526
|
+
const { items, visible, ordering } = profileCollection(scope, providerId);
|
|
527
|
+
if (ordering.sort !== "custom") return;
|
|
528
|
+
const ids = reorderedVisibleIds(items, visible, itemId, insertionIndex);
|
|
529
|
+
if (items.every((item, index) => item.id === ids[index])) return;
|
|
530
|
+
const payload = scope === "models" ? { providerId, ids } : { ids };
|
|
531
|
+
await execute(() => save(scope + ".reorder", payload, t("顺序已保存", "Order saved")));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function moveProfile(target) {
|
|
535
|
+
const { scope, item, provider: providerId, direction } = target.dataset;
|
|
536
|
+
const { visible } = profileCollection(scope, providerId);
|
|
537
|
+
const index = visible.findIndex((entry) => entry.id === item);
|
|
538
|
+
if (index < 0 || !visible[index + Number(direction)]) return;
|
|
539
|
+
const insertionIndex = Number(direction) < 0 ? index - 1 : index + 2;
|
|
540
|
+
await reorderProfile({ scope, providerId, itemId: item, insertionIndex });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function hideProfileInsertion() {
|
|
544
|
+
profileInsertionLine.remove();
|
|
545
|
+
if (profileDrag) { profileDrag.list = null; profileDrag.insertionIndex = null; }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function clearProfileDrag() {
|
|
549
|
+
profileDrag = null;
|
|
550
|
+
hideProfileInsertion();
|
|
551
|
+
for (const row of app.querySelectorAll(".order-dragging")) row.classList.remove("order-dragging");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function profileDropList(event) {
|
|
555
|
+
const list = event.target.closest("[data-order-list]");
|
|
556
|
+
if (!profileDrag || state.busy || !list || list.dataset.orderScope !== profileDrag.scope || list.dataset.orderProvider !== profileDrag.providerId) return null;
|
|
557
|
+
if (profileCollection(profileDrag.scope, profileDrag.providerId).ordering.sort !== "custom") return null;
|
|
558
|
+
return list;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function showProfileInsertion(list, clientY) {
|
|
562
|
+
const rows = [...list.querySelectorAll("[data-order-item]")].map((row) => row.getBoundingClientRect());
|
|
563
|
+
if (!rows.length) { hideProfileInsertion(); return; }
|
|
564
|
+
const gaps = [rows[0].top, ...rows.slice(1).map((row, index) => (rows[index].bottom + row.top) / 2), rows.at(-1).bottom];
|
|
565
|
+
const insertionIndex = gaps.reduce((closest, y, index) => Math.abs(clientY - y) < Math.abs(clientY - gaps[closest]) ? index : closest, 0);
|
|
566
|
+
const host = list.closest(".table-wrap") ?? list;
|
|
567
|
+
const bounds = host.getBoundingClientRect();
|
|
568
|
+
const top = gaps[insertionIndex] - bounds.top + host.scrollTop - 1;
|
|
569
|
+
profileInsertionLine.style.top = Math.max(0, Math.min(top, host.scrollHeight - 2)) + "px";
|
|
570
|
+
profileInsertionLine.style.left = rows[0].left - bounds.left + host.scrollLeft + "px";
|
|
571
|
+
profileInsertionLine.style.width = rows[0].width + "px";
|
|
572
|
+
if (profileInsertionLine.parentElement !== host) host.append(profileInsertionLine);
|
|
573
|
+
profileDrag.list = list;
|
|
574
|
+
profileDrag.insertionIndex = insertionIndex;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
document.addEventListener("dragstart", (event) => {
|
|
578
|
+
const handle = event.target.closest("[data-order-handle]");
|
|
579
|
+
if (!handle) return;
|
|
580
|
+
if (state.busy) { event.preventDefault(); return; }
|
|
581
|
+
profileDrag = { scope: handle.dataset.scope, providerId: handle.dataset.provider, itemId: handle.dataset.item };
|
|
582
|
+
event.dataTransfer.effectAllowed = "move";
|
|
583
|
+
event.dataTransfer.setData("text/plain", profileDrag.itemId);
|
|
584
|
+
handle.closest("[data-order-item]").classList.add("order-dragging");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
document.addEventListener("dragover", (event) => {
|
|
588
|
+
const list = profileDropList(event);
|
|
589
|
+
if (!list) { hideProfileInsertion(); return; }
|
|
590
|
+
event.preventDefault();
|
|
591
|
+
event.dataTransfer.dropEffect = "move";
|
|
592
|
+
showProfileInsertion(list, event.clientY);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
document.addEventListener("drop", (event) => {
|
|
596
|
+
const list = profileDropList(event);
|
|
597
|
+
if (!list || profileDrag.list !== list || profileDrag.insertionIndex == null) { clearProfileDrag(); return; }
|
|
598
|
+
event.preventDefault();
|
|
599
|
+
const { scope, providerId, itemId, insertionIndex } = profileDrag;
|
|
600
|
+
clearProfileDrag();
|
|
601
|
+
void reorderProfile({ scope, providerId, itemId, insertionIndex }).catch(showError);
|
|
602
|
+
});
|
|
603
|
+
document.addEventListener("dragend", clearProfileDrag);
|
|
604
|
+
|
|
605
|
+
async function handleAction(action, target) {
|
|
606
|
+
const providerId = target?.dataset.provider ?? (dialog.open && dialogContext?.kind === "model" ? dialogContext.providerId : state.providerId);
|
|
607
|
+
const provider = state.snapshot?.providers.find((item) => item.id === providerId);
|
|
608
|
+
const modelId = target?.dataset.model ?? state.modelId;
|
|
609
|
+
const model = provider?.models.find((item) => item.id === modelId);
|
|
610
|
+
switch (action) {
|
|
611
|
+
case "theme": setTheme(target.value); render(); break;
|
|
612
|
+
case "choose-default": {
|
|
613
|
+
const [providerId, modelId] = JSON.parse(target.value);
|
|
614
|
+
await execute(() => save("model.default", { providerId, modelId }, t("默认模型已切换", "Default model changed")));
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
case "skip-to-content": document.getElementById("main")?.focus(); break;
|
|
618
|
+
case "toggle-nav":
|
|
619
|
+
state.navOpen = !state.navOpen; render();
|
|
620
|
+
document.querySelector(state.navOpen ? ".nav-link.active" : ".mobile-menu")?.focus();
|
|
621
|
+
break;
|
|
622
|
+
case "close-nav": state.navOpen = false; render(); document.querySelector(".mobile-menu")?.focus(); break;
|
|
623
|
+
case "close-toast": document.getElementById("toast-region").textContent = ""; break;
|
|
624
|
+
case "help": openDialog(dialogs.helpDialog()); break;
|
|
625
|
+
case "close-dialog": closeDialog(); break;
|
|
626
|
+
case "confirm": { const task = dialogContext.task; await execute(task); break; }
|
|
627
|
+
case "reload": await execute(async () => { await refresh(); toast(t("已重新读取本地配置", "Local configuration reloaded")); }); break;
|
|
628
|
+
case "new-provider": editProvider(); break;
|
|
629
|
+
case "toggle-search": {
|
|
630
|
+
const { scope } = target.dataset;
|
|
631
|
+
setSearch(scope, !state[scope + "SearchOpen"]);
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
case "profile-sort": {
|
|
635
|
+
const { scope, provider: providerId } = target.dataset;
|
|
636
|
+
const payload = scope === "models" ? { providerId, value: target.value } : { value: target.value };
|
|
637
|
+
await execute(() => save(scope + ".sort", payload, t("排序方式已保存", "Sort preference saved")));
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
case "move-profile": await moveProfile(target); break;
|
|
641
|
+
case "edit-provider": editProvider(provider); break;
|
|
642
|
+
case "select-provider":
|
|
643
|
+
state.modelQuery = ""; state.modelId = null;
|
|
644
|
+
navigate("profiles", "provider", target.dataset.provider); break;
|
|
645
|
+
case "provider-filter": state.providerFilter = target.value; render(); break;
|
|
646
|
+
case "duplicate-provider":
|
|
647
|
+
if (!provider) break;
|
|
648
|
+
await execute(async () => {
|
|
649
|
+
const result = await save("provider.duplicate", { providerId: provider.id }, t("Provider 已复制", "Provider duplicated"));
|
|
650
|
+
state.providerQuery = ""; state.providerFilter = "all";
|
|
651
|
+
navigate("profiles", "provider", result.providerId);
|
|
652
|
+
}); break;
|
|
653
|
+
case "remove-provider":
|
|
654
|
+
if (!provider) break;
|
|
655
|
+
confirm({ title: t("删除 Provider?", "Delete provider?"), description: t("这会从本地库删除此 Provider,并取消其 Pi 同步。关联的默认模型也会清除。", "This deletes the provider from your local library and Pi, and clears its default model if selected."), detail: provider.id, label: t("删除 Provider", "Delete provider"), danger: true, checkbox: provider.hasAuth ? { label: t("同时删除 auth.json 中的凭据", "Also delete the credential in auth.json"), checked: true } : null },
|
|
656
|
+
() => save("provider.remove", { providerId: provider.id, removeAuth: Boolean(provider.hasAuth && dialog.querySelector("[data-confirm-option]")?.checked) }, t("Provider 已删除", "Provider deleted"))); break;
|
|
657
|
+
case "sync-provider": {
|
|
658
|
+
const inPi = !provider.inPi;
|
|
659
|
+
if (!inPi && state.snapshot.defaultProvider === provider.id) {
|
|
660
|
+
confirm({ title: t("取消同步到 Pi?", "Remove this provider from Pi?"), description: t("当前默认模型属于此 Provider。取消同步会清除默认模型,本地配置仍会保留。", "Your default model belongs to this provider. Removing it from Pi clears the default; the local configuration is kept."), detail: provider.id + " / " + state.snapshot.defaultModel, label: t("取消同步并清除默认", "Remove and clear default") },
|
|
661
|
+
() => save("provider.sync", { providerId: provider.id, inPi }, t("已取消同步,本地配置已保留", "Removed from Pi. Local configuration kept.")));
|
|
662
|
+
} else {
|
|
663
|
+
await execute(() => save("provider.sync", { providerId: provider.id, inPi }, inPi ? t("Provider 已同步到 Pi", "Provider synced to Pi") : t("已取消同步,本地配置已保留", "Removed from Pi. Local configuration kept.")));
|
|
664
|
+
} break;
|
|
665
|
+
}
|
|
666
|
+
case "new-model": editModel(); break;
|
|
667
|
+
case "edit-model": state.modelId = modelId; editModel(model, false, provider); break;
|
|
668
|
+
case "duplicate-model": if (model) editModel(model, true, provider); break;
|
|
669
|
+
case "select-model": state.modelId = modelId; document.querySelectorAll(".model-row").forEach((row) => row.classList.toggle("selected", row.dataset.model === modelId)); break;
|
|
670
|
+
case "default-model":
|
|
671
|
+
if (!model) break;
|
|
672
|
+
if (!provider.inPi) throw new Error(t("请先将 Provider 同步到 Pi。", "Sync the provider to Pi first."));
|
|
673
|
+
await execute(() => save("model.default", { providerId: provider.id, modelId }, t("默认模型已切换为 ", "Default model changed to ") + (model.name || model.id))); break;
|
|
674
|
+
case "remove-model":
|
|
675
|
+
if (!model) break;
|
|
676
|
+
confirm({ title: t("删除模型?", "Delete model?"), description: t("此模型将从 Provider 中删除,并同步更新 Pi。若它是默认模型,默认设置也会清除。", "This removes the model and updates Pi if synced. If selected as default, the default is also cleared."), detail: provider.id + " / " + model.id, label: t("删除模型", "Delete model"), danger: true },
|
|
677
|
+
() => save("model.remove", { providerId: provider.id, modelId: model.id }, t("模型已删除", "Model deleted"))); break;
|
|
678
|
+
case "import-models": if (provider) await importModels(provider); break;
|
|
679
|
+
case "opencode": await importOpenCode(); break;
|
|
680
|
+
case "select-all-import":
|
|
681
|
+
for (const item of dialogContext.items.filter((item) => item.id.toLowerCase().includes(dialogContext.query.toLowerCase()))) dialogContext.selected.add(item.id);
|
|
682
|
+
openDialog(dialogs.selectionDialog(dialogContext), dialogContext); break;
|
|
683
|
+
case "clear-import": dialogContext.selected.clear(); openDialog(dialogs.selectionDialog(dialogContext), dialogContext); break;
|
|
684
|
+
case "import-selection":
|
|
685
|
+
if (target.checked) dialogContext.selected.add(target.value); else dialogContext.selected.delete(target.value);
|
|
686
|
+
document.getElementById("import-selected-count").textContent = dialogContext.selected.size; break;
|
|
687
|
+
case "import-overwrite": dialogContext.updateExisting = target.checked; break;
|
|
688
|
+
case "reveal-key": {
|
|
689
|
+
const input = dialog.querySelector('[name="apiKey"]');
|
|
690
|
+
input.type = input.type === "password" ? "text" : "password";
|
|
691
|
+
target.setAttribute("aria-pressed", input.type === "text"); break;
|
|
692
|
+
}
|
|
693
|
+
case "draft-inpi": {
|
|
694
|
+
const warning = document.getElementById("unsync-confirmation");
|
|
695
|
+
if (warning) { warning.hidden = target.checked; warning.querySelector("input").required = !target.checked; }
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case "draft-reasoning": document.getElementById("thinking-fields").hidden = !target.checked; break;
|
|
699
|
+
case "doctor": await loadDialog(t("配置检查", "Configuration checks"), "doctor", (result) => { state.checks = result.checks; render(); return dialogs.doctorDialog(result.checks); }); break;
|
|
700
|
+
case "backups": await loadDialog(t("配置备份", "Configuration backups"), "backups.list", (result) => dialogs.backupsDialog(result.backups)); break;
|
|
701
|
+
case "restore-backup": {
|
|
702
|
+
const name = target.dataset.backup;
|
|
703
|
+
confirm({ title: t("恢复这份备份?", "Restore this backup?"), description: t("当前的本地库、Pi 配置和 pi-switch 设置会被替换。恢复前会自动备份当前配置。", "This replaces the current provider library, Pi configuration, and pi-switch settings. Current files are backed up first."), detail: name, label: t("恢复配置", "Restore configuration") },
|
|
704
|
+
() => save("backups.restore", { name }, t("配置已恢复", "Configuration restored"))); break;
|
|
705
|
+
}
|
|
706
|
+
case "language": await execute(() => save("settings.language", { value: target.value }, t("语言设置已保存", "Language preference saved"))); break;
|
|
707
|
+
case "key-storage": await execute(() => save("settings.key-storage", { value: target.value }, t("密钥保存位置已更新", "Key storage preference saved"))); break;
|
|
708
|
+
case "metadata": await execute(() => save("settings.metadata", { value: target.checked }, t("元数据设置已保存", "Metadata preference saved"))); break;
|
|
709
|
+
case "auto-updates": await execute(() => save("settings.updates", { value: target.checked }, t("更新设置已保存", "Update preference saved"))); break;
|
|
710
|
+
case "model-defaults": openDialog(dialogs.defaultsDialog(state.snapshot.modelDefaults), { kind: "defaults" }); break;
|
|
711
|
+
case "copy-path": await copyText(target.dataset.path); break;
|
|
712
|
+
case "refresh-sessions": await loadSessions(); break;
|
|
713
|
+
case "select-session": navigate("sessions", "session", target.dataset.session); break;
|
|
714
|
+
case "clear-session": navigate("sessions"); break;
|
|
715
|
+
case "reload-preview": await loadPreview(); break;
|
|
716
|
+
case "named-only": state.namedOnly = target.checked; render(); break;
|
|
717
|
+
case "user-only": state.userOnly = target.checked; await loadPreview(); break;
|
|
718
|
+
case "preview-mode": setPreviewMode(target.dataset.mode); break;
|
|
719
|
+
case "select-message": focusMessage(target.dataset.message); break;
|
|
720
|
+
case "toggle-branch": {
|
|
721
|
+
const id = target?.dataset.message ?? state.messageId;
|
|
722
|
+
toggleMessageBranch(id, !target);
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
case "collapse-tree": foldMessageTree(true); break;
|
|
726
|
+
case "expand-tree": foldMessageTree(false); break;
|
|
727
|
+
case "active-message": locateActiveMessage(); break;
|
|
728
|
+
case "copy-message": {
|
|
729
|
+
const message = state.preview?.messages.find((item) => item.id === (target?.dataset.message ?? state.messageId));
|
|
730
|
+
if (message) await copyText(message.text);
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
case "delete-session": {
|
|
734
|
+
const session = state.sessions.find((item) => item.id === (target?.dataset.session ?? state.sessionId));
|
|
735
|
+
if (!session) break;
|
|
736
|
+
confirm({ title: t("删除这个会话?", "Delete this session?"), description: t("将优先移到系统回收站;如果回收站不可用,则永久删除该会话文件。", "The session is moved to the system trash when available; otherwise its file is permanently deleted."), detail: session.title + "\n" + session.id, label: t("删除会话", "Delete session"), danger: true }, async () => {
|
|
737
|
+
const result = await save("sessions.delete", { id: session.id }, null);
|
|
738
|
+
if (state.sessionId === session.id) navigate("sessions", null, null, { replace: true });
|
|
739
|
+
await loadSessions();
|
|
740
|
+
toast(result.method === "trash" ? t("会话已移到回收站", "Session moved to trash") : t("会话文件已永久删除", "Session file permanently deleted"));
|
|
741
|
+
}); break;
|
|
742
|
+
}
|
|
743
|
+
case "check-updates": {
|
|
744
|
+
openDialog(dialogs.loadingDialog(t("检查更新", "Check for updates")));
|
|
745
|
+
const version = dialogVersion;
|
|
746
|
+
try {
|
|
747
|
+
const result = await api("updates.check");
|
|
748
|
+
if (!dialog.open || version !== dialogVersion) break;
|
|
749
|
+
if (result.available) {
|
|
750
|
+
confirm({ title: t("发现新版本", "Update available"), description: t("将通过 npm 全局安装最新版本。完成后需重启 pi-switch。", "The latest version will be installed globally with npm. Restart pi-switch after installation."), detail: result.current + " → " + result.latest, label: t("安装更新", "Install update") },
|
|
751
|
+
() => save("updates.install", {}, t("更新已安装,请重启 pi-switch 生效", "Update installed. Restart pi-switch to apply.")));
|
|
752
|
+
} else {
|
|
753
|
+
openDialog(dialogs.frame({ title: t("已是最新版本", "You're up to date"), body: '<p class="confirm-description">pi-switch v' + h(result.current) + "</p>" }));
|
|
754
|
+
}
|
|
755
|
+
} catch (error) { if (dialog.open && version === dialogVersion) { openDialog(dialogs.frame({ title: t("更新检查失败", "Update check failed"), body: "" })); showError(error); } }
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function dispatch(action, target) {
|
|
762
|
+
if (state.busy && !BUSY_ACTIONS.has(action)) return;
|
|
763
|
+
Promise.resolve(handleAction(action, target)).catch(showError);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
document.addEventListener("click", (event) => {
|
|
767
|
+
const target = event.target.closest("[data-action]");
|
|
768
|
+
if (!target || target.disabled || target.matches('input,select')) return;
|
|
769
|
+
if (target.matches(".reading-message") && (event.target.closest("a,button,input,select,textarea") || window.getSelection().toString())) return;
|
|
770
|
+
if (target.dataset.action === "skip-to-content") event.preventDefault();
|
|
771
|
+
dispatch(target.dataset.action, target);
|
|
772
|
+
});
|
|
773
|
+
document.addEventListener("change", (event) => {
|
|
774
|
+
const target = event.target;
|
|
775
|
+
if (target.dataset.action) dispatch(target.dataset.action, target);
|
|
776
|
+
});
|
|
777
|
+
document.addEventListener("input", (event) => {
|
|
778
|
+
const scope = SEARCH_SCOPES.get(event.target.id);
|
|
779
|
+
if (scope) { state[scope + "Query"] = event.target.value; render(); }
|
|
780
|
+
if (event.target.id === "import-search") {
|
|
781
|
+
dialogContext.query = event.target.value;
|
|
782
|
+
openDialog(dialogs.selectionDialog(dialogContext), dialogContext, true);
|
|
783
|
+
}
|
|
784
|
+
event.target.removeAttribute("aria-invalid");
|
|
785
|
+
});
|
|
786
|
+
document.addEventListener("submit", (event) => {
|
|
787
|
+
const form = event.target;
|
|
788
|
+
if (!form.dataset.form) return;
|
|
789
|
+
event.preventDefault();
|
|
790
|
+
if (!form.reportValidity()) return;
|
|
791
|
+
const context = dialogContext;
|
|
792
|
+
void execute(async () => {
|
|
793
|
+
dialog.querySelectorAll(".field-error").forEach((element) => { element.textContent = ""; });
|
|
794
|
+
dialog.querySelector("#dialog-error").textContent = "";
|
|
795
|
+
switch (form.dataset.form) {
|
|
796
|
+
case "provider": {
|
|
797
|
+
const draft = dialogs.providerDraft(form, context.provider);
|
|
798
|
+
const previousId = context.provider?.id ?? null;
|
|
799
|
+
const finish = () => { state.providerQuery = ""; state.providerFilter = "all"; navigate("profiles", "provider", draft.id); };
|
|
800
|
+
const result = await save("provider.save", { previousId, draft }, null, { close: false });
|
|
801
|
+
if (result.requiresCredentialOverwrite) {
|
|
802
|
+
confirm({
|
|
803
|
+
title: t("覆盖已有的 Pi 凭据?", "Replace the existing Pi credential?"),
|
|
804
|
+
description: t("auth.json 里该 ID 已有 Pi 凭据(例如 OAuth 登录)。继续会丢弃它,Pi 需要重新登录才能恢复。", "auth.json already stores a Pi credential for this ID (an OAuth sign-in, for example). Continuing discards it, and Pi has to sign in again to restore it."),
|
|
805
|
+
detail: draft.id,
|
|
806
|
+
label: t("覆盖", "Replace"),
|
|
807
|
+
danger: true,
|
|
808
|
+
},
|
|
809
|
+
() => save("provider.save", { previousId, draft, overwriteCredential: true }, t("Provider 已保存", "Provider saved")).then(finish),
|
|
810
|
+
() => openDialog(dialogs.providerDialog(state.snapshot, draft, Boolean(context.provider)), context));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
dialog.close();
|
|
814
|
+
toast(t("Provider 已保存", "Provider saved"));
|
|
815
|
+
finish(); break;
|
|
816
|
+
}
|
|
817
|
+
case "model": {
|
|
818
|
+
const draft = dialogs.modelDraft(form);
|
|
819
|
+
const action = context.sourceModelId ? "model.duplicate" : "model.save";
|
|
820
|
+
const source = context.sourceModelId ? { sourceModelId: context.sourceModelId } : { previousId: context.previousId };
|
|
821
|
+
await save(action, { providerId: context.providerId, ...source, draft }, t("模型已保存", "Model saved"));
|
|
822
|
+
state.modelId = draft.id; state.modelQuery = "";
|
|
823
|
+
navigate("profiles", "provider", context.providerId); break;
|
|
824
|
+
}
|
|
825
|
+
case "defaults": await save("settings.defaults", { value: dialogs.numericDraft(form) }, t("默认参数已保存", "Default parameters saved")); break;
|
|
826
|
+
case "import": await submitImport(); break;
|
|
827
|
+
case "ambiguities": {
|
|
828
|
+
const candidateIndices = Array.from({ length: context.count }, (_, index) => Number(form.elements["candidate-" + index].value));
|
|
829
|
+
const retry = async () => showImportResult(await api(context.action, { ...context.payload, candidateIndices, overwriteCredential: true }));
|
|
830
|
+
const result = await api(context.action, { ...context.payload, candidateIndices });
|
|
831
|
+
if (await confirmCredentialOverwrite(result, retry)) break;
|
|
832
|
+
showImportResult(result); break;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
function moveList(direction) {
|
|
839
|
+
const active = document.activeElement;
|
|
840
|
+
if (active.closest(".tree-panel,.message-reader,.reading-view") && !active.matches(".message-node")) return false;
|
|
841
|
+
const selector = active.matches(".message-node") ? ".message-node" : active.closest(".session-list") ? ".session-option" : active.closest(".model-row") ? ".model-row" : state.page === "profiles" ? ".provider-option" : state.page === "sessions" ? ".session-option" : null;
|
|
842
|
+
if (!selector) return false;
|
|
843
|
+
const list = [...document.querySelectorAll(selector)];
|
|
844
|
+
if (!list.length) return false;
|
|
845
|
+
const providerOption = active.closest(".provider-row")?.querySelector(".provider-option");
|
|
846
|
+
const current = list.findIndex((item) => item === active || item.contains(active) || item === providerOption);
|
|
847
|
+
const next = list[Math.max(0, Math.min(list.length - 1, current + direction))];
|
|
848
|
+
next.focus();
|
|
849
|
+
if (selector === ".message-node") focusMessage(next.dataset.message);
|
|
850
|
+
if (selector === ".model-row") dispatch("select-model", next);
|
|
851
|
+
if (selector === ".provider-option") dispatch("select-provider", next);
|
|
852
|
+
if (selector === ".session-option") dispatch("select-session", next);
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function treeDirection(direction) {
|
|
857
|
+
const current = state.preview && sessionTree(state.preview).nodes.get(state.messageId);
|
|
858
|
+
if (!current) return;
|
|
859
|
+
const collapsed = state.folded.has(current.id);
|
|
860
|
+
if (canFoldBranch(current) && (direction > 0 ? collapsed : !collapsed)) {
|
|
861
|
+
toggleMessageBranch(current.id, true);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const next = direction < 0 ? current.parentId : current.children[0];
|
|
865
|
+
if (next) focusMessage(next);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
document.addEventListener("keydown", (event) => {
|
|
869
|
+
if (event.defaultPrevented || event.isComposing || !state.snapshot) return;
|
|
870
|
+
const searchScope = SEARCH_SCOPES.get(event.target.id);
|
|
871
|
+
if (!dialog.open && event.key === "Escape" && searchScope) {
|
|
872
|
+
event.preventDefault();
|
|
873
|
+
setSearch(searchScope, false);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (dialog.open || event.target.closest("input,textarea,select,[contenteditable=true]")) return;
|
|
877
|
+
const orderRow = event.target.closest("[data-order-item]");
|
|
878
|
+
if (event.altKey && !event.ctrlKey && !event.metaKey && ["ArrowUp", "ArrowDown"].includes(event.key) && orderRow && !state.busy) {
|
|
879
|
+
const { orderScope: scope, orderProvider: provider, orderId: item } = orderRow.dataset;
|
|
880
|
+
if (profileCollection(scope, provider).ordering.sort === "custom") {
|
|
881
|
+
event.preventDefault();
|
|
882
|
+
dispatch("move-profile", { dataset: { scope, provider, item, direction: event.key === "ArrowUp" ? "-1" : "1" } });
|
|
883
|
+
}
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (state.navOpen && mobileViewport.matches) {
|
|
887
|
+
if (event.key === "Escape") { event.preventDefault(); dispatch("close-nav"); return; }
|
|
888
|
+
if (event.key === "Tab") {
|
|
889
|
+
const links = [...document.querySelectorAll("#sidebar a")];
|
|
890
|
+
const edge = event.shiftKey ? links[0] : links.at(-1);
|
|
891
|
+
if (document.activeElement === edge) {
|
|
892
|
+
event.preventDefault();
|
|
893
|
+
(event.shiftKey ? links.at(-1) : links[0]).focus();
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const messageContext = event.target.closest(".message-node,.message-reader");
|
|
899
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c" && messageContext && !window.getSelection().toString()) {
|
|
900
|
+
event.preventDefault(); dispatch("copy-message", { dataset: { message: messageContext.dataset.message ?? messageContext.dataset.messageContext } }); return;
|
|
901
|
+
}
|
|
902
|
+
if (event.ctrlKey || event.metaKey || event.altKey || state.busy) return;
|
|
903
|
+
const key = event.key;
|
|
904
|
+
if (["1", "2", "3", "4"].includes(key)) { event.preventDefault(); navigate(Object.keys(pages)[Number(key) - 1]); return; }
|
|
905
|
+
if (key === "?") { event.preventDefault(); dispatch("help"); return; }
|
|
906
|
+
if (key === "/") {
|
|
907
|
+
event.preventDefault();
|
|
908
|
+
if (state.page === "sessions") setSearch("session", true);
|
|
909
|
+
else if (state.page === "profiles") setSearch(event.target.closest(".models-panel") ? "model" : "provider", true);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (event.target.closest(".order-handle")) return;
|
|
913
|
+
if (["ArrowDown", "ArrowUp", "j", "k"].includes(key)) {
|
|
914
|
+
if (moveList(key === "ArrowDown" || key === "j" ? 1 : -1)) event.preventDefault();
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (["ArrowLeft", "ArrowRight", "h", "l"].includes(key) && ["profiles", "sessions"].includes(state.page)) {
|
|
918
|
+
if (event.target.closest(".tree-panel,.message-reader,.reading-view") && !event.target.matches(".message-node")) return;
|
|
919
|
+
event.preventDefault();
|
|
920
|
+
const direction = key === "ArrowRight" || key === "l" ? 1 : -1;
|
|
921
|
+
if (event.target.matches(".message-node")) treeDirection(direction);
|
|
922
|
+
else if (state.page === "profiles") document.querySelector(direction > 0 ? ".model-row" : '.provider-option[aria-current="true"]')?.focus();
|
|
923
|
+
else if (state.page === "sessions") {
|
|
924
|
+
if (direction > 0) revealMessage(true);
|
|
925
|
+
else document.querySelector('.session-option[aria-current="true"]')?.focus();
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (key === "Escape") {
|
|
930
|
+
state.navOpen = false; render(); document.querySelector(state.page === "profiles" ? '.provider-option[aria-current="true"]' : '.session-option[aria-current="true"]')?.focus(); return;
|
|
931
|
+
}
|
|
932
|
+
if (key === "r") { event.preventDefault(); dispatch(state.page === "sessions" ? "refresh-sessions" : "reload"); return; }
|
|
933
|
+
if (key === "b") { event.preventDefault(); dispatch("backups"); return; }
|
|
934
|
+
if (key === "v") {
|
|
935
|
+
event.preventDefault();
|
|
936
|
+
if (state.page === "sessions") setPreviewMode(state.previewMode === "tree" ? "reading" : "tree");
|
|
937
|
+
else dispatch("doctor");
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (state.page === "sessions") {
|
|
941
|
+
if (["Home", "End"].includes(key) && event.target.matches(".message-node")) {
|
|
942
|
+
event.preventDefault();
|
|
943
|
+
const nodes = visibleTreeNodes(state.preview, state.folded);
|
|
944
|
+
const next = key === "Home" ? nodes[0] : nodes.at(-1);
|
|
945
|
+
if (next) focusMessage(next.id);
|
|
946
|
+
}
|
|
947
|
+
if (key === "n") { event.preventDefault(); state.namedOnly = !state.namedOnly; render(); }
|
|
948
|
+
if (key === "u") { event.preventDefault(); state.userOnly = !state.userOnly; void loadPreview(); }
|
|
949
|
+
if (key === "d" || key === "Delete") { event.preventDefault(); dispatch("delete-session", event.target.closest(".session-option")); }
|
|
950
|
+
if (key === " " && event.target.matches(".message-node")) { event.preventDefault(); dispatch("toggle-branch"); }
|
|
951
|
+
if (key === "Enter" && event.target.matches(".message-node")) { event.preventDefault(); dispatch("select-message", event.target); }
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (state.page !== "profiles") return;
|
|
955
|
+
const inModels = Boolean(event.target.closest(".model-row"));
|
|
956
|
+
const actions = { n: inModels ? "new-model" : "new-provider", e: inModels ? "edit-model" : "edit-provider", c: inModels ? "duplicate-model" : "duplicate-provider", d: inModels ? "remove-model" : "remove-provider", Delete: inModels ? "remove-model" : "remove-provider", i: "import-models" };
|
|
957
|
+
if (actions[key]) { event.preventDefault(); dispatch(actions[key], inModels ? event.target.closest(".model-row") : event.target.closest(".provider-row")?.querySelector(".provider-option")); return; }
|
|
958
|
+
if (key === " " && (event.target.matches(".model-row") || event.target.matches('.provider-option[aria-current="true"]'))) {
|
|
959
|
+
event.preventDefault();
|
|
960
|
+
if (inModels) dispatch("default-model", event.target.closest(".model-row"));
|
|
961
|
+
else dispatch("sync-provider", event.target);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
function focusSelector(element) {
|
|
966
|
+
if (!element) return null;
|
|
967
|
+
if (element.id) return "#" + CSS.escape(element.id);
|
|
968
|
+
if (element.matches(".message-node")) return '.message-node[data-message="' + CSS.escape(element.dataset.message) + '"]';
|
|
969
|
+
if (element.matches(".model-row")) return '.model-row[data-model="' + CSS.escape(element.dataset.model) + '"]';
|
|
970
|
+
if (element.dataset.action || element.hasAttribute("data-order-handle")) {
|
|
971
|
+
let selector = element.dataset.action ? '[data-action="' + CSS.escape(element.dataset.action) + '"]' : "[data-order-handle]";
|
|
972
|
+
for (const key of ["provider", "model", "session", "message", "value", "mode", "scope", "item", "view"]) {
|
|
973
|
+
if (element.dataset[key]) selector += '[data-' + key + '="' + CSS.escape(element.dataset[key]) + '"]';
|
|
974
|
+
}
|
|
975
|
+
return selector;
|
|
976
|
+
}
|
|
977
|
+
if (element.matches("a[href]")) return 'a[href="' + CSS.escape(element.getAttribute("href")) + '"]';
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
mobileViewport.addEventListener("change", () => { state.navOpen = false; render(); });
|
|
982
|
+
window.addEventListener("hashchange", route);
|
|
983
|
+
route();
|
|
984
|
+
try {
|
|
985
|
+
applySnapshot(await api("snapshot"));
|
|
986
|
+
render();
|
|
987
|
+
void loadSessions();
|
|
988
|
+
} catch (error) {
|
|
989
|
+
app.innerHTML = '<div class="boot-state">' + emptyState(t("无法读取本地配置", "Could not load configuration"), error.message, "warning", '<button class="btn primary" data-action="reload">' + icon("refresh") + t("重新连接", "Reconnect") + "</button>") + "</div>";
|
|
990
|
+
}
|