Add virtual folder system that surfaces Skills, Memories, and Chat sessions in the workspace sidebar alongside real dench files. Rearchitect the home page into a landing hub and move the ChatPanel into the workspace as the default view. New API route — virtual-file: - apps/web/app/api/workspace/virtual-file/route.ts: new GET/POST API that resolves virtual paths (~skills/*, ~memories/*) to absolute filesystem paths in ~/.openclaw/skills/ d ~/.openclaw/workspace/. Includes path traversal protection and directory allowlisting. Tree API — virtual folder builders: - apps/web/app/api/workspace/tree/route.ts: add `virtual` field to TreeNode type. Add ildSkillsVirtualFolder() scanning ~/.openclaw/skills/ and ~/.openclaw/workspace/skills/ with SKILL.md frontmatter parsing (name + emoji). Add buildMemoriesVirtualFolder() scanning MEMORY.md and daily logs from ~/.openclaw/workspace/memory/. Virtual folders are appended after real workspace entries and are also returned when no dench root exists. File manager tree — virtual node awareness: - apps/web/app/components/workspace/file-manager-tree.tsx: add isVirtualNode() helper and RESERVED_FOLDER_NAMES set (Chats, Skills, Memories). Virtual nodes show a lock badge, disable drag-and-drop, block rename/delete, and reject reserved names during create/rename. Add ChatBubbleIcon for ~chats/ paths. Markdown editor — virtual path routing: - apps/web/app/components/workspace/markdown-editor.tsx: save to /api/workspace/virtual-file for paths starting with ~ instead of the regular /api/workspace/file endpoint. Home page redesign: - apps/web/app/page.tsx: replace the chat-first layout (SidebarhatPanel) with a branded landing page showing the OpenClaw Dench heading, tagline, and a single "Open Workspace" CTA linking to /workspace. Workspace page — unified layout with integrated chat: - apps/web/app/workspace/page.tsx: ChatPanel is now the default main view when no file is selected. Add session fetching from /api/web-sessions and build an enhanced tree with a virtual "Chats" folder listing all sessions. Clicking ~chats/<id> loads the session; clicking ~chats starts a new one. Add isVirtualPath()/fileApiUrl() helpers for virtual file reads. Add a "Back to chat" button in the top bar alongside the chat sidebar toggle. Sidebar + empty-state cosmetic updates: - apps/web/app/components/workspace/workspace-sidebar.tsx: rename BackIcon to HomeIcon (house SVG), change label from "Back to Chat" to "Home". - apps/web/app/components/workspace/empty-state.tsx: update link text from "Back to Chat" to "Back to Home".
152 lines
4.5 KiB
TypeScript
152 lines
4.5 KiB
TypeScript
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
import { join, dirname, resolve, normalize } from "node:path";
|
|
import { homedir } from "node:os";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|
|
|
|
/**
|
|
* Resolve a virtual path (~skills/... or ~memories/...) to an absolute filesystem path.
|
|
* Returns null if the path is invalid or tries to escape.
|
|
*/
|
|
function resolveVirtualPath(virtualPath: string): string | null {
|
|
const home = homedir();
|
|
|
|
if (virtualPath.startsWith("~skills/")) {
|
|
// ~skills/<skillName>/SKILL.md
|
|
const rest = virtualPath.slice("~skills/".length);
|
|
// Validate: must be <name>/SKILL.md
|
|
const parts = rest.split("/");
|
|
if (parts.length !== 2 || parts[1] !== "SKILL.md" || !parts[0]) {
|
|
return null;
|
|
}
|
|
const skillName = parts[0];
|
|
// Prevent path traversal
|
|
if (skillName.includes("..") || skillName.includes("/")) {
|
|
return null;
|
|
}
|
|
|
|
// Check workspace skills first, then managed skills
|
|
const candidates = [
|
|
join(home, ".openclaw", "workspace", "skills", skillName, "SKILL.md"),
|
|
join(home, ".openclaw", "skills", skillName, "SKILL.md"),
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
// Default to workspace skills dir for new files
|
|
return candidates[0];
|
|
}
|
|
|
|
if (virtualPath.startsWith("~memories/")) {
|
|
const rest = virtualPath.slice("~memories/".length);
|
|
// Prevent path traversal
|
|
if (rest.includes("..") || rest.includes("/")) {
|
|
return null;
|
|
}
|
|
|
|
const workspaceDir = join(home, ".openclaw", "workspace");
|
|
|
|
if (rest === "MEMORY.md") {
|
|
// Check both casing
|
|
for (const filename of ["MEMORY.md", "memory.md"]) {
|
|
const candidate = join(workspaceDir, filename);
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
// Default to MEMORY.md for new files
|
|
return join(workspaceDir, "MEMORY.md");
|
|
}
|
|
|
|
// Daily log: must be a .md file in the memory/ subdirectory
|
|
if (!rest.endsWith(".md")) {
|
|
return null;
|
|
}
|
|
return join(workspaceDir, "memory", rest);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Double-check that the resolved path stays within expected directories.
|
|
*/
|
|
function isSafePath(absPath: string): boolean {
|
|
const home = homedir();
|
|
const normalized = normalize(resolve(absPath));
|
|
const allowed = [
|
|
normalize(join(home, ".openclaw", "skills")),
|
|
normalize(join(home, ".openclaw", "workspace", "skills")),
|
|
normalize(join(home, ".openclaw", "workspace")),
|
|
];
|
|
return allowed.some((dir) => normalized.startsWith(dir));
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
const url = new URL(req.url);
|
|
const path = url.searchParams.get("path");
|
|
|
|
if (!path) {
|
|
return Response.json({ error: "Missing 'path' query parameter" }, { status: 400 });
|
|
}
|
|
|
|
const absPath = resolveVirtualPath(path);
|
|
if (!absPath || !isSafePath(absPath)) {
|
|
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
|
}
|
|
|
|
if (!existsSync(absPath)) {
|
|
return Response.json({ error: "File not found" }, { status: 404 });
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(absPath, "utf-8");
|
|
const ext = absPath.split(".").pop()?.toLowerCase();
|
|
let type: "markdown" | "yaml" | "text" = "text";
|
|
if (ext === "md" || ext === "mdx") {type = "markdown";}
|
|
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
|
|
return Response.json({ content, type });
|
|
} catch (err) {
|
|
return Response.json(
|
|
{ error: err instanceof Error ? err.message : "Read failed" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function POST(req: Request) {
|
|
let body: { path?: string; content?: string };
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
}
|
|
|
|
const { path: virtualPath, content } = body;
|
|
if (!virtualPath || typeof virtualPath !== "string" || typeof content !== "string") {
|
|
return Response.json(
|
|
{ error: "Missing 'path' and 'content' fields" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const absPath = resolveVirtualPath(virtualPath);
|
|
if (!absPath || !isSafePath(absPath)) {
|
|
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
mkdirSync(dirname(absPath), { recursive: true });
|
|
writeFileSync(absPath, content, "utf-8");
|
|
return Response.json({ ok: true, path: virtualPath });
|
|
} catch (err) {
|
|
return Response.json(
|
|
{ error: err instanceof Error ? err.message : "Write failed" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|