mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
} from "@/lib/file-access";
|
||||
import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "@/lib/file-fuzzy";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Same skip lists as /api/files — only used for the non-git readdir fallback.
|
||||
// Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior).
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
/** Cap on the plain (no-query) response used as the client-side index */
|
||||
const MAX_FILES = 5000;
|
||||
/** Hard caps on the full in-memory listing that ?q= searches against */
|
||||
const GIT_HARD_CAP = 200_000;
|
||||
const WALK_HARD_CAP = 50_000;
|
||||
const MAX_WALK_DEPTH = 8;
|
||||
const MAX_QUERY_LENGTH = 500;
|
||||
const CACHE_TTL_MS = 10_000;
|
||||
const CACHE_MAX_ENTRIES = 20;
|
||||
|
||||
interface FileListing {
|
||||
/** Full listing up to the hard cap (not the client cap) */
|
||||
files: string[];
|
||||
/** True when even the hard cap was exceeded */
|
||||
hardTruncated: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
listing: FileListing;
|
||||
/** Derived lazily on the first ?q= search against this listing */
|
||||
entries?: FileIndexEntry[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// Per-cwd cache on globalThis so it survives Next.js hot-reload; the @ menu
|
||||
// re-requests on every open and searches on every keystroke, so listings must
|
||||
// not be recomputed within a short window.
|
||||
declare global {
|
||||
var __piFileIndexCache: Map<string, CacheEntry> | undefined;
|
||||
}
|
||||
|
||||
function getIndexCache(): Map<string, CacheEntry> {
|
||||
if (!globalThis.__piFileIndexCache) globalThis.__piFileIndexCache = new Map();
|
||||
return globalThis.__piFileIndexCache;
|
||||
}
|
||||
|
||||
async function listWithGit(cwd: string): Promise<FileListing | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
{ timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } },
|
||||
);
|
||||
const all = stdout.split("\0").filter(Boolean);
|
||||
if (all.length > GIT_HARD_CAP) {
|
||||
return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true };
|
||||
}
|
||||
return { files: all, hardTruncated: false };
|
||||
} catch {
|
||||
// Not a git repo (or git unavailable) — caller falls back to readdir walk.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listWithWalk(cwd: string): FileListing {
|
||||
const files: string[] = [];
|
||||
// BFS so shallow files win when the cap truncates the listing.
|
||||
const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }];
|
||||
while (queue.length > 0) {
|
||||
const { abs, rel, depth } = queue.shift()!;
|
||||
let dirents: fs.Dirent[];
|
||||
try {
|
||||
dirents = fs.readdirSync(abs, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of dirents) {
|
||||
if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue;
|
||||
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (d.isDirectory()) {
|
||||
if (depth + 1 <= MAX_WALK_DEPTH) {
|
||||
queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 });
|
||||
}
|
||||
} else if (d.isFile()) {
|
||||
if (files.length >= WALK_HARD_CAP) {
|
||||
return { files, hardTruncated: true };
|
||||
}
|
||||
files.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, hardTruncated: false };
|
||||
}
|
||||
|
||||
// GET /api/file-index?cwd=/abs/path[&q=query]
|
||||
// Without q: { files: string[] (relative to cwd, capped at MAX_FILES),
|
||||
// truncated: boolean } — the client-side index for local filtering.
|
||||
// With q: { matches: { path, isDir }[] } — ranked against the FULL listing so
|
||||
// repos larger than MAX_FILES still find deep files (cap applied after
|
||||
// matching, like the TUI passing the query to fd).
|
||||
// Guarded by the same allow-list as /api/files.
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const cwd = req.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
const query = req.nextUrl.searchParams.get("q")?.slice(0, MAX_QUERY_LENGTH) ?? "";
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const cache = getIndexCache();
|
||||
const now = Date.now();
|
||||
let cached = cache.get(cwd);
|
||||
if (!cached || cached.expiresAt <= now) {
|
||||
const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd);
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.expiresAt <= now) cache.delete(key);
|
||||
}
|
||||
if (cache.size >= CACHE_MAX_ENTRIES) cache.clear();
|
||||
cached = { listing, expiresAt: now + CACHE_TTL_MS };
|
||||
cache.set(cwd, cached);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
cached.entries ??= buildEntriesFromFiles(cached.listing.files);
|
||||
return NextResponse.json({ matches: filterFileEntries(cached.entries, query) });
|
||||
}
|
||||
|
||||
const { files, hardTruncated } = cached.listing;
|
||||
return NextResponse.json({
|
||||
files: files.slice(0, MAX_FILES),
|
||||
truncated: hardTruncated || files.length > MAX_FILES,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user