feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { tmpdir } from "node:os";
import { Readable } from "node:stream";
import {
MAX_INLINE_BASH_OUTPUT_BYTES,
openRegularFileNoFollow,
readUtf8FileWithinLimit,
resolveBashOutputPath,
} from "@/lib/bash-output";
import { isBashOutputPathReferencedBySession } from "@/lib/session-file-references";
// GET /api/agent/[id]/bash-output?path=<absPath>
// Reads a bash output temp file referenced by this session. Inline display is
// size-limited; download responses stream the file without buffering it.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
let path: string | null = null;
let download = false;
try {
const url = new URL(_req.url);
path = url.searchParams.get("path");
download = url.searchParams.get("download") === "1";
} catch {
return NextResponse.json({ error: "invalid url" }, { status: 400 });
}
if (!path) {
return NextResponse.json({ error: "path required" }, { status: 400 });
}
const resolved = resolveBashOutputPath(path, tmpdir());
if (!resolved) {
return NextResponse.json({ error: "invalid path" }, { status: 400 });
}
if (!await isBashOutputPathReferencedBySession(resolved, id)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
try {
if (download) {
const { handle } = await openRegularFileNoFollow(resolved);
const stream = Readable.toWeb(handle.createReadStream()) as ReadableStream<Uint8Array>;
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "attachment; filename=\"bash-output.log\"",
"Cache-Control": "no-store",
},
});
}
const result = await readUtf8FileWithinLimit(resolved);
if (result.tooLarge) {
return NextResponse.json({
error: `Full output is too large to display (limit ${MAX_INLINE_BASH_OUTPUT_BYTES} bytes)`,
data: { size: result.size, maxBytes: MAX_INLINE_BASH_OUTPUT_BYTES },
}, { status: 413 });
}
return NextResponse.json({ success: true, data: { output: result.content } });
} catch {
return NextResponse.json({ error: "full output unavailable" }, { status: 404 });
}
}
+71
View File
@@ -0,0 +1,71 @@
import { resolveSessionPath } from "@/lib/session-reader";
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
import { SessionManager } from "@earendil-works/pi-coding-agent";
export const dynamic = "force-dynamic";
// GET /api/agent/[id]/events - SSE stream of agent events
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Fast path: already-running session
let session = getRpcSession(id);
if (!session || !session.isAlive()) {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return new Response("Session not found", { status: 404 });
}
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
try {
({ session } = await startRpcSession(id, filePath, cwd));
} catch (error) {
return new Response(`Failed to start agent: ${error}`, { status: 500 });
}
}
const stream = new ReadableStream({
start(controller) {
const encode = (data: unknown) => {
const text = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(text));
};
// Send initial connected event
encode({ type: "connected", sessionId: id });
const unsubscribe = session.onEvent((event) => {
encode(event);
});
// Heartbeat every 30s to prevent server/proxy timeout (Next.js default ~120-150s)
const heartbeat = setInterval(() => {
try {
controller.enqueue(new TextEncoder().encode(":\n\n"));
} catch {
// controller already closed
}
}, 30_000);
// Cleanup when client disconnects
const cleanup = () => {
clearInterval(heartbeat);
unsubscribe();
controller.close();
};
// Detect client disconnect via abort signal
req.signal?.addEventListener("abort", cleanup);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { resolveSessionPath } from "@/lib/session-reader";
import { startRpcSession, getRpcSession } from "@/lib/rpc-manager";
import { SessionManager } from "@earendil-works/pi-coding-agent";
// POST /api/agent/[id] - Send a command to an existing session
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const body = await req.json() as { type: string; [key: string]: unknown };
// Fast path: already-running session
const existing = getRpcSession(id);
if (existing?.isAlive()) {
const result = await existing.send(body);
return NextResponse.json({ success: true, data: result });
}
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
const { session } = await startRpcSession(id, filePath, cwd);
const result = await session.send(body);
return NextResponse.json({ success: true, data: result });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// GET /api/agent/[id] - Get current agent state
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const session = getRpcSession(id);
if (!session || !session.isAlive()) {
return NextResponse.json({ running: false });
}
const state = await session.send({ type: "get_state" });
return NextResponse.json({ running: true, state });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}