@oversk7/pi-web-dsh-style 1.0.0 → 1.0.1
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 +39 -3
- package/frp-process.ts +135 -0
- package/index.ts +35 -3
- package/local-session.ts +10 -2
- package/network-access.ts +171 -0
- package/network-settings.ts +146 -0
- package/package.json +5 -2
- package/server.ts +204 -32
- package/web/app.css +79 -2
- package/web/app.js +437 -44
- package/web/index.html +1 -1
package/web/app.js
CHANGED
|
@@ -52,6 +52,10 @@ const S = {
|
|
|
52
52
|
maintenanceMessage: null,
|
|
53
53
|
maintenanceGeneration: 0,
|
|
54
54
|
serverInstanceId: null,
|
|
55
|
+
localClient: true,
|
|
56
|
+
lanEnabled: false,
|
|
57
|
+
lanUrls: [],
|
|
58
|
+
passwordConfigured: false,
|
|
55
59
|
theme: localStorage.getItem("pi-web-theme") || "system",
|
|
56
60
|
};
|
|
57
61
|
|
|
@@ -69,10 +73,15 @@ function el(html) {
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
async function api(path, options = {}) {
|
|
72
|
-
const
|
|
76
|
+
const request = () => fetch(path, {
|
|
73
77
|
headers: { "content-type": "application/json", ...(options.headers || {}) },
|
|
74
78
|
...options,
|
|
75
79
|
});
|
|
80
|
+
let res = await request();
|
|
81
|
+
if (res.status === 401 && path !== "/api/auth") {
|
|
82
|
+
await requireLogin();
|
|
83
|
+
res = await request();
|
|
84
|
+
}
|
|
76
85
|
const data = await res.json().catch(() => ({}));
|
|
77
86
|
if (!res.ok) {
|
|
78
87
|
const error = new Error(data.error || `${res.status} ${res.statusText}`);
|
|
@@ -130,8 +139,8 @@ function toggleTheme() {
|
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
const ICONS = {
|
|
133
|
-
logo: `<svg width="30" height="30" viewBox="0 0 50 50" fill="none"><rect width="50" height="50" rx="12" fill="var(--dsw-alias-brand-primary, #3964fe)"/><text x="25" y="34" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="#fff">π</text></svg>`,
|
|
134
|
-
wordmark: `<span class="pi-brandLogo"><svg width="30" height="30" viewBox="0 0 50 50" fill="none" aria-hidden="true"><rect width="50" height="50" rx="12" fill="var(--dsw-alias-brand-primary, #3964fe)"/><text x="25" y="34" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="#fff">π</text></svg><span class="pi-brandName">Pi</span></span>`,
|
|
142
|
+
logo: `<svg width="30" height="30" viewBox="0 0 50 50" fill="none"><rect width="50" height="50" rx="12" fill="var(--dsw-alias-brand-primary, #3964fe)"/><text x="25" y="34" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="var(--dsw-alias-bg-base, #fff)">π</text></svg>`,
|
|
143
|
+
wordmark: `<span class="pi-brandLogo"><svg width="30" height="30" viewBox="0 0 50 50" fill="none" aria-hidden="true"><rect width="50" height="50" rx="12" fill="var(--dsw-alias-brand-primary, #3964fe)"/><text x="25" y="34" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="var(--dsw-alias-bg-base, #fff)">π</text></svg><span class="pi-brandName">Pi</span></span>`,
|
|
135
144
|
panel: `<svg class="hHd-Xa_panelIcon" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-9Z" stroke="currentColor" stroke-width="1.4"/><path d="M6 2v12" stroke="currentColor" stroke-width="1.4"/></svg>`,
|
|
136
145
|
plus: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1.5V14.5M1.5 8H14.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
|
137
146
|
search: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="5.4" stroke="currentColor" stroke-width="1.4"/><path d="M11 11L15 15" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
|
|
@@ -316,6 +325,22 @@ function usesMobileSidebar() {
|
|
|
316
325
|
return matchMedia("(max-width: 640px)").matches;
|
|
317
326
|
}
|
|
318
327
|
|
|
328
|
+
function usesTouchInput() {
|
|
329
|
+
return matchMedia("(pointer: coarse)").matches;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function updateViewport() {
|
|
333
|
+
const viewport = window.visualViewport;
|
|
334
|
+
if (viewport && viewport.scale !== 1) return;
|
|
335
|
+
document.documentElement.style.setProperty("--pi-viewport-height", `${viewport?.height || window.innerHeight}px`);
|
|
336
|
+
document.documentElement.style.setProperty("--pi-viewport-top", `${viewport?.offsetTop || 0}px`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function closeMobileSidebar() {
|
|
340
|
+
S.mobileSidebarOpen = false;
|
|
341
|
+
applyFrameLayout();
|
|
342
|
+
}
|
|
343
|
+
|
|
319
344
|
function currentPanelWidths() {
|
|
320
345
|
const frame = $(".pI_x6G_frame");
|
|
321
346
|
const frameWidth = frame?.clientWidth || window.innerWidth;
|
|
@@ -336,6 +361,8 @@ function applyFrameLayout() {
|
|
|
336
361
|
const { sidebarWidth, previewWidth } = currentPanelWidths();
|
|
337
362
|
frame.dataset.sidebarCollapsed = String(mobileSidebar || S.sidebarCollapsed);
|
|
338
363
|
frame.toggleAttribute("data-mobile-sidebar-open", mobileSidebar && S.mobileSidebarOpen);
|
|
364
|
+
const sidebar = $(".pI_x6G_sidebarCol", frame);
|
|
365
|
+
if (sidebar) sidebar.inert = mobileSidebar && !S.mobileSidebarOpen;
|
|
339
366
|
frame.toggleAttribute("data-details-collapsed", !S.filePreview);
|
|
340
367
|
frame.style.setProperty("--pi-sidebar-width", `${sidebarWidth}px`);
|
|
341
368
|
frame.style.setProperty("--pi-details-width", `${previewWidth}px`);
|
|
@@ -714,7 +741,7 @@ function renderComposerBar(hero = false) {
|
|
|
714
741
|
<div class="uV2eYG_row">
|
|
715
742
|
<div class="uV2eYG_tools">
|
|
716
743
|
<button type="button" class="uV2eYG_add" aria-label="命令" title="命令" data-action="commands" ${disabled ? "disabled" : ""}>${ICONS.plus}</button>
|
|
717
|
-
<button type="button" class="uV2eYG_add pi-imagePaste" aria-label="粘贴剪贴板图片" title="${modelSupportsImages(model) ? "粘贴剪贴板图片(Alt+V)" : "当前模型不支持图片输入"}" data-action="paste-image" ${disabled || S.clipboardBusy || !modelSupportsImages(model) ? "disabled" : ""}>${ICONS.image}</button>
|
|
744
|
+
<button type="button" class="uV2eYG_add pi-imagePaste" aria-label="${S.localClient ? "粘贴剪贴板图片" : "选择图片"}" title="${modelSupportsImages(model) ? (S.localClient ? "粘贴剪贴板图片(Alt+V)" : "从手机选择图片") : "当前模型不支持图片输入"}" data-action="paste-image" ${disabled || S.clipboardBusy || !modelSupportsImages(model) ? "disabled" : ""}>${ICONS.image}</button>
|
|
718
745
|
</div>
|
|
719
746
|
<div class="uV2eYG_trailing">
|
|
720
747
|
<div class="_7KE1Ra_root pi-modelControl">
|
|
@@ -764,8 +791,7 @@ function renderStats(snap) {
|
|
|
764
791
|
const add = (text, className = "") => {
|
|
765
792
|
if (text !== "" && text != null) parts.push({ text: String(text), className });
|
|
766
793
|
};
|
|
767
|
-
if (
|
|
768
|
-
if (stats.toolCalls != null) add(`工具 ${stats.toolCalls}`);
|
|
794
|
+
if (cu.tokens != null) add(`上下文 ${fmtNum(cu.tokens)}/${fmtNum(cu.contextWindow ?? 0)} (${Math.round(cu.percent ?? 0)}%)`, "FJxK0a_context");
|
|
769
795
|
const pendingMessages = Number(snap.state?.pendingMessageCount ?? 0);
|
|
770
796
|
if (pendingMessages > 0) add(`待处理 ${pendingMessages}`, "FJxK0a_statusWarning");
|
|
771
797
|
if (tokens.input != null || tokens.output != null) add(`输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} tok`);
|
|
@@ -773,8 +799,7 @@ function renderStats(snap) {
|
|
|
773
799
|
if (Number(tokens.cacheRead) > 0) cacheParts.push(`缓存读 ${fmtNum(tokens.cacheRead)}`);
|
|
774
800
|
if (Number(tokens.cacheWrite) > 0) cacheParts.push(`写 ${fmtNum(tokens.cacheWrite)}`);
|
|
775
801
|
if (cacheParts.length) add(cacheParts.join(" · "));
|
|
776
|
-
if (
|
|
777
|
-
if (typeof stats.cost === "number") add(`$${stats.cost.toFixed(3)}`);
|
|
802
|
+
if (typeof stats.cost === "number") add(formatCost(stats.cost));
|
|
778
803
|
for (const [, text] of Object.entries(snap.extensionStatuses || {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
779
804
|
add(text, extensionStatusClass(text));
|
|
780
805
|
}
|
|
@@ -782,6 +807,11 @@ function renderStats(snap) {
|
|
|
782
807
|
return `<div class="FJxK0a_root" role="status">${parts.map((part) => `<span class="${part.className}" title="${esc(part.text)}">${esc(part.text)}</span>`).join('<span class="FJxK0a_sep" aria-hidden="true">|</span>')}</div>`;
|
|
783
808
|
}
|
|
784
809
|
|
|
810
|
+
function formatCost(cost) {
|
|
811
|
+
if (cost > 0 && cost < 0.000001) return "<$0.000001";
|
|
812
|
+
return `$${cost.toFixed(cost > 0 && cost < 0.001 ? 6 : 3)}`;
|
|
813
|
+
}
|
|
814
|
+
|
|
785
815
|
function fmtNum(n) {
|
|
786
816
|
if (!Number.isFinite(Number(n))) return String(n ?? 0);
|
|
787
817
|
const v = Number(n);
|
|
@@ -799,9 +829,6 @@ function renderHeader() {
|
|
|
799
829
|
<div class="wSkVaW_titleRow"><div class="wSkVaW_titleCluster">
|
|
800
830
|
<nav class="wSkVaW_crumbs" aria-label="会话层级"><span class="wSkVaW_crumbSeg"><button type="button" class="wSkVaW_crumb wSkVaW_crumbCurrent" disabled>${esc(session.title || "新会话")}</button></span></nav>
|
|
801
831
|
</div></div>
|
|
802
|
-
<div class="wSkVaW_headerUtilities">
|
|
803
|
-
<button type="button" class="nL4_yW_sessionLogButton" data-action="export-current"><span>导出记录</span></button>
|
|
804
|
-
</div>
|
|
805
832
|
<div class="wSkVaW_tabs" role="tablist">
|
|
806
833
|
<button type="button" role="tab" aria-selected="${!historyActive}" class="wSkVaW_tab ${historyActive ? "" : "wSkVaW_tabActive"}" data-action="conversation-tab">对话</button>
|
|
807
834
|
<button type="button" role="tab" aria-selected="${historyActive}" class="wSkVaW_tab ${historyActive ? "wSkVaW_tabActive" : ""}" data-action="history-current">会话历史</button>
|
|
@@ -2186,7 +2213,7 @@ function renderHistoryView(view) {
|
|
|
2186
2213
|
const errorBanner = dialog.error && nodes.length > 0 ? `<div class="pi-historyErrorBanner" role="alert">${esc(dialog.error)}</div>` : "";
|
|
2187
2214
|
const busyText = dialog.busyRestoreMode === "all" ? "正在回溯对话和文件…" : "正在切换会话分支…";
|
|
2188
2215
|
return `<section class="pi-historyView" role="tabpanel" aria-label="会话历史">
|
|
2189
|
-
<div class="pi-historyHeader"><div><div class="pi-historyTitle">会话历史</div><div class="pi-historySubtitle">${nodes.filter((node) => node.rewindable).length} 条用户消息 · ${nodes.filter((node) => !node.active).length} 个历史分支节点</div></div>${returnButton}
|
|
2216
|
+
<div class="pi-historyHeader"><div><div class="pi-historyTitle">会话历史</div><div class="pi-historySubtitle">${nodes.filter((node) => node.rewindable).length} 条用户消息 · ${nodes.filter((node) => !node.active).length} 个历史分支节点</div></div><div class="pi-historyActions">${returnButton}<button type="button" class="nL4_yW_sessionLogButton" data-action="export-current"><span>导出记录</span></button></div></div>
|
|
2190
2217
|
<div class="pi-historyTabs" role="tablist">
|
|
2191
2218
|
<button type="button" role="tab" aria-selected="${dialog.mode === "rewind"}" class="pi-historyTab ${dialog.mode === "rewind" ? "pi-historyTabActive" : ""}" data-action="history-mode" data-mode="rewind">回溯</button>
|
|
2192
2219
|
<button type="button" role="tab" aria-selected="${dialog.mode === "tree"}" class="pi-historyTab ${dialog.mode === "tree" ? "pi-historyTabActive" : ""}" data-action="history-mode" data-mode="tree">会话树</button>
|
|
@@ -2260,6 +2287,148 @@ async function navigateHistory(entryId, role, restoreMode) {
|
|
|
2260
2287
|
}
|
|
2261
2288
|
}
|
|
2262
2289
|
|
|
2290
|
+
const networkSettings = { data: null, form: null, dirty: false, saving: false, loading: false, error: "", request: 0 };
|
|
2291
|
+
|
|
2292
|
+
function networkForm() {
|
|
2293
|
+
if (networkSettings.form) return networkSettings.form;
|
|
2294
|
+
const form = el(`<form id="webNetworkForm" class="pi-networkForm">
|
|
2295
|
+
<label>访问方式<select name="mode" class="pi-passwordInput"><option value="local">仅本机</option><option value="lan">局域网</option><option value="relay">服务器中转</option></select></label>
|
|
2296
|
+
<div class="pi-networkRelay" hidden>
|
|
2297
|
+
<label>服务器地址<input name="serverAddr" class="pi-passwordInput" placeholder="服务器 IP 或域名" autocomplete="off"></label>
|
|
2298
|
+
<label>frps 端口<input name="serverPort" class="pi-passwordInput" type="number" min="1" max="65535" value="7000"></label>
|
|
2299
|
+
<label>认证密钥<input name="token" class="pi-passwordInput" type="password" autocomplete="new-password" placeholder="输入认证密钥"></label>
|
|
2300
|
+
<label>HTTPS 手机入口<input name="origin" class="pi-passwordInput" type="url" placeholder="https://pi.example.com" autocomplete="off"></label>
|
|
2301
|
+
<label>frpc 原生路径<input name="frpcPath" class="pi-passwordInput" placeholder="frpc" autocomplete="off"></label>
|
|
2302
|
+
<p>首次使用请<a href="https://github.com/fatedier/frp/releases" target="_blank" rel="noopener noreferrer">下载原生 frpc</a>。路径为空时使用 PATH 中的 frpc,扩展会托管该进程。</p>
|
|
2303
|
+
<details><summary>高级设置</summary><div class="pi-networkAdvanced">
|
|
2304
|
+
<label>隧道名<input name="proxyName" class="pi-passwordInput" autocomplete="off" placeholder="使用默认名称"></label>
|
|
2305
|
+
<label>服务器内部端口<input name="visitorPort" class="pi-passwordInput" type="number" min="1" max="65535" value="13000"></label>
|
|
2306
|
+
</div></details>
|
|
2307
|
+
</div>
|
|
2308
|
+
<p class="pi-networkHint">选择模式后保存生效。</p>
|
|
2309
|
+
<button type="submit" class="pi-settingsAction pi-settingsActionPrimary">保存设置</button>
|
|
2310
|
+
<div class="pi-networkStatus pi-settingsStatus" role="status"></div>
|
|
2311
|
+
<div class="pi-networkUrls"></div>
|
|
2312
|
+
<div class="pi-networkDownloads" hidden><p>下载已保存的服务器配置,证书路径请按服务器修改。</p><div class="pi-networkDownloadButtons">${["frps.toml", "visitor.toml", "nginx.conf"].map((name) => `<button type="button" class="pi-settingsAction" data-network-file="${name}">下载 ${name}</button>`).join("")}</div></div>
|
|
2313
|
+
</form>`);
|
|
2314
|
+
networkSettings.form = form;
|
|
2315
|
+
form.oninput = form.onchange = () => {
|
|
2316
|
+
networkSettings.dirty = true;
|
|
2317
|
+
updateNetworkForm();
|
|
2318
|
+
};
|
|
2319
|
+
form.onsubmit = saveNetworkSettings;
|
|
2320
|
+
form.querySelectorAll("[data-network-file]").forEach((button) => {
|
|
2321
|
+
button.onclick = async () => {
|
|
2322
|
+
if (button.disabled || !S.localClient) return;
|
|
2323
|
+
button.disabled = true;
|
|
2324
|
+
try {
|
|
2325
|
+
const { files } = await api("/api/network/server-files");
|
|
2326
|
+
const name = button.dataset.networkFile;
|
|
2327
|
+
if (typeof files?.[name] !== "string") throw new Error("服务器未返回此配置文件");
|
|
2328
|
+
const url = URL.createObjectURL(new Blob([files[name]], { type: "text/plain;charset=utf-8" }));
|
|
2329
|
+
const link = document.createElement("a");
|
|
2330
|
+
link.href = url;
|
|
2331
|
+
link.download = name;
|
|
2332
|
+
link.click();
|
|
2333
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
2334
|
+
} catch (error) {
|
|
2335
|
+
networkSettings.error = error.message || String(error);
|
|
2336
|
+
} finally {
|
|
2337
|
+
button.disabled = false;
|
|
2338
|
+
updateNetworkForm();
|
|
2339
|
+
}
|
|
2340
|
+
};
|
|
2341
|
+
});
|
|
2342
|
+
return form;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function updateNetworkForm(sync = false) {
|
|
2346
|
+
if (!S.localClient) return;
|
|
2347
|
+
const form = networkForm();
|
|
2348
|
+
const state = networkSettings;
|
|
2349
|
+
if (sync && state.data && !state.dirty && !state.saving) {
|
|
2350
|
+
const { settings } = state.data;
|
|
2351
|
+
const relay = settings.relay || {};
|
|
2352
|
+
const values = { mode: settings.mode, serverAddr: relay.serverAddr || "", serverPort: relay.serverPort || 7000, token: "", origin: relay.origin || "", frpcPath: relay.frpcPath || "", proxyName: relay.proxyName || "", visitorPort: relay.visitorPort || 13000 };
|
|
2353
|
+
for (const [name, value] of Object.entries(values)) form.elements.namedItem(name).value = value;
|
|
2354
|
+
}
|
|
2355
|
+
const relayMode = form.elements.namedItem("mode").value === "relay";
|
|
2356
|
+
$(".pi-networkRelay", form).hidden = !relayMode;
|
|
2357
|
+
form.querySelectorAll("input[name],select[name]").forEach((input) => {
|
|
2358
|
+
input.disabled = state.saving || !state.data || (input.tagName === "INPUT" && !relayMode);
|
|
2359
|
+
});
|
|
2360
|
+
for (const name of ["serverAddr", "serverPort", "origin"]) form.elements.namedItem(name).required = relayMode;
|
|
2361
|
+
form.elements.namedItem("token").placeholder = state.data?.settings.relay?.tokenConfigured ? "已设置,留空保留现有密钥" : "输入认证密钥";
|
|
2362
|
+
const submit = $('button[type="submit"]', form);
|
|
2363
|
+
submit.disabled = state.saving || !state.data || Boolean(state.data.applying);
|
|
2364
|
+
submit.textContent = state.saving ? "保存中…" : state.data?.applying ? "正在应用…" : relayMode ? "保存并连接" : "保存设置";
|
|
2365
|
+
const status = $(".pi-networkStatus", form);
|
|
2366
|
+
const labels = { stopped: "未连接", connecting: "连接中", connected: "已连接", error: "连接失败" };
|
|
2367
|
+
status.textContent = state.error || (state.loading && !state.data ? "正在加载网络设置…" : state.data ? state.data.applying ? "正在应用配置…" : state.data.status.message || labels[state.data.status.state] : "正在加载网络设置…");
|
|
2368
|
+
status.classList.toggle("pi-settingsStatus-error", Boolean(state.error || state.data?.status.state === "error"));
|
|
2369
|
+
const urls = $(".pi-networkUrls", form);
|
|
2370
|
+
const urlsKey = JSON.stringify(state.data?.urls || []);
|
|
2371
|
+
if (urls.dataset.urls !== urlsKey) {
|
|
2372
|
+
urls.dataset.urls = urlsKey;
|
|
2373
|
+
urls.innerHTML = (state.data?.urls || []).map((url) => `<div class="pi-settingsActionRow"><input class="pi-lanUrl" aria-label="手机访问链接" readonly value="${esc(url)}"><button type="button" class="pi-settingsAction" data-action="copy-text" data-text="${esc(url)}">复制链接</button></div>`).join("");
|
|
2374
|
+
}
|
|
2375
|
+
$(".pi-networkDownloads", form).hidden = state.data?.settings.mode !== "relay";
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
async function loadNetworkSettings() {
|
|
2379
|
+
if (!S.localClient || networkSettings.saving) return;
|
|
2380
|
+
const state = networkSettings;
|
|
2381
|
+
const request = ++state.request;
|
|
2382
|
+
state.loading = true;
|
|
2383
|
+
updateNetworkForm();
|
|
2384
|
+
try {
|
|
2385
|
+
const data = await api("/api/network");
|
|
2386
|
+
if (request !== state.request) return;
|
|
2387
|
+
state.data = data;
|
|
2388
|
+
state.error = "";
|
|
2389
|
+
updateNetworkForm(true);
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
if (request !== state.request) return;
|
|
2392
|
+
state.error = error.status === 404 ? "重新加载运行时以启用网络设置。" : `加载网络设置失败:${error.message || String(error)}。重新打开设置可重试。`;
|
|
2393
|
+
} finally {
|
|
2394
|
+
if (request === state.request) {
|
|
2395
|
+
state.loading = false;
|
|
2396
|
+
updateNetworkForm();
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
async function saveNetworkSettings(event) {
|
|
2402
|
+
event.preventDefault();
|
|
2403
|
+
const state = networkSettings;
|
|
2404
|
+
if (!S.localClient || state.saving || !state.data || state.data.applying) return;
|
|
2405
|
+
const form = event.currentTarget;
|
|
2406
|
+
const value = (name) => form.elements.namedItem(name).value;
|
|
2407
|
+
const mode = value("mode");
|
|
2408
|
+
const body = { mode };
|
|
2409
|
+
if (mode === "relay") body.relay = {
|
|
2410
|
+
serverAddr: value("serverAddr").trim(), serverPort: Number(value("serverPort")), token: value("token"),
|
|
2411
|
+
origin: value("origin").trim(), frpcPath: value("frpcPath").trim() || "frpc",
|
|
2412
|
+
proxyName: value("proxyName").trim() || undefined, visitorPort: Number(value("visitorPort") || 13000),
|
|
2413
|
+
};
|
|
2414
|
+
++state.request;
|
|
2415
|
+
state.dirty = true;
|
|
2416
|
+
state.saving = true;
|
|
2417
|
+
state.error = "";
|
|
2418
|
+
updateNetworkForm();
|
|
2419
|
+
try {
|
|
2420
|
+
state.data = await post("/api/network", body);
|
|
2421
|
+
state.dirty = false;
|
|
2422
|
+
form.elements.namedItem("token").value = "";
|
|
2423
|
+
} catch (error) {
|
|
2424
|
+
state.error = `保存失败:${error.message || String(error)}`;
|
|
2425
|
+
} finally {
|
|
2426
|
+
state.saving = false;
|
|
2427
|
+
updateNetworkForm(!state.dirty);
|
|
2428
|
+
if (!state.dirty) void loadNetworkSettings();
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2263
2432
|
function renderSettings() {
|
|
2264
2433
|
const busy = Boolean(S.maintenanceBusy);
|
|
2265
2434
|
const themes = [
|
|
@@ -2282,6 +2451,15 @@ function renderSettings() {
|
|
|
2282
2451
|
${themes.map(([value, label]) => `<button type="button" data-action="choose-theme" data-theme="${value}" aria-pressed="${S.theme === value}" ${busy ? "disabled" : ""}>${label}</button>`).join("")}
|
|
2283
2452
|
</div>
|
|
2284
2453
|
</section>
|
|
2454
|
+
<section class="pi-settingsSection" aria-labelledby="pi-settings-network">
|
|
2455
|
+
<h3 id="pi-settings-network">手机访问</h3>
|
|
2456
|
+
${S.localClient ? '<div id="webNetworkMount"></div>' : '<p>已连接远程服务</p>'}
|
|
2457
|
+
${S.localClient ? `<form id="webPasswordForm" class="pi-passwordForm">
|
|
2458
|
+
<label for="webPassword">${S.passwordConfigured ? "更换访问密码" : "设置访问密码"}</label>
|
|
2459
|
+
<div class="pi-settingsActionRow"><input id="webPassword" class="pi-passwordInput" type="password" name="password" autocomplete="new-password" maxlength="256" required placeholder="${S.passwordConfigured ? "输入新密码" : "输入固定密码"}"><button type="submit" class="pi-settingsAction">保存密码</button></div>
|
|
2460
|
+
<p class="pi-passwordStatus" role="status">${S.passwordConfigured ? "已设置密码,修改后手机需重新登录。" : "设置后,手机可用此密码登录。"}</p>
|
|
2461
|
+
</form>` : ""}
|
|
2462
|
+
</section>
|
|
2285
2463
|
<section class="pi-settingsSection" aria-labelledby="pi-settings-maintenance">
|
|
2286
2464
|
<h3 id="pi-settings-maintenance">Pi 维护</h3>
|
|
2287
2465
|
<div class="pi-settingsActionRow">
|
|
@@ -2302,12 +2480,56 @@ function renderOverlay() {
|
|
|
2302
2480
|
const overlay = $("#overlayMount");
|
|
2303
2481
|
if (!overlay) return;
|
|
2304
2482
|
if (S.settingsOpen) {
|
|
2483
|
+
const passwordForm = $("#webPasswordForm", overlay);
|
|
2484
|
+
const focused = overlay.contains(document.activeElement) ? document.activeElement : null;
|
|
2485
|
+
const scrollTop = $(".pi-settingsPanel", overlay)?.scrollTop || 0;
|
|
2486
|
+
passwordForm?.remove();
|
|
2487
|
+
networkSettings.form?.remove();
|
|
2305
2488
|
overlay.innerHTML = renderSettings();
|
|
2489
|
+
const nextForm = $("#webPasswordForm", overlay);
|
|
2490
|
+
if (passwordForm && nextForm) {
|
|
2491
|
+
$("label", passwordForm).textContent = $("label", nextForm).textContent;
|
|
2492
|
+
$(".pi-passwordStatus", passwordForm).textContent = $(".pi-passwordStatus", nextForm).textContent;
|
|
2493
|
+
$("#webPassword", passwordForm).placeholder = $("#webPassword", nextForm).placeholder;
|
|
2494
|
+
nextForm.replaceWith(passwordForm);
|
|
2495
|
+
}
|
|
2496
|
+
const form = $("#webPasswordForm", overlay);
|
|
2497
|
+
if (form) form.onsubmit = saveWebPassword;
|
|
2498
|
+
const networkMount = $("#webNetworkMount", overlay);
|
|
2499
|
+
if (networkMount) {
|
|
2500
|
+
networkMount.replaceWith(networkForm());
|
|
2501
|
+
updateNetworkForm();
|
|
2502
|
+
}
|
|
2503
|
+
if (focused?.isConnected) focused.focus({ preventScroll: true });
|
|
2504
|
+
$(".pi-settingsPanel", overlay).scrollTop = scrollTop;
|
|
2306
2505
|
return;
|
|
2307
2506
|
}
|
|
2308
2507
|
overlay.innerHTML = "";
|
|
2309
2508
|
}
|
|
2310
2509
|
|
|
2510
|
+
async function saveWebPassword(event) {
|
|
2511
|
+
event.preventDefault();
|
|
2512
|
+
const form = event.currentTarget;
|
|
2513
|
+
const input = $("#webPassword", form);
|
|
2514
|
+
const button = $("button", form);
|
|
2515
|
+
if (input.disabled) return;
|
|
2516
|
+
const password = input.value;
|
|
2517
|
+
input.disabled = true;
|
|
2518
|
+
button.disabled = true;
|
|
2519
|
+
try {
|
|
2520
|
+
await post("/api/auth/password", { password });
|
|
2521
|
+
input.value = "";
|
|
2522
|
+
S.passwordConfigured = true;
|
|
2523
|
+
renderOverlay();
|
|
2524
|
+
showToast("访问密码已保存");
|
|
2525
|
+
} catch (error) {
|
|
2526
|
+
showToast(error.message || String(error), "error");
|
|
2527
|
+
} finally {
|
|
2528
|
+
input.disabled = false;
|
|
2529
|
+
button.disabled = false;
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2311
2533
|
function syncComposerInput() {
|
|
2312
2534
|
const input = $("#composerInput");
|
|
2313
2535
|
if (!input) return;
|
|
@@ -2345,10 +2567,12 @@ function bindComposerInput() {
|
|
|
2345
2567
|
const hasImage = [...(event.clipboardData?.items || [])].some((item) => item.type.startsWith("image/"));
|
|
2346
2568
|
if (!hasImage) return;
|
|
2347
2569
|
event.preventDefault();
|
|
2348
|
-
void pasteSystemClipboard();
|
|
2570
|
+
if (S.localClient) void pasteSystemClipboard();
|
|
2571
|
+
else void uploadImages([...(event.clipboardData?.items || [])].filter((item) => item.type.startsWith("image/")).map((item) => item.getAsFile()).filter(Boolean));
|
|
2349
2572
|
});
|
|
2350
2573
|
input.addEventListener("keydown", (event) => {
|
|
2351
|
-
if (event.isComposing) return;
|
|
2574
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
2575
|
+
if (event.key === "Enter" && usesTouchInput() && !event.ctrlKey && !event.metaKey) return;
|
|
2352
2576
|
if (S.commandMenuOpen) {
|
|
2353
2577
|
const commands = commandSuggestions();
|
|
2354
2578
|
if ((event.key === "ArrowUp" || event.key === "ArrowDown") && commands.length > 0) {
|
|
@@ -2427,10 +2651,16 @@ async function loadBootstrap() {
|
|
|
2427
2651
|
const boot = await api("/api/bootstrap");
|
|
2428
2652
|
S.workspaces = applySavedWorkspaceOrder(boot.workspaces || []);
|
|
2429
2653
|
S.collapsed = new Set(S.workspaces.map((workspace) => workspace.id));
|
|
2430
|
-
|
|
2431
|
-
S.currentSessionId =
|
|
2654
|
+
const savedSessionId = sessionStorage.getItem("pi-web-current-session");
|
|
2655
|
+
S.currentSessionId = S.workspaces.some((workspace) => workspace.sessions?.some((session) => session.id === savedSessionId))
|
|
2656
|
+
? savedSessionId : boot.currentSessionId;
|
|
2432
2657
|
S.serverInstanceId = boot.instanceId || null;
|
|
2433
|
-
S.
|
|
2658
|
+
S.localClient = boot.localClient !== false;
|
|
2659
|
+
S.lanEnabled = boot.lanEnabled === true;
|
|
2660
|
+
S.lanUrls = boot.lanUrls || [];
|
|
2661
|
+
S.passwordConfigured = boot.passwordConfigured === true;
|
|
2662
|
+
S.currentWorkspaceId = S.workspaces.find((workspace) => workspace.sessions?.some((session) => session.id === S.currentSessionId))?.id
|
|
2663
|
+
|| boot.currentWorkspaceId || S.workspaces[0]?.id || null;
|
|
2434
2664
|
S.newSessionSnapshot = {
|
|
2435
2665
|
session: null,
|
|
2436
2666
|
state: boot.modelInfo?.state || null,
|
|
@@ -2444,10 +2674,12 @@ async function loadBootstrap() {
|
|
|
2444
2674
|
dialogs: [],
|
|
2445
2675
|
};
|
|
2446
2676
|
renderApp();
|
|
2447
|
-
if (S.currentSessionId) openSession(S.currentSessionId, { silent: true });
|
|
2677
|
+
if (S.currentSessionId) await openSession(S.currentSessionId, { silent: true });
|
|
2448
2678
|
}
|
|
2449
2679
|
|
|
2450
2680
|
async function openSession(id, opts = {}) {
|
|
2681
|
+
closeMobileSidebar();
|
|
2682
|
+
sessionStorage.setItem("pi-web-current-session", id);
|
|
2451
2683
|
closeCommandMenu();
|
|
2452
2684
|
++S.historyGeneration;
|
|
2453
2685
|
S.conversationTab = "conversation";
|
|
@@ -2536,6 +2768,7 @@ async function adoptWorkspacePath(path) {
|
|
|
2536
2768
|
|
|
2537
2769
|
async function promptWorkspace() {
|
|
2538
2770
|
closePopovers();
|
|
2771
|
+
if (!S.localClient) return promptWorkspaceBrowse();
|
|
2539
2772
|
try {
|
|
2540
2773
|
const data = await post("/api/fs/pick");
|
|
2541
2774
|
if (data.error) throw new Error(data.error);
|
|
@@ -2690,6 +2923,7 @@ async function navigate(path) {
|
|
|
2690
2923
|
target = data;
|
|
2691
2924
|
browser.home = data.home || browser.home;
|
|
2692
2925
|
browser.parent = data;
|
|
2926
|
+
browser.pathDraft = null;
|
|
2693
2927
|
browser.loading = false;
|
|
2694
2928
|
renderBrowser();
|
|
2695
2929
|
} catch (error) {
|
|
@@ -2955,6 +3189,28 @@ async function refreshWorkspaces() {
|
|
|
2955
3189
|
return S.workspaces;
|
|
2956
3190
|
}
|
|
2957
3191
|
|
|
3192
|
+
async function copyText(value) {
|
|
3193
|
+
try {
|
|
3194
|
+
await navigator.clipboard.writeText(value);
|
|
3195
|
+
return true;
|
|
3196
|
+
} catch {
|
|
3197
|
+
const previous = document.activeElement;
|
|
3198
|
+
const field = document.createElement("textarea");
|
|
3199
|
+
field.value = value;
|
|
3200
|
+
field.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px";
|
|
3201
|
+
document.body.appendChild(field);
|
|
3202
|
+
field.select();
|
|
3203
|
+
const copied = document.execCommand("copy");
|
|
3204
|
+
field.remove();
|
|
3205
|
+
previous?.focus({ preventScroll: true });
|
|
3206
|
+
if (copied) return true;
|
|
3207
|
+
const dialog = el(`<div class="pi-dialogBackdrop"><div class="pi-dialogCard" role="dialog" aria-label="手动复制"><div class="pi-dialogTitle">长按文本复制</div><textarea class="pi-copyField" readonly>${esc(value)}</textarea><button type="button" class="pi-settingsAction">关闭</button></div></div>`);
|
|
3208
|
+
dialog.querySelector("button").onclick = () => dialog.remove();
|
|
3209
|
+
document.body.appendChild(dialog);
|
|
3210
|
+
return false;
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
|
|
2958
3214
|
let toastTimer = 0;
|
|
2959
3215
|
function showToast(message, type = "info") {
|
|
2960
3216
|
$(".pi-toast")?.remove();
|
|
@@ -3073,7 +3329,50 @@ async function deleteImageAttachment(id) {
|
|
|
3073
3329
|
await api(`/api/attachments/${encodeURIComponent(id)}`, { method: "DELETE" }).catch(() => {});
|
|
3074
3330
|
}
|
|
3075
3331
|
|
|
3332
|
+
function readImageData(file) {
|
|
3333
|
+
return new Promise((resolve, reject) => {
|
|
3334
|
+
const reader = new FileReader();
|
|
3335
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1]);
|
|
3336
|
+
reader.onerror = () => reject(new Error("无法读取图片"));
|
|
3337
|
+
reader.readAsDataURL(file);
|
|
3338
|
+
});
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
async function uploadImages(files) {
|
|
3342
|
+
if (S.clipboardBusy || !files.length) return;
|
|
3343
|
+
if (!modelSupportsImages()) return showToast("当前模型不支持图片输入,请先切换视觉模型", "warning");
|
|
3344
|
+
if (S.draftAttachments.length + files.length > 4) return showToast("每条消息最多附加 4 张图片", "warning");
|
|
3345
|
+
if (files.some((file) => file.size > 15 * 1024 * 1024)) return showToast("图片不能超过 15 MB", "warning");
|
|
3346
|
+
const key = draftKey();
|
|
3347
|
+
setClipboardBusy(true);
|
|
3348
|
+
try {
|
|
3349
|
+
for (const file of files) {
|
|
3350
|
+
const data = await post("/api/attachments", { data: await readImageData(file) });
|
|
3351
|
+
if (draftKey() !== key) {
|
|
3352
|
+
await deleteImageAttachment(data.attachment?.id);
|
|
3353
|
+
showToast("会话已切换,本次上传已取消", "warning");
|
|
3354
|
+
break;
|
|
3355
|
+
}
|
|
3356
|
+
setDraftAttachments([...S.draftAttachments, data.attachment]);
|
|
3357
|
+
}
|
|
3358
|
+
queueRender(true, { conversation: true });
|
|
3359
|
+
} catch (error) {
|
|
3360
|
+
showToast(error.message || String(error), "error");
|
|
3361
|
+
} finally {
|
|
3362
|
+
setClipboardBusy(false);
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3076
3366
|
async function pasteSystemClipboard() {
|
|
3367
|
+
if (!S.localClient) {
|
|
3368
|
+
const picker = document.createElement("input");
|
|
3369
|
+
picker.type = "file";
|
|
3370
|
+
picker.accept = "image/png,image/jpeg,image/gif,image/webp,image/bmp";
|
|
3371
|
+
picker.multiple = true;
|
|
3372
|
+
picker.addEventListener("change", () => { void uploadImages([...picker.files]); });
|
|
3373
|
+
picker.click();
|
|
3374
|
+
return;
|
|
3375
|
+
}
|
|
3077
3376
|
if (S.clipboardBusy) return;
|
|
3078
3377
|
if (!modelSupportsImages()) {
|
|
3079
3378
|
showToast("当前模型不支持图片输入,请先切换视觉模型", "warning");
|
|
@@ -3245,6 +3544,7 @@ async function executeLocalSlashCommand(text) {
|
|
|
3245
3544
|
S.settingsOpen = true;
|
|
3246
3545
|
closePopovers();
|
|
3247
3546
|
renderOverlay();
|
|
3547
|
+
void loadNetworkSettings();
|
|
3248
3548
|
} else if (name === "new") {
|
|
3249
3549
|
await ensureWorkspaceThen((ws) => createAndOpenSession(ws.id, ""));
|
|
3250
3550
|
} else if (name === "rewind" || name === "tree") {
|
|
@@ -3272,8 +3572,7 @@ async function executeLocalSlashCommand(text) {
|
|
|
3272
3572
|
const value = assistantMessageText(assistant);
|
|
3273
3573
|
if (!value) showToast("暂无可复制的助手回复", "warning");
|
|
3274
3574
|
else {
|
|
3275
|
-
await
|
|
3276
|
-
showToast("已复制最后一条助手回复");
|
|
3575
|
+
if (await copyText(value)) showToast("已复制最后一条助手回复");
|
|
3277
3576
|
}
|
|
3278
3577
|
} else if (name === "session") {
|
|
3279
3578
|
const snap = currentSnapshot();
|
|
@@ -3285,7 +3584,7 @@ async function executeLocalSlashCommand(text) {
|
|
|
3285
3584
|
`路径:${snap.session?.cwd || "-"}`,
|
|
3286
3585
|
`模型:${snap.state?.model?.provider || "-"}/${snap.state?.model?.id || "-"} · 思考 ${thinkingLabel(snap.state?.thinkingLevel || "off")}`,
|
|
3287
3586
|
`上下文:${fmtNum(cu.tokens ?? 0)} / ${fmtNum(cu.contextWindow ?? 0)} tok(${Math.round(cu.percent ?? 0)}%)`,
|
|
3288
|
-
`输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} tok · 成本 ${typeof stats.cost === "number" ?
|
|
3587
|
+
`输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} tok · 成本 ${typeof stats.cost === "number" ? formatCost(stats.cost) : "-"}`,
|
|
3289
3588
|
].join("\n"));
|
|
3290
3589
|
}
|
|
3291
3590
|
return true;
|
|
@@ -3655,6 +3954,7 @@ async function onClick(ev) {
|
|
|
3655
3954
|
S.settingsOpen = true;
|
|
3656
3955
|
closePopovers();
|
|
3657
3956
|
renderOverlay();
|
|
3957
|
+
void loadNetworkSettings();
|
|
3658
3958
|
} else if (action === "settings-close") {
|
|
3659
3959
|
if (!S.maintenanceBusy) {
|
|
3660
3960
|
S.settingsOpen = false;
|
|
@@ -3780,11 +4080,7 @@ async function onClick(ev) {
|
|
|
3780
4080
|
const command = availableSlashCommands().find((item) => item.name === target.dataset.command);
|
|
3781
4081
|
await applyCommandSuggestion(command, false);
|
|
3782
4082
|
} else if (action === "copy-text") {
|
|
3783
|
-
|
|
3784
|
-
await navigator.clipboard.writeText(target.dataset.text || "");
|
|
3785
|
-
} catch {
|
|
3786
|
-
// ignore
|
|
3787
|
-
}
|
|
4083
|
+
if (await copyText(target.dataset.text || "")) showToast("已复制");
|
|
3788
4084
|
} else if (action === "copy-assistant-message") {
|
|
3789
4085
|
const messageIndex = Number.parseInt(target.dataset.messageIndex || "", 10);
|
|
3790
4086
|
const message = currentSnapshot().messages?.[messageIndex];
|
|
@@ -3794,8 +4090,7 @@ async function onClick(ev) {
|
|
|
3794
4090
|
return;
|
|
3795
4091
|
}
|
|
3796
4092
|
try {
|
|
3797
|
-
await
|
|
3798
|
-
showToast("已复制本条 AI 回复");
|
|
4093
|
+
if (await copyText(value)) showToast("已复制本条 AI 回复");
|
|
3799
4094
|
} catch (error) {
|
|
3800
4095
|
showToast(`复制失败:${error.message || String(error)}`, "error");
|
|
3801
4096
|
}
|
|
@@ -3955,7 +4250,7 @@ async function onClick(ev) {
|
|
|
3955
4250
|
contextLine,
|
|
3956
4251
|
`输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} · 缓存读 ${fmtNum(tokens.cacheRead ?? 0)} · 缓存写 ${fmtNum(tokens.cacheWrite ?? 0)} tok`,
|
|
3957
4252
|
`消息 ${stats.totalMessages ?? "?"}(用户 ${stats.userMessages ?? "?"} / 助手 ${stats.assistantMessages ?? "?"} / 工具 ${stats.toolCalls ?? "?"})`,
|
|
3958
|
-
typeof stats.cost === "number" ? `成本
|
|
4253
|
+
typeof stats.cost === "number" ? `成本 ${formatCost(stats.cost)}` : "",
|
|
3959
4254
|
].filter(Boolean);
|
|
3960
4255
|
alert(lines.join("\n"));
|
|
3961
4256
|
} else if (action === "view-options") {
|
|
@@ -4140,12 +4435,109 @@ function onWorkspaceDragEnd() {
|
|
|
4140
4435
|
resetWorkspaceDrag();
|
|
4141
4436
|
}
|
|
4142
4437
|
|
|
4438
|
+
let loginPromise;
|
|
4439
|
+
|
|
4440
|
+
function requireLogin() {
|
|
4441
|
+
if (loginPromise) return loginPromise;
|
|
4442
|
+
eventSource?.close();
|
|
4443
|
+
loginPromise = (async () => {
|
|
4444
|
+
let { passwordConfigured } = await api("/api/auth");
|
|
4445
|
+
const dialog = el(`<div class="pi-loginBackdrop">
|
|
4446
|
+
<form class="pi-loginCard" aria-labelledby="pi-login-title">
|
|
4447
|
+
<h1 id="pi-login-title">登录 Pi Web</h1>
|
|
4448
|
+
<p class="pi-loginMessage" role="status">${passwordConfigured ? "输入访问密码,继续使用会话。" : "请先在电脑网页的「设置 → 手机访问」中设置密码。"}</p>
|
|
4449
|
+
<label for="pi-login-password">访问密码</label>
|
|
4450
|
+
<input id="pi-login-password" class="pi-passwordInput" type="password" name="password" autocomplete="current-password" maxlength="256" required ${passwordConfigured ? "" : "disabled"}>
|
|
4451
|
+
<button type="submit" class="pi-settingsAction pi-settingsActionPrimary">${passwordConfigured ? "登录" : "重新检查"}</button>
|
|
4452
|
+
</form>
|
|
4453
|
+
</div>`);
|
|
4454
|
+
const root = $("#root");
|
|
4455
|
+
root.inert = true;
|
|
4456
|
+
document.body.appendChild(dialog);
|
|
4457
|
+
const form = $("form", dialog);
|
|
4458
|
+
const input = $("input", dialog);
|
|
4459
|
+
const button = $("button", dialog);
|
|
4460
|
+
const message = $(".pi-loginMessage", dialog);
|
|
4461
|
+
await new Promise((resolve) => {
|
|
4462
|
+
form.onsubmit = async (event) => {
|
|
4463
|
+
event.preventDefault();
|
|
4464
|
+
if (button.disabled) return;
|
|
4465
|
+
button.disabled = true;
|
|
4466
|
+
try {
|
|
4467
|
+
if (!passwordConfigured) {
|
|
4468
|
+
({ passwordConfigured } = await api("/api/auth"));
|
|
4469
|
+
input.disabled = !passwordConfigured;
|
|
4470
|
+
button.textContent = passwordConfigured ? "登录" : "重新检查";
|
|
4471
|
+
if (passwordConfigured) {
|
|
4472
|
+
message.textContent = "输入访问密码,继续使用会话。";
|
|
4473
|
+
input.focus();
|
|
4474
|
+
}
|
|
4475
|
+
return;
|
|
4476
|
+
}
|
|
4477
|
+
await post("/api/auth", { password: input.value });
|
|
4478
|
+
input.value = "";
|
|
4479
|
+
dialog.remove();
|
|
4480
|
+
root.inert = false;
|
|
4481
|
+
resolve();
|
|
4482
|
+
} catch (error) {
|
|
4483
|
+
message.textContent = error.message || String(error);
|
|
4484
|
+
input.focus();
|
|
4485
|
+
} finally {
|
|
4486
|
+
button.disabled = false;
|
|
4487
|
+
}
|
|
4488
|
+
};
|
|
4489
|
+
});
|
|
4490
|
+
if (eventSource) connectEvents();
|
|
4491
|
+
})().finally(() => { loginPromise = null; });
|
|
4492
|
+
return loginPromise;
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
let eventSource;
|
|
4496
|
+
let resyncPending;
|
|
4497
|
+
|
|
4498
|
+
async function resyncAfterReconnect() {
|
|
4499
|
+
if (resyncPending) return resyncPending;
|
|
4500
|
+
resyncPending = (async () => {
|
|
4501
|
+
const boot = await api("/api/bootstrap");
|
|
4502
|
+
S.workspaces = applySavedWorkspaceOrder(boot.workspaces || []);
|
|
4503
|
+
S.serverInstanceId = boot.instanceId || null;
|
|
4504
|
+
S.lanEnabled = boot.lanEnabled === true;
|
|
4505
|
+
S.lanUrls = boot.lanUrls || [];
|
|
4506
|
+
S.passwordConfigured = boot.passwordConfigured === true;
|
|
4507
|
+
if (S.settingsOpen || networkSettings.data) void loadNetworkSettings();
|
|
4508
|
+
const id = S.currentSessionId;
|
|
4509
|
+
const generation = S.openGeneration;
|
|
4510
|
+
if (id && S.workspaces.some((workspace) => workspace.sessions?.some((session) => session.id === id))) {
|
|
4511
|
+
const before = S.snapshots.get(id);
|
|
4512
|
+
const data = await post(`/api/sessions/${encodeURIComponent(id)}/open`);
|
|
4513
|
+
if (S.snapshots.get(id) === before) S.snapshots.set(id, data);
|
|
4514
|
+
} else if (id && generation === S.openGeneration) {
|
|
4515
|
+
rememberCurrentDraft();
|
|
4516
|
+
++S.openGeneration;
|
|
4517
|
+
S.currentSessionId = null;
|
|
4518
|
+
S.loadingSession = null;
|
|
4519
|
+
loadCurrentDraftValue();
|
|
4520
|
+
}
|
|
4521
|
+
queueRender(true);
|
|
4522
|
+
})().catch((error) => {
|
|
4523
|
+
if (error.status === 401) showToast(error.message, "error");
|
|
4524
|
+
}).finally(() => { resyncPending = null; });
|
|
4525
|
+
return resyncPending;
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4143
4528
|
function connectEvents() {
|
|
4529
|
+
eventSource?.close();
|
|
4144
4530
|
const es = new EventSource("/api/events");
|
|
4531
|
+
eventSource = es;
|
|
4532
|
+
es.onopen = () => { void resyncAfterReconnect(); };
|
|
4145
4533
|
es.onmessage = (ev) => {
|
|
4146
4534
|
try {
|
|
4147
4535
|
const event = JSON.parse(ev.data);
|
|
4148
4536
|
if (event.type === "heartbeat") return;
|
|
4537
|
+
if (event.type === "network_changed") {
|
|
4538
|
+
void loadNetworkSettings();
|
|
4539
|
+
return;
|
|
4540
|
+
}
|
|
4149
4541
|
if (event.type === "maintenance_error") {
|
|
4150
4542
|
S.maintenanceGeneration += 1;
|
|
4151
4543
|
S.maintenanceBusy = null;
|
|
@@ -4163,17 +4555,10 @@ function connectEvents() {
|
|
|
4163
4555
|
return;
|
|
4164
4556
|
}
|
|
4165
4557
|
if (event.type === "editor_text") {
|
|
4166
|
-
if (event.sessionId === S.currentSessionId) {
|
|
4167
|
-
|
|
4558
|
+
if (event.sessionId === S.currentSessionId && event.text !== S.draft) {
|
|
4559
|
+
restoreMessagesToDraft(event.sessionId, [event.text]);
|
|
4168
4560
|
closeCommandMenu();
|
|
4169
|
-
restoreDraft();
|
|
4170
|
-
syncComposerInput();
|
|
4171
4561
|
queueRender(true, { sidebar: false, conversation: true, overlay: false });
|
|
4172
|
-
requestAnimationFrame(() => {
|
|
4173
|
-
const input = $("#composerInput");
|
|
4174
|
-
input?.focus();
|
|
4175
|
-
input?.setSelectionRange(input.value.length, input.value.length);
|
|
4176
|
-
});
|
|
4177
4562
|
}
|
|
4178
4563
|
return;
|
|
4179
4564
|
}
|
|
@@ -4210,17 +4595,25 @@ function connectEvents() {
|
|
|
4210
4595
|
// ignore malformed event
|
|
4211
4596
|
}
|
|
4212
4597
|
};
|
|
4213
|
-
es.onerror = () => {
|
|
4214
|
-
// EventSource reconnects automatically.
|
|
4215
|
-
};
|
|
4598
|
+
es.onerror = () => { void resyncAfterReconnect(); };
|
|
4216
4599
|
}
|
|
4217
4600
|
|
|
4218
|
-
window.addEventListener("resize", applyFrameLayout);
|
|
4601
|
+
window.addEventListener("resize", () => { updateViewport(); applyFrameLayout(); });
|
|
4602
|
+
window.visualViewport?.addEventListener("resize", updateViewport);
|
|
4603
|
+
window.visualViewport?.addEventListener("scroll", updateViewport);
|
|
4604
|
+
window.addEventListener("online", () => { if (eventSource && !loginPromise) connectEvents(); });
|
|
4605
|
+
document.addEventListener("visibilitychange", () => {
|
|
4606
|
+
if (!document.hidden && eventSource && !loginPromise) connectEvents();
|
|
4607
|
+
});
|
|
4608
|
+
updateViewport();
|
|
4219
4609
|
bindGlobalEvents();
|
|
4220
|
-
|
|
4610
|
+
async function startApp() {
|
|
4611
|
+
await loadBootstrap();
|
|
4612
|
+
connectEvents();
|
|
4613
|
+
}
|
|
4614
|
+
startApp().catch((error) => {
|
|
4221
4615
|
document.body.insertAdjacentHTML(
|
|
4222
4616
|
"beforeend",
|
|
4223
4617
|
`<div class="pi-dialogBackdrop"><div class="pi-dialogCard"><div class="pi-dialogTitle">无法连接到 pi-web</div><div class="pi-dialogMessage">${esc(error.message)}</div></div></div>`,
|
|
4224
4618
|
);
|
|
4225
4619
|
});
|
|
4226
|
-
connectEvents();
|