feat: update version to 0.0.3 and improve error handling in API routes

This commit is contained in:
Xi Zhang
2026-06-03 02:34:04 +01:00
parent 7d601ba746
commit bbb5c888f2
11 changed files with 186 additions and 71 deletions
+2 -2
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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 }
);
+25 -7
View File
@@ -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
}
+5 -7
View File
@@ -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 }
);
+2 -7
View File
@@ -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 }
);
+11 -4
View File
@@ -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 ? (
+38 -14
View File
@@ -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>
+1 -9
View File
@@ -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([
+84 -12
View File
@@ -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>
+10 -6
View File
@@ -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.`);
}