Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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=<build-version>`,静态缓存名称也必须包含同一个版本;不能只使用长期不变的 `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.

Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -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 会话文件,在浏览器里提供会话管理、实时对话、模型配置、技能管理和项目文件预览。

Expand Down Expand Up @@ -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 代理
Expand Down Expand Up @@ -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 默认工作目录
Expand Down
18 changes: 17 additions & 1 deletion app/api/agent/[id]/events/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down
41 changes: 41 additions & 0 deletions app/api/project-directories/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
6 changes: 3 additions & 3 deletions app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
16 changes: 13 additions & 3 deletions app/api/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -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) },
Expand Down
20 changes: 20 additions & 0 deletions app/api/trash/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
24 changes: 24 additions & 0 deletions app/api/trash/route.ts
Original file line number Diff line number Diff line change
@@ -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=<fileName> — 从回收站彻底删除单个会话
// 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() });
}
40 changes: 15 additions & 25 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -992,32 +989,30 @@ 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 {
transform: translateX(0);
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 {
Expand Down Expand Up @@ -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;
}
}
Loading