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".
334 lines
9.7 KiB
TypeScript
334 lines
9.7 KiB
TypeScript
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery, isDatabaseFile } from "@/lib/workspace";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|
|
|
|
export type TreeNode = {
|
|
name: string;
|
|
path: string; // relative to dench/ (or ~skills/, ~memories/ for virtual nodes)
|
|
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
|
icon?: string;
|
|
defaultView?: "table" | "kanban";
|
|
children?: TreeNode[];
|
|
/** Virtual nodes live outside the dench workspace (e.g. Skills, Memories). */
|
|
virtual?: boolean;
|
|
};
|
|
|
|
type DbObject = {
|
|
name: string;
|
|
icon?: string;
|
|
default_view?: string;
|
|
};
|
|
|
|
/** Read .object.yaml metadata from a directory if it exists. */
|
|
function readObjectMeta(
|
|
dirPath: string,
|
|
): { icon?: string; defaultView?: string } | null {
|
|
const yamlPath = join(dirPath, ".object.yaml");
|
|
if (!existsSync(yamlPath)) {return null;}
|
|
|
|
try {
|
|
const content = readFileSync(yamlPath, "utf-8");
|
|
const parsed = parseSimpleYaml(content);
|
|
return {
|
|
icon: parsed.icon as string | undefined,
|
|
defaultView: parsed.default_view as string | undefined,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query DuckDB for all objects so we can identify object directories
|
|
* even when .object.yaml files are missing.
|
|
*/
|
|
function loadDbObjects(): Map<string, DbObject> {
|
|
const map = new Map<string, DbObject>();
|
|
const rows = duckdbQuery<DbObject>(
|
|
"SELECT name, icon, default_view FROM objects",
|
|
);
|
|
for (const row of rows) {
|
|
map.set(row.name, row);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** Recursively build a tree of the knowledge/ directory. */
|
|
function buildTree(
|
|
absDir: string,
|
|
relativeBase: string,
|
|
dbObjects: Map<string, DbObject>,
|
|
): TreeNode[] {
|
|
const nodes: TreeNode[] = [];
|
|
|
|
let entries: Dirent[];
|
|
try {
|
|
entries = readdirSync(absDir, { withFileTypes: true });
|
|
} catch {
|
|
return nodes;
|
|
}
|
|
|
|
// Sort: directories first, then files, alphabetical within each group
|
|
const sorted = entries
|
|
.filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml")
|
|
.toSorted((a, b) => {
|
|
if (a.isDirectory() && !b.isDirectory()) {return -1;}
|
|
if (!a.isDirectory() && b.isDirectory()) {return 1;}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
for (const entry of sorted) {
|
|
// Skip hidden files except .object.yaml (but don't list it as a node)
|
|
if (entry.name === ".object.yaml") {continue;}
|
|
if (entry.name.startsWith(".")) {continue;}
|
|
|
|
const absPath = join(absDir, entry.name);
|
|
const relPath = relativeBase
|
|
? `${relativeBase}/${entry.name}`
|
|
: entry.name;
|
|
|
|
if (entry.isDirectory()) {
|
|
const objectMeta = readObjectMeta(absPath);
|
|
const dbObject = dbObjects.get(entry.name);
|
|
const children = buildTree(absPath, relPath, dbObjects);
|
|
|
|
if (objectMeta || dbObject) {
|
|
// This directory represents a CRM object (from .object.yaml OR DuckDB)
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: relPath,
|
|
type: "object",
|
|
icon: objectMeta?.icon ?? dbObject?.icon,
|
|
defaultView:
|
|
((objectMeta?.defaultView ?? dbObject?.default_view) as
|
|
| "table"
|
|
| "kanban") ?? "table",
|
|
children: children.length > 0 ? children : undefined,
|
|
});
|
|
} else {
|
|
// Regular folder
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: relPath,
|
|
type: "folder",
|
|
children: children.length > 0 ? children : undefined,
|
|
});
|
|
}
|
|
} else if (entry.isFile()) {
|
|
const ext = entry.name.split(".").pop()?.toLowerCase();
|
|
const isReport = entry.name.endsWith(".report.json");
|
|
const isDocument = ext === "md" || ext === "mdx";
|
|
const isDatabase = isDatabaseFile(entry.name);
|
|
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: relPath,
|
|
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
|
|
});
|
|
}
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
/** Classify a top-level file's type. */
|
|
function classifyFileType(name: string): TreeNode["type"] {
|
|
if (name.endsWith(".report.json")) {return "report";}
|
|
if (isDatabaseFile(name)) {return "database";}
|
|
const ext = name.split(".").pop()?.toLowerCase();
|
|
if (ext === "md" || ext === "mdx") {return "document";}
|
|
return "file";
|
|
}
|
|
|
|
// --- Virtual folder builders ---
|
|
|
|
/** Parse YAML frontmatter from a SKILL.md file (lightweight). */
|
|
function parseSkillFrontmatter(content: string): { name?: string; emoji?: string } {
|
|
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
if (!match) {return {};}
|
|
const yaml = match[1];
|
|
const result: Record<string, string> = {};
|
|
for (const line of yaml.split("\n")) {
|
|
const kv = line.match(/^(\w+)\s*:\s*(.+)/);
|
|
if (kv) {result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();}
|
|
}
|
|
return { name: result.name, emoji: result.emoji };
|
|
}
|
|
|
|
/** Build a virtual "Skills" folder from ~/.openclaw/skills/ and ~/.openclaw/workspace/skills/. */
|
|
function buildSkillsVirtualFolder(): TreeNode | null {
|
|
const home = homedir();
|
|
const dirs = [
|
|
join(home, ".openclaw", "skills"),
|
|
join(home, ".openclaw", "workspace", "skills"),
|
|
];
|
|
|
|
const children: TreeNode[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const dir of dirs) {
|
|
if (!existsSync(dir)) {continue;}
|
|
try {
|
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || seen.has(entry.name)) {continue;}
|
|
const skillMdPath = join(dir, entry.name, "SKILL.md");
|
|
if (!existsSync(skillMdPath)) {continue;}
|
|
|
|
seen.add(entry.name);
|
|
let displayName = entry.name;
|
|
try {
|
|
const content = readFileSync(skillMdPath, "utf-8");
|
|
const meta = parseSkillFrontmatter(content);
|
|
if (meta.name) {displayName = meta.name;}
|
|
if (meta.emoji) {displayName = `${meta.emoji} ${displayName}`;}
|
|
} catch {
|
|
// skip
|
|
}
|
|
|
|
children.push({
|
|
name: displayName,
|
|
path: `~skills/${entry.name}/SKILL.md`,
|
|
type: "document",
|
|
virtual: true,
|
|
});
|
|
}
|
|
} catch {
|
|
// dir unreadable
|
|
}
|
|
}
|
|
|
|
if (children.length === 0) {return null;}
|
|
children.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return {
|
|
name: "Skills",
|
|
path: "~skills",
|
|
type: "folder",
|
|
virtual: true,
|
|
children,
|
|
};
|
|
}
|
|
|
|
/** Build a virtual "Memories" folder from ~/.openclaw/workspace/. */
|
|
function buildMemoriesVirtualFolder(): TreeNode | null {
|
|
const workspaceDir = join(homedir(), ".openclaw", "workspace");
|
|
const children: TreeNode[] = [];
|
|
|
|
// MEMORY.md
|
|
for (const filename of ["MEMORY.md", "memory.md"]) {
|
|
const memPath = join(workspaceDir, filename);
|
|
if (existsSync(memPath)) {
|
|
children.push({
|
|
name: "MEMORY.md",
|
|
path: `~memories/MEMORY.md`,
|
|
type: "document",
|
|
virtual: true,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Daily logs from memory/
|
|
const memoryDir = join(workspaceDir, "memory");
|
|
if (existsSync(memoryDir)) {
|
|
try {
|
|
const entries = readdirSync(memoryDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".md")) {continue;}
|
|
children.push({
|
|
name: entry.name,
|
|
path: `~memories/${entry.name}`,
|
|
type: "document",
|
|
virtual: true,
|
|
});
|
|
}
|
|
} catch {
|
|
// dir unreadable
|
|
}
|
|
}
|
|
|
|
if (children.length === 0) {return null;}
|
|
// Sort: MEMORY.md first, then reverse chronological for daily logs
|
|
children.sort((a, b) => {
|
|
if (a.name === "MEMORY.md") {return -1;}
|
|
if (b.name === "MEMORY.md") {return 1;}
|
|
return b.name.localeCompare(a.name);
|
|
});
|
|
|
|
return {
|
|
name: "Memories",
|
|
path: "~memories",
|
|
type: "folder",
|
|
virtual: true,
|
|
children,
|
|
};
|
|
}
|
|
|
|
export async function GET() {
|
|
const root = resolveDenchRoot();
|
|
if (!root) {
|
|
// Even without a dench workspace, return virtual folders if they exist
|
|
const tree: TreeNode[] = [];
|
|
const skillsFolder = buildSkillsVirtualFolder();
|
|
if (skillsFolder) {tree.push(skillsFolder);}
|
|
const memoriesFolder = buildMemoriesVirtualFolder();
|
|
if (memoriesFolder) {tree.push(memoriesFolder);}
|
|
return Response.json({ tree, exists: false });
|
|
}
|
|
|
|
// Load objects from DuckDB for smart directory detection
|
|
const dbObjects = loadDbObjects();
|
|
|
|
const knowledgeDir = join(root, "knowledge");
|
|
const reportsDir = join(root, "reports");
|
|
const tree: TreeNode[] = [];
|
|
|
|
// Build knowledge tree (real files first)
|
|
if (existsSync(knowledgeDir)) {
|
|
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
|
|
}
|
|
|
|
// Build reports tree
|
|
if (existsSync(reportsDir)) {
|
|
const reportNodes = buildTree(reportsDir, "reports", dbObjects);
|
|
if (reportNodes.length > 0) {
|
|
tree.push({
|
|
name: "reports",
|
|
path: "reports",
|
|
type: "folder",
|
|
children: reportNodes,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add top-level files (WORKSPACE.md, workspace_context.yaml, workspace.duckdb, etc.)
|
|
try {
|
|
const topLevel = readdirSync(root, { withFileTypes: true });
|
|
for (const entry of topLevel) {
|
|
if (!entry.isFile()) {continue;}
|
|
if (entry.name.startsWith(".")) {continue;}
|
|
|
|
tree.push({
|
|
name: entry.name,
|
|
path: entry.name,
|
|
type: classifyFileType(entry.name),
|
|
});
|
|
}
|
|
} catch {
|
|
// skip if root unreadable
|
|
}
|
|
|
|
// Virtual folders go after all real files/folders
|
|
const skillsFolder = buildSkillsVirtualFolder();
|
|
if (skillsFolder) {tree.push(skillsFolder);}
|
|
const memoriesFolder = buildMemoriesVirtualFolder();
|
|
if (memoriesFolder) {tree.push(memoriesFolder);}
|
|
|
|
return Response.json({ tree, exists: true });
|
|
}
|