@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/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 res = await fetch(path, {
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 (stats.totalMessages != null) add(`消息 ${stats.totalMessages}`);
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 (cu.tokens != null) add(`上下文 ${fmtNum(cu.tokens)}/${fmtNum(cu.contextWindow ?? 0)} (${Math.round(cu.percent ?? 0)}%)`, "FJxK0a_context");
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}</div>
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
- S.currentWorkspaceId = boot.currentWorkspaceId;
2431
- S.currentSessionId = boot.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.currentWorkspaceId = boot.currentWorkspaceId || S.workspaces[0]?.id || null;
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 navigator.clipboard.writeText(value);
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" ? `$${stats.cost.toFixed(3)}` : "-"}`,
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
- try {
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 navigator.clipboard.writeText(value);
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" ? `成本 $${stats.cost.toFixed(3)}` : "",
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
- setDraftValue(event.text || "");
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
- loadBootstrap().catch((error) => {
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();