diff --git a/apps/web/app/api/chat/subagent-stream/route.ts b/apps/web/app/api/chat/subagent-stream/route.ts index 75a93999587..a0d1c8f74ba 100644 --- a/apps/web/app/api/chat/subagent-stream/route.ts +++ b/apps/web/app/api/chat/subagent-stream/route.ts @@ -1,4 +1,4 @@ -import { subscribeToSubagent, hasActiveSubagent, isSubagentRunning, ensureRegisteredFromDisk, ensureSubagentStreamable } from "@/lib/subagent-runs"; +import { subscribeToSubagent, hasActiveSubagent, isSubagentRunning, ensureRegisteredFromDisk } from "@/lib/subagent-runs"; import type { SseEvent } from "@/lib/subagent-runs"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -51,10 +51,6 @@ export async function GET(req: Request) { return new Response("Subagent not found", { status: 404 }); } - // For still-running subagents rehydrated from disk, activate the gateway - // WebSocket subscription so new events arrive in real time. - ensureSubagentStreamable(sessionKey); - const isActive = isSubagentRunning(sessionKey); const encoder = new TextEncoder(); let closed = false; diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts index 204e81756ff..7c2de3e1f1a 100644 --- a/apps/web/app/api/web-sessions/route.ts +++ b/apps/web/app/api/web-sessions/route.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { resolveWebChatDir } from "@/lib/workspace"; @@ -23,68 +23,15 @@ function ensureDir() { return dir; } -/** - * Read the session index, auto-discovering any orphaned .jsonl files - * that aren't in the index (e.g. from profile switches or missing index). - */ function readIndex(): WebSessionMeta[] { const dir = ensureDir(); const indexFile = join(dir, "index.json"); - let index: WebSessionMeta[] = []; - if (existsSync(indexFile)) { - try { - index = JSON.parse(readFileSync(indexFile, "utf-8")); - } catch { - index = []; - } - } - - // Scan for orphaned .jsonl files not in the index + if (!existsSync(indexFile)) {return [];} try { - const indexed = new Set(index.map((s) => s.id)); - const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")); - let dirty = false; - for (const file of files) { - const id = file.replace(/\.jsonl$/, ""); - if (indexed.has(id)) {continue;} - - // Build a minimal index entry from the file - const fp = join(dir, file); - const stat = statSync(fp); - let title = "New Chat"; - let messageCount = 0; - try { - const content = readFileSync(fp, "utf-8"); - const lines = content.split("\n").filter((l) => l.trim()); - messageCount = lines.length; - // Try to extract a title from the first user message - for (const line of lines) { - const parsed = JSON.parse(line); - if (parsed.role === "user" && parsed.content) { - const text = String(parsed.content); - title = text.length > 60 ? text.slice(0, 60) + "..." : text; - break; - } - } - } catch { /* best-effort */ } - - index.push({ - id, - title, - createdAt: stat.birthtimeMs || stat.mtimeMs, - updatedAt: stat.mtimeMs, - messageCount, - }); - dirty = true; - } - - if (dirty) { - index.sort((a, b) => b.updatedAt - a.updatedAt); - writeFileSync(indexFile, JSON.stringify(index, null, 2)); - } - } catch { /* best-effort */ } - - return index; + return JSON.parse(readFileSync(indexFile, "utf-8")); + } catch { + return []; + } } function writeIndex(sessions: WebSessionMeta[]) { diff --git a/apps/web/app/api/workspace/browse-file/route.ts b/apps/web/app/api/workspace/browse-file/route.ts index eeed22d9857..3b5b3300ec5 100644 --- a/apps/web/app/api/workspace/browse-file/route.ts +++ b/apps/web/app/api/workspace/browse-file/route.ts @@ -18,8 +18,6 @@ const MIME_MAP: Record = { wav: "audio/wav", ogg: "audio/ogg", pdf: "application/pdf", - html: "text/html", - htm: "text/html", }; /** Extensions recognized as code files for syntax-highlighted viewing. */ diff --git a/apps/web/app/api/workspace/browse/route.ts b/apps/web/app/api/workspace/browse/route.ts index 8fa024977c6..3304bfbb29e 100644 --- a/apps/web/app/api/workspace/browse/route.ts +++ b/apps/web/app/api/workspace/browse/route.ts @@ -1,4 +1,4 @@ -import { readdirSync, statSync, type Dirent } from "node:fs"; +import { readdirSync, type Dirent } from "node:fs"; import { join, dirname, resolve } from "node:path"; import { resolveWorkspaceRoot } from "@/lib/workspace"; @@ -10,34 +10,16 @@ type BrowseNode = { path: string; // absolute path type: "folder" | "file" | "document" | "database"; children?: BrowseNode[]; - symlink?: boolean; }; /** Directories to skip when browsing the filesystem. */ const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]); -/** Resolve a dirent's effective type, following symlinks to their target. */ -function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null { - if (entry.isDirectory()) {return "directory";} - if (entry.isFile()) {return "file";} - if (entry.isSymbolicLink()) { - try { - const st = statSync(absPath); - if (st.isDirectory()) {return "directory";} - if (st.isFile()) {return "file";} - } catch { - // Broken symlink - } - } - return null; -} - /** Build a depth-limited tree from an absolute directory. */ function buildBrowseTree( absDir: string, maxDepth: number, currentDepth = 0, - showHidden = false, ): BrowseNode[] { if (currentDepth >= maxDepth) {return [];} @@ -48,43 +30,29 @@ function buildBrowseTree( return []; } - const filtered = entries - .filter((e) => showHidden || !e.name.startsWith(".")) - .filter((e) => { - const absPath = join(absDir, e.name); - const t = resolveEntryType(e, absPath); - return !(t === "directory" && SKIP_DIRS.has(e.name)); + const sorted = entries + .filter((e) => !e.name.startsWith(".")) + .filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name))) + .toSorted((a, b) => { + if (a.isDirectory() && !b.isDirectory()) {return -1;} + if (!a.isDirectory() && b.isDirectory()) {return 1;} + return a.name.localeCompare(b.name); }); - const sorted = filtered.toSorted((a, b) => { - const absA = join(absDir, a.name); - const absB = join(absDir, b.name); - const typeA = resolveEntryType(a, absA); - const typeB = resolveEntryType(b, absB); - const dirA = typeA === "directory"; - const dirB = typeB === "directory"; - if (dirA && !dirB) {return -1;} - if (!dirA && dirB) {return 1;} - return a.name.localeCompare(b.name); - }); - const nodes: BrowseNode[] = []; for (const entry of sorted) { const absPath = join(absDir, entry.name); - const isSymlink = entry.isSymbolicLink(); - const effectiveType = resolveEntryType(entry, absPath); - if (effectiveType === "directory") { - const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden); + if (entry.isDirectory()) { + const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1); nodes.push({ name: entry.name, path: absPath, type: "folder", children: children.length > 0 ? children : undefined, - ...(isSymlink && { symlink: true }), }); - } else if (effectiveType === "file") { + } else if (entry.isFile()) { const ext = entry.name.split(".").pop()?.toLowerCase(); const isDocument = ext === "md" || ext === "mdx"; const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db"; @@ -93,7 +61,6 @@ function buildBrowseTree( name: entry.name, path: absPath, type: isDatabase ? "database" : isDocument ? "document" : "file", - ...(isSymlink && { symlink: true }), }); } } @@ -104,8 +71,8 @@ function buildBrowseTree( export async function GET(req: Request) { const url = new URL(req.url); let dir = url.searchParams.get("dir"); - const showHidden = url.searchParams.get("showHidden") === "1"; + // Default to the workspace root if (!dir) { dir = resolveWorkspaceRoot(); } @@ -116,9 +83,10 @@ export async function GET(req: Request) { ); } + // Resolve and normalize the directory path const resolved = resolve(dir); - const entries = buildBrowseTree(resolved, 3, 0, showHidden); + const entries = buildBrowseTree(resolved, 3); const parentDir = resolved === "/" ? null : dirname(resolved); return Response.json({ diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index 6fd4165c7c5..949ae402786 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -1,239 +1,38 @@ -import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { homedir } from "node:os"; -import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace"; +import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -// --------------------------------------------------------------------------- -// Bootstrap file names (must match src/agents/workspace.ts) -// --------------------------------------------------------------------------- +const BOOTSTRAP_FILES: Record = { + "AGENTS.md": `# Workspace Agent Instructions -const BOOTSTRAP_FILENAMES = [ - "AGENTS.md", - "SOUL.md", - "TOOLS.md", - "IDENTITY.md", - "USER.md", - "HEARTBEAT.md", - "BOOTSTRAP.md", -] as const; +Add instructions here that your agent should follow when working in this workspace. +`, + "SOUL.md": `# Soul -// 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", +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. +`, }; -// --------------------------------------------------------------------------- -// 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." }, @@ -257,6 +56,7 @@ export async function POST(req: Request) { } } + // Create the workspace directory try { mkdirSync(workspaceDir, { recursive: true }); } catch (err) { @@ -266,57 +66,24 @@ export async function POST(req: Request) { ); } + // Seed bootstrap files const seedBootstrap = body.seedBootstrap !== false; const seeded: string[] = []; - if (seedBootstrap) { - const projectRoot = resolveProjectRoot(); - - // Seed all bootstrap files from templates - for (const filename of BOOTSTRAP_FILENAMES) { + for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) { const filePath = join(workspaceDir, filename); if (!existsSync(filePath)) { - const content = loadTemplateContent(filename, projectRoot); - if (writeIfMissing(filePath, content)) { + try { + writeFileSync(filePath, content, "utf-8"); 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 - } - } } - // Remember custom-path workspaces in the registry - if (body.path?.trim() && profileName) { - registerWorkspacePath(profileName, workspaceDir); - } - - // Switch to the new profile + // If a profile was specified, switch to it if (profileName) { setUIActiveProfile(profileName === "default" ? null : profileName); } diff --git a/apps/web/app/api/workspace/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts index 25c0084fe13..82861ef8073 100644 --- a/apps/web/app/api/workspace/raw-file/route.ts +++ b/apps/web/app/api/workspace/raw-file/route.ts @@ -33,8 +33,6 @@ const MIME_MAP: Record = { m4a: "audio/mp4", // Documents pdf: "application/pdf", - html: "text/html", - htm: "text/html", }; /** diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index c603d38b4eb..30e759bb3c4 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs"; +import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; import { join } from "node:path"; import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace"; @@ -14,8 +14,6 @@ export type TreeNode = { children?: TreeNode[]; /** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */ virtual?: boolean; - /** True when the entry is a symbolic link. */ - symlink?: boolean; }; type DbObject = { @@ -60,28 +58,11 @@ function loadDbObjects(): Map { return map; } -/** Resolve a dirent's effective type, following symlinks to their target. */ -function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null { - if (entry.isDirectory()) {return "directory";} - if (entry.isFile()) {return "file";} - if (entry.isSymbolicLink()) { - try { - const st = statSync(absPath); - if (st.isDirectory()) {return "directory";} - if (st.isFile()) {return "file";} - } catch { - // Broken symlink -- skip - } - } - return null; -} - /** Recursively build a tree from a workspace directory. */ function buildTree( absDir: string, relativeBase: string, dbObjects: Map, - showHidden = false, ): TreeNode[] { const nodes: TreeNode[] = []; @@ -92,44 +73,32 @@ function buildTree( return nodes; } - const filtered = entries.filter((e) => { - // .object.yaml is always needed for metadata; also shown as a node when showHidden is on - if (e.name === ".object.yaml") {return true;} - if (e.name.startsWith(".")) {return showHidden;} - return true; - }); - // Sort: directories first, then files, alphabetical within each group - const sorted = filtered.toSorted((a, b) => { - const absA = join(absDir, a.name); - const absB = join(absDir, b.name); - const typeA = resolveEntryType(a, absA); - const typeB = resolveEntryType(b, absB); - const dirA = typeA === "directory"; - const dirB = typeB === "directory"; - if (dirA && !dirB) {return -1;} - if (!dirA && dirB) {return 1;} - return a.name.localeCompare(b.name); - }); + 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) { - // .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files - if (entry.name === ".object.yaml" && !showHidden) {continue;} + // 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; - const isSymlink = entry.isSymbolicLink(); - const effectiveType = resolveEntryType(entry, absPath); - - if (effectiveType === "directory") { + if (entry.isDirectory()) { const objectMeta = readObjectMeta(absPath); const dbObject = dbObjects.get(entry.name); - const children = buildTree(absPath, relPath, dbObjects, showHidden); + 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, @@ -140,18 +109,17 @@ function buildTree( | "table" | "kanban") ?? "table", children: children.length > 0 ? children : undefined, - ...(isSymlink && { symlink: true }), }); } else { + // Regular folder nodes.push({ name: entry.name, path: relPath, type: "folder", children: children.length > 0 ? children : undefined, - ...(isSymlink && { symlink: true }), }); } - } else if (effectiveType === "file") { + } else if (entry.isFile()) { const ext = entry.name.split(".").pop()?.toLowerCase(); const isReport = entry.name.endsWith(".report.json"); const isDocument = ext === "md" || ext === "mdx"; @@ -161,7 +129,6 @@ function buildTree( name: entry.name, path: relPath, type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file", - ...(isSymlink && { symlink: true }), }); } } @@ -239,24 +206,27 @@ function buildSkillsVirtualFolder(): TreeNode | null { } -export async function GET(req: Request) { - const url = new URL(req.url); - const showHidden = url.searchParams.get("showHidden") === "1"; - +export async function GET() { const openclawDir = resolveOpenClawStateDir(); const profile = getEffectiveProfile(); const root = resolveWorkspaceRoot(); if (!root) { + // Even without a workspace, return virtual folders if they exist const tree: TreeNode[] = []; const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile }); } + // Load objects from DuckDB for smart directory detection const dbObjects = loadDbObjects(); - const tree = buildTree(root, "", dbObjects, showHidden); + // Scan the workspace root — it IS the knowledge base. + // All top-level directories, files, objects, and documents are visible + // in the sidebar (USER.md, SOUL.md, memory/, etc. are all part of the tree). + const tree = buildTree(root, "", dbObjects); + // Virtual folders go after all real files/folders const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} diff --git a/apps/web/app/api/workspace/watch/route.ts b/apps/web/app/api/workspace/watch/route.ts index e75777ced84..e9062fcc119 100644 --- a/apps/web/app/api/workspace/watch/route.ts +++ b/apps/web/app/api/workspace/watch/route.ts @@ -3,137 +3,95 @@ import { resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -// --------------------------------------------------------------------------- -// Singleton watcher: one chokidar instance shared across all SSE connections. -// Uses polling (no native fs.watch FDs) so it doesn't compete with Next.js's -// own dev watcher for the macOS per-process file-descriptor limit. -// --------------------------------------------------------------------------- - -type Listener = (type: string, relPath: string) => void; - -let listeners = new Set(); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let sharedWatcher: any = null; -let sharedRoot: string | null = null; -let __watcherReady = false; - -async function ensureWatcher(root: string) { - if (sharedWatcher && sharedRoot === root) {return;} - - // Root changed (e.g. profile switch) -- close the old watcher first. - if (sharedWatcher) { - await sharedWatcher.close(); - sharedWatcher = null; - sharedRoot = null; - _watcherReady = false; - } - - try { - const chokidar = await import("chokidar"); - sharedRoot = root; - sharedWatcher = chokidar.watch(root, { - ignoreInitial: true, - usePolling: true, - interval: 1500, - binaryInterval: 3000, - ignored: [ - /(^|[\\/])node_modules([\\/]|$)/, - /(^|[\\/])\.git([\\/]|$)/, - /(^|[\\/])\.next([\\/]|$)/, - /(^|[\\/])dist([\\/]|$)/, - /\.duckdb\.wal$/, - /\.duckdb\.tmp$/, - ], - depth: 5, - }); - - sharedWatcher.on("all", (eventType: string, filePath: string) => { - const rel = filePath.startsWith(root) - ? filePath.slice(root.length + 1) - : filePath; - for (const fn of listeners) {fn(eventType, rel);} - }); - - sharedWatcher.once("ready", () => {_watcherReady = true;}); - - sharedWatcher.on("error", () => { - // Swallow; polling mode shouldn't hit EMFILE but be safe. - }); - } catch { - // chokidar unavailable -- listeners simply won't fire. - } -} - -function stopWatcherIfIdle() { - if (listeners.size > 0 || !sharedWatcher) {return;} - sharedWatcher.close(); - sharedWatcher = null; - sharedRoot = null; - _watcherReady = false; -} - /** * GET /api/workspace/watch * * Server-Sent Events endpoint that watches the workspace for file changes. + * Sends events: { type: "add"|"change"|"unlink"|"addDir"|"unlinkDir", path: string } * Falls back gracefully if chokidar is unavailable. */ -export async function GET(req: Request) { +export async function GET() { const root = resolveWorkspaceRoot(); if (!root) { return new Response("Workspace not found", { status: 404 }); } const encoder = new TextEncoder(); - let closed = false; - let heartbeat: ReturnType | null = null; - let debounceTimer: ReturnType | null = null; const stream = new ReadableStream({ async start(controller) { + // Send initial heartbeat so the client knows the connection is alive controller.enqueue(encoder.encode("event: connected\ndata: {}\n\n")); - const listener: Listener = (_type, _rel) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let watcher: any = null; + let closed = false; + + // Debounce: batch rapid events into a single "refresh" signal + let debounceTimer: ReturnType | null = null; + + function sendEvent(type: string, filePath: string) { if (closed) {return;} if (debounceTimer) {clearTimeout(debounceTimer);} debounceTimer = setTimeout(() => { if (closed) {return;} try { - const data = JSON.stringify({ type: _type, path: _rel }); + const data = JSON.stringify({ type, path: filePath }); controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`)); - } catch { /* stream closed */ } - }, 300); - }; + } catch { + // Stream may have been closed + } + }, 200); + } - heartbeat = setInterval(() => { + // Keep-alive heartbeat every 30s to prevent proxy/timeout disconnects + const heartbeat = setInterval(() => { if (closed) {return;} try { controller.enqueue(encoder.encode(": heartbeat\n\n")); - } catch { /* closed */ } + } catch { + // Ignore if closed + } }, 30_000); - function teardown() { - if (closed) {return;} - closed = true; - listeners.delete(listener); - if (heartbeat) {clearInterval(heartbeat);} - if (debounceTimer) {clearTimeout(debounceTimer);} - stopWatcherIfIdle(); - } + try { + // Dynamic import so the route still compiles if chokidar is missing + const chokidar = await import("chokidar"); + watcher = chokidar.watch(root, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }, + ignored: [ + /(^|[\\/])node_modules([\\/]|$)/, + /\.duckdb\.wal$/, + /\.duckdb\.tmp$/, + ], + depth: 10, + }); - req.signal.addEventListener("abort", teardown, { once: true }); - - listeners.add(listener); - await ensureWatcher(root); - - if (!sharedWatcher) { + watcher.on("all", (eventType: string, filePath: string) => { + // Make path relative to workspace root + const rel = filePath.startsWith(root) + ? filePath.slice(root.length + 1) + : filePath; + sendEvent(eventType, rel); + }); + } catch { + // chokidar not available, send a fallback event and close controller.enqueue( encoder.encode("event: error\ndata: {\"error\":\"File watching unavailable\"}\n\n"), ); } - }, - cancel() { - closed = true; + + // Cleanup when the client disconnects + // The cancel callback is invoked by the runtime when the response is aborted + const originalCancel = stream.cancel?.bind(stream); + stream.cancel = async (reason) => { + closed = true; + clearInterval(heartbeat); + if (debounceTimer) {clearTimeout(debounceTimer);} + if (watcher) {await watcher.close();} + if (originalCancel) {return originalCancel(reason);} + }; }, }); diff --git a/apps/web/app/components/sidebar.tsx b/apps/web/app/components/sidebar.tsx index f4575c955b4..094683da12f 100644 --- a/apps/web/app/components/sidebar.tsx +++ b/apps/web/app/components/sidebar.tsx @@ -437,7 +437,6 @@ export function Sidebar({ setShowCreateWorkspace(true)} - activeProfileHint={String(sidebarRefreshKey)} /> diff --git a/apps/web/app/components/workspace/create-workspace-dialog.tsx b/apps/web/app/components/workspace/create-workspace-dialog.tsx index 4f6ba07f8d4..cdbdbf39b3e 100644 --- a/apps/web/app/components/workspace/create-workspace-dialog.tsx +++ b/apps/web/app/components/workspace/create-workspace-dialog.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useRef, useEffect } from "react"; -import { DirectoryPickerModal } from "./directory-picker-modal"; type CreateWorkspaceDialogProps = { isOpen: boolean; @@ -9,15 +8,10 @@ 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); @@ -31,23 +25,22 @@ 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 (only if dir picker is not open) + // Close on Escape useEffect(() => { function handleKey(e: KeyboardEvent) { - if (e.key === "Escape" && !showDirPicker) {onClose();} + if (e.key === "Escape") {onClose();} } if (isOpen) { document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); } - }, [isOpen, onClose, showDirPicker]); + }, [isOpen, onClose]); const handleCreate = async () => { const name = profileName.trim(); @@ -229,65 +222,18 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork {useCustomPath && ( -
- {customPath ? ( -
-
- - - -
-
-

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

-

- {shortenPath(customPath)} -

-
- - -
- ) : ( - - )} -
+ 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)", + }} + /> )} @@ -304,7 +250,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork className="text-sm" style={{ color: "var(--color-text-secondary)" }} > - Seed bootstrap files and workspace database + Seed bootstrap files (AGENTS.md, SOUL.md, USER.md) @@ -363,13 +309,6 @@ 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 deleted file mode 100644 index ed56bad6323..00000000000 --- a/apps/web/app/components/workspace/directory-picker-modal.tsx +++ /dev/null @@ -1,474 +0,0 @@ -"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..."} -

-
-
- - -
-
-
-
- ); -} diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index a01694d0dc5..8f1104f537f 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -28,8 +28,6 @@ export type TreeNode = { children?: TreeNode[]; /** When true, the node represents a virtual folder/file outside the real workspace (e.g. Skills, Memories). CRUD ops are disabled. */ virtual?: boolean; - /** True when the entry is a symbolic link / shortcut. */ - symlink?: boolean; }; /** Folder names reserved for virtual sections -- cannot be created/renamed to. */ @@ -155,14 +153,6 @@ function LockBadge() { ); } -function SymlinkBadge() { - return ( - - - - ); -} - function ChevronIcon({ open }: { open: boolean }) { return ( )} - {/* Symlink indicator */} - {node.symlink && !compact && ( - - - - )} - {/* Type badge for objects */} {node.type === "object" && ( ; - } - - const { content, filename, type } = props; +export function FileViewer({ content, filename, type }: FileViewerProps) { const lines = content.split("\n"); return (
- + {/* File header */} +
+ + + + + + {filename} + + + {type.toUpperCase()} + +
+ {/* File content */}
+ {/* Line number */} + {/* Line content */} - {icon ?? ( - - - - - )} - - {filename} - - - {label} - -
- ); -} - -// --------------------------------------------------------------------------- -// Spreadsheet viewer -// --------------------------------------------------------------------------- - -function SpreadsheetViewer({ filename, url }: { filename: string; url: string }) { - const [workbook, setWorkbook] = useState(null); - const [activeSheet, setActiveSheet] = useState(0); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - setWorkbook(null); - setActiveSheet(0); - setError(null); - - fetch(url) - .then((res) => { - if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);} - return res.arrayBuffer(); - }) - .then((buf) => { - if (cancelled) {return;} - const wb = read(buf, { type: "array" }); - setWorkbook(wb); - }) - .catch((err) => { - if (!cancelled) {setError(String(err));} - }); - - return () => { cancelled = true; }; - }, [url]); - - const ext = filename.split(".").pop()?.toUpperCase() ?? "SPREADSHEET"; - - if (error) { - return ( -
- } /> -
- Failed to load spreadsheet: {error} -
-
- ); - } - - if (!workbook) { - return ( -
- } /> -
- Loading spreadsheet... -
-
- ); - } - - const sheetNames = workbook.SheetNames; - const sheet = workbook.Sheets[sheetNames[activeSheet]]; - const rows: string[][] = sheet ? utils.sheet_to_json(sheet, { header: 1, defval: "" }) : []; - - return ( -
- } /> - - {/* Sheet tabs */} - {sheetNames.length > 1 && ( -
- {sheetNames.map((name, idx) => ( - - ))} -
- )} - - {/* Table */} -
- {rows.length === 0 ? ( -
- This sheet is empty. -
- ) : ( - - - - {/* Row number header */} - - ))} - - - - {rows.map((row, rowIdx) => ( - - - {row.map((cell, colIdx) => ( - - ))} - - ))} - -
- {rows[0]?.map((_cell, colIdx) => ( - - {columnLabel(colIdx)} -
- {rowIdx + 1} - - {String(cell)} -
- )} -
- -
- {rows.length} row{rows.length !== 1 ? "s" : ""} - {rows[0] ? ` \u00d7 ${rows[0].length} column${rows[0].length !== 1 ? "s" : ""}` : ""} - {sheetNames.length > 1 ? ` \u00b7 ${sheetNames.length} sheets` : ""} -
-
- ); -} - -/** Convert zero-based column index to Excel-style label (A, B, ..., Z, AA, AB, ...) */ -function columnLabel(idx: number): string { - let label = ""; - let n = idx; - do { - label = String.fromCharCode(65 + (n % 26)) + label; - n = Math.floor(n / 26) - 1; - } while (n >= 0); - return label; -} - -function SpreadsheetIcon() { - return ( - - - - - - - - - ); -} - /** Simple YAML syntax highlighting */ function YamlLine({ line }: { line: string }) { // Comment diff --git a/apps/web/app/components/workspace/html-viewer.tsx b/apps/web/app/components/workspace/html-viewer.tsx deleted file mode 100644 index aebc7b21bfa..00000000000 --- a/apps/web/app/components/workspace/html-viewer.tsx +++ /dev/null @@ -1,276 +0,0 @@ -"use client"; - -import { useState, useEffect, useMemo, useCallback } from "react"; -import { createHighlighter, type Highlighter } from "shiki"; - -type HtmlViewerProps = { - filename: string; - /** Raw URL for iframe rendering (served with text/html) */ - rawUrl: string; - /** JSON API URL to fetch source content on demand (for code view) */ - contentUrl: string; -}; - -type ViewMode = "rendered" | "code"; - -let highlighterPromise: Promise | null = null; - -function getHighlighter(): Promise { - if (!highlighterPromise) { - highlighterPromise = createHighlighter({ - themes: ["github-dark", "github-light"], - langs: ["html"], - }); - } - return highlighterPromise; -} - -export function HtmlViewer({ filename, rawUrl, contentUrl }: HtmlViewerProps) { - const [mode, setMode] = useState("rendered"); - const [source, setSource] = useState(null); - const [sourceLoading, setSourceLoading] = useState(false); - - const handleCodeToggle = useCallback(() => { - setMode("code"); - if (source !== null) {return;} - setSourceLoading(true); - void fetch(contentUrl) - .then((r) => r.json()) - .then((data: { content: string }) => setSource(data.content)) - .catch(() => setSource("")) - .finally(() => setSourceLoading(false)); - }, [contentUrl, source]); - - return ( -
- {/* Header bar */} -
- - - {filename} - - - HTML - - - {/* Mode toggle */} -
- - -
- - {/* Open in new tab */} - { - (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; - }} - onMouseLeave={(e) => { - (e.currentTarget as HTMLElement).style.background = "transparent"; - }} - > - - -
- - {/* Content */} - {mode === "rendered" ? ( - - ) : sourceLoading || source === null ? ( -
-
-
- ) : ( - - )} -
- ); -} - -// --- Rendered HTML view (sandboxed iframe) --- - -function RenderedView({ rawUrl }: { rawUrl: string }) { - return ( -
-