mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
import { generateSessionTitle } from "@/lib/session-title";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
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 existing = getRpcSession(id);
|
||||
const { session } = existing?.isAlive()
|
||||
? { session: existing }
|
||||
: await startRpcSession(id, filePath, cwd);
|
||||
|
||||
// globalThis keeps wrappers alive across dev hot reloads; older instances
|
||||
// may predate waitUntilReady(), but those have already completed startup.
|
||||
await session.waitUntilReady?.();
|
||||
const result = await generateSessionTitle(session.inner as unknown as AgentSession);
|
||||
|
||||
if (!session.isAlive()) {
|
||||
return NextResponse.json(
|
||||
{ error: "The session was closed while its title was being generated. Please try again." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
session.inner.setSessionName(result.title);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ title: result.title, usage: result.usage ?? null });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import { resolveSessionPath, buildSessionContext } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const url = new URL(req.url);
|
||||
const leafId = url.searchParams.get("leafId") ?? undefined;
|
||||
const deferThinking = url.searchParams.has("deferThinking");
|
||||
const deferToolResultImages = url.searchParams.has("deferMedia");
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const context = buildSessionContext(sm.getEntries() as never, leafId, {
|
||||
deferThinking,
|
||||
deferToolResultImages,
|
||||
});
|
||||
|
||||
return NextResponse.json({ context });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionEntries, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string; entryId: string }> },
|
||||
) {
|
||||
const { id, entryId } = await params;
|
||||
const blockIndexParam = new URL(req.url).searchParams.get("blockIndex");
|
||||
const blockIndex = blockIndexParam === null ? Number.NaN : Number(blockIndexParam);
|
||||
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) {
|
||||
return NextResponse.json({ error: "Valid blockIndex is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
|
||||
// SessionManager-backed parsing preserves the SDK's malformed-line tolerance.
|
||||
const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId);
|
||||
if (!entry || entry.type !== "message" || entry.message.role !== "assistant") {
|
||||
return NextResponse.json({ error: "Assistant message not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const block = entry.message.content[blockIndex];
|
||||
if (!block || block.type !== "thinking") {
|
||||
return NextResponse.json({ error: "Thinking block not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ thinking: block.thinking });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { execFile } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { promisify } from "util";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PiCodingAgentModule = {
|
||||
getPackageDir: () => string;
|
||||
};
|
||||
|
||||
type ExportHtmlModule = {
|
||||
exportFromFile: (inputPath: string, outputPath: string) => Promise<string>;
|
||||
};
|
||||
|
||||
async function getPiPackageDir(): Promise<string | null> {
|
||||
try {
|
||||
const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule;
|
||||
return getPackageDir();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(fileName: string, inline: boolean): string {
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html";
|
||||
const disposition = inline ? "inline" : "attachment";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
async function getPiCliPath(): Promise<string | null> {
|
||||
const candidates = new Set<string>();
|
||||
const packageDir = await getPiPackageDir();
|
||||
|
||||
if (packageDir) {
|
||||
candidates.add(join(packageDir, "dist", "cli.js"));
|
||||
}
|
||||
|
||||
try {
|
||||
const resolver = (import.meta as ImportMeta & {
|
||||
resolve?: (specifier: string) => string | Promise<string>;
|
||||
}).resolve;
|
||||
if (typeof resolver === "function") {
|
||||
const indexUrl = await resolver("@earendil-works/pi-coding-agent");
|
||||
candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js"));
|
||||
}
|
||||
} catch {
|
||||
// Next.js production bundles can strip import.meta.resolve.
|
||||
}
|
||||
|
||||
candidates.add(
|
||||
join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js"
|
||||
)
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the exported HTML to fix recursive functions that overflow
|
||||
* the call stack on deep linear session trees (e.g., 5000+ entries).
|
||||
*
|
||||
* ## Root Cause
|
||||
* pi-coding-agent's template.js uses recursive helpers to render and
|
||||
* navigate the session tree in the exported HTML:
|
||||
*
|
||||
* 1. sortChildren(node) — recursively sorts children of every node.
|
||||
* Calls itself via node.children.forEach(sortChildren).
|
||||
* On a 5527-entry linear chain (no branches), this recurses 5527
|
||||
* levels deep → stack overflow.
|
||||
*
|
||||
* 2. mapNodes(node) — recursively indexes tree nodes the first time
|
||||
* a tree item is clicked. Same depth -> same overflow.
|
||||
*
|
||||
* 3. markActive(node) — recursively marks nodes on the active path.
|
||||
* Calls itself via markActive(child) for each child.
|
||||
* Same depth → same overflow.
|
||||
*
|
||||
* Both functions are inlined in the HTML by pi-coding-agent at export
|
||||
* time. We cannot modify template.js directly (it's in node_modules
|
||||
* and would be overwritten on npm install). Instead, we patch the
|
||||
* generated HTML string before returning it to the client.
|
||||
*
|
||||
* ## Fix
|
||||
* Replace each recursive function with an iterative equivalent:
|
||||
*
|
||||
* sortChildren → explicit stack (DFS pre-order, push children in
|
||||
* reverse to maintain order)
|
||||
* mapNodes → explicit stack (DFS pre-order)
|
||||
* markActive → two-stack post-order (stack1 for traversal,
|
||||
* stack2 for processing children before parent)
|
||||
*
|
||||
* ## Line Ending Normalization
|
||||
* This file (route.ts) uses CRLF (Windows), while template.js uses LF
|
||||
* (Unix). The template strings in the backtick literals inherit the
|
||||
* file's CRLF line endings. At runtime, readFileSync() also returns
|
||||
* CRLF on Windows. We normalize everything to LF before matching.
|
||||
*
|
||||
* The helper `n(s)` strips \r\n → \n on both the HTML and the
|
||||
* replacement strings, ensuring cross-platform matching.
|
||||
*/
|
||||
function patchExportHtml(html: string): string {
|
||||
// Normalize line endings: route.ts is CRLF, template.js is LF.
|
||||
// Without this, the replace() below would fail on Windows.
|
||||
const n = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
html = n(html);
|
||||
|
||||
const replaceRequired = (source: string, name: string, search: string, replacement: string) => {
|
||||
const normalizedSearch = n(search);
|
||||
const normalizedReplacement = n(replacement);
|
||||
const matches = source.split(normalizedSearch).length - 1;
|
||||
if (matches !== 1) {
|
||||
throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`);
|
||||
}
|
||||
return source.replace(normalizedSearch, normalizedReplacement);
|
||||
};
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"sortChildren",
|
||||
` function sortChildren(node) {
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
node.children.forEach(sortChildren);
|
||||
}`,
|
||||
` function sortChildren(root) {
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"mapNodes",
|
||||
` function mapNodes(node) {
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
node.children.forEach(mapNodes);
|
||||
}
|
||||
tree.forEach(mapNodes);`,
|
||||
` const stack = [...tree].reverse();
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"markActive",
|
||||
` function markActive(node) {
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (markActive(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
return has;
|
||||
}`,
|
||||
` function markActive(root) {
|
||||
// Post-order traversal using two stacks
|
||||
const stack1 = [root];
|
||||
const stack2 = [];
|
||||
while (stack1.length) {
|
||||
const node = stack1.pop();
|
||||
stack2.push(node);
|
||||
for (const child of node.children) {
|
||||
stack1.push(child);
|
||||
}
|
||||
}
|
||||
while (stack2.length) {
|
||||
const node = stack2.pop();
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (containsActive.get(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async function exportSession(filePath: string, outputPath: string): Promise<void> {
|
||||
const cliPath = await getPiCliPath();
|
||||
if (cliPath) {
|
||||
await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_OFFLINE: "1",
|
||||
PI_SKIP_VERSION_CHECK: "1",
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const packageDir = await getPiPackageDir();
|
||||
if (!packageDir) throw new Error("pi CLI not found");
|
||||
|
||||
const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href;
|
||||
const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule;
|
||||
await exportFromFile(filePath, outputPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const inline = new URL(req.url).searchParams.get("inline") === "1";
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const tempDir = join(tmpdir(), "pi-web-export");
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const sessionBase = basename(filePath, ".jsonl");
|
||||
const fileName = `pi-session-${sessionBase}.html`;
|
||||
const outputPath = join(tempDir, `${randomUUID()}.html`);
|
||||
|
||||
try {
|
||||
await exportSession(filePath, outputPath);
|
||||
|
||||
const html = readFileSync(outputPath, "utf8");
|
||||
const patchedHtml = patchExportHtml(html);
|
||||
return new Response(patchedHtml, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": getContentDisposition(fileName, inline),
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(outputPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
resolveSessionPath,
|
||||
resolveSessionIdByPath,
|
||||
invalidateSessionPathCache,
|
||||
invalidateSessionListCache,
|
||||
buildSessionContext,
|
||||
readSessionHeader,
|
||||
} from "@/lib/session-reader";
|
||||
import { sessionPathKey } from "@/lib/session-path";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
|
||||
// BranchNavigator still traverses recursively, so keep the response tree shallow.
|
||||
const MAX_PROJECTED_TREE_DEPTH = 200;
|
||||
|
||||
/**
|
||||
* Project the session tree into the shallow navigation tree sent to the client.
|
||||
* Keeps roots, branch points, and leaves while contracting single-child chains
|
||||
* without recursive traversal. Contracted entry IDs are attached to the next
|
||||
* visible node so the UI can still recognize an active leaf inside the chain.
|
||||
*/
|
||||
function projectTreeForResponse<T extends { entry: { id: string }; children: T[]; compressedEntryIds?: string[] }>(
|
||||
nodes: T[]
|
||||
): T[] {
|
||||
const keep = new Set<T>();
|
||||
const roots = new Set(nodes);
|
||||
const seen = new Set<T>();
|
||||
const stack = [...nodes];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (seen.has(node)) continue;
|
||||
seen.add(node);
|
||||
|
||||
if (
|
||||
roots.has(node) ||
|
||||
node.children.length !== 1
|
||||
) {
|
||||
keep.add(node);
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
const cloneNode = (node: T, compressedEntryIds?: string[]): T => ({
|
||||
...node,
|
||||
children: [],
|
||||
...(compressedEntryIds?.length ? { compressedEntryIds } : {}),
|
||||
});
|
||||
const projectedRoots = nodes.map((node) => cloneNode(node));
|
||||
const tasks = nodes.map((source, index) => ({
|
||||
source,
|
||||
projected: projectedRoots[index],
|
||||
depth: 1,
|
||||
}));
|
||||
|
||||
const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => {
|
||||
const pending = [{ node: source, compressedEntryIds: [] as string[] }];
|
||||
const flattenedSeen = new Set<T>();
|
||||
|
||||
while (pending.length > 0) {
|
||||
const { node, compressedEntryIds } = pending.pop()!;
|
||||
if (flattenedSeen.has(node)) continue;
|
||||
flattenedSeen.add(node);
|
||||
|
||||
if (keep.has(node)) {
|
||||
projectedParent.children.push(cloneNode(node, compressedEntryIds));
|
||||
}
|
||||
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
pending.push({
|
||||
node: node.children[i],
|
||||
compressedEntryIds: keep.has(node)
|
||||
? []
|
||||
: [...compressedEntryIds, node.entry.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while (tasks.length > 0) {
|
||||
const { source, projected, depth } = tasks.pop()!;
|
||||
|
||||
for (const sourceChild of source.children) {
|
||||
let child = sourceChild;
|
||||
|
||||
if (depth >= MAX_PROJECTED_TREE_DEPTH) {
|
||||
appendFlattenedKeptDescendants(child, projected);
|
||||
continue;
|
||||
}
|
||||
|
||||
const compressedEntryIds: string[] = [];
|
||||
while (!keep.has(child) && child.children.length === 1) {
|
||||
compressedEntryIds.push(child.entry.id);
|
||||
child = child.children[0];
|
||||
}
|
||||
|
||||
if (!keep.has(child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectedChild = cloneNode(child, compressedEntryIds);
|
||||
projected.children.push(projectedChild);
|
||||
tasks.push({ source: child, projected: projectedChild, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return projectedRoots;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const entries = sm.getEntries() as never;
|
||||
const leafId = sm.getLeafId();
|
||||
const tree = projectTreeForResponse(sm.getTree());
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const deferThinking = searchParams.has("deferThinking");
|
||||
const deferToolResultImages = searchParams.has("deferMedia");
|
||||
const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages });
|
||||
|
||||
const header = sm.getHeader();
|
||||
let modified = header?.timestamp ?? new Date().toISOString();
|
||||
try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ }
|
||||
const parentSessionId = header?.parentSession
|
||||
? await resolveSessionIdByPath(header.parentSession)
|
||||
: undefined;
|
||||
const info = header ? {
|
||||
path: filePath,
|
||||
id: header.id,
|
||||
cwd: header.cwd ?? "",
|
||||
name: sm.getSessionName(),
|
||||
created: header.timestamp,
|
||||
modified,
|
||||
messageCount: context.messages.length,
|
||||
firstMessage: context.messages.find((m) => m.role === "user")
|
||||
? (() => {
|
||||
const msg = context.messages.find((m) => m.role === "user")!;
|
||||
const c = (msg as { content: unknown }).content;
|
||||
return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)";
|
||||
})()
|
||||
: "(no messages)",
|
||||
parentSessionId,
|
||||
} : null;
|
||||
|
||||
return NextResponse.json({
|
||||
sessionId: id,
|
||||
filePath,
|
||||
info,
|
||||
leafId,
|
||||
tree,
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/sessions/[id] body: { name: string }
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { name } = await req.json() as { name?: string };
|
||||
if (typeof name !== "string") {
|
||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
}
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
const sm = SessionManager.open(filePath);
|
||||
sm.appendSessionInfo(name.trim());
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sessions/[id]
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Read only the bounded header before deleting.
|
||||
const parentSessionPath = readSessionHeader(filePath)?.parentSession;
|
||||
|
||||
// Re-attach all direct children to this session's parent (cascade re-parent)
|
||||
// Scan sibling files in the same directory
|
||||
const targetPathKey = sessionPathKey(filePath);
|
||||
const dir = dirname(filePath);
|
||||
try {
|
||||
const files = readdirSync(dir).filter(
|
||||
(file) => file.endsWith(".jsonl") && sessionPathKey(join(dir, file)) !== targetPathKey,
|
||||
);
|
||||
for (const file of files) {
|
||||
const childPath = join(dir, file);
|
||||
try {
|
||||
const content = readFileSync(childPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string };
|
||||
if (
|
||||
header.type === "session" &&
|
||||
header.parentSession &&
|
||||
sessionPathKey(header.parentSession) === targetPathKey
|
||||
) {
|
||||
// Rewrite header with new parentSession
|
||||
header.parentSession = parentSessionPath;
|
||||
lines[0] = JSON.stringify(header);
|
||||
writeFileSync(childPath, lines.join("\n"));
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
} catch { /* skip if dir unreadable */ }
|
||||
|
||||
getRpcSession(id)?.destroy();
|
||||
unlinkSync(filePath);
|
||||
invalidateSessionPathCache(id);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
if (!await resolveSessionPath(id)) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rpc = getRpcSession(id);
|
||||
if (!rpc?.isAlive()) return NextResponse.json({ running: false });
|
||||
|
||||
const state = await rpc.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user