👌 IMPROVE: dench workspace
This commit is contained in:
parent
04da69c89b
commit
f74327445e
116
apps/web/app/api/workspace/context/route.ts
Normal file
116
apps/web/app/api/workspace/context/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDenchRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export type WorkspaceContext = {
|
||||
exists: boolean;
|
||||
organization?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
members?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}>;
|
||||
defaults?: {
|
||||
default_view?: string;
|
||||
date_format?: string;
|
||||
naming_convention?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse workspace_context.yaml with basic YAML extraction.
|
||||
* Handles the specific structure defined by the Dench skill.
|
||||
*/
|
||||
function parseWorkspaceContext(content: string): WorkspaceContext {
|
||||
const ctx: WorkspaceContext = { exists: true };
|
||||
|
||||
// Extract organization block
|
||||
const orgMatch = content.match(
|
||||
/organization:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (orgMatch) {
|
||||
const orgBlock = orgMatch[1];
|
||||
const org: Record<string, string> = {};
|
||||
for (const line of orgBlock.split("\n")) {
|
||||
const kv = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
if (kv) {org[kv[1]] = kv[2].trim();}
|
||||
}
|
||||
ctx.organization = {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract members list
|
||||
const membersMatch = content.match(
|
||||
/members:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (membersMatch) {
|
||||
const membersBlock = membersMatch[1];
|
||||
const members: WorkspaceContext["members"] = [];
|
||||
let current: Record<string, string> = {};
|
||||
|
||||
for (const line of membersBlock.split("\n")) {
|
||||
const itemStart = line.match(/^\s+-\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
const propLine = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
|
||||
if (itemStart) {
|
||||
if (current.id) {members.push(current as never);}
|
||||
current = { [itemStart[1]]: itemStart[2].trim() };
|
||||
} else if (propLine && !line.trim().startsWith("-")) {
|
||||
current[propLine[1]] = propLine[2].trim();
|
||||
}
|
||||
}
|
||||
if (current.id) {members.push(current as never);}
|
||||
ctx.members = members;
|
||||
}
|
||||
|
||||
// Extract defaults block
|
||||
const defaultsMatch = content.match(
|
||||
/defaults:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (defaultsMatch) {
|
||||
const defaultsBlock = defaultsMatch[1];
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const line of defaultsBlock.split("\n")) {
|
||||
const kv = line.match(/^\s+(\w[\w_]*)\s*:\s*(.+)/);
|
||||
if (kv) {defaults[kv[1]] = kv[2].trim();}
|
||||
}
|
||||
ctx.defaults = {
|
||||
default_view: defaults.default_view,
|
||||
date_format: defaults.date_format,
|
||||
naming_convention: defaults.naming_convention,
|
||||
};
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
return Response.json({ exists: false } satisfies WorkspaceContext);
|
||||
}
|
||||
|
||||
const ctxPath = join(root, "workspace_context.yaml");
|
||||
if (!existsSync(ctxPath)) {
|
||||
return Response.json({ exists: true } satisfies WorkspaceContext);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ctxPath, "utf-8");
|
||||
const parsed = parseWorkspaceContext(content);
|
||||
return Response.json(parsed);
|
||||
} catch {
|
||||
return Response.json({ exists: true } satisfies WorkspaceContext);
|
||||
}
|
||||
}
|
||||
26
apps/web/app/api/workspace/file/route.ts
Normal file
26
apps/web/app/api/workspace/file/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { readWorkspaceFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
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 file = readWorkspaceFile(path);
|
||||
if (!file) {
|
||||
return Response.json(
|
||||
{ error: "File not found or access denied" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(file);
|
||||
}
|
||||
171
apps/web/app/api/workspace/objects/[name]/route.ts
Normal file
171
apps/web/app/api/workspace/objects/[name]/route.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { duckdbQuery, duckdbPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
immutable?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
enum_values?: string;
|
||||
enum_colors?: string;
|
||||
enum_multiple?: boolean;
|
||||
related_object_id?: string;
|
||||
relationship_type?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type StatusRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
type EavRow = {
|
||||
entry_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
field_name: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pivot raw EAV rows into one object per entry with field names as keys.
|
||||
* Input: [{ entry_id, field_name, value }, ...]
|
||||
* Output: [{ entry_id, "Full Name": "Sarah", "Email": "sarah@..." }, ...]
|
||||
*/
|
||||
function pivotEavRows(
|
||||
rows: EavRow[],
|
||||
): Record<string, unknown>[] {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
let entry = grouped.get(row.entry_id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
entry_id: row.entry_id,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
grouped.set(row.entry_id, entry);
|
||||
}
|
||||
if (row.field_name) {
|
||||
entry[row.field_name] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB database not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize name to prevent injection (only allow alphanumeric + underscore)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch object metadata
|
||||
const objects = duckdbQuery<ObjectRow>(
|
||||
`SELECT * FROM objects WHERE name = '${name}' LIMIT 1`,
|
||||
);
|
||||
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const obj = objects[0];
|
||||
|
||||
// Fetch fields for this object
|
||||
const fields = duckdbQuery<FieldRow>(
|
||||
`SELECT * FROM fields WHERE object_id = '${obj.id}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// Fetch statuses for this object
|
||||
const statuses = duckdbQuery<StatusRow>(
|
||||
`SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
|
||||
let entries: Record<string, unknown>[] = [];
|
||||
|
||||
// Attempt PIVOT view
|
||||
const pivotEntries = duckdbQuery(
|
||||
`SELECT * FROM v_${name} ORDER BY created_at DESC LIMIT 200`,
|
||||
);
|
||||
|
||||
if (pivotEntries.length > 0) {
|
||||
entries = pivotEntries;
|
||||
} else {
|
||||
// Fallback: raw EAV query, then pivot in JS
|
||||
const rawRows = duckdbQuery<EavRow>(
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.object_id = '${obj.id}'
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5000`,
|
||||
);
|
||||
|
||||
entries = pivotEavRows(rawRows);
|
||||
}
|
||||
|
||||
// Parse enum JSON strings in fields
|
||||
const parsedFields = fields.map((f) => ({
|
||||
...f,
|
||||
enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined,
|
||||
enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined,
|
||||
}));
|
||||
|
||||
return Response.json({
|
||||
object: obj,
|
||||
fields: parsedFields,
|
||||
statuses,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
function tryParseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") {return value;}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
43
apps/web/app/api/workspace/query/route.ts
Normal file
43
apps/web/app/api/workspace/query/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { duckdbQuery } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { sql?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { sql } = body;
|
||||
if (!sql || typeof sql !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'sql' field in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic SQL safety: reject obviously dangerous statements
|
||||
const upper = sql.toUpperCase().trim();
|
||||
if (
|
||||
upper.startsWith("DROP") ||
|
||||
upper.startsWith("DELETE") ||
|
||||
upper.startsWith("INSERT") ||
|
||||
upper.startsWith("UPDATE") ||
|
||||
upper.startsWith("ALTER") ||
|
||||
upper.startsWith("CREATE")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Only SELECT queries are allowed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = duckdbQuery(sql);
|
||||
return Response.json({ rows });
|
||||
}
|
||||
170
apps/web/app/api/workspace/tree/route.ts
Normal file
170
apps/web/app/api/workspace/tree/route.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string; // relative to dench/
|
||||
type: "object" | "document" | "folder" | "file";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
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 isDocument = ext === "md" || ext === "mdx";
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: isDocument ? "document" : "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
return Response.json({ tree: [], exists: false });
|
||||
}
|
||||
|
||||
// Load objects from DuckDB for smart directory detection
|
||||
const dbObjects = loadDbObjects();
|
||||
|
||||
const knowledgeDir = join(root, "knowledge");
|
||||
const tree: TreeNode[] = [];
|
||||
|
||||
// Build knowledge tree
|
||||
if (existsSync(knowledgeDir)) {
|
||||
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
|
||||
}
|
||||
|
||||
// Add top-level files (WORKSPACE.md, workspace_context.yaml, etc.)
|
||||
try {
|
||||
const topLevel = readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of topLevel) {
|
||||
if (!entry.isFile()) {continue;}
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
|
||||
tree.push({
|
||||
name: entry.name,
|
||||
path: entry.name,
|
||||
type: isDocument ? "document" : "file",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip if root unreadable
|
||||
}
|
||||
|
||||
return Response.json({ tree, exists: true });
|
||||
}
|
||||
@ -24,7 +24,16 @@ type MemoryFile = {
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
type SidebarSection = "chats" | "skills" | "memories";
|
||||
type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
type SidebarSection = "chats" | "skills" | "memories" | "workspace";
|
||||
|
||||
type SidebarProps = {
|
||||
onSessionSelect?: (sessionId: string) => void;
|
||||
@ -38,11 +47,11 @@ type SidebarProps = {
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 60) {return `${seconds}s ago`;}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (minutes < 60) {return `${minutes}m ago`;}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) {return `${hours}h ago`;}
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
@ -200,6 +209,170 @@ function MemoriesSection({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace Section ---
|
||||
|
||||
function WorkspaceTreeNode({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
}) {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpandable = hasChildren || node.type === "folder" || node.type === "object";
|
||||
const isOpen = expanded.has(node.path);
|
||||
|
||||
const iconColor =
|
||||
node.type === "object"
|
||||
? "var(--color-accent)"
|
||||
: node.type === "document"
|
||||
? "#60a5fa"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 py-1 px-2 rounded-md text-sm cursor-pointer transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ paddingLeft: `${depth * 14 + 8}px`, color: "var(--color-text-muted)" }}
|
||||
onClick={() => {
|
||||
if (isExpandable) {onToggle(node.path);}
|
||||
// Navigate to workspace page for actionable items
|
||||
if (node.type === "object" || node.type === "document" || node.type === "file") {
|
||||
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<span className="w-3.5 flex-shrink-0 flex items-center justify-center" style={{ opacity: isExpandable ? 1 : 0 }}>
|
||||
{isExpandable && (
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 150ms" }}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex-shrink-0" style={{ color: iconColor }}>
|
||||
{node.type === "object" ? (
|
||||
node.defaultView === "kanban" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
|
||||
</svg>
|
||||
)
|
||||
) : node.type === "document" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
||||
</svg>
|
||||
) : node.type === "folder" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Name */}
|
||||
<span className="truncate flex-1 text-xs">{node.name.replace(/\.md$/, "")}</span>
|
||||
|
||||
{/* Type badge for objects */}
|
||||
{node.type === "object" && (
|
||||
<span
|
||||
className="text-[9px] px-1 py-0 rounded flex-shrink-0"
|
||||
style={{ background: "rgba(232,93,58,0.15)", color: "var(--color-accent)" }}
|
||||
>
|
||||
{node.defaultView === "kanban" ? "board" : "table"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && hasChildren && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
<WorkspaceTreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSection({ tree }: { tree: TreeNode[] }) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
// Auto-expand first level
|
||||
const initial = new Set<string>();
|
||||
for (const node of tree) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
initial.add(node.path);
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggle = (path: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {next.delete(path);}
|
||||
else {next.add(path);}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
|
||||
No workspace data yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{tree.map((node) => (
|
||||
<WorkspaceTreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Full workspace link */}
|
||||
<a
|
||||
href="/workspace"
|
||||
className="flex items-center gap-1.5 mx-2 mt-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
Open full workspace
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Collapsible Header ---
|
||||
|
||||
function SectionHeader({
|
||||
@ -246,18 +419,19 @@ export function Sidebar({
|
||||
activeSessionId,
|
||||
refreshKey,
|
||||
}: SidebarProps) {
|
||||
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats"]));
|
||||
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats", "workspace"]));
|
||||
const [webSessions, setWebSessions] = useState<WebSession[]>([]);
|
||||
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
||||
const [mainMemory, setMainMemory] = useState<string | null>(null);
|
||||
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
|
||||
const [workspaceTree, setWorkspaceTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const toggleSection = (section: SidebarSection) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
if (next.has(section)) {next.delete(section);}
|
||||
else {next.add(section);}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@ -267,15 +441,17 @@ export function Sidebar({
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [webSessionsRes, skillsRes, memoriesRes] = await Promise.all([
|
||||
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes] = await Promise.all([
|
||||
fetch("/api/web-sessions").then((r) => r.json()),
|
||||
fetch("/api/skills").then((r) => r.json()),
|
||||
fetch("/api/memories").then((r) => r.json()),
|
||||
fetch("/api/workspace/tree").then((r) => r.json()).catch(() => ({ tree: [] })),
|
||||
]);
|
||||
setWebSessions(webSessionsRes.sessions ?? []);
|
||||
setSkills(skillsRes.skills ?? []);
|
||||
setMainMemory(memoriesRes.mainMemory ?? null);
|
||||
setDailyLogs(memoriesRes.dailyLogs ?? []);
|
||||
setWorkspaceTree(workspaceRes.tree ?? []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load sidebar data:", err);
|
||||
} finally {
|
||||
@ -339,6 +515,21 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
{workspaceTree.length > 0 && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Workspace"
|
||||
count={workspaceTree.length}
|
||||
isOpen={openSections.has("workspace")}
|
||||
onToggle={() => toggleSection("workspace")}
|
||||
/>
|
||||
{openSections.has("workspace") && (
|
||||
<WorkspaceSection tree={workspaceTree} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
|
||||
86
apps/web/app/components/workspace/breadcrumbs.tsx
Normal file
86
apps/web/app/components/workspace/breadcrumbs.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
type BreadcrumbsProps = {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
};
|
||||
|
||||
export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate("")}
|
||||
className="px-1.5 py-0.5 rounded transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--color-text)";
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
workspace
|
||||
</button>
|
||||
|
||||
{segments.map((segment, idx) => {
|
||||
const partialPath = segments.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === segments.length - 1;
|
||||
|
||||
return (
|
||||
<span key={partialPath} className="flex items-center gap-1">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.4 }}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
||||
{isLast ? (
|
||||
<span
|
||||
className="px-1.5 py-0.5"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{segment.replace(/\.md$/, "")}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(partialPath)}
|
||||
className="px-1.5 py-0.5 rounded transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color =
|
||||
"var(--color-text)";
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"transparent";
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
52
apps/web/app/components/workspace/document-view.tsx
Normal file
52
apps/web/app/components/workspace/document-view.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
|
||||
const MarkdownContent = dynamic(
|
||||
() =>
|
||||
import("./markdown-content").then((mod) => mod.MarkdownContent),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="animate-pulse space-y-3 py-4">
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type DocumentViewProps = {
|
||||
content: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function DocumentView({ content, title }: DocumentViewProps) {
|
||||
// Strip YAML frontmatter if present
|
||||
const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
||||
|
||||
// Extract title from first H1 if no title provided
|
||||
const h1Match = body.match(/^#\s+(.+)/m);
|
||||
const displayTitle = title ?? h1Match?.[1];
|
||||
const markdownBody =
|
||||
displayTitle && h1Match ? body.replace(/^#\s+.+\n?/, "") : body;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
{displayTitle && (
|
||||
<h1
|
||||
className="text-3xl font-bold mb-6"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<div className="workspace-prose">
|
||||
<MarkdownContent content={markdownBody} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
apps/web/app/components/workspace/empty-state.tsx
Normal file
119
apps/web/app/components/workspace/empty-state.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
export function EmptyState({ workspaceExists }: { workspaceExists: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-6 px-8">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="text-center max-w-md">
|
||||
<h2
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{workspaceExists
|
||||
? "Workspace is empty"
|
||||
: "No workspace found"}
|
||||
</h2>
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{workspaceExists ? (
|
||||
<>
|
||||
The Dench workspace exists but has no knowledge tree yet.
|
||||
Ask the CRM agent to create objects and documents to populate it.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The Dench workspace directory was not found. To initialize it,
|
||||
start a conversation with the CRM agent and it will create the
|
||||
workspace structure automatically.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-lg text-sm"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-accent)", flexShrink: 0 }}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
<span>
|
||||
Expected location:{" "}
|
||||
<code
|
||||
className="px-1.5 py-0.5 rounded text-xs"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
~/.openclaw/workspace/dench/
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-sm mt-2 transition-colors"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
Back to Chat
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
apps/web/app/components/workspace/file-viewer.tsx
Normal file
168
apps/web/app/components/workspace/file-viewer.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
type FileViewerProps = {
|
||||
content: string;
|
||||
filename: string;
|
||||
type: "yaml" | "text";
|
||||
};
|
||||
|
||||
export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
const lines = content.split("\n");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* File header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* File content */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<pre className="text-sm leading-6" style={{ margin: 0 }}>
|
||||
<code>
|
||||
{lines.map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
{/* Line number */}
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
minWidth: "3rem",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* Line content */}
|
||||
<span
|
||||
className="pr-4 flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{type === "yaml" ? (
|
||||
<YamlLine line={line} />
|
||||
) : (
|
||||
line || " "
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple YAML syntax highlighting */
|
||||
function YamlLine({ line }: { line: string }) {
|
||||
// Comment
|
||||
if (line.trim().startsWith("#")) {
|
||||
return <span style={{ color: "var(--color-text-muted)" }}>{line}</span>;
|
||||
}
|
||||
|
||||
// Key: value
|
||||
const kvMatch = line.match(/^(\s*)([\w][\w_-]*)\s*(:)(.*)/);
|
||||
if (kvMatch) {
|
||||
const [, indent, key, colon, value] = kvMatch;
|
||||
return (
|
||||
<>
|
||||
<span>{indent}</span>
|
||||
<span style={{ color: "#60a5fa" }}>{key}</span>
|
||||
<span style={{ color: "var(--color-text-muted)" }}>{colon}</span>
|
||||
<YamlValue value={value} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// List item
|
||||
const listMatch = line.match(/^(\s*)(-)(\s*)(.*)/);
|
||||
if (listMatch) {
|
||||
const [, indent, dash, space, value] = listMatch;
|
||||
return (
|
||||
<>
|
||||
<span>{indent}</span>
|
||||
<span style={{ color: "var(--color-accent)" }}>{dash}</span>
|
||||
<span>{space}</span>
|
||||
<span style={{ color: "var(--color-text)" }}>{value}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{line || " "}</span>;
|
||||
}
|
||||
|
||||
function YamlValue({ value }: { value: string }) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// String in quotes
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return <span style={{ color: "#a5d6a7" }}> {trimmed}</span>;
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (trimmed === "true" || trimmed === "false") {
|
||||
return <span style={{ color: "#f59e0b" }}> {trimmed}</span>;
|
||||
}
|
||||
|
||||
// Number
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
return <span style={{ color: "#c084fc" }}> {trimmed}</span>;
|
||||
}
|
||||
|
||||
// Null
|
||||
if (trimmed === "null") {
|
||||
return (
|
||||
<span style={{ color: "var(--color-text-muted)", fontStyle: "italic" }}>
|
||||
{" "}
|
||||
{trimmed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span style={{ color: "var(--color-text)" }}> {value}</span>;
|
||||
}
|
||||
261
apps/web/app/components/workspace/knowledge-tree.tsx
Normal file
261
apps/web/app/components/workspace/knowledge-tree.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
// --- Icons (inline SVG for zero-dep) ---
|
||||
|
||||
function FolderIcon({ open }: { open?: boolean }) {
|
||||
return open ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TableIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
transform: open ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 150ms ease",
|
||||
}}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Node Icon Resolver ---
|
||||
|
||||
function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
|
||||
switch (node.type) {
|
||||
case "object":
|
||||
return node.defaultView === "kanban" ? <KanbanIcon /> : <TableIcon />;
|
||||
case "document":
|
||||
return <DocumentIcon />;
|
||||
case "folder":
|
||||
return <FolderIcon open={open} />;
|
||||
default:
|
||||
return <FileIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tree Node Component ---
|
||||
|
||||
function TreeNodeItem({
|
||||
node,
|
||||
depth,
|
||||
activePath,
|
||||
onSelect,
|
||||
expandedPaths,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
activePath: string | null;
|
||||
onSelect: (node: TreeNode) => void;
|
||||
expandedPaths: Set<string>;
|
||||
onToggleExpand: (path: string) => void;
|
||||
}) {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpandable = hasChildren || node.type === "folder" || node.type === "object";
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isActive = activePath === node.path;
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect(node);
|
||||
if (isExpandable) {
|
||||
onToggleExpand(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
const typeColor =
|
||||
node.type === "object"
|
||||
? "var(--color-accent)"
|
||||
: node.type === "document"
|
||||
? "#60a5fa"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
paddingLeft: `${depth * 16 + 8}px`,
|
||||
background: isActive ? "var(--color-surface-hover)" : "transparent",
|
||||
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive)
|
||||
{(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive)
|
||||
{(e.currentTarget as HTMLElement).style.background = "transparent";}
|
||||
}}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
<span
|
||||
className="flex-shrink-0 w-4 h-4 flex items-center justify-center"
|
||||
style={{ opacity: isExpandable ? 1 : 0 }}
|
||||
>
|
||||
{isExpandable && <ChevronIcon open={isExpanded} />}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<span
|
||||
className="flex-shrink-0 flex items-center"
|
||||
style={{ color: typeColor }}
|
||||
>
|
||||
<NodeIcon node={node} open={isExpanded} />
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="truncate flex-1">
|
||||
{node.name.replace(/\.md$/, "")}
|
||||
</span>
|
||||
|
||||
{/* Type badge for objects */}
|
||||
{node.type === "object" && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "rgba(232, 93, 58, 0.15)",
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
{node.defaultView === "kanban" ? "board" : "table"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
borderLeft: depth > 0 ? "1px solid var(--color-border)" : "none",
|
||||
marginLeft: `${depth * 16 + 16}px`,
|
||||
}}
|
||||
>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Exported Tree Component ---
|
||||
|
||||
export function KnowledgeTree({
|
||||
tree,
|
||||
activePath,
|
||||
onSelect,
|
||||
}: {
|
||||
tree: TreeNode[];
|
||||
activePath: string | null;
|
||||
onSelect: (node: TreeNode) => void;
|
||||
}) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {next.delete(path);}
|
||||
else {next.add(path);}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-6 text-center text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No files in workspace
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
{tree.map((node) => (
|
||||
<TreeNodeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/web/app/components/workspace/markdown-content.tsx
Normal file
12
apps/web/app/components/workspace/markdown-content.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export function MarkdownContent({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
332
apps/web/app/components/workspace/object-kanban.tsx
Normal file
332
apps/web/app/components/workspace/object-kanban.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
type Field = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enum_values?: string[];
|
||||
enum_colors?: string[];
|
||||
};
|
||||
|
||||
type Status = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type ObjectKanbanProps = {
|
||||
objectName: string;
|
||||
fields: Field[];
|
||||
entries: Record<string, unknown>[];
|
||||
statuses: Status[];
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
// --- Card component ---
|
||||
|
||||
function KanbanCard({
|
||||
entry,
|
||||
fields,
|
||||
members,
|
||||
}: {
|
||||
entry: Record<string, unknown>;
|
||||
fields: Field[];
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
// Show first 4 non-status fields
|
||||
const displayFields = fields
|
||||
.filter(
|
||||
(f) =>
|
||||
f.type !== "richtext" &&
|
||||
entry[f.name] !== null &&
|
||||
entry[f.name] !== undefined &&
|
||||
entry[f.name] !== "",
|
||||
)
|
||||
.slice(0, 4);
|
||||
|
||||
// Find a "name" or "title" field for the card header
|
||||
const titleField = fields.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes("name") ||
|
||||
f.name.toLowerCase().includes("title"),
|
||||
);
|
||||
const title = titleField
|
||||
? String(entry[titleField.name] ?? "Untitled")
|
||||
: String(entry[fields[0]?.name] ?? "Untitled");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-3 mb-2 transition-all duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-border)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium mb-1.5 truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{displayFields
|
||||
.filter((f) => f !== titleField)
|
||||
.slice(0, 3)
|
||||
.map((field) => {
|
||||
const val = entry[field.name];
|
||||
if (!val) {return null;}
|
||||
|
||||
// Resolve user fields
|
||||
let displayVal = String(val);
|
||||
if (field.type === "user") {
|
||||
const member = members?.find((m) => m.id === displayVal);
|
||||
if (member) {displayVal = member.name;}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.id} className="flex items-center gap-1.5 text-xs">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
{field.name}:
|
||||
</span>
|
||||
{field.type === "enum" ? (
|
||||
<EnumBadgeMini
|
||||
value={displayVal}
|
||||
enumValues={field.enum_values}
|
||||
enumColors={field.enum_colors}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{displayVal}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnumBadgeMini({
|
||||
value,
|
||||
enumValues,
|
||||
enumColors,
|
||||
}: {
|
||||
value: string;
|
||||
enumValues?: string[];
|
||||
enumColors?: string[];
|
||||
}) {
|
||||
const idx = enumValues?.indexOf(value) ?? -1;
|
||||
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0 rounded text-[11px] font-medium"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Kanban Board ---
|
||||
|
||||
export function ObjectKanban({
|
||||
objectName,
|
||||
fields,
|
||||
entries,
|
||||
statuses,
|
||||
members,
|
||||
}: ObjectKanbanProps) {
|
||||
// Find the grouping field: prefer a "Status" enum field, fallback to first enum
|
||||
const groupField = useMemo(() => {
|
||||
const statusField = fields.find(
|
||||
(f) =>
|
||||
f.type === "enum" &&
|
||||
f.name.toLowerCase().includes("status"),
|
||||
);
|
||||
if (statusField) {return statusField;}
|
||||
return fields.find((f) => f.type === "enum") ?? null;
|
||||
}, [fields]);
|
||||
|
||||
// Determine columns: from statuses table, or from enum_values, or from unique values
|
||||
const columns = useMemo(() => {
|
||||
if (statuses.length > 0) {
|
||||
return statuses.map((s) => ({
|
||||
name: s.name,
|
||||
color: s.color ?? "#94a3b8",
|
||||
}));
|
||||
}
|
||||
if (groupField?.enum_values) {
|
||||
return groupField.enum_values.map((v, i) => ({
|
||||
name: v,
|
||||
color: groupField.enum_colors?.[i] ?? "#94a3b8",
|
||||
}));
|
||||
}
|
||||
// Fallback: derive from data
|
||||
const unique = new Set<string>();
|
||||
for (const e of entries) {
|
||||
const val = groupField ? e[groupField.name] : undefined;
|
||||
if (val) {unique.add(String(val));}
|
||||
}
|
||||
return Array.from(unique).map((v) => ({ name: v, color: "#94a3b8" }));
|
||||
}, [statuses, groupField, entries]);
|
||||
|
||||
// Group entries by column
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, Record<string, unknown>[]> = {};
|
||||
for (const col of columns) {groups[col.name] = [];}
|
||||
|
||||
// Ungrouped bucket
|
||||
groups["_ungrouped"] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const val = groupField ? String(entry[groupField.name] ?? "") : "";
|
||||
if (groups[val]) {
|
||||
groups[val].push(entry);
|
||||
} else {
|
||||
groups["_ungrouped"].push(entry);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [columns, entries, groupField]);
|
||||
|
||||
// Non-grouping fields for cards
|
||||
const cardFields = fields.filter((f) => f !== groupField);
|
||||
|
||||
if (!groupField) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No enum field found for kanban grouping in{" "}
|
||||
<span className="font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{objectName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 px-1" style={{ minHeight: "400px" }}>
|
||||
{columns.map((col) => {
|
||||
const items = grouped[col.name] ?? [];
|
||||
return (
|
||||
<div
|
||||
key={col.name}
|
||||
className="flex-shrink-0 flex flex-col rounded-xl"
|
||||
style={{
|
||||
width: "280px",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: col.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-medium flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{col.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-8 rounded-lg border border-dashed text-xs"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
No entries
|
||||
</div>
|
||||
) : (
|
||||
items.map((entry, idx) => (
|
||||
<KanbanCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ungrouped entries */}
|
||||
{grouped["_ungrouped"]?.length > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col rounded-xl"
|
||||
style={{
|
||||
width: "280px",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px dashed var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Ungrouped
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{grouped["_ungrouped"].length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{grouped["_ungrouped"].map((entry, idx) => (
|
||||
<KanbanCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
apps/web/app/components/workspace/object-table.tsx
Normal file
275
apps/web/app/components/workspace/object-table.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
type Field = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enum_values?: string[];
|
||||
enum_colors?: string[];
|
||||
enum_multiple?: boolean;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type ObjectTableProps = {
|
||||
objectName: string;
|
||||
fields: Field[];
|
||||
entries: Record<string, unknown>[];
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
// --- Sort helpers ---
|
||||
|
||||
type SortState = {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
} | null;
|
||||
|
||||
function SortIcon({ active, direction }: { active: boolean; direction: "asc" | "desc" }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ opacity: active ? 1 : 0.3 }}
|
||||
>
|
||||
{direction === "asc" ? (
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
) : (
|
||||
<path d="m19 12-7 7-7-7" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Cell Renderers ---
|
||||
|
||||
function EnumBadge({
|
||||
value,
|
||||
enumValues,
|
||||
enumColors,
|
||||
}: {
|
||||
value: string;
|
||||
enumValues?: string[];
|
||||
enumColors?: string[];
|
||||
}) {
|
||||
const idx = enumValues?.indexOf(value) ?? -1;
|
||||
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BooleanCell({ value }: { value: unknown }) {
|
||||
const isTrue =
|
||||
value === true || value === "true" || value === "1" || value === "yes";
|
||||
return (
|
||||
<span style={{ color: isTrue ? "#22c55e" : "var(--color-text-muted)" }}>
|
||||
{isTrue ? "Yes" : "No"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCell({
|
||||
value,
|
||||
members,
|
||||
}: {
|
||||
value: unknown;
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const memberId = String(value);
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-medium flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{(member?.name ?? memberId).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{member?.name ?? memberId}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CellValue({
|
||||
value,
|
||||
field,
|
||||
members,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: Field;
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return (
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
|
||||
--
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case "enum":
|
||||
return (
|
||||
<EnumBadge
|
||||
value={String(value)}
|
||||
enumValues={field.enum_values}
|
||||
enumColors={field.enum_colors}
|
||||
/>
|
||||
);
|
||||
case "boolean":
|
||||
return <BooleanCell value={value} />;
|
||||
case "user":
|
||||
return <UserCell value={value} members={members} />;
|
||||
case "email":
|
||||
return (
|
||||
<a
|
||||
href={`mailto:${value}`}
|
||||
className="underline underline-offset-2"
|
||||
style={{ color: "#60a5fa" }}
|
||||
>
|
||||
{String(value)}
|
||||
</a>
|
||||
);
|
||||
case "date":
|
||||
return <span>{String(value)}</span>;
|
||||
case "number":
|
||||
return <span className="tabular-nums">{String(value)}</span>;
|
||||
default:
|
||||
return <span className="truncate block max-w-[300px]">{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Table Component ---
|
||||
|
||||
export function ObjectTable({
|
||||
objectName,
|
||||
fields,
|
||||
entries,
|
||||
members,
|
||||
}: ObjectTableProps) {
|
||||
const [sort, setSort] = useState<SortState>(null);
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
setSort((prev) => {
|
||||
if (prev?.column === column) {
|
||||
return prev.direction === "asc"
|
||||
? { column, direction: "desc" }
|
||||
: null;
|
||||
}
|
||||
return { column, direction: "asc" };
|
||||
});
|
||||
};
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
if (!sort) {return entries;}
|
||||
return [...entries].toSorted((a, b) => {
|
||||
const aVal = String(a[sort.column] ?? "");
|
||||
const bVal = String(b[sort.column] ?? "");
|
||||
const cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
|
||||
return sort.direction === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [entries, sort]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" />
|
||||
</svg>
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No entries in <span className="font-medium" style={{ color: "var(--color-text)" }}>{objectName}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{fields.map((field) => (
|
||||
<th
|
||||
key={field.id}
|
||||
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider cursor-pointer select-none whitespace-nowrap border-b"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={() => handleSort(field.name)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{field.name}
|
||||
<SortIcon
|
||||
active={sort?.column === field.name}
|
||||
direction={sort?.column === field.name ? sort.direction : "asc"}
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEntries.map((entry, idx) => (
|
||||
<tr
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
className="transition-colors duration-75"
|
||||
style={{
|
||||
background:
|
||||
idx % 2 === 0 ? "transparent" : "var(--color-surface)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
idx % 2 === 0 ? "transparent" : "var(--color-surface)";
|
||||
}}
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<td
|
||||
key={field.id}
|
||||
className="px-3 py-2 border-b whitespace-nowrap"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<CellValue
|
||||
value={entry[field.name]}
|
||||
field={field}
|
||||
members={members}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
apps/web/app/components/workspace/workspace-sidebar.tsx
Normal file
118
apps/web/app/components/workspace/workspace-sidebar.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { KnowledgeTree, type TreeNode } from "./knowledge-tree";
|
||||
|
||||
type WorkspaceSidebarProps = {
|
||||
tree: TreeNode[];
|
||||
activePath: string | null;
|
||||
onSelect: (node: TreeNode) => void;
|
||||
orgName?: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function WorkspaceLogo() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BackIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
tree,
|
||||
activePath,
|
||||
onSelect,
|
||||
orgName,
|
||||
loading,
|
||||
}: WorkspaceSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className="flex flex-col h-screen border-r flex-shrink-0"
|
||||
style={{
|
||||
width: "260px",
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-3 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<WorkspaceLogo />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{orgName || "Workspace"}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Dench CRM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section label */}
|
||||
<div
|
||||
className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Knowledge
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 overflow-y-auto px-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgeTree
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="px-3 py-2.5 border-t"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<BackIcon />
|
||||
Back to Chat
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -37,3 +37,188 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Workspace Prose (markdown document view)
|
||||
======================================== */
|
||||
|
||||
.workspace-prose {
|
||||
color: var(--color-text);
|
||||
line-height: 1.75;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.workspace-prose h1,
|
||||
.workspace-prose h2,
|
||||
.workspace-prose h3,
|
||||
.workspace-prose h4,
|
||||
.workspace-prose h5,
|
||||
.workspace-prose h6 {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.75em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.workspace-prose h1 {
|
||||
font-size: 1.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.workspace-prose h2 {
|
||||
font-size: 1.375rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
.workspace-prose h3 { font-size: 1.125rem; }
|
||||
.workspace-prose h4 { font-size: 1rem; }
|
||||
|
||||
.workspace-prose p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.workspace-prose a {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.workspace-prose a:hover {
|
||||
color: #93bbfd;
|
||||
}
|
||||
|
||||
.workspace-prose strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workspace-prose em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.workspace-prose ul,
|
||||
.workspace-prose ol {
|
||||
margin-bottom: 1em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.workspace-prose ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.workspace-prose ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.workspace-prose li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.workspace-prose li > ul,
|
||||
.workspace-prose li > ol {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.workspace-prose blockquote {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding: 0.5em 1em;
|
||||
margin: 1em 0;
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.workspace-prose code {
|
||||
font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace;
|
||||
font-size: 0.85em;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.15em 0.35em;
|
||||
}
|
||||
|
||||
.workspace-prose pre {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1em;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.workspace-prose pre code {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.workspace-prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.workspace-prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.workspace-prose th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
padding: 0.6em 0.75em;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.workspace-prose td {
|
||||
padding: 0.5em 0.75em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.workspace-prose tr:hover td {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.workspace-prose img {
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
/* Task list (GFM) */
|
||||
.workspace-prose input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 0.2em;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-prose input[type="checkbox"]:checked {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.workspace-prose input[type="checkbox"]:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
650
apps/web/app/workspace/page.tsx
Normal file
650
apps/web/app/workspace/page.tsx
Normal file
@ -0,0 +1,650 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
|
||||
import { type TreeNode } from "../components/workspace/knowledge-tree";
|
||||
import { ObjectTable } from "../components/workspace/object-table";
|
||||
import { ObjectKanban } from "../components/workspace/object-kanban";
|
||||
import { DocumentView } from "../components/workspace/document-view";
|
||||
import { FileViewer } from "../components/workspace/file-viewer";
|
||||
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
||||
import { EmptyState } from "../components/workspace/empty-state";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type WorkspaceContext = {
|
||||
exists: boolean;
|
||||
organization?: { id?: string; name?: string; slug?: string };
|
||||
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
||||
};
|
||||
|
||||
type ObjectData = {
|
||||
object: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
};
|
||||
fields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enum_values?: string[];
|
||||
enum_colors?: string[];
|
||||
enum_multiple?: boolean;
|
||||
sort_order?: number;
|
||||
}>;
|
||||
statuses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
}>;
|
||||
entries: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
type FileData = {
|
||||
content: string;
|
||||
type: "markdown" | "yaml" | "text";
|
||||
};
|
||||
|
||||
type ContentState =
|
||||
| { kind: "none" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "object"; data: ObjectData }
|
||||
| { kind: "document"; data: FileData; title: string }
|
||||
| { kind: "file"; data: FileData; filename: string }
|
||||
| { kind: "directory"; node: TreeNode };
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Find a node in the tree by path. */
|
||||
function findNode(
|
||||
tree: TreeNode[],
|
||||
path: string,
|
||||
): TreeNode | null {
|
||||
for (const node of tree) {
|
||||
if (node.path === path) {return node;}
|
||||
if (node.children) {
|
||||
const found = findNode(node.children, path);
|
||||
if (found) {return found;}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract the object name from a tree path (last segment). */
|
||||
function objectNameFromPath(path: string): string {
|
||||
const segments = path.split("/");
|
||||
return segments[segments.length - 1];
|
||||
}
|
||||
|
||||
// --- Main Page ---
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialPathHandled = useRef(false);
|
||||
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [context, setContext] = useState<WorkspaceContext | null>(null);
|
||||
const [activePath, setActivePath] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<ContentState>({ kind: "none" });
|
||||
const [treeLoading, setTreeLoading] = useState(true);
|
||||
const [workspaceExists, setWorkspaceExists] = useState(true);
|
||||
|
||||
// Fetch tree and context on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setTreeLoading(true);
|
||||
try {
|
||||
const [treeRes, ctxRes] = await Promise.all([
|
||||
fetch("/api/workspace/tree"),
|
||||
fetch("/api/workspace/context"),
|
||||
]);
|
||||
|
||||
const treeData = await treeRes.json();
|
||||
const ctxData = await ctxRes.json();
|
||||
|
||||
if (cancelled) {return;}
|
||||
|
||||
setTree(treeData.tree ?? []);
|
||||
setWorkspaceExists(treeData.exists ?? false);
|
||||
setContext(ctxData);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTree([]);
|
||||
setWorkspaceExists(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {setTreeLoading(false);}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load content when path changes
|
||||
const loadContent = useCallback(
|
||||
async (node: TreeNode) => {
|
||||
setActivePath(node.path);
|
||||
setContent({ kind: "loading" });
|
||||
|
||||
try {
|
||||
if (node.type === "object") {
|
||||
const name = objectNameFromPath(node.path);
|
||||
const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`);
|
||||
if (!res.ok) {
|
||||
setContent({ kind: "none" });
|
||||
return;
|
||||
}
|
||||
const data: ObjectData = await res.json();
|
||||
setContent({ kind: "object", data });
|
||||
} else if (node.type === "document") {
|
||||
const res = await fetch(
|
||||
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
setContent({ kind: "none" });
|
||||
return;
|
||||
}
|
||||
const data: FileData = await res.json();
|
||||
setContent({
|
||||
kind: "document",
|
||||
data,
|
||||
title: node.name.replace(/\.md$/, ""),
|
||||
});
|
||||
} else if (node.type === "file") {
|
||||
const res = await fetch(
|
||||
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
setContent({ kind: "none" });
|
||||
return;
|
||||
}
|
||||
const data: FileData = await res.json();
|
||||
setContent({ kind: "file", data, filename: node.name });
|
||||
} else if (node.type === "folder") {
|
||||
setContent({ kind: "directory", node });
|
||||
}
|
||||
} catch {
|
||||
setContent({ kind: "none" });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
(node: TreeNode) => {
|
||||
loadContent(node);
|
||||
},
|
||||
[loadContent],
|
||||
);
|
||||
|
||||
// Auto-navigate to path from URL query param after tree loads
|
||||
useEffect(() => {
|
||||
if (initialPathHandled.current || treeLoading || tree.length === 0) {return;}
|
||||
|
||||
const pathParam = searchParams.get("path");
|
||||
if (pathParam) {
|
||||
const node = findNode(tree, pathParam);
|
||||
if (node) {
|
||||
initialPathHandled.current = true;
|
||||
loadContent(node);
|
||||
}
|
||||
}
|
||||
}, [tree, treeLoading, searchParams, loadContent]);
|
||||
|
||||
const handleBreadcrumbNavigate = useCallback(
|
||||
(path: string) => {
|
||||
if (!path) {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
return;
|
||||
}
|
||||
const node = findNode(tree, path);
|
||||
if (node) {
|
||||
loadContent(node);
|
||||
}
|
||||
},
|
||||
[tree, loadContent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen" style={{ background: "var(--color-bg)" }}>
|
||||
{/* Sidebar */}
|
||||
<WorkspaceSidebar
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
onSelect={handleNodeSelect}
|
||||
orgName={context?.organization?.name}
|
||||
loading={treeLoading}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Top bar with breadcrumbs */}
|
||||
{activePath && (
|
||||
<div
|
||||
className="px-6 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<Breadcrumbs
|
||||
path={activePath}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ContentRenderer
|
||||
content={content}
|
||||
workspaceExists={workspaceExists}
|
||||
tree={tree}
|
||||
members={context?.members}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Content Renderer ---
|
||||
|
||||
function ContentRenderer({
|
||||
content,
|
||||
workspaceExists,
|
||||
tree,
|
||||
members,
|
||||
onNodeSelect,
|
||||
}: {
|
||||
content: ContentState;
|
||||
workspaceExists: boolean;
|
||||
tree: TreeNode[];
|
||||
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
||||
onNodeSelect: (node: TreeNode) => void;
|
||||
}) {
|
||||
switch (content.kind) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "object":
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Object header */}
|
||||
<div className="mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold capitalize"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{content.data.object.name}
|
||||
</h1>
|
||||
{content.data.object.description && (
|
||||
<p
|
||||
className="text-sm mt-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{content.data.object.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{content.data.entries.length} entries
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{content.data.fields.length} fields
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table or Kanban */}
|
||||
{content.data.object.default_view === "kanban" ? (
|
||||
<ObjectKanban
|
||||
objectName={content.data.object.name}
|
||||
fields={content.data.fields}
|
||||
entries={content.data.entries}
|
||||
statuses={content.data.statuses}
|
||||
members={members}
|
||||
/>
|
||||
) : (
|
||||
<ObjectTable
|
||||
objectName={content.data.object.name}
|
||||
fields={content.data.fields}
|
||||
entries={content.data.entries}
|
||||
members={members}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "document":
|
||||
return (
|
||||
<DocumentView
|
||||
content={content.data.content}
|
||||
title={content.title}
|
||||
/>
|
||||
);
|
||||
|
||||
case "file":
|
||||
return (
|
||||
<FileViewer
|
||||
content={content.data.content}
|
||||
filename={content.filename}
|
||||
type={content.data.type === "yaml" ? "yaml" : "text"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "directory":
|
||||
return (
|
||||
<DirectoryListing
|
||||
node={content.node}
|
||||
onNodeSelect={onNodeSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
case "none":
|
||||
default:
|
||||
if (tree.length === 0) {
|
||||
return <EmptyState workspaceExists={workspaceExists} />;
|
||||
}
|
||||
return <WelcomeView tree={tree} onNodeSelect={onNodeSelect} />;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Directory Listing ---
|
||||
|
||||
function DirectoryListing({
|
||||
node,
|
||||
onNodeSelect,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
onNodeSelect: (node: TreeNode) => void;
|
||||
}) {
|
||||
const children = node.children ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-1 capitalize"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{node.name}
|
||||
</h1>
|
||||
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
|
||||
{children.length} items
|
||||
</p>
|
||||
|
||||
{children.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
This folder is empty.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{children.map((child) => (
|
||||
<button
|
||||
type="button"
|
||||
key={child.path}
|
||||
onClick={() => onNodeSelect(child)}
|
||||
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-border)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
child.type === "object"
|
||||
? "rgba(232, 93, 58, 0.1)"
|
||||
: child.type === "document"
|
||||
? "rgba(96, 165, 250, 0.1)"
|
||||
: "var(--color-surface-hover)",
|
||||
color:
|
||||
child.type === "object"
|
||||
? "var(--color-accent)"
|
||||
: child.type === "document"
|
||||
? "#60a5fa"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<NodeTypeIcon type={child.type} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{child.name.replace(/\.md$/, "")}
|
||||
</div>
|
||||
<div className="text-xs capitalize" style={{ color: "var(--color-text-muted)" }}>
|
||||
{child.type}
|
||||
{child.children ? ` (${child.children.length})` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Welcome View (no selection) ---
|
||||
|
||||
function WelcomeView({
|
||||
tree,
|
||||
onNodeSelect,
|
||||
}: {
|
||||
tree: TreeNode[];
|
||||
onNodeSelect: (node: TreeNode) => void;
|
||||
}) {
|
||||
// Collect all objects and documents for quick access
|
||||
const objects: TreeNode[] = [];
|
||||
const documents: TreeNode[] = [];
|
||||
|
||||
function collect(nodes: TreeNode[]) {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "object") {objects.push(n);}
|
||||
else if (n.type === "document") {documents.push(n);}
|
||||
if (n.children) {collect(n.children);}
|
||||
}
|
||||
}
|
||||
collect(tree);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-2"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
Workspace
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: "var(--color-text-muted)" }}>
|
||||
Select an item from the sidebar, or browse the sections below.
|
||||
</p>
|
||||
|
||||
{/* Objects section */}
|
||||
{objects.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2
|
||||
className="text-sm font-medium uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Objects
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{objects.map((obj) => (
|
||||
<button
|
||||
type="button"
|
||||
key={obj.path}
|
||||
onClick={() => onNodeSelect(obj)}
|
||||
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-border)";
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: "rgba(232, 93, 58, 0.1)",
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
<NodeTypeIcon type="object" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="text-sm font-medium capitalize truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{obj.name}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{obj.defaultView === "kanban" ? "Kanban board" : "Table view"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents section */}
|
||||
{documents.length > 0 && (
|
||||
<div>
|
||||
<h2
|
||||
className="text-sm font-medium uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Documents
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{documents.map((doc) => (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.path}
|
||||
onClick={() => onNodeSelect(doc)}
|
||||
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "#60a5fa";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-border)";
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: "rgba(96, 165, 250, 0.1)",
|
||||
color: "#60a5fa",
|
||||
}}
|
||||
>
|
||||
<NodeTypeIcon type="document" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{doc.name.replace(/\.md$/, "")}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Document
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared icon for node types ---
|
||||
|
||||
function NodeTypeIcon({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case "object":
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
|
||||
</svg>
|
||||
);
|
||||
case "document":
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
||||
</svg>
|
||||
);
|
||||
case "folder":
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
188
apps/web/lib/workspace.ts
Normal file
188
apps/web/lib/workspace.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve, normalize } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
/**
|
||||
* Resolve the dench workspace directory, checking in order:
|
||||
* 1. DENCH_WORKSPACE env var
|
||||
* 2. ~/.openclaw/workspace/dench/
|
||||
* 3. ./dench/ (relative to process cwd)
|
||||
*/
|
||||
export function resolveDenchRoot(): string | null {
|
||||
const candidates = [
|
||||
process.env.DENCH_WORKSPACE,
|
||||
join(homedir(), ".openclaw", "workspace", "dench"),
|
||||
join(process.cwd(), "dench"),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const dir of candidates) {
|
||||
if (existsSync(dir)) {return dir;}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Path to the DuckDB database file, or null if workspace doesn't exist. */
|
||||
export function duckdbPath(): string | null {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {return null;}
|
||||
const dbPath = join(root, "workspace.duckdb");
|
||||
return existsSync(dbPath) ? dbPath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the duckdb CLI binary path.
|
||||
* Checks common locations since the Next.js server may have a minimal PATH.
|
||||
*/
|
||||
function resolveDuckdbBin(): string | null {
|
||||
const home = homedir();
|
||||
const candidates = [
|
||||
// User-local installs
|
||||
join(home, ".duckdb", "cli", "latest", "duckdb"),
|
||||
join(home, ".local", "bin", "duckdb"),
|
||||
// Homebrew
|
||||
"/opt/homebrew/bin/duckdb",
|
||||
"/usr/local/bin/duckdb",
|
||||
// System
|
||||
"/usr/bin/duckdb",
|
||||
];
|
||||
|
||||
for (const bin of candidates) {
|
||||
if (existsSync(bin)) {return bin;}
|
||||
}
|
||||
|
||||
// Fallback: try bare `duckdb` and hope it's in PATH
|
||||
try {
|
||||
execSync("which duckdb", { encoding: "utf-8", timeout: 2000 });
|
||||
return "duckdb";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a DuckDB query and return parsed JSON rows.
|
||||
* Uses the duckdb CLI with -json output format.
|
||||
*/
|
||||
export function duckdbQuery<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
): T[] {
|
||||
const db = duckdbPath();
|
||||
if (!db) {return [];}
|
||||
|
||||
const bin = resolveDuckdbBin();
|
||||
if (!bin) {return [];}
|
||||
|
||||
try {
|
||||
// Escape single quotes in SQL for shell safety
|
||||
const escapedSql = sql.replace(/'/g, "'\\''");
|
||||
const result = execSync(`'${bin}' -json '${db}' '${escapedSql}'`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
||||
shell: "/bin/sh",
|
||||
});
|
||||
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed || trimmed === "[]") {return [];}
|
||||
return JSON.parse(trimmed) as T[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a path within the dench workspace.
|
||||
* Prevents path traversal by ensuring the resolved path stays within root.
|
||||
* Returns the absolute path or null if invalid/nonexistent.
|
||||
*/
|
||||
export function safeResolvePath(
|
||||
relativePath: string,
|
||||
): string | null {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {return null;}
|
||||
|
||||
// Reject obvious traversal attempts
|
||||
const normalized = normalize(relativePath);
|
||||
if (normalized.startsWith("..") || normalized.includes("/../")) {return null;}
|
||||
|
||||
const absolute = resolve(root, normalized);
|
||||
|
||||
// Ensure the resolved path is still within the workspace root
|
||||
if (!absolute.startsWith(resolve(root))) {return null;}
|
||||
if (!existsSync(absolute)) {return null;}
|
||||
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight YAML frontmatter / simple-value parser.
|
||||
* Handles flat key: value pairs and simple nested structures.
|
||||
* Good enough for .object.yaml and workspace_context.yaml top-level fields.
|
||||
*/
|
||||
export function parseSimpleYaml(
|
||||
content: string,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.trim().startsWith("#") || !line.trim()) {continue;}
|
||||
|
||||
// Match top-level key: value
|
||||
const match = line.match(/^(\w[\w_-]*)\s*:\s*(.+)/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
let value: unknown = match[2].trim();
|
||||
|
||||
// Strip quotes
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))
|
||||
) {
|
||||
value = (value as string).slice(1, -1);
|
||||
}
|
||||
|
||||
// Parse booleans and numbers
|
||||
if (value === "true") {value = true;}
|
||||
else if (value === "false") {value = false;}
|
||||
else if (value === "null") {value = null;}
|
||||
else if (
|
||||
typeof value === "string" &&
|
||||
/^-?\d+(\.\d+)?$/.test(value)
|
||||
) {
|
||||
value = Number(value);
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from the workspace safely.
|
||||
* Returns content and detected type, or null if not found.
|
||||
*/
|
||||
export function readWorkspaceFile(
|
||||
relativePath: string,
|
||||
): { content: string; type: "markdown" | "yaml" | "text" } | null {
|
||||
const absolute = safeResolvePath(relativePath);
|
||||
if (!absolute) {return null;}
|
||||
|
||||
try {
|
||||
const content = readFileSync(absolute, "utf-8");
|
||||
const ext = relativePath.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 { content, type };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@ const nextConfig: NextConfig = {
|
||||
// Allow long-running API routes for agent streaming
|
||||
serverExternalPackages: [],
|
||||
|
||||
// Transpile ESM-only packages so webpack can bundle them
|
||||
transpilePackages: ["react-markdown", "remark-gfm"],
|
||||
|
||||
// Ensure Node.js built-ins work correctly
|
||||
webpack: (config, { isServer }) => {
|
||||
if (isServer) {
|
||||
|
||||
1501
apps/web/package-lock.json
generated
1501
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,9 @@
|
||||
"ai": "^6.0.73",
|
||||
"next": "^15.3.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user