diff --git a/AGENTS.md b/AGENTS.md index ad4735ecb..244dc799e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ app/api/ models-config/discover/route.ts POST fetch a configured provider's upstream model list models-config/test/route.ts POST test a configured model/provider plugins/route.ts GET/POST package plugin management + project-directories/route.ts GET/POST/DELETE persisted sidebar directories skills/route.ts GET/PATCH loaded skills and disable-model-invocation skills/install/route.ts POST install skills through npx skills add skills/search/route.ts GET/POST skills.sh search @@ -76,6 +77,7 @@ lib/ markdown.ts shared markdown helpers npx.ts npx runner used by skill install pi-types.ts local structural types for pi SDK objects + project-directories.ts persisted directory list in ~/.pi/agent/pi-web-projects.json rpc-manager.ts AgentSessionWrapper + registry + startRpcSession session-reader.ts SessionManager wrappers + path cache + buildSessionContext adapter tool-presets.ts PRESET_NONE/DEFAULT/FULL + getPresetFromTools() @@ -182,6 +184,13 @@ Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `au - `hooks/useAudio.ts` stores the toggle in `localStorage` as `pi-sound-enabled` and reuses one `AudioContext`. - Browser autoplay policy means sound must be unlocked from a user gesture; `ChatInput` calls the unlock hook from interactive controls, and `ChatWindow` plays the tone from `onAgentEnd`. +### PWA 版本与 Service Worker 更新策略 +- 生产环境必须使用每次构建唯一的版本标识注册 `/sw.js?v=`,静态缓存名称也必须包含同一个版本;不能只使用长期不变的 `package.json` 版本,否则代码变化后浏览器可能继续命中旧 chunk。 +- 新 Service Worker 安装完成后保持 `waiting`,由界面提示“发现新版本”;用户确认后发送 `SKIP_WAITING`,并在 `controllerchange` 后刷新页面。不要在 `install` 阶段无条件调用 `skipWaiting()`。 +- 激活新 Service Worker 时只清理 `pi-web-` 前缀下的旧版本缓存,不得清理其它站点数据或认证信息。`/sw.js`、页面导航和 API 请求必须绕过静态资源缓存。 +- 开发环境不注册 Service Worker,并在 Next 客户端代码加载前注销同源旧注册、删除 `pi-web-` 缓存;清理完成后若页面仍被旧 worker 控制,只自动重载一次,避免刷新循环。 +- 重启 8081 的 Node/Next.js 进程不会注销浏览器中的 Service Worker。已有标签页或独立 PWA 窗口可能继续由旧 worker 控制,必须让页面重新加载并执行清理逻辑;必要时关闭该同源的全部页面后重新打开。 + ### Exported session HTML - `/api/sessions/[id]/export` delegates to pi's export helper, then patches recursive tree helpers in the generated HTML to iterative versions so very deep linear sessions do not overflow the browser call stack. diff --git a/README.md b/README.md index 0f666c087..89b85149f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pi Web -[中文文档](./README.zh-CN.md) | [日本語](./README.ja.md) | [Русский](./README.ru.md) +[中文文档](./README.zh-CN.md) | [日本語](./README.ja.md) Local web UI for the [pi coding agent](https://github.com/badlogic/pi-mono). Pi Web reads your local pi session files and gives you a browser workspace for session browsing, real-time chat, model configuration, skill management, and project file preview. @@ -38,13 +38,10 @@ pi-web --no-open # do not open the browser automatically PORT=8080 pi-web # environment variable is also supported PI_WEB_HOSTNAME=0.0.0.0 pi-web # explicit network exposure PI_WEB_ALLOWED_HOSTS=pi-web.internal pi-web # allow an exact proxy/custom hostname -PI_WEB_PASSWORD='a-long-random-password' pi-web # require Basic Auth (username: pi) PI_WEB_NO_OPEN=1 pi-web # useful when running as a background service ``` -Set `PI_WEB_PASSWORD` to protect the web interface and every API endpoint with HTTP Basic Auth. The username is always `pi`. Leaving the variable unset or empty disables authentication. - -Pi Web can invoke a high-privilege agent. Basic Auth does not encrypt the password in transit, so do not expose plain HTTP to the internet. Use HTTPS through a trusted reverse proxy or a trusted VPN for remote access. +On first access, Pi Web asks you to create an access password. The password hash and session secret are stored in `~/.pi/agent/pi-web-auth.json` with private file permissions. Pi Web can invoke a high-privilege agent, so do not expose it directly to the internet; only use non-loopback bindings on a trusted network or behind a properly secured reverse proxy. API requests accept loopback names, IP literals, the selected bind hostname, and exact comma-separated names in `PI_WEB_ALLOWED_HOSTS`. Configure that variable when a trusted reverse proxy uses a different external hostname. ## HTTP Proxy @@ -113,7 +110,7 @@ Avoid running `next build` / `npm run build` during local development. It writes app/ api/ agent/ # creates/drives AgentSession and exposes SSE events - auth/ # OAuth and API key management + auth/ # password sessions, OAuth, and API key management cwd/browse/ # browsable server directory listing cwd/validate/ # custom working directory validation default-cwd/ # pi default working directory lookup diff --git a/README.zh-CN.md b/README.zh-CN.md index ae0820071..94f734737 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Pi Web -[English](./README.md) | [日本語](./README.ja.md) | [Русский](./README.ru.md) +[English](./README.md) | [日本語](./README.ja.md) [pi 编程智能体](https://github.com/badlogic/pi-mono) 的本地网页界面。它会读取本机的 pi 会话文件,在浏览器里提供会话管理、实时对话、模型配置、技能管理和项目文件预览。 @@ -36,13 +36,10 @@ pi-web --no-open # 不自动打开浏览器 PORT=8080 pi-web # 也支持环境变量 PI_WEB_HOSTNAME=0.0.0.0 pi-web # 显式开放网络访问 PI_WEB_ALLOWED_HOSTS=pi-web.internal pi-web # 允许指定的代理或自定义主机名 -PI_WEB_PASSWORD='足够长的随机密码' pi-web # 启用 Basic Auth(用户名固定为 pi) PI_WEB_NO_OPEN=1 pi-web # 适用于后台服务或开机自启 ``` -设置 `PI_WEB_PASSWORD` 后,网页和所有 API 端点都会启用 HTTP Basic Auth,用户名固定为 `pi`。未设置或设置为空值时不启用认证。 - -Pi Web 可以调用高权限智能体。Basic Auth 不会加密传输中的密码,因此不要把明文 HTTP 暴露到互联网。远程访问时应使用可信反向代理提供 HTTPS,或通过可信 VPN 访问。 +首次访问 Pi Web 时需要设置访问密码。密码哈希和会话密钥保存在 `~/.pi/agent/pi-web-auth.json`,文件权限仅限当前用户。Pi Web 可以调用高权限智能体,请勿直接暴露到互联网;仅在可信网络中使用非 loopback 监听地址,或放在安全配置的反向代理之后。 API 请求仅接受 loopback 名称、IP 字面量、当前监听主机名,以及 `PI_WEB_ALLOWED_HOSTS` 中以逗号分隔的精确主机名。可信反向代理使用不同的外部主机名时,请配置该变量。 ## HTTP 代理 @@ -109,7 +106,7 @@ npm run lint app/ api/ agent/ # 创建/驱动 AgentSession,提供 SSE 事件流 - auth/ # OAuth 和 API key 管理 + auth/ # 密码会话、OAuth 和 API key 管理 cwd/browse/ # 服务端目录浏览 cwd/validate/ # 自定义工作目录校验 default-cwd/ # 获取 pi 默认工作目录 diff --git a/app/api/agent/[id]/events/route.ts b/app/api/agent/[id]/events/route.ts index 1a6f95710..20a53d61f 100644 --- a/app/api/agent/[id]/events/route.ts +++ b/app/api/agent/[id]/events/route.ts @@ -1,4 +1,4 @@ -import { resolveSessionPath } from "@/lib/session-reader"; +import { resolveSessionPath, invalidateSessionListCache } from "@/lib/session-reader"; import { getRpcSession, startRpcSession, type AgentEvent } from "@/lib/rpc-manager"; export const dynamic = "force-dynamic"; @@ -16,6 +16,18 @@ function toClientEvent(event: AgentEvent): AgentEvent | null { return event; } +// 会话文件在第一条 assistant 消息之后的任一条 entry 落盘(pi 的 flushed 机制), +// 而 /api/sessions 有 30s 列表缓存。新会话落盘后必须失效缓存,否则左侧列表 +// 要等缓存过期或刷新页面才会出现。这些事件都发生在 entry 落盘前后, +// 在这里失效缓存即可让下一次列表拉取扫到新会话。 +function isSessionFlushBoundaryEvent(event: { type?: string; message?: { role?: string } }): boolean { + if (event.type === "message_end") { + // user 消息落盘同样可能触发 flushed(assistant 已存在时) + return true; + } + return event.type === "tool_execution_end" || event.type === "agent_end"; +} + // GET /api/agent/[id]/events - SSE stream of agent events export async function GET( req: Request, @@ -49,6 +61,10 @@ export async function GET( encode({ type: "connected", sessionId: id }); const unsubscribe = session.onEvent((event) => { + // 新会话文件落盘后立即失效列表缓存(见 isSessionFlushBoundaryEvent 注释) + if (isSessionFlushBoundaryEvent(event as { type?: string; message?: { role?: string } })) { + invalidateSessionListCache(); + } const clientEvent = toClientEvent(event); if (clientEvent) encode(clientEvent); }); diff --git a/app/api/project-directories/route.ts b/app/api/project-directories/route.ts new file mode 100644 index 000000000..cebbe465d --- /dev/null +++ b/app/api/project-directories/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { allowFileRoot } from "@/lib/file-access"; +import { + addProjectDirectory, + normalizeProjectDirectory, + readProjectDirectories, + removeProjectDirectory, +} from "@/lib/project-directories"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const projects = readProjectDirectories(); + projects.forEach(allowFileRoot); + return NextResponse.json({ projects }); +} + +export async function POST(request: Request) { + try { + const body = await request.json() as { cwd?: unknown }; + const cwd = normalizeProjectDirectory(body.cwd); + allowFileRoot(cwd); + return NextResponse.json({ projects: addProjectDirectory(cwd), cwd }); + } catch (error) { + console.error("保存 Pi Web 项目目录失败", error); + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }); + } +} + +export async function DELETE(request: Request) { + try { + const body = await request.json() as { cwd?: unknown }; + if (typeof body.cwd !== "string" || !body.cwd.trim()) { + return NextResponse.json({ error: "cwd required" }, { status: 400 }); + } + return NextResponse.json({ projects: removeProjectDirectory(body.cwd.trim()) }); + } catch (error) { + console.error("移除 Pi Web 项目目录失败", error); + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index 9303a66a9..0f57bea3d 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { @@ -12,6 +12,7 @@ import { } from "@/lib/session-reader"; import { sessionPathKey } from "@/lib/session-path"; import { getRpcSession } from "@/lib/rpc-manager"; +import { trashSessionFile } from "@/lib/trash"; // BranchNavigator still traverses recursively, so keep the response tree shallow. const MAX_PROJECTED_TREE_DEPTH = 200; @@ -193,7 +194,6 @@ export async function PATCH( return NextResponse.json({ error: String(error) }, { status: 500 }); } } - // DELETE /api/sessions/[id] export async function DELETE( _req: Request, @@ -238,7 +238,7 @@ export async function DELETE( } catch { /* skip if dir unreadable */ } await getRpcSession(id)?.shutdown(); - unlinkSync(filePath); + trashSessionFile(filePath); invalidateSessionPathCache(id); invalidateSessionListCache(); return NextResponse.json({ ok: true }); diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index c575b4b80..b52e9fdea 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -1,11 +1,21 @@ import { NextResponse } from "next/server"; -import { listAllSessions } from "@/lib/session-reader"; +import { listAllSessions, invalidateSessionListCache } from "@/lib/session-reader"; import { getRunningRpcSessionIds } from "@/lib/rpc-manager"; export async function GET() { try { - const sessions = await listAllSessions(); - return NextResponse.json({ sessions, runningSessionIds: getRunningRpcSessionIds() }); + const runningSessionIds = getRunningRpcSessionIds(); + let sessions = await listAllSessions(); + + // 兜底:正在运行的会话不在列表里,说明其 .jsonl 文件刚落盘而列表缓存 + // 还是旧的(SSE 断线等场景可能漏掉 events 路由里的失效)。失效后重扫一次。 + const missingRunning = runningSessionIds.filter((id) => !sessions.some((s) => s.id === id)); + if (missingRunning.length > 0) { + invalidateSessionListCache(); + sessions = await listAllSessions(); + } + + return NextResponse.json({ sessions, runningSessionIds }); } catch (error) { return NextResponse.json( { error: String(error) }, diff --git a/app/api/trash/[name]/route.ts b/app/api/trash/[name]/route.ts new file mode 100644 index 000000000..bb718fd11 --- /dev/null +++ b/app/api/trash/[name]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { restoreTrashedSession } from "@/lib/trash"; +import { cacheSessionPath, invalidateSessionListCache } from "@/lib/session-reader"; + +// POST /api/trash/[name] — 恢复回收站中的会话到原 cwd +export async function POST( + _req: Request, + { params }: { params: Promise<{ name: string }> } +) { + const { name } = await params; + try { + const { restoredPath, sessionId } = restoreTrashedSession(name); + // 重建路径缓存并让侧边栏会话列表重新扫描 + cacheSessionPath(sessionId, restoredPath); + invalidateSessionListCache(); + return NextResponse.json({ ok: true, path: restoredPath }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 400 }); + } +} diff --git a/app/api/trash/route.ts b/app/api/trash/route.ts new file mode 100644 index 000000000..c8e6d4327 --- /dev/null +++ b/app/api/trash/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { clearTrashedSessions, listTrashedSessions, purgeTrashedSession } from "@/lib/trash"; + +// GET /api/trash — 列出回收站中的会话(按最后更新时间倒序) +export async function GET() { + return NextResponse.json({ sessions: listTrashedSessions() }); +} + +// DELETE /api/trash?name= — 从回收站彻底删除单个会话 +// DELETE /api/trash — 清空回收站中的全部会话 +export async function DELETE(req: Request) { + const { searchParams } = new URL(req.url); + const name = searchParams.get("name"); + // 只有未传 name 参数才清空;空字符串走单删分支,由 assertTrashFileName 拒绝 + if (name !== null) { + try { + purgeTrashedSession(name); + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 400 }); + } + } + return NextResponse.json({ ok: true, removed: clearTrashedSessions() }); +} diff --git a/app/globals.css b/app/globals.css index 1c0e917bd..02f621cd1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -72,13 +72,9 @@ html.dark { } html, body { - width: 100%; - height: 100vh; - height: 100dvh; - height: var(--app-viewport-height, 100dvh); + height: 100%; margin: 0; padding: 0; - overflow: hidden; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; @@ -87,8 +83,15 @@ html, body { } body { - display: flex; - flex-direction: column; + height: 100vh; + height: 100dvh; + overflow: hidden; +} + +/* 根布局容器:vh 回退保证不支持 dvh 的浏览器/WebView 高度正确, + 否则 body 可滚动,顶栏会被滚出视口(对话变长后尤其明显)。 */ +.app-shell-root { + height: 100vh; } ::-webkit-scrollbar { @@ -116,9 +119,6 @@ pre, code { /* react-markdown output styles */ .markdown-body { - min-width: 0; - max-width: 100%; - overflow-x: hidden; font-size: 14px; line-height: 1.7; color: var(--text); @@ -366,9 +366,6 @@ pre, code { .markdown-code-block { position: relative; - width: 100%; - min-width: 0; - max-width: 100%; margin: 6px 0; border: 1px solid var(--border); border-radius: 7px; @@ -962,7 +959,7 @@ span.linenumber { .right-panel-container { position: fixed; top: 0; - right: env(safe-area-inset-right); + right: 0; bottom: 0; z-index: 250; width: min(560px, calc(100vw - 48px)); @@ -992,11 +989,10 @@ span.linenumber { .sidebar-container { position: fixed !important; top: 0; - left: env(safe-area-inset-left); + left: 0; bottom: 0; width: 280px; max-width: 85vw; - height: var(--app-viewport-height, 100dvh); transition: transform 0.25s ease, box-shadow 0.25s ease; } .sidebar-container.sidebar-open { @@ -1004,20 +1000,19 @@ span.linenumber { box-shadow: 4px 0 20px rgba(0,0,0,0.15); } .sidebar-container.sidebar-closed { - transform: translateX(calc(-100% - env(safe-area-inset-left))); + transform: translateX(-100%); box-shadow: none; } .right-panel-container { position: fixed; - inset: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left); - height: var(--app-viewport-height, 100dvh); + inset: 0; z-index: 250; overflow: hidden; transition: transform 0.2s ease; } .right-panel-container.right-panel-open { - width: auto; + width: 100%; min-width: 0; } .right-panel-container.right-panel-closed { @@ -1046,9 +1041,4 @@ span.linenumber { flex-wrap: wrap; padding-bottom: max(10px, env(safe-area-inset-bottom)) !important; } - textarea, - input, - select { - font-size: 16px !important; - } } diff --git a/app/layout.tsx b/app/layout.tsx index 054989f56..9482435aa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Noto_Sans_Mono } from "next/font/google"; +import Script from "next/script"; import { PwaRegistration } from "@/components/PwaRegistration"; import "katex/dist/katex.min.css"; import "./globals.css"; @@ -10,6 +11,25 @@ const notoSansMono = Noto_Sans_Mono({ display: "swap", }); +const DEV_PWA_CLEANUP_SCRIPT = `(function(){ + if (!("serviceWorker" in navigator)) return; + Promise.all([ + navigator.serviceWorker.getRegistrations().then(function(registrations){ + return Promise.all(registrations.map(function(registration){ return registration.unregister(); })); + }), + ("caches" in window) ? caches.keys().then(function(keys){ + return Promise.all(keys.filter(function(key){ return key.indexOf("pi-web-") === 0; }).map(function(key){ return caches.delete(key); })); + }) : Promise.resolve() + ]).then(function(){ + if (navigator.serviceWorker.controller && !sessionStorage.getItem("pi-web-dev-sw-cleaned")) { + sessionStorage.setItem("pi-web-dev-sw-cleaned", "1"); + location.reload(); + } else { + sessionStorage.removeItem("pi-web-dev-sw-cleaned"); + } + }).catch(function(error){ console.error("PWA 开发缓存清理失败", error); }); +})();`; + export const metadata: Metadata = { title: "Pi Web", description: "Pi Web interface for the pi coding agent", @@ -66,6 +86,9 @@ export default function RootLayout({ __html: `(function(){try{var t=localStorage.getItem("pi-theme");if(t==="dark")document.documentElement.classList.add("dark")}catch(e){}})();`, }} /> + {process.env.NODE_ENV !== "production" && ( +