diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 7a33d15d..32e7458e 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; -import type { CompanyPortabilityExportResult } from "@paperclipai/shared"; +import type { CompanyPortabilityExportResult, CompanyPortabilityManifest } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; @@ -323,15 +323,133 @@ function FrontmatterCard({ ); } +// ── Client-side README generation ──────────────────────────────────── + +const ROLE_LABELS: Record = { + ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", coo: "COO", + vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent", +}; + +/** + * Regenerate README.md content based on the currently checked files. + * Only counts/lists entities whose files are in the checked set. + */ +function generateReadmeFromSelection( + manifest: CompanyPortabilityManifest, + checkedFiles: Set, + companyName: string, + companyDescription: string | null, +): string { + const slugs = checkedSlugs(checkedFiles); + const hasOrgChart = checkedFiles.has("images/org-chart.svg"); + + const agents = manifest.agents.filter((a) => slugs.agents.has(a.slug)); + const projects = manifest.projects.filter((p) => slugs.projects.has(p.slug)); + const tasks = manifest.issues.filter((t) => slugs.tasks.has(t.slug)); + const skills = manifest.skills.filter((s) => { + // Skill files live under skills/{key}/... + return [...checkedFiles].some((f) => f.startsWith(`skills/${s.key}/`) || f.startsWith(`skills/`) && f.includes(`/${s.slug}/`)); + }); + + const lines: string[] = []; + lines.push(`# ${companyName}`); + lines.push(""); + if (companyDescription) { + lines.push(`> ${companyDescription}`); + lines.push(""); + } + if (hasOrgChart) { + lines.push(`![Org Chart](images/org-chart.svg)`); + lines.push(""); + } + + lines.push("## What's Inside"); + lines.push(""); + lines.push("This is an [Agent Company](https://paperclip.ing) package."); + lines.push(""); + + const counts: Array<[string, number]> = []; + if (agents.length > 0) counts.push(["Agents", agents.length]); + if (projects.length > 0) counts.push(["Projects", projects.length]); + if (skills.length > 0) counts.push(["Skills", skills.length]); + if (tasks.length > 0) counts.push(["Tasks", tasks.length]); + + if (counts.length > 0) { + lines.push("| Content | Count |"); + lines.push("|---------|-------|"); + for (const [label, count] of counts) { + lines.push(`| ${label} | ${count} |`); + } + lines.push(""); + } + + if (agents.length > 0) { + lines.push("### Agents"); + lines.push(""); + lines.push("| Agent | Role | Reports To |"); + lines.push("|-------|------|------------|"); + for (const agent of agents) { + const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; + const reportsTo = agent.reportsToSlug ?? "\u2014"; + lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`); + } + lines.push(""); + } + + if (projects.length > 0) { + lines.push("### Projects"); + lines.push(""); + for (const project of projects) { + const desc = project.description ? ` \u2014 ${project.description}` : ""; + lines.push(`- **${project.name}**${desc}`); + } + lines.push(""); + } + + lines.push("## Getting Started"); + lines.push(""); + lines.push("```bash"); + lines.push("pnpm paperclipai company import this-github-url-or-folder"); + lines.push("```"); + lines.push(""); + lines.push("See [Paperclip](https://paperclip.ing) for more information."); + lines.push(""); + lines.push("---"); + lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`); + lines.push(""); + + return lines.join("\n"); +} + +/** + * Resolve relative image paths in markdown content using the export files map. + * Converts SVG references to inline data URIs so they render in the preview. + */ +function resolveMarkdownImages(markdown: string, files: Record): string { + return markdown.replace( + /!\[([^\]]*)\]\(([^)]+)\)/g, + (_match, alt: string, src: string) => { + const svgContent = files[src]; + if (svgContent && src.endsWith(".svg")) { + const dataUri = `data:image/svg+xml;base64,${btoa(svgContent)}`; + return `![${alt}](${dataUri})`; + } + return _match; + }, + ); +} + // ── Preview pane ────────────────────────────────────────────────────── function ExportPreviewPane({ selectedFile, content, + files, onSkillClick, }: { selectedFile: string | null; content: string | null; + files: Record; onSkillClick?: (skill: string) => void; }) { if (!selectedFile || content === null) { @@ -343,6 +461,9 @@ function ExportPreviewPane({ const isMarkdown = selectedFile.endsWith(".md"); const isSvg = selectedFile.endsWith(".svg"); const parsed = isMarkdown ? parseFrontmatter(content) : null; + // Resolve relative image paths (e.g. images/org-chart.svg) for markdown preview + const resolvedBody = parsed?.body ? resolveMarkdownImages(parsed.body, files) : null; + const resolvedContent = isMarkdown && !parsed ? resolveMarkdownImages(content, files) : content; return (
@@ -353,10 +474,10 @@ function ExportPreviewPane({ {parsed ? ( <> - {parsed.body.trim() && {parsed.body}} + {resolvedBody?.trim() && {resolvedBody}} ) : isMarkdown ? ( - {content} + {resolvedContent} ) : isSvg ? (
{ if (!exportData) return {} as Record; - const yamlPath = exportData.paperclipExtensionPath; - if (!yamlPath || !exportData.files[yamlPath]) return exportData.files; const filtered = { ...exportData.files }; - filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles); + + // Filter .paperclip.yaml + const yamlPath = exportData.paperclipExtensionPath; + if (yamlPath && exportData.files[yamlPath]) { + filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles); + } + + // Regenerate README.md based on checked selection + if (exportData.files["README.md"]) { + const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company"; + const companyDescription = exportData.manifest.company?.description ?? null; + filtered["README.md"] = generateReadmeFromSelection( + exportData.manifest, + checkedFiles, + companyName, + companyDescription, + ); + } + return filtered; - }, [exportData, checkedFiles]); + }, [exportData, checkedFiles, selectedCompany?.name]); const totalFiles = useMemo(() => countFiles(tree), [tree]); const selectedCount = checkedFiles.size; @@ -671,7 +808,7 @@ export function CompanyExport() {
- +