From 97a22261edbf629b32b7d13c979814f7d73aa1ea Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 19:29:21 +0800 Subject: [PATCH 1/4] feat: support sub-path deployment via PI_WEB_BASE_PATH (Next basePath) - next.config.ts: basePath from PI_WEB_BASE_PATH env; expose NEXT_PUBLIC_BASE_PATH; allow public hostnames in allowedDevOrigins for dev HMR through a reverse proxy - lib/base-path.ts: withBasePath/apiUrl helpers - client fetch/EventSource call sites prefixed via apiUrl() so all API traffic follows the base path - layout.tsx manifest/icons, PwaRegistration scope, public/sw.js made base-path aware (scope-derived URLs) - no behavior change when PI_WEB_BASE_PATH is unset (root deployment) --- .gitignore | 2 +- app/layout.tsx | 7 ++++--- components/AppShell.tsx | 12 +++++++----- components/ChatInput.tsx | 8 +++++--- components/DirectoryPicker.tsx | 4 +++- components/FileExplorer.tsx | 6 ++++-- components/FileViewer.tsx | 6 ++++-- components/ModelsConfig.tsx | 28 +++++++++++++++------------- components/PluginsConfig.tsx | 8 +++++--- components/PwaRegistration.tsx | 5 +++-- components/SessionSidebar.tsx | 22 ++++++++++++---------- components/SkillsConfig.tsx | 14 ++++++++------ hooks/useAgentSession.ts | 20 +++++++++++--------- lib/agent-client.ts | 3 ++- lib/base-path.ts | 25 +++++++++++++++++++++++++ next.config.ts | 7 +++++++ public/sw.js | 25 ++++++++++++++++++------- 17 files changed, 134 insertions(+), 68 deletions(-) create mode 100644 lib/base-path.ts diff --git a/.gitignore b/.gitignore index 3a44c749f..bf1cf2383 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -.factory \ No newline at end of file +.factory diff --git a/app/layout.tsx b/app/layout.tsx index 054989f56..bd45b1e0d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Noto_Sans_Mono } from "next/font/google"; import { PwaRegistration } from "@/components/PwaRegistration"; +import { withBasePath } from "@/lib/base-path"; import "katex/dist/katex.min.css"; import "./globals.css"; @@ -14,18 +15,18 @@ export const metadata: Metadata = { title: "Pi Web", description: "Pi Web interface for the pi coding agent", applicationName: "Pi Web", - manifest: "/manifest.webmanifest", + manifest: withBasePath("/manifest.webmanifest"), icons: { icon: [ { - url: "/icons/icon-192.png", + url: withBasePath("/icons/icon-192.png"), sizes: "192x192", type: "image/png", }, ], apple: [ { - url: "/icons/apple-touch-icon.png", + url: withBasePath("/icons/apple-touch-icon.png"), sizes: "180x180", type: "image/png", }, diff --git a/components/AppShell.tsx b/components/AppShell.tsx index d4d827fa7..cccf748d4 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -2,6 +2,8 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { apiUrl } from "@/lib/base-path"; + import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { SessionSidebar } from "./SessionSidebar"; import { ChatWindow } from "./ChatWindow"; @@ -277,7 +279,7 @@ export function AppShell() { setInitialCwdStatus("validating"); setInitialCwdError(null); - void fetch("/api/cwd/validate", { + void fetch(apiUrl("/api/cwd/validate"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: requestedCwd }), @@ -391,7 +393,7 @@ export function AppShell() { // handleCwdChange relies on. Hydrate it from the session list so switching // worktrees right after creating a session doesn't close the chat. const hydrateSelectedSession = useCallback((sessionId: string) => { - void fetch("/api/sessions") + void fetch(apiUrl("/api/sessions")) .then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) .then((d) => { const full = d?.sessions.find((s) => s.id === sessionId); @@ -423,7 +425,7 @@ export function AppShell() { setAutoNameStatus({ kind: "naming" }); try { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/auto-name`, { + const response = await fetch(apiUrl(`/api/sessions/${encodeURIComponent(sessionId)}/auto-name`), { method: "POST", }); const body = (await response.json().catch(() => ({}))) as { title?: string; error?: string }; @@ -562,7 +564,7 @@ export function AppShell() { if (!projectTrustCwd) return; const controller = new AbortController(); - fetch(`/api/project-trust?cwd=${encodeURIComponent(projectTrustCwd)}`, { + fetch(apiUrl(`/api/project-trust?cwd=${encodeURIComponent(projectTrustCwd)}`), { signal: controller.signal, }) .then(async (response) => { @@ -582,7 +584,7 @@ export function AppShell() { setProjectTrustBusy(true); setProjectTrustError(null); try { - const response = await fetch("/api/project-trust", { + const response = await fetch(apiUrl("/api/project-trust"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: projectTrustCwd }), diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 35fa00bdf..703bccbc6 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -4,6 +4,8 @@ import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, f import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; import type { SkillsResponse } from "@/lib/api-types"; import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store"; +import { apiUrl } from "@/lib/base-path"; + import { MAX_ATTACHED_IMAGE_BYTES, MAX_ATTACHED_IMAGES, @@ -625,7 +627,7 @@ export const ChatInput = forwardRef(function ChatInput({ const fetchCwd = cwd; const query = atQueryText; const timer = setTimeout(() => { - fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}&q=${encodeURIComponent(query)}`) + fetch(apiUrl(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}&q=${encodeURIComponent(query)}`)) .then((res) => { if (!res.ok) throw new Error(`file search failed: ${res.status}`); return res.json() as Promise<{ matches?: FileIndexEntry[] }>; @@ -668,7 +670,7 @@ export const ChatInput = forwardRef(function ChatInput({ fileIndexFetchingRef.current = cwd; const fetchCwd = cwd; setFileIndexLoading(true); - fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}`) + fetch(apiUrl(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}`)) .then((res) => { if (!res.ok) throw new Error(`file index failed: ${res.status}`); return res.json() as Promise<{ files?: string[]; truncated?: boolean }>; @@ -1004,7 +1006,7 @@ export const ChatInput = forwardRef(function ChatInput({ const requestCwd = cwd; let cancelled = false; setSkillDormancyState({ cwd: requestCwd, values: {} }); - fetch(`/api/skills?cwd=${encodeURIComponent(requestCwd)}`) + fetch(apiUrl(`/api/skills?cwd=${encodeURIComponent(requestCwd)}`)) .then((res) => { if (!res.ok) throw new Error(`skills fetch failed: ${res.status}`); return res.json() as Promise>; diff --git a/components/DirectoryPicker.tsx b/components/DirectoryPicker.tsx index f9ee53e33..1e0d72882 100644 --- a/components/DirectoryPicker.tsx +++ b/components/DirectoryPicker.tsx @@ -3,6 +3,8 @@ import { FormEvent, useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useI18n } from "@/hooks/useI18n"; +import { apiUrl } from "@/lib/base-path"; + interface DirectoryEntry { name: string; @@ -19,7 +21,7 @@ interface BrowseResponse { async function loadDirectories(directory?: string): Promise { const query = directory ? `?path=${encodeURIComponent(directory)}` : ""; - const response = await fetch(`/api/cwd/browse${query}`); + const response = await fetch(apiUrl(`/api/cwd/browse${query}`)); const data = await response.json() as BrowseResponse; if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); return data; diff --git a/components/FileExplorer.tsx b/components/FileExplorer.tsx index 6803f21c5..d5a96b93c 100644 --- a/components/FileExplorer.tsx +++ b/components/FileExplorer.tsx @@ -2,6 +2,8 @@ import { forwardRef, useState, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { getFileIcon, FolderIcon } from "./FileIcons"; +import { apiUrl } from "@/lib/base-path"; + import { encodeFilePathForApi, getFileDirectory, @@ -76,7 +78,7 @@ interface PendingConflict { async function fetchEntries(dirPath: string): Promise { const encoded = encodeFilePathForApi(dirPath); - const res = await fetch(`/api/files/${encoded}?type=list`); + const res = await fetch(apiUrl(`/api/files/${encoded}?type=list`)); if (!res.ok) { let message = `Failed to load files (HTTP ${res.status})`; try { @@ -100,7 +102,7 @@ async function fetchEntries(dirPath: string): Promise { async function fetchGitStatus(cwd: string): Promise { const params = new URLSearchParams({ cwd }); - const res = await fetch(`/api/git/status?${params.toString()}`); + const res = await fetch(apiUrl(`/api/git/status?${params.toString()}`)); if (!res.ok) throw new Error(`Failed to load Git status (HTTP ${res.status})`); return res.json() as Promise; } diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index 9635b4c6d..a83d64f92 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -1,6 +1,8 @@ "use client"; import { useEffect, useState, useRef, useCallback, useMemo, type CSSProperties, type MouseEvent } from "react"; +import { apiUrl } from "@/lib/base-path"; + import { Prism as SyntaxHighlighter, createElement as renderSyntaxNode, @@ -204,7 +206,7 @@ function getFileApiUrl( for (const [key, value] of Object.entries(params)) { if (value !== undefined) searchParams.set(key, String(value)); } - return `/api/files/${encoded}?${searchParams.toString()}`; + return apiUrl(`/api/files/${encoded}?${searchParams.toString()}`); } function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) { @@ -841,7 +843,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL try { const params = new URLSearchParams({ cwd, path: targetPath }); - const response = await fetch(`/api/git/diff?${params.toString()}`); + const response = await fetch(apiUrl(`/api/git/diff?${params.toString()}`)); const next = await response.json() as GitFileDiffResponse & { error?: string }; if (requestId !== gitDiffRequestRef.current) return; setGitDiff(response.ok && next.supported && typeof next.patch === "string" ? next : null); diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index 6b7de8a17..a017a834e 100644 --- a/components/ModelsConfig.tsx +++ b/components/ModelsConfig.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useIsMobile } from "@/hooks/useIsMobile"; +import { apiUrl } from "@/lib/base-path"; + import { useI18n } from "@/hooks/useI18n"; import type { ModelCatalogPreset, ModelCatalogRecommendation } from "@/lib/model-catalog"; import type { DiscoveredModel } from "@/lib/model-discovery"; @@ -340,7 +342,7 @@ function ProviderDetail({ name, provider, onChange, onRename, onDelete, onAddMod setDiscoveryState({ phase: "loading" }); setSelectedModelIds([]); try { - const res = await fetch("/api/models-config/discover", { + const res = await fetch(apiUrl("/api/models-config/discover"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerName: name, provider: { ...provider, models: undefined } }), @@ -797,7 +799,7 @@ function ModelDetail({ if (!model.id.trim() || testState.phase === "testing") return; setTestState({ phase: "testing" }); try { - const res = await fetch("/api/models-config/test", { + const res = await fetch(apiUrl("/api/models-config/test"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerName, provider, model }), @@ -837,7 +839,7 @@ function ModelDetail({ try { const params = new URLSearchParams({ q: query, provider: providerName, limit: "50" }); if (provider.baseUrl?.trim()) params.set("baseUrl", provider.baseUrl.trim()); - const res = await fetch(`/api/models-config/catalog?${params}`); + const res = await fetch(apiUrl(`/api/models-config/catalog?${params}`)); const data = await res.json() as { recommendation?: ModelCatalogRecommendation; error?: string }; if (requestId !== catalogRequestIdRef.current) return; if (!res.ok || data.error || !data.recommendation) { @@ -1109,7 +1111,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre setLoginState({ phase: "connecting" }); setInputValue(""); - const es = new EventSource(`/api/auth/login/${encodeURIComponent(provider.id)}`); + const es = new EventSource(apiUrl(`/api/auth/login/${encodeURIComponent(provider.id)}`)); eventSourceRef.current = es; es.onmessage = (e) => { @@ -1156,7 +1158,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre }, [provider.id, onRefresh]); const handleLogout = useCallback(async () => { - await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, { method: "POST" }); + await fetch(apiUrl(`/api/auth/logout/${encodeURIComponent(provider.id)}`), { method: "POST" }); setLoginState({ phase: "idle" }); onRefresh(); }, [provider.id, onRefresh]); @@ -1165,7 +1167,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre if (!code.trim()) return; setLoginState({ phase: "progress", message: "Verifying…" }); try { - const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, { + const res = await fetch(apiUrl(`/api/auth/login/${encodeURIComponent(provider.id)}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, code: code.trim() }), @@ -1185,7 +1187,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre const submitSelection = useCallback(async (token: string, value: string) => { setLoginState({ phase: "progress", message: "Continuing…" }); try { - const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, { + const res = await fetch(apiUrl(`/api/auth/login/${encodeURIComponent(provider.id)}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, code: value }), @@ -1360,7 +1362,7 @@ function ApiKeyDetail({ provider, onRefresh }: { provider: ApiKeyProvider; onRef setError(null); setSavedOk(false); try { - const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, { + const res = await fetch(apiUrl(`/api/auth/api-key/${encodeURIComponent(provider.id)}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: apiKey.trim() }), @@ -1385,7 +1387,7 @@ function ApiKeyDetail({ provider, onRefresh }: { provider: ApiKeyProvider; onRef setRemoving(true); setError(null); try { - const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, { method: "DELETE" }); + const res = await fetch(apiUrl(`/api/auth/api-key/${encodeURIComponent(provider.id)}`), { method: "DELETE" }); const d = await res.json() as { success?: boolean; error?: string }; if (!res.ok || d.error) setError(d.error ?? `HTTP ${res.status}`); else onRefresh(); @@ -1661,14 +1663,14 @@ export function ModelsConfig({ onClose }: { onClose: () => void }) { const [pickerOpen, setPickerOpen] = useState(false); const loadOAuthProviders = useCallback(() => { - fetch("/api/auth/providers") + fetch(apiUrl("/api/auth/providers")) .then((r) => r.json()) .then((d: { providers: OAuthProvider[] }) => setOauthProviders(d.providers)) .catch(() => {}); }, []); const loadApiKeyProviders = useCallback(() => { - fetch("/api/auth/all-providers") + fetch(apiUrl("/api/auth/all-providers")) .then((r) => r.json()) .then((d: { providers: ApiKeyProvider[] }) => setApiKeyProviders(d.providers)) .catch(() => {}); @@ -1684,7 +1686,7 @@ export function ModelsConfig({ onClose }: { onClose: () => void }) { }, [loadOAuthProviders, loadApiKeyProviders]); useEffect(() => { - fetch("/api/models-config") + fetch(apiUrl("/api/models-config")) .then((r) => r.json()) .then((d: ModelsJson) => { const normalized = d.providers ? d : { ...d, providers: {} }; @@ -1789,7 +1791,7 @@ export function ModelsConfig({ onClose }: { onClose: () => void }) { setSaveError(null); setSavedOk(false); try { - const res = await fetch("/api/models-config", { + const res = await fetch(apiUrl("/api/models-config"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(config), diff --git a/components/PluginsConfig.tsx b/components/PluginsConfig.tsx index 1d3674d1d..5af3bdbf7 100644 --- a/components/PluginsConfig.tsx +++ b/components/PluginsConfig.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { sendAgentCommand } from "@/lib/agent-client"; +import { apiUrl } from "@/lib/base-path"; + import { useIsMobile } from "@/hooks/useIsMobile"; import type { PluginPackageInfo, PluginsResponse } from "@/lib/api-types"; import { useI18n } from "@/hooks/useI18n"; @@ -650,7 +652,7 @@ export function PluginsConfig({ setLoading(true); setError(null); try { - const res = await fetch(`/api/plugins?cwd=${encodeURIComponent(cwd)}`); + const res = await fetch(apiUrl(`/api/plugins?cwd=${encodeURIComponent(cwd)}`)); const next = (await res.json()) as PluginsResponse & { error?: string }; if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`); setData(next); @@ -676,7 +678,7 @@ export function PluginsConfig({ setActionError(null); setActionMessage(null); try { - const res = await fetch("/api/plugins", { + const res = await fetch(apiUrl("/api/plugins"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, source: pkg.source, scope: pkg.scope, cwd }), @@ -713,7 +715,7 @@ export function PluginsConfig({ setActionError(null); setActionMessage(null); try { - const res = await fetch("/api/plugins", { + const res = await fetch(apiUrl("/api/plugins"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "install", source, scope: installScope, cwd }), diff --git a/components/PwaRegistration.tsx b/components/PwaRegistration.tsx index d5c6af9e2..5c9e08d12 100644 --- a/components/PwaRegistration.tsx +++ b/components/PwaRegistration.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { withBasePath } from "@/lib/base-path"; export function PwaRegistration() { useEffect(() => { @@ -10,10 +11,10 @@ export function PwaRegistration() { const register = () => { const appVersion = process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"; - const scriptUrl = `/sw.js?v=${encodeURIComponent(appVersion)}`; + const scriptUrl = `${withBasePath("/sw.js")}?v=${encodeURIComponent(appVersion)}`; void navigator.serviceWorker.register(scriptUrl, { - scope: "/", + scope: withBasePath("/"), updateViaCache: "none", }).catch((error: unknown) => { console.error("Failed to register the Pi Web service worker:", error); diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index 0514b55e5..eaa45d735 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -3,6 +3,8 @@ import { useEffect, useLayoutEffect, useState, useCallback, useRef, type CSSProperties, type ReactNode } from "react"; import type { SessionInfo } from "@/lib/types"; import { useI18n } from "@/hooks/useI18n"; +import { apiUrl } from "@/lib/base-path"; + import { DirectoryPicker } from "./DirectoryPicker"; import { FileExplorer, type FileExplorerHandle } from "./FileExplorer"; @@ -429,7 +431,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio const loadSessions = useCallback(async (showLoading = false) => { try { if (showLoading) setLoading(true); - const res = await fetch("/api/sessions"); + const res = await fetch(apiUrl("/api/sessions")); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as { sessions: SessionInfo[]; runningSessionIds?: string[] }; setAllSessions(data.sessions); @@ -493,7 +495,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio controller?.abort(); controller = current; try { - const res = await fetch("/api/agent/running", { + const res = await fetch(apiUrl("/api/agent/running"), { cache: "no-store", signal: current.signal, }); @@ -565,7 +567,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio }, [explorerRefreshKey]); useEffect(() => { - fetch("/api/home").then((r) => r.json()).then((d: { home?: string }) => { + fetch(apiUrl("/api/home")).then((r) => r.json()).then((d: { home?: string }) => { if (d.home) setHomeDir(d.home); }).catch(() => {}); }, []); @@ -614,7 +616,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio } let cancelled = false; setWorktreeLoadingCwd(selectedCwd); - fetch(`/api/worktrees?cwd=${encodeURIComponent(selectedCwd)}`) + fetch(apiUrl(`/api/worktrees?cwd=${encodeURIComponent(selectedCwd)}`)) .then((r) => r.json()) .then((d: { projectRoot?: string; isGit?: boolean; isTopLevel?: boolean; worktrees?: WorktreeEntry[]; error?: string }) => { if (cancelled) return; @@ -669,7 +671,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio setCustomPathValidating(true); setCustomPathError(null); try { - const res = await fetch("/api/cwd/validate", { + const res = await fetch(apiUrl("/api/cwd/validate"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: path }), @@ -697,7 +699,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio }, []); const handleDefaultCwd = useCallback(async () => { try { - const res = await fetch("/api/default-cwd", { method: "POST" }); + const res = await fetch(apiUrl("/api/default-cwd"), { method: "POST" }); const data = await res.json() as { cwd?: string; error?: string }; if (data.cwd) { setSelectedCwd(data.cwd); @@ -717,7 +719,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio setWtBusy(true); setWtError(null); try { - const res = await fetch("/api/worktrees", { + const res = await fetch(apiUrl("/api/worktrees"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: worktreeState.projectRoot, branch }), @@ -752,7 +754,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio setWtBusy(true); setWtError(null); try { - const res = await fetch("/api/worktrees", { + const res = await fetch(apiUrl("/api/worktrees"), { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: worktreeState.projectRoot, path, force }), @@ -1844,7 +1846,7 @@ function SessionItem({ setRenaming(false); if (name === (session.name ?? "")) return; try { - await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { + await fetch(apiUrl(`/api/sessions/${encodeURIComponent(session.id)}`), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), @@ -1859,7 +1861,7 @@ function SessionItem({ setConfirmDelete(false); setDeleting(true); try { - await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); + await fetch(apiUrl(`/api/sessions/${encodeURIComponent(session.id)}`), { method: "DELETE" }); onDeleted?.(session.id); } catch { setDeleting(false); diff --git a/components/SkillsConfig.tsx b/components/SkillsConfig.tsx index 427c69564..97999d991 100644 --- a/components/SkillsConfig.tsx +++ b/components/SkillsConfig.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useIsMobile } from "@/hooks/useIsMobile"; +import { apiUrl } from "@/lib/base-path"; + import { useI18n } from "@/hooks/useI18n"; import type { SkillInfo as Skill, @@ -397,7 +399,7 @@ function AddSkillPanel({ setSearchError(null); setResults([]); try { - const res = await fetch("/api/skills/search", { + const res = await fetch(apiUrl("/api/skills/search"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: q.trim() }), @@ -424,7 +426,7 @@ function AddSkillPanel({ setInstalling(pkg); setInstallError(null); try { - const res = await fetch("/api/skills/install", { + const res = await fetch(apiUrl("/api/skills/install"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg, scope, cwd }), @@ -734,7 +736,7 @@ export function SkillsConfig({ setLoading(true); setError(null); try { - const res = await fetch(`/api/skills?cwd=${encodeURIComponent(cwd)}`); + const res = await fetch(apiUrl(`/api/skills?cwd=${encodeURIComponent(cwd)}`)); const d = (await res.json()) as Partial & { error?: string }; if (!res.ok || d.error) throw new Error(d.error ?? `HTTP ${res.status}`); const list = d.skills ?? []; @@ -778,7 +780,7 @@ export function SkillsConfig({ setCheckingUpdates((current) => new Set([...current, ...keys])); if (!skill) setCheckingAll(true); try { - const res = await fetch("/api/skills/check", { + const res = await fetch(apiUrl("/api/skills/check"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -817,7 +819,7 @@ export function SkillsConfig({ setUpdatingSkill(key); setUpdateError(null); try { - const res = await fetch("/api/skills/update", { + const res = await fetch(apiUrl("/api/skills/update"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -858,7 +860,7 @@ export function SkillsConfig({ setToggling((s) => new Set(s).add(skill.filePath)); setSaveError(null); try { - const res = await fetch("/api/skills", { + const res = await fetch(apiUrl("/api/skills"), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index a482f7120..422dac43d 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1,6 +1,8 @@ "use client"; import { useState, useCallback, useRef, useEffect, useMemo, useReducer } from "react"; +import { apiUrl } from "@/lib/base-path"; + import type { AgentMessage, ExtensionStatusItem, @@ -457,7 +459,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { try { if (showLoading) setLoading(true); const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" }); - const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}?${params}`); + const res = await fetch(apiUrl(`/api/sessions/${encodeURIComponent(sid)}?${params}`)); if (res.status === 404) { if (showLoading) { setData(null); @@ -485,7 +487,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!includeState) return null; try { - const stateRes = await fetch(`/api/sessions/${encodeURIComponent(sid)}/state`); + const stateRes = await fetch(apiUrl(`/api/sessions/${encodeURIComponent(sid)}/state`)); if (!stateRes.ok) throw new Error(`HTTP ${stateRes.status}`); const agentState = await stateRes.json() as { running: boolean; state?: AgentStateResponse }; if (sessionIdRef.current !== sid) return null; @@ -569,7 +571,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const selectedThinkingLevel = thinkingLevelOverrideRef.current; if (selectedModel) setPendingModel(selectedModel); const toolNames = getToolNamesForPreset(toolPreset); - const res = await fetch("/api/agent/new", { + const res = await fetch(apiUrl("/api/agent/new"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -650,7 +652,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const connectEvents = useCallback((sid: string): Promise => { closeEvents(); - const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`); + const es = new EventSource(apiUrl(`/api/agent/${encodeURIComponent(sid)}/events`)); eventSourceRef.current = es; eventSourceSessionIdRef.current = sid; @@ -853,7 +855,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { ) return; try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(apiUrl(`/api/agent/${encodeURIComponent(sid)}`)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; if ( @@ -928,7 +930,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { while (agentRunningRef.current && Date.now() - startedAt < PROMPT_SETTLE_MAX_MS) { if (runId !== undefined && promptRunIdRef.current !== runId) return; try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(apiUrl(`/api/agent/${encodeURIComponent(sid)}`)); if (res.ok) { const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; const state = data.state; @@ -955,7 +957,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { ) { await delay(BASH_STATE_RECONCILE_MS); try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(apiUrl(`/api/agent/${encodeURIComponent(sid)}`)); if (!res.ok) continue; const data = await res.json() as { state?: AgentStateResponse }; if (data.state?.isBashRunning) continue; @@ -981,7 +983,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!agentRunningRef.current) return; const runId = promptRunIdRef.current; try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(apiUrl(`/api/agent/${encodeURIComponent(sid)}`)); if (!res.ok) return; const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; // A slow response can straddle a run boundary (previous run finished @@ -1057,7 +1059,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { dispatch({ type: "end" }); if (sessionIdRef.current) { loadSession(sessionIdRef.current); - fetch(`/api/agent/${encodeURIComponent(sessionIdRef.current)}`) + fetch(apiUrl(`/api/agent/${encodeURIComponent(sessionIdRef.current)}`)) .then((r) => r.json()) .then((d: { state?: AgentStateResponse }) => { if (d.state?.contextUsage !== undefined) setContextUsage(d.state.contextUsage ?? null); diff --git a/lib/agent-client.ts b/lib/agent-client.ts index 8fbb70eb9..203c5c4c0 100644 --- a/lib/agent-client.ts +++ b/lib/agent-client.ts @@ -1,3 +1,4 @@ +import { apiUrl } from "@/lib/base-path"; // Client-side helper for POST /api/agent/[id]. // // Every /api/agent/[id] route returns one of: @@ -11,7 +12,7 @@ export async function sendAgentCommand( sessionId: string, command: Record, ): Promise { - const res = await fetch(`/api/agent/${encodeURIComponent(sessionId)}`, { + const res = await fetch(apiUrl(`/api/agent/${encodeURIComponent(sessionId)}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(command), diff --git a/lib/base-path.ts b/lib/base-path.ts new file mode 100644 index 000000000..02bde9330 --- /dev/null +++ b/lib/base-path.ts @@ -0,0 +1,25 @@ +// Base-path helpers for sub-path deployments. +// +// pi-web does not hardcode support for Next.js `basePath` everywhere — client +// fetches, EventSource streams and asset references use absolute root paths +// (`/api/...`, `/sw.js`). These helpers make every client-originated URL +// base-path aware so the app can be served under e.g. `https://host/dev/`. +// +// The value comes from `NEXT_PUBLIC_BASE_PATH` (set alongside `basePath` in +// next.config.ts) and is empty for a root deployment. + +export const BASE_PATH: string = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); + +/** Prefix a root-absolute path with the configured base path. */ +export function withBasePath(path: string): string { + if (!BASE_PATH) return path; + if (path === BASE_PATH || path.startsWith(`${BASE_PATH}/`) || path.startsWith(`${BASE_PATH}?`)) { + return path; + } + return `${BASE_PATH}${path.startsWith("/") ? path : `/${path}`}`; +} + +/** Prefix an API path (`/api/...`) with the configured base path. */ +export function apiUrl(path: string): string { + return withBasePath(path); +} diff --git a/next.config.ts b/next.config.ts index fb7c5cf03..f45d9afdd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,7 +9,13 @@ try { piVersion = (JSON.parse(readFileSync(piPkgPath, "utf8")) as { version: string }).version; } catch { /* package not found, use default */ } +// Optional sub-path deployment, e.g. PI_WEB_BASE_PATH=/dev serves the app at +// https://host/dev/. Client code reads NEXT_PUBLIC_BASE_PATH via +// lib/base-path.ts. Empty (default) = root deployment. +const basePath = (process.env.PI_WEB_BASE_PATH ?? "").replace(/\/+$/, ""); + const nextConfig: NextConfig = { + basePath: basePath || undefined, serverExternalPackages: [ "undici", "@earendil-works/pi-coding-agent", @@ -44,6 +50,7 @@ const nextConfig: NextConfig = { env: { NEXT_PUBLIC_APP_VERSION: version, NEXT_PUBLIC_PI_VERSION: piVersion, + NEXT_PUBLIC_BASE_PATH: basePath, }, }; diff --git a/public/sw.js b/public/sw.js index 89ff2423a..1eab1a6b1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,13 +1,24 @@ +// Service worker for pi-web. +// +// Scope-aware: when the app is served under a sub-path (Next.js basePath), +// this worker is registered at e.g. /dev/sw.js and its scope is /dev/. +// All URL paths below are derived from self.registration.scope so the same +// worker works for both root and sub-path deployments. + const CACHE_PREFIX = "pi-web"; const CACHE_VERSION = new URL(self.location.href).searchParams.get("v") || "dev"; const STATIC_CACHE = `${CACHE_PREFIX}-static-${CACHE_VERSION}`; -const OFFLINE_URL = "/offline.html"; + +// self.registration.scope is "/" for root deployments and "/dev/" etc. for +// sub-path deployments. +const SCOPE = self.registration.scope.replace(/\/+$/, "") + "/"; +const OFFLINE_URL = `${SCOPE}offline.html`; const PRECACHE_URLS = [ OFFLINE_URL, - "/manifest.webmanifest", - "/icons/icon-192.png", - "/icons/icon-512.png", - "/icons/apple-touch-icon.png", + `${SCOPE}manifest.webmanifest`, + `${SCOPE}icons/icon-192.png`, + `${SCOPE}icons/icon-512.png`, + `${SCOPE}icons/apple-touch-icon.png`, ]; self.addEventListener("install", (event) => { @@ -42,7 +53,7 @@ self.addEventListener("fetch", (event) => { if (url.origin !== self.location.origin) return; // Session data and live agent traffic must always come from the local server. - if (url.pathname.startsWith("/api/") || url.pathname === "/sw.js") return; + if (url.pathname.startsWith(`${SCOPE}api/`) || url.pathname === `${SCOPE}sw.js`) return; if (request.mode === "navigate") { event.respondWith( @@ -55,7 +66,7 @@ self.addEventListener("fetch", (event) => { } const isStaticAsset = - url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith(`${SCOPE}_next/static/`) || PRECACHE_URLS.includes(url.pathname); if (isStaticAsset) { From 9ac06cf2402ba9442adbfb22c62946db51414703 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 19:29:21 +0800 Subject: [PATCH 2/4] fix: guard oversized message rendering (100KB+) against browser freeze Multi-hundred-KB messages (pasted HAR/log dumps) freeze the browser because react-markdown + KaTeX + syntax highlighting run on the entire payload. SafeMarkdownBody renders such messages as a click-to-reveal plain-text
 instead; applies to user messages and assistant text
blocks. i18n: en/zh-CN.
---
 .gitignore                 |  2 +-
 components/MessageView.tsx | 66 ++++++++++++++++++++++++++++++++++++--
 lib/i18n/messages/en.ts    |  1 +
 lib/i18n/messages/zh-CN.ts |  1 +
 4 files changed, 67 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index bf1cf2383..3a44c749f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,4 +39,4 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 next-env.d.ts
-.factory
+.factory
\ No newline at end of file
diff --git a/components/MessageView.tsx b/components/MessageView.tsx
index d73bee37f..826d3a41e 100644
--- a/components/MessageView.tsx
+++ b/components/MessageView.tsx
@@ -24,6 +24,68 @@ import type {
 const MAX_THINKING_CACHE_ENTRIES = 100;
 const thinkingContentCache = new Map>();
 
+// Messages larger than this skip markdown rendering entirely. react-markdown +
+// KaTeX + syntax highlighting on multi-hundred-KB payloads (e.g. pasted HAR or
+// log dumps) freezes the browser main thread.
+const MAX_MARKDOWN_CHARS = 100_000;
+
+function formatMessageBytes(n: number): string {
+  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`;
+  if (n >= 1_000) return `${Math.round(n / 1_000)} KB`;
+  return `${n} B`;
+}
+
+/**
+ * MarkdownBody with an oversized-content guard: huge messages render as a
+ * click-to-reveal plain-text 
 instead of running the markdown pipeline.
+ */
+function SafeMarkdownBody({ children, className, ...props }: React.ComponentProps) {
+  const { t } = useI18n();
+  const [showRaw, setShowRaw] = useState(false);
+
+  if (children.length <= MAX_MARKDOWN_CHARS) {
+    return {children};
+  }
+  if (!showRaw) {
+    return (
+      
+    );
+  }
+  return (
+    
+
+        {children}
+      
+
+ ); +} + function loadThinkingContent(sessionId: string, entryId: string, blockIndex: number): Promise { const key = `${sessionId}:${entryId}:${blockIndex}`; const cached = thinkingContentCache.get(key); @@ -222,7 +284,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o })} )} - {content && {content}} + {content && {content}} @@ -620,7 +682,7 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal } function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) { - return {block.text}; + return {block.text}; } function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..155d5e1ea 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -409,6 +409,7 @@ export const enLocale: LocalePlugin = { "i18n.packageEnabled": "Package enabled.", "i18n.sessionReloaded": "Session reloaded.", "i18n.thinking": "Thinking", + "i18n.largeMessageReveal": "Message content is very large ({size}). Click to view as plain text — markdown rendering is disabled to keep the page responsive.", "i18n.loadingThinking": "Loading thinking...", "i18n.copyMessage": "Copy message", "i18n.editFromHere": "Edit from here", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..d2ea2aa74 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -409,6 +409,7 @@ export const zhCNLocale: LocalePlugin = { "i18n.packageEnabled": "包已启用。", "i18n.sessionReloaded": "会话已重新加载。", "i18n.thinking": "思考", + "i18n.largeMessageReveal": "消息内容过大({size})。点击以纯文本查看 — 已禁用 markdown 渲染以避免页面卡顿。", "i18n.loadingThinking": "正在加载思考内容...", "i18n.copyMessage": "复制消息", "i18n.editFromHere": "从此处编辑", From 94520277a4256915dcd402032cd9c88e017c8ab5 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 19:43:32 +0800 Subject: [PATCH 3/4] fix: production builds must not inherit dev basePath; manifest basePath-aware - next.config: ignore PI_WEB_BASE_PATH during production builds unless PI_WEB_BUILD_BASEPATH=1 (builds spawned from the dev-server env otherwise bake a /dev asset prefix into .next and break the root deployment) - app/manifest.ts: start_url/scope/icons via withBasePath for sub-path PWA - .gitignore: ignore .playwright-mcp test artifacts --- .gitignore | 5 ++++- app/manifest.ts | 11 ++++++----- next.config.ts | 19 ++++++++++++++++--- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 3a44c749f..8d46769dd 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -.factory \ No newline at end of file +.factory + +# Playwright MCP test artifacts +.playwright-mcp/ diff --git a/app/manifest.ts b/app/manifest.ts index 37ab7a342..ebb4c5cf9 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,13 +1,14 @@ import type { MetadataRoute } from "next"; +import { withBasePath } from "@/lib/base-path"; export default function manifest(): MetadataRoute.Manifest { return { - id: "/", + id: withBasePath("/"), name: "Pi Web", short_name: "Pi Web", description: "Local web interface for the pi coding agent", - start_url: "/", - scope: "/", + start_url: withBasePath("/"), + scope: withBasePath("/"), display: "standalone", background_color: "#1a1a1a", theme_color: "#1a1a1a", @@ -16,13 +17,13 @@ export default function manifest(): MetadataRoute.Manifest { lang: "en", icons: [ { - src: "/icons/icon-192.png", + src: withBasePath("/icons/icon-192.png"), sizes: "192x192", type: "image/png", purpose: "any", }, { - src: "/icons/icon-512.png", + src: withBasePath("/icons/icon-512.png"), sizes: "512x512", type: "image/png", purpose: "any", diff --git a/next.config.ts b/next.config.ts index f45d9afdd..c002a7c4a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,9 +10,22 @@ try { } catch { /* package not found, use default */ } // Optional sub-path deployment, e.g. PI_WEB_BASE_PATH=/dev serves the app at -// https://host/dev/. Client code reads NEXT_PUBLIC_BASE_PATH via -// lib/base-path.ts. Empty (default) = root deployment. -const basePath = (process.env.PI_WEB_BASE_PATH ?? "").replace(/\/+$/, ""); +// https://host/dev/. Empty string = root deployment. +// +// Safety: a production build must never accidentally inherit the dev server's +// basePath (builds spawned from the dev process env do). If PI_WEB_BASE_PATH +// is set during a production build, it is ignored unless the operator +// explicitly opts in with PI_WEB_BUILD_BASEPATH=1. +const envBasePath = (process.env.PI_WEB_BASE_PATH ?? "").replace(/\/+$/, ""); +const isProdBuild = process.env.NODE_ENV === "production" && !process.env.PI_WEB_DEV_DIST; +const allowProdBasePath = process.env.PI_WEB_BUILD_BASEPATH === "1"; +const basePath = + isProdBuild && envBasePath && !allowProdBasePath + ? (console.warn( + `[pi-web] Ignoring PI_WEB_BASE_PATH="${envBasePath}" for the production build (set PI_WEB_BUILD_BASEPATH=1 to allow it).`, + ), + "") + : envBasePath; const nextConfig: NextConfig = { basePath: basePath || undefined, From 94798d32ad0b064008a70e618819394316168045 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 22:31:36 +0800 Subject: [PATCH 4/4] fix: /api/models fetch goes through apiUrl under basePath The models list endpoint was still hardcoded, so with a sub-path deployment (/dev) GET /api/models 404'd and the model selector failed to load. --- hooks/useAgentSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 422dac43d..e5de3580d 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1464,7 +1464,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const loadModels = useCallback(async (signal?: AbortSignal) => { const modelCwd = newSessionCwd ?? session?.cwd ?? ""; - const modelsUrl = modelCwd ? `/api/models?cwd=${encodeURIComponent(modelCwd)}` : "/api/models"; + const modelsUrl = modelCwd ? apiUrl(`/api/models?cwd=${encodeURIComponent(modelCwd)}`) : apiUrl("/api/models"); const res = await fetch(modelsUrl, signal ? { signal } : undefined); if (!res.ok) throw new Error(`HTTP ${res.status}`); const d = await res.json() as ModelsResponse;