feat: update version to 0.0.3 and improve error handling in API routes
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@evoscientist/webui",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@evoscientist/webui",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@langchain/core": "1.1.19",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@evoscientist/webui",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.",
|
||||
"author": "Xi Zhang <zacharyzhang2022@gmail.com>",
|
||||
"type": "module",
|
||||
|
||||
@@ -7,7 +7,10 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const name = request.nextUrl.searchParams.get("name");
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Missing skill name." }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "Missing skill name." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const detail = await getSkillDetail(name);
|
||||
return NextResponse.json(detail);
|
||||
@@ -15,7 +18,9 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to load skill detail.",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load skill detail.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { join, resolve } from "path";
|
||||
import { join, resolve, sep } from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { SKILL_DIRS, recordUninstall } from "@/lib/server/skills";
|
||||
import {
|
||||
SKILL_DIRS,
|
||||
recordUninstall,
|
||||
isValidSkillName,
|
||||
} from "@/lib/server/skills";
|
||||
|
||||
// SKILL_DIRS (the global ~/.evoscientist/skills tier + legacy ~/.config
|
||||
// fallback) is the single source of truth, shared with the install route.
|
||||
@@ -34,8 +38,10 @@ async function readSkills(): Promise<SkillCard[]> {
|
||||
const seen = new Set<string>();
|
||||
for (const dir of SKILL_DIRS) {
|
||||
let entries: string[] = [];
|
||||
let realRoot: string;
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
realRoot = await fs.realpath(dir);
|
||||
} catch {
|
||||
continue; // dir doesn't exist
|
||||
}
|
||||
@@ -43,9 +49,15 @@ async function readSkills(): Promise<SkillCard[]> {
|
||||
if (entry.startsWith(".")) continue;
|
||||
const skillDir = join(dir, entry);
|
||||
try {
|
||||
const stat = await fs.stat(skillDir);
|
||||
// Canonicalize so a symlinked skill dir / SKILL.md can't read outside
|
||||
// the tier (consistent with getSkillDetail's guard).
|
||||
const realDir = await fs.realpath(skillDir);
|
||||
if (realDir !== realRoot && !realDir.startsWith(realRoot + sep)) {
|
||||
continue;
|
||||
}
|
||||
const stat = await fs.stat(realDir);
|
||||
if (!stat.isDirectory()) continue;
|
||||
const md = await fs.readFile(join(skillDir, "SKILL.md"), "utf-8");
|
||||
const md = await fs.readFile(join(realDir, "SKILL.md"), "utf-8");
|
||||
const { name, description } = parseFrontmatter(md);
|
||||
// Identity is the DIRECTORY name (what install/uninstall/dedup key on);
|
||||
// the frontmatter name is display-only.
|
||||
@@ -81,14 +93,20 @@ export async function GET() {
|
||||
// only delete inside the known skill dirs.
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const name = req.nextUrl.searchParams.get("name");
|
||||
if (!name || /[\\/]|\.\./.test(name)) {
|
||||
// Strict name check (blocks dotfiles like `.installed.yaml`, traversal, odd
|
||||
// chars) — must match install-side validation, not the old slash/`..`-only one.
|
||||
if (!name || !isValidSkillName(name)) {
|
||||
return NextResponse.json({ error: "Invalid skill name" }, { status: 400 });
|
||||
}
|
||||
for (const dir of SKILL_DIRS) {
|
||||
const target = resolve(join(dir, name));
|
||||
if (!target.startsWith(resolve(dir) + "/")) continue;
|
||||
if (target !== resolve(dir) && !target.startsWith(resolve(dir) + sep)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.stat(target);
|
||||
// Only ever remove an actual skill directory, never a stray file.
|
||||
const stat = await fs.stat(target);
|
||||
if (!stat.isDirectory()) continue;
|
||||
} catch {
|
||||
continue; // not here
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ function contentDisposition(fileName: string, asAttachment: boolean): string {
|
||||
/['()*]/g,
|
||||
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
|
||||
);
|
||||
return `${asAttachment ? "attachment" : "inline"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||
return `${
|
||||
asAttachment ? "attachment" : "inline"
|
||||
}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -77,10 +79,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const stat = await fs.stat(target);
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not a file." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ error: "Not a file." }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = extname(target).slice(1).toLowerCase();
|
||||
@@ -111,8 +110,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to read file.",
|
||||
error: error instanceof Error ? error.message : "Failed to read file.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
@@ -95,10 +95,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const stat = await fs.stat(dir);
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not a directory." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ error: "Not a directory." }, { status: 400 });
|
||||
}
|
||||
|
||||
// "By type" view: flat list of every file under the workspace.
|
||||
@@ -169,9 +166,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to list workspace.",
|
||||
error instanceof Error ? error.message : "Failed to list workspace.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
@@ -39,7 +39,8 @@ export const SkillDetailDialog = React.memo<{
|
||||
fetch(`/api/skills/detail?name=${encodeURIComponent(skill.name)}`)
|
||||
.then(async (res) => {
|
||||
const d = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(d?.error || `Failed to load (${res.status})`);
|
||||
if (!res.ok)
|
||||
throw new Error(d?.error || `Failed to load (${res.status})`);
|
||||
return d as FetchedDetail;
|
||||
})
|
||||
.then((d) => {
|
||||
@@ -92,8 +93,11 @@ export const SkillDetailDialog = React.memo<{
|
||||
</span>
|
||||
)}
|
||||
{installed && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--brand)]/10 px-2 py-0.5 text-[11px] font-medium text-[var(--brand)]">
|
||||
<CheckCircle2 className="size-3" aria-hidden="true" />
|
||||
<span className="bg-[var(--brand)]/10 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium text-[var(--brand)]">
|
||||
<CheckCircle2
|
||||
className="size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
@@ -112,7 +116,10 @@ export const SkillDetailDialog = React.memo<{
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
<Loader2
|
||||
className="size-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Loading skill contents…
|
||||
</div>
|
||||
) : error ? (
|
||||
|
||||
@@ -95,7 +95,10 @@ export function SkillsMarketplace() {
|
||||
|
||||
// Install and update hit the same endpoint (it overwrites + re-records the
|
||||
// manifest commit); the mode only changes the busy label and success state.
|
||||
const install = async (name: string, mode: "install" | "update" = "install") => {
|
||||
const install = async (
|
||||
name: string,
|
||||
mode: "install" | "update" = "install"
|
||||
) => {
|
||||
setBusy((b) => ({ ...b, [name]: mode }));
|
||||
setError(null);
|
||||
try {
|
||||
@@ -203,7 +206,10 @@ export function SkillsMarketplace() {
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
<Loader2
|
||||
className="size-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Loading skills…
|
||||
</div>
|
||||
) : (
|
||||
@@ -225,7 +231,9 @@ export function SkillsMarketplace() {
|
||||
key={s.name}
|
||||
title={s.title}
|
||||
description={s.description}
|
||||
meta={`${s.fileCount} file${s.fileCount === 1 ? "" : "s"}`}
|
||||
meta={`${s.fileCount} file${
|
||||
s.fileCount === 1 ? "" : "s"
|
||||
}`}
|
||||
installed={s.installed}
|
||||
installedVersion={s.installedVersion}
|
||||
latestVersion={s.latestVersion}
|
||||
@@ -359,20 +367,24 @@ function SkillTile({
|
||||
onClick={onUpdate}
|
||||
disabled={!!busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand)] px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
title={
|
||||
latestVersion ? `Update to v${latestVersion}` : "Update"
|
||||
}
|
||||
title={latestVersion ? `Update to v${latestVersion}` : "Update"}
|
||||
>
|
||||
{busy === "update" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ArrowUpCircle className="size-3.5" aria-hidden="true" />
|
||||
<ArrowUpCircle
|
||||
className="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{busy === "update"
|
||||
? "Updating…"
|
||||
: latestVersion
|
||||
? `Update → v${latestVersion}`
|
||||
: "Update"}
|
||||
? `Update → v${latestVersion}`
|
||||
: "Update"}
|
||||
</button>
|
||||
)}
|
||||
{installed ? (
|
||||
@@ -383,9 +395,15 @@ function SkillTile({
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
||||
>
|
||||
{busy === "uninstall" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
<Trash2
|
||||
className="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{busy === "uninstall" ? "Removing…" : "Uninstall"}
|
||||
</button>
|
||||
@@ -397,9 +415,15 @@ function SkillTile({
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand)] px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy === "install" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Download className="size-3.5" aria-hidden="true" />
|
||||
<Download
|
||||
className="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{busy === "install" ? "Installing…" : "Install"}
|
||||
</button>
|
||||
|
||||
@@ -44,15 +44,7 @@ const LANGUAGE_MAP: Record<string, string> = {
|
||||
r: "r",
|
||||
};
|
||||
|
||||
const IMAGE_EXTS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"bmp",
|
||||
]);
|
||||
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]);
|
||||
// Extensions we render as text. Anything not here and not an image/pdf is
|
||||
// treated as a binary download.
|
||||
const TEXT_EXTS = new Set([
|
||||
|
||||
@@ -25,7 +25,10 @@ async function listDir(path: string): Promise<WorkspaceEntry[]> {
|
||||
return (body?.entries ?? []) as WorkspaceEntry[];
|
||||
}
|
||||
|
||||
async function listAll(): Promise<{ entries: WorkspaceEntry[]; truncated: boolean }> {
|
||||
async function listAll(): Promise<{
|
||||
entries: WorkspaceEntry[];
|
||||
truncated: boolean;
|
||||
}> {
|
||||
const res = await fetch("/api/workspace?recursive=1");
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(body?.error || "Failed to load workspace.");
|
||||
@@ -41,25 +44,86 @@ const CATEGORIES = [
|
||||
key: "docs",
|
||||
label: "Papers & docs",
|
||||
Icon: FileText,
|
||||
exts: ["pdf", "tex", "bib", "md", "markdown", "txt", "docx", "doc", "rtf", "odt"],
|
||||
exts: [
|
||||
"pdf",
|
||||
"tex",
|
||||
"bib",
|
||||
"md",
|
||||
"markdown",
|
||||
"txt",
|
||||
"docx",
|
||||
"doc",
|
||||
"rtf",
|
||||
"odt",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "figures",
|
||||
label: "Figures",
|
||||
Icon: ImageIcon,
|
||||
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tiff", "tif", "eps"],
|
||||
exts: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"webp",
|
||||
"bmp",
|
||||
"tiff",
|
||||
"tif",
|
||||
"eps",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "data",
|
||||
label: "Data",
|
||||
Icon: Database,
|
||||
exts: ["json", "jsonl", "csv", "tsv", "xlsx", "xls", "parquet", "pkl", "npy", "npz", "h5", "hdf5", "db", "sqlite", "yaml", "yml", "xml"],
|
||||
exts: [
|
||||
"json",
|
||||
"jsonl",
|
||||
"csv",
|
||||
"tsv",
|
||||
"xlsx",
|
||||
"xls",
|
||||
"parquet",
|
||||
"pkl",
|
||||
"npy",
|
||||
"npz",
|
||||
"h5",
|
||||
"hdf5",
|
||||
"db",
|
||||
"sqlite",
|
||||
"yaml",
|
||||
"yml",
|
||||
"xml",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "Code",
|
||||
Icon: Code2,
|
||||
exts: ["py", "ipynb", "js", "ts", "tsx", "jsx", "sh", "bash", "r", "jl", "cpp", "cc", "c", "h", "hpp", "java", "go", "rs", "m", "rb"],
|
||||
exts: [
|
||||
"py",
|
||||
"ipynb",
|
||||
"js",
|
||||
"ts",
|
||||
"tsx",
|
||||
"jsx",
|
||||
"sh",
|
||||
"bash",
|
||||
"r",
|
||||
"jl",
|
||||
"cpp",
|
||||
"cc",
|
||||
"c",
|
||||
"h",
|
||||
"hpp",
|
||||
"java",
|
||||
"go",
|
||||
"rs",
|
||||
"m",
|
||||
"rb",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
const OTHER = { key: "other", label: "Other", Icon: FileIcon } as const;
|
||||
@@ -75,7 +139,9 @@ export function WorkspacePanel() {
|
||||
const [view, setView] = useState<ViewMode>("tree");
|
||||
|
||||
// --- Tree view state (listing cache keyed by dir path; "" = root) ---
|
||||
const [children, setChildren] = useState<Record<string, WorkspaceEntry[]>>({});
|
||||
const [children, setChildren] = useState<Record<string, WorkspaceEntry[]>>(
|
||||
{}
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState<Set<string>>(new Set());
|
||||
const [rootLoading, setRootLoading] = useState(false);
|
||||
@@ -86,9 +152,10 @@ export function WorkspacePanel() {
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<{ path: string; size: number } | null>(
|
||||
null
|
||||
);
|
||||
const [selected, setSelected] = useState<{
|
||||
path: string;
|
||||
size: number;
|
||||
} | null>(null);
|
||||
|
||||
const loadDir = useCallback(async (path: string) => {
|
||||
setLoading((prev) => new Set(prev).add(path));
|
||||
@@ -232,7 +299,9 @@ export function WorkspacePanel() {
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
{entry.type === "dir" && isOpen && renderEntries(entry.path, depth + 1)}
|
||||
{entry.type === "dir" &&
|
||||
isOpen &&
|
||||
renderEntries(entry.path, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -252,7 +321,8 @@ export function WorkspacePanel() {
|
||||
<div className="space-y-3">
|
||||
{truncated && (
|
||||
<p className="px-1 text-[11px] text-muted-foreground">
|
||||
Showing the first files only — the workspace has more than the limit.
|
||||
Showing the first files only — the workspace has more than the
|
||||
limit.
|
||||
</p>
|
||||
)}
|
||||
{groups.map((cat) => {
|
||||
@@ -335,7 +405,9 @@ export function WorkspacePanel() {
|
||||
aria-label="Refresh workspace"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", refreshing && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +191,8 @@ async function resolveRef(): Promise<string> {
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { sha?: string };
|
||||
if (typeof data.sha === "string" && SHA_RE.test(data.sha)) return data.sha;
|
||||
if (typeof data.sha === "string" && SHA_RE.test(data.sha))
|
||||
return data.sha;
|
||||
}
|
||||
} catch {
|
||||
// fall back to the branch name below
|
||||
@@ -299,7 +300,10 @@ async function writeManifest(m: Manifest): Promise<void> {
|
||||
await fs.rename(tmp, path);
|
||||
}
|
||||
|
||||
async function recordInstall(name: string, commit: string | null): Promise<void> {
|
||||
async function recordInstall(
|
||||
name: string,
|
||||
commit: string | null
|
||||
): Promise<void> {
|
||||
const manifest = await readManifest();
|
||||
const source = manifestSource(name);
|
||||
manifest[name] = commit ? { source, commit } : { source };
|
||||
@@ -459,14 +463,14 @@ export async function getSkillDetail(name: string): Promise<SkillDetail> {
|
||||
}
|
||||
|
||||
/** Download every file of `skills/<name>/` into the install dir, atomically. */
|
||||
export async function installSkill(
|
||||
name: string
|
||||
): Promise<{ files: number }> {
|
||||
export async function installSkill(name: string): Promise<{ files: number }> {
|
||||
if (!isValidSkillName(name)) throw new Error("Invalid skill name.");
|
||||
|
||||
const { ref, tree } = await getRepoSnapshot();
|
||||
const prefix = `${SKILLS_PREFIX}${name}/`;
|
||||
const blobs = tree.filter((t) => t.type === "blob" && t.path.startsWith(prefix));
|
||||
const blobs = tree.filter(
|
||||
(t) => t.type === "blob" && t.path.startsWith(prefix)
|
||||
);
|
||||
if (blobs.length === 0) {
|
||||
throw new Error(`Skill "${name}" was not found in the catalog.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user