diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index 949ae402786..6fd4165c7c5 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -1,38 +1,239 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs"; +import { join, resolve } from "node:path"; import { homedir } from "node:os"; -import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace"; +import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -const BOOTSTRAP_FILES: Record = { - "AGENTS.md": `# Workspace Agent Instructions +// --------------------------------------------------------------------------- +// Bootstrap file names (must match src/agents/workspace.ts) +// --------------------------------------------------------------------------- -Add instructions here that your agent should follow when working in this workspace. -`, - "SOUL.md": `# Soul +const BOOTSTRAP_FILENAMES = [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", +] as const; -Describe the personality and behavior of your agent here. -`, - "USER.md": `# User - -Describe yourself — your preferences, context, and how you'd like the agent to interact with you. -`, +// Minimal fallback content used when templates can't be loaded from disk +const FALLBACK_CONTENT: Record = { + "AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n", + "SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n", + "TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n", + "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n", + "USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n", + "HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n", + "BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n", }; +// --------------------------------------------------------------------------- +// CRM seed objects (mirrors src/agents/workspace-seed.ts) +// --------------------------------------------------------------------------- + +type SeedField = { + name: string; + type: string; + required?: boolean; + enumValues?: string[]; +}; + +type SeedObject = { + id: string; + name: string; + description: string; + icon: string; + defaultView: string; + entryCount: number; + fields: SeedField[]; +}; + +const SEED_OBJECTS: SeedObject[] = [ + { + id: "seed_obj_people_00000000000000", + name: "people", + description: "Contact management", + icon: "users", + defaultView: "table", + entryCount: 5, + fields: [ + { name: "Full Name", type: "text", required: true }, + { name: "Email Address", type: "email", required: true }, + { name: "Phone Number", type: "phone" }, + { name: "Company", type: "text" }, + { name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] }, + { name: "Notes", type: "richtext" }, + ], + }, + { + id: "seed_obj_company_0000000000000", + name: "company", + description: "Company tracking", + icon: "building-2", + defaultView: "table", + entryCount: 3, + fields: [ + { name: "Company Name", type: "text", required: true }, + { + name: "Industry", + type: "enum", + enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"], + }, + { name: "Website", type: "text" }, + { name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] }, + { name: "Notes", type: "richtext" }, + ], + }, + { + id: "seed_obj_task_000000000000000", + name: "task", + description: "Task tracking board", + icon: "check-square", + defaultView: "kanban", + entryCount: 5, + fields: [ + { name: "Title", type: "text", required: true }, + { name: "Description", type: "text" }, + { name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] }, + { name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] }, + { name: "Due Date", type: "date" }, + { name: "Notes", type: "richtext" }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stripFrontMatter(content: string): string { + if (!content.startsWith("---")) {return content;} + const endIndex = content.indexOf("\n---", 3); + if (endIndex === -1) {return content;} + return content.slice(endIndex + "\n---".length).replace(/^\s+/, ""); +} + +/** Try multiple candidate paths to find the monorepo root. */ +function resolveProjectRoot(): string | null { + const marker = join("docs", "reference", "templates", "AGENTS.md"); + const cwd = process.cwd(); + + // CWD is the repo root (standalone builds) + if (existsSync(join(cwd, marker))) {return cwd;} + + // CWD is apps/web/ (dev mode) + const fromApps = resolve(cwd, "..", ".."); + if (existsSync(join(fromApps, marker))) {return fromApps;} + + return null; +} + +function loadTemplateContent(filename: string, projectRoot: string | null): string { + if (projectRoot) { + const templatePath = join(projectRoot, "docs", "reference", "templates", filename); + try { + const raw = readFileSync(templatePath, "utf-8"); + return stripFrontMatter(raw); + } catch { + // fall through to fallback + } + } + return FALLBACK_CONTENT[filename] ?? ""; +} + +function generateObjectYaml(obj: SeedObject): string { + const lines: string[] = [ + `id: "${obj.id}"`, + `name: "${obj.name}"`, + `description: "${obj.description}"`, + `icon: "${obj.icon}"`, + `default_view: "${obj.defaultView}"`, + `entry_count: ${obj.entryCount}`, + "fields:", + ]; + + for (const field of obj.fields) { + lines.push(` - name: "${field.name}"`); + lines.push(` type: ${field.type}`); + if (field.required) {lines.push(" required: true");} + if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);} + } + + return lines.join("\n") + "\n"; +} + +function generateWorkspaceMd(objects: SeedObject[]): string { + const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""]; + for (const obj of objects) { + lines.push(`## ${obj.name}`, ""); + lines.push(`- **Description**: ${obj.description}`); + lines.push(`- **View**: \`${obj.defaultView}\``); + lines.push(`- **Entries**: ${obj.entryCount}`); + lines.push("- **Fields**:"); + for (const field of obj.fields) { + const req = field.required ? " (required)" : ""; + const vals = field.enumValues ? ` — ${field.enumValues.join(", ")}` : ""; + lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`); + } + lines.push(""); + } + return lines.join("\n"); +} + +function writeIfMissing(filePath: string, content: string): boolean { + if (existsSync(filePath)) {return false;} + try { + writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" }); + return true; + } catch { + return false; + } +} + +function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean { + const destPath = join(workspaceDir, "workspace.duckdb"); + if (existsSync(destPath)) {return false;} + + if (!projectRoot) {return false;} + + const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb"); + if (!existsSync(seedDb)) {return false;} + + try { + copyFileSync(seedDb, destPath); + } catch { + return false; + } + + // Create filesystem projections for CRM objects + for (const obj of SEED_OBJECTS) { + const objDir = join(workspaceDir, obj.name); + mkdirSync(objDir, { recursive: true }); + writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj)); + } + + writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS)); + + return true; +} + +// --------------------------------------------------------------------------- +// Route handler +// --------------------------------------------------------------------------- + export async function POST(req: Request) { const body = (await req.json()) as { profile?: string; - /** Absolute path override (optional; defaults to profile-based resolution). */ path?: string; - /** Seed bootstrap files into the new workspace. Default true. */ seedBootstrap?: boolean; }; const profileName = body.profile?.trim() || null; - // Validate profile name if provided if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) { return Response.json( { error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." }, @@ -56,7 +257,6 @@ export async function POST(req: Request) { } } - // Create the workspace directory try { mkdirSync(workspaceDir, { recursive: true }); } catch (err) { @@ -66,24 +266,57 @@ export async function POST(req: Request) { ); } - // Seed bootstrap files const seedBootstrap = body.seedBootstrap !== false; const seeded: string[] = []; + if (seedBootstrap) { - for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) { + const projectRoot = resolveProjectRoot(); + + // Seed all bootstrap files from templates + for (const filename of BOOTSTRAP_FILENAMES) { const filePath = join(workspaceDir, filename); if (!existsSync(filePath)) { - try { - writeFileSync(filePath, content, "utf-8"); + const content = loadTemplateContent(filename, projectRoot); + if (writeIfMissing(filePath, content)) { seeded.push(filename); - } catch { - // Skip files that can't be written (permissions, etc.) } } } + + // Seed DuckDB + CRM object projections + if (seedDuckDB(workspaceDir, projectRoot)) { + seeded.push("workspace.duckdb"); + for (const obj of SEED_OBJECTS) { + seeded.push(`${obj.name}/.object.yaml`); + } + } + + // Write workspace state so the gateway knows seeding was done + const stateDir = join(workspaceDir, ".openclaw"); + const statePath = join(stateDir, "workspace-state.json"); + if (!existsSync(statePath)) { + try { + mkdirSync(stateDir, { recursive: true }); + const state = { + version: 1, + bootstrapSeededAt: new Date().toISOString(), + duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb")) + ? new Date().toISOString() + : undefined, + }; + writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); + } catch { + // Best-effort state tracking + } + } } - // If a profile was specified, switch to it + // Remember custom-path workspaces in the registry + if (body.path?.trim() && profileName) { + registerWorkspacePath(profileName, workspaceDir); + } + + // Switch to the new profile if (profileName) { setUIActiveProfile(profileName === "default" ? null : profileName); } diff --git a/apps/web/app/components/workspace/create-workspace-dialog.tsx b/apps/web/app/components/workspace/create-workspace-dialog.tsx index cdbdbf39b3e..4f6ba07f8d4 100644 --- a/apps/web/app/components/workspace/create-workspace-dialog.tsx +++ b/apps/web/app/components/workspace/create-workspace-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useRef, useEffect } from "react"; +import { DirectoryPickerModal } from "./directory-picker-modal"; type CreateWorkspaceDialogProps = { isOpen: boolean; @@ -8,10 +9,15 @@ type CreateWorkspaceDialogProps = { onCreated?: () => void; }; +function shortenPath(p: string): string { + return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~"); +} + export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) { const [profileName, setProfileName] = useState(""); const [customPath, setCustomPath] = useState(""); const [useCustomPath, setUseCustomPath] = useState(false); + const [showDirPicker, setShowDirPicker] = useState(false); const [seedBootstrap, setSeedBootstrap] = useState(true); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); @@ -25,22 +31,23 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork setProfileName(""); setCustomPath(""); setUseCustomPath(false); + setShowDirPicker(false); setError(null); setResult(null); setTimeout(() => inputRef.current?.focus(), 100); } }, [isOpen]); - // Close on Escape + // Close on Escape (only if dir picker is not open) useEffect(() => { function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") {onClose();} + if (e.key === "Escape" && !showDirPicker) {onClose();} } if (isOpen) { document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); } - }, [isOpen, onClose]); + }, [isOpen, onClose, showDirPicker]); const handleCreate = async () => { const name = profileName.trim(); @@ -222,18 +229,65 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork {useCustomPath && ( - setCustomPath(e.target.value)} - placeholder="~/my-workspace or /absolute/path" - className="w-full mt-2 px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]" - style={{ - background: "var(--color-bg)", - border: "1px solid var(--color-border)", - color: "var(--color-text)", - }} - /> +
+ {customPath ? ( +
+
+ + + +
+
+

+ {customPath.split("/").pop()} +

+

+ {shortenPath(customPath)} +

+
+ + +
+ ) : ( + + )} +
)} @@ -250,7 +304,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork className="text-sm" style={{ color: "var(--color-text-secondary)" }} > - Seed bootstrap files (AGENTS.md, SOUL.md, USER.md) + Seed bootstrap files and workspace database @@ -309,6 +363,13 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork )} + + {/* Directory picker modal */} + setShowDirPicker(false)} + onSelect={(path) => setCustomPath(path)} + /> ); } diff --git a/apps/web/app/components/workspace/directory-picker-modal.tsx b/apps/web/app/components/workspace/directory-picker-modal.tsx new file mode 100644 index 00000000000..ed56bad6323 --- /dev/null +++ b/apps/web/app/components/workspace/directory-picker-modal.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; + +type BrowseEntry = { + name: string; + path: string; + type: "folder" | "file" | "document" | "database"; +}; + +type DirectoryPickerModalProps = { + open: boolean; + onClose: () => void; + onSelect: (path: string) => void; + /** Starting directory (absolute). Falls back to the workspace root / home. */ + startDir?: string; +}; + +function buildBreadcrumbs(dir: string): { label: string; path: string }[] { + const segments: { label: string; path: string }[] = []; + const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/); + const homeDir = homeMatch?.[1]; + + if (homeDir) { + segments.push({ label: "~", path: homeDir }); + const rest = dir.slice(homeDir.length); + const parts = rest.split("/").filter(Boolean); + let currentPath = homeDir; + for (const part of parts) { + currentPath += "/" + part; + segments.push({ label: part, path: currentPath }); + } + } else if (dir === "/") { + segments.push({ label: "/", path: "/" }); + } else { + segments.push({ label: "/", path: "/" }); + const parts = dir.split("/").filter(Boolean); + let currentPath = ""; + for (const part of parts) { + currentPath += "/" + part; + segments.push({ label: part, path: currentPath }); + } + } + return segments; +} + +const folderColors = { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" }; + +function FolderIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function DirectoryPickerModal({ + open, + onClose, + onSelect, + startDir, +}: DirectoryPickerModalProps) { + const [currentDir, setCurrentDir] = useState(startDir ?? null); + const [displayDir, setDisplayDir] = useState(""); + const [entries, setEntries] = useState([]); + const [parentDir, setParentDir] = useState(null); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(""); + const [creatingFolder, setCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [error, setError] = useState(null); + + const [visible, setVisible] = useState(false); + useEffect(() => { + if (open) { + requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true))); + } else { + setVisible(false); + } + }, [open]); + + useEffect(() => { + if (!open) { + setSearch(""); + setCreatingFolder(false); + setNewFolderName(""); + setError(null); + } + }, [open]); + + // Reset to startDir when reopening + useEffect(() => { + if (open) { + setCurrentDir(startDir ?? null); + } + }, [open, startDir]); + + const searchRef = useRef(null); + const newFolderRef = useRef(null); + + const fetchDir = useCallback(async (dir: string | null) => { + setLoading(true); + setError(null); + try { + const url = dir + ? `/api/workspace/browse?dir=${encodeURIComponent(dir)}` + : "/api/workspace/browse"; + const res = await fetch(url); + if (!res.ok) {throw new Error("Failed to list directory");} + const data = await res.json(); + setEntries(data.entries || []); + setDisplayDir(data.currentDir || ""); + setParentDir(data.parentDir ?? null); + } catch { + setError("Could not load this directory"); + setEntries([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (open) { void fetchDir(currentDir); } + }, [open, currentDir, fetchDir]); + + useEffect(() => { + if (!open) {return;} + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") {onClose();} + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + const navigateInto = useCallback((path: string) => { + setCurrentDir(path); + setSearch(""); + setCreatingFolder(false); + }, []); + + const handleCreateFolder = useCallback(async () => { + if (!newFolderName.trim() || !displayDir) {return;} + const folderPath = `${displayDir}/${newFolderName.trim()}`; + try { + await fetch("/api/workspace/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: folderPath }), + }); + setCreatingFolder(false); + setNewFolderName(""); + void fetchDir(currentDir); + } catch { + setError("Failed to create folder"); + } + }, [newFolderName, displayDir, currentDir, fetchDir]); + + const handleSelectCurrent = useCallback(() => { + if (displayDir) { + onSelect(displayDir); + onClose(); + } + }, [displayDir, onSelect, onClose]); + + // Only show folders + const folders = entries + .filter((e) => e.type === "folder") + .filter((e) => !search || e.name.toLowerCase().includes(search.toLowerCase())) + .toSorted((a, b) => a.name.localeCompare(b.name)); + + const breadcrumbs = displayDir ? buildBreadcrumbs(displayDir) : []; + + // Shorten display path for the footer + const shortDir = displayDir + .replace(/^\/Users\/[^/]+/, "~") + .replace(/^\/home\/[^/]+/, "~"); + + if (!open) {return null;} + + return ( +
+
+ +
+ {/* Header */} +
+
+
+ +
+
+

+ Choose Directory +

+

+ Navigate to a folder for the workspace +

+
+
+ +
+ + {/* Breadcrumbs */} + {displayDir && ( +
+ {breadcrumbs.map((seg, i) => ( + + {i > 0 && ( + + / + + )} + + + ))} +
+ )} + + {/* Search + New Folder */} +
+
+ + + + setSearch(e.target.value)} + placeholder="Filter folders..." + className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]" + style={{ color: "var(--color-text)" }} + /> +
+ +
+ + {/* Folder list */} +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> + {/* Go up */} + {parentDir && ( + + )} + + {/* New folder input */} + {creatingFolder && ( +
+
+ +
+ setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") {void handleCreateFolder();} + if (e.key === "Escape") { + setCreatingFolder(false); + setNewFolderName(""); + } + }} + placeholder="Folder name..." + className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1" + style={{ + color: "var(--color-text)", + background: "var(--color-surface)", + border: "1px solid var(--color-accent)", + }} + /> +
+ )} + + {/* Folder entries */} + {folders.length === 0 && !parentDir && ( +
+ No subfolders here +
+ )} + {folders.map((entry) => ( + + ))} + + )} +
+ + {/* Footer */} +
+
+

+ {shortDir || "Loading..."} +

+
+
+ + +
+
+
+
+ ); +}