mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Add app clip share and import UI
This commit is contained in:
1 parent
881ada6e02
commit
dd75b4b204
14 files changed
+1263
-6
No files matched your search
@@ -1,4 +1,3 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
CompanyPortabilityAgentManifestEntry,
|
||||
@@ -924,7 +923,91 @@ function cloneManifest(manifest: CompanyPortabilityManifest): CompanyPortability
|
||||
}
|
||||
|
||||
function sha256Stable(value: unknown) {
|
||||
return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`;
|
||||
return `sha256:${sha256Hex(stableStringify(value))}`;
|
||||
}
|
||||
|
||||
const SHA256_K = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
|
||||
function sha256Hex(input: string) {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
const bitLengthHigh = Math.floor((bytes.length * 8) / 0x100000000);
|
||||
const bitLengthLow = (bytes.length * 8) >>> 0;
|
||||
const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6);
|
||||
const padded = new Uint8Array(paddedLength);
|
||||
padded.set(bytes);
|
||||
padded[bytes.length] = 0x80;
|
||||
padded[paddedLength - 8] = bitLengthHigh >>> 24;
|
||||
padded[paddedLength - 7] = bitLengthHigh >>> 16;
|
||||
padded[paddedLength - 6] = bitLengthHigh >>> 8;
|
||||
padded[paddedLength - 5] = bitLengthHigh;
|
||||
padded[paddedLength - 4] = bitLengthLow >>> 24;
|
||||
padded[paddedLength - 3] = bitLengthLow >>> 16;
|
||||
padded[paddedLength - 2] = bitLengthLow >>> 8;
|
||||
padded[paddedLength - 1] = bitLengthLow;
|
||||
|
||||
const state = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
];
|
||||
const words = new Uint32Array(64);
|
||||
|
||||
for (let offset = 0; offset < padded.length; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const wordOffset = offset + index * 4;
|
||||
words[index] =
|
||||
((padded[wordOffset] << 24) |
|
||||
(padded[wordOffset + 1] << 16) |
|
||||
(padded[wordOffset + 2] << 8) |
|
||||
padded[wordOffset + 3]) >>> 0;
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const s0 = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3);
|
||||
const s1 = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10);
|
||||
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
|
||||
}
|
||||
|
||||
let [a, b, c, d, e, f, g, h] = state;
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const temp1 = (h + s1 + ch + SHA256_K[index] + words[index]) >>> 0;
|
||||
const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temp2 = (s0 + maj) >>> 0;
|
||||
h = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + temp1) >>> 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (temp1 + temp2) >>> 0;
|
||||
}
|
||||
|
||||
state[0] = (state[0] + a) >>> 0;
|
||||
state[1] = (state[1] + b) >>> 0;
|
||||
state[2] = (state[2] + c) >>> 0;
|
||||
state[3] = (state[3] + d) >>> 0;
|
||||
state[4] = (state[4] + e) >>> 0;
|
||||
state[5] = (state[5] + f) >>> 0;
|
||||
state[6] = (state[6] + g) >>> 0;
|
||||
state[7] = (state[7] + h) >>> 0;
|
||||
}
|
||||
|
||||
return state.map((word) => word.toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
|
||||
function rotateRight(value: number, bits: number) {
|
||||
return (value >>> bits) | (value << (32 - bits));
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
|
||||
@@ -730,6 +730,12 @@ export {
|
||||
type ClipDependency,
|
||||
type ClipRevision,
|
||||
type PublicClip,
|
||||
type ClipSharePreviewRequest,
|
||||
type ClipSharePreviewResult,
|
||||
type ClipImportPreviewRequest,
|
||||
type ClipImportPreviewResult,
|
||||
type ClipImportApplyRequest,
|
||||
type ClipImportApplyResult,
|
||||
} from "./types/clip.js";
|
||||
|
||||
export {
|
||||
@@ -779,6 +785,9 @@ export {
|
||||
createClipCommentSchema,
|
||||
createClipShowcaseSchema,
|
||||
createClipImportTelemetrySchema,
|
||||
clipSharePreviewSchema,
|
||||
clipImportPreviewSchema,
|
||||
clipImportApplySchema,
|
||||
type CreateClipCreatorProfile,
|
||||
type PublishClip,
|
||||
type CreateClipRevision,
|
||||
@@ -786,6 +795,9 @@ export {
|
||||
type CreateClipVote,
|
||||
type CreateClipReport,
|
||||
type CreateClipComment,
|
||||
type ClipSharePreview,
|
||||
type ClipImportPreview,
|
||||
type ClipImportApply,
|
||||
type CreateClipShowcase,
|
||||
type CreateClipImportTelemetry,
|
||||
environmentDriverSchema,
|
||||
|
||||
@@ -142,3 +142,75 @@ export interface PublicClip {
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface ClipSharePreviewRequest {
|
||||
source: {
|
||||
type: ClipType;
|
||||
id: string;
|
||||
};
|
||||
title?: string;
|
||||
summary?: string;
|
||||
slug?: string;
|
||||
visibility?: ClipVisibility;
|
||||
revisionNote?: string | null;
|
||||
}
|
||||
|
||||
export interface ClipSharePreviewResult {
|
||||
source: {
|
||||
type: ClipType;
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
publishRequest: Record<string, unknown>;
|
||||
exportPreview: Record<string, unknown>;
|
||||
manifest: Record<string, unknown>;
|
||||
dependencyCounts: {
|
||||
adapters: number;
|
||||
plugins: number;
|
||||
skills: number;
|
||||
secrets: number;
|
||||
permissions: number;
|
||||
workspaces: number;
|
||||
};
|
||||
redactionSummary: {
|
||||
allowed: number;
|
||||
redacted: number;
|
||||
summarized: number;
|
||||
omitted: number;
|
||||
};
|
||||
dangerousCapabilities: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ClipImportPreviewRequest {
|
||||
url: string;
|
||||
collisionStrategy?: "rename" | "skip";
|
||||
}
|
||||
|
||||
export interface ClipImportPreviewResult {
|
||||
clip: PublicClip;
|
||||
preview: Record<string, unknown>;
|
||||
safety: {
|
||||
dangerousCapabilities: string[];
|
||||
requiredSecrets: string[];
|
||||
permissions: string[];
|
||||
routineTriggersEnabledByDefault: boolean;
|
||||
webhookSecretsRegenerated: boolean;
|
||||
};
|
||||
source: {
|
||||
url: string;
|
||||
revisionNumber: number;
|
||||
manifestChecksum: string;
|
||||
artifactChecksum: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClipImportApplyRequest extends ClipImportPreviewRequest {
|
||||
selectedOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ClipImportApplyResult {
|
||||
importResult: Record<string, unknown>;
|
||||
clip: PublicClip;
|
||||
source: ClipImportPreviewResult["source"];
|
||||
}
|
||||
@@ -178,3 +178,30 @@ export const createClipImportTelemetrySchema = z.object({
|
||||
});
|
||||
|
||||
export type CreateClipImportTelemetry = z.infer<typeof createClipImportTelemetrySchema>;
|
||||
|
||||
export const clipSharePreviewSchema = z.object({
|
||||
source: z.object({
|
||||
type: clipTypeSchema,
|
||||
id: z.string().trim().min(1).max(160),
|
||||
}),
|
||||
title: z.string().trim().min(1).max(160).optional(),
|
||||
summary: shortPublicTextSchema.optional(),
|
||||
slug: slugSchema.optional(),
|
||||
visibility: clipVisibilitySchema.optional(),
|
||||
revisionNote: z.string().trim().max(4_000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type ClipSharePreview = z.infer<typeof clipSharePreviewSchema>;
|
||||
|
||||
export const clipImportPreviewSchema = z.object({
|
||||
url: z.string().trim().min(1).max(2_000),
|
||||
collisionStrategy: z.enum(["rename", "skip"]).optional(),
|
||||
});
|
||||
|
||||
export type ClipImportPreview = z.infer<typeof clipImportPreviewSchema>;
|
||||
|
||||
export const clipImportApplySchema = clipImportPreviewSchema.extend({
|
||||
selectedOptions: recordSchema.optional(),
|
||||
});
|
||||
|
||||
export type ClipImportApply = z.infer<typeof clipImportApplySchema>;
|
||||
@@ -47,6 +47,9 @@ export {
|
||||
createClipCommentSchema,
|
||||
createClipShowcaseSchema,
|
||||
createClipImportTelemetrySchema,
|
||||
clipSharePreviewSchema,
|
||||
clipImportPreviewSchema,
|
||||
clipImportApplySchema,
|
||||
type CreateClipCreatorProfile,
|
||||
type ClipDependencyInput,
|
||||
type ClipRevisionInput,
|
||||
@@ -58,6 +61,9 @@ export {
|
||||
type CreateClipComment,
|
||||
type CreateClipShowcase,
|
||||
type CreateClipImportTelemetry,
|
||||
type ClipSharePreview,
|
||||
type ClipImportPreview,
|
||||
type ClipImportApply,
|
||||
} from "./clip.js";
|
||||
export {
|
||||
environmentDriverSchema,
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@ export async function createApp(
|
||||
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(secretRoutes(db));
|
||||
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(clipRoutes(db));
|
||||
api.use(clipRoutes(db, opts.storageService));
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(userProfileRoutes(db));
|
||||
|
||||
+389
-3
@@ -1,6 +1,15 @@
|
||||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
buildAgentClipSnapshot,
|
||||
buildBundleClipSnapshot,
|
||||
buildRoutineClipSnapshot,
|
||||
buildSkillClipSnapshot,
|
||||
buildTeamClipSnapshot,
|
||||
clipImportApplySchema,
|
||||
clipImportPreviewSchema,
|
||||
clipManifestSchema,
|
||||
clipSharePreviewSchema,
|
||||
createClipCommentSchema,
|
||||
createClipCreatorProfileSchema,
|
||||
createClipImportTelemetrySchema,
|
||||
@@ -10,12 +19,14 @@ import {
|
||||
createClipVoteSchema,
|
||||
publishClipSchema,
|
||||
updateClipSchema,
|
||||
type CompanyPortabilityFileEntry,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { clipService } from "../services/clips.js";
|
||||
import { logActivity } from "../services/index.js";
|
||||
import { companyPortabilityService, companyService, logActivity } from "../services/index.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const CLIP_RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
const CLIP_RATE_LIMIT_MAX_REQUESTS = 60;
|
||||
@@ -72,9 +83,140 @@ function consumeClipRateLimit(req: Request, res: { setHeader(name: string, value
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clipRoutes(db: Db) {
|
||||
export function clipRoutes(db: Db, storage?: StorageService) {
|
||||
const router = Router();
|
||||
const svc = clipService(db);
|
||||
const portability = companyPortabilityService(db, storage);
|
||||
const companies = companyService(db);
|
||||
|
||||
function slugify(value: string, fallback: string) {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.slice(0, 96);
|
||||
return slug.length >= 3 ? slug : fallback;
|
||||
}
|
||||
|
||||
function clipSlugFromUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed)) return trimmed;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const index = parts.findIndex((part) => part === "clips");
|
||||
const slug = index >= 0 ? parts[index + 1] : parts.at(-1);
|
||||
if (slug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return slug;
|
||||
} catch {
|
||||
// Fall through to the validation error below.
|
||||
}
|
||||
throw unprocessable("Enter a clip URL or slug.");
|
||||
}
|
||||
|
||||
function dependencyInputsFromManifest(manifest: ReturnType<typeof clipManifestSchema.parse>) {
|
||||
return [
|
||||
...manifest.dependencies.adapters.map((entry) => ({
|
||||
type: "adapter" as const,
|
||||
key: entry.type,
|
||||
displayName: entry.type,
|
||||
required: entry.required ? "required" as const : "optional" as const,
|
||||
metadata: { sourceRefs: entry.sourceRefs, note: entry.note },
|
||||
})),
|
||||
...manifest.dependencies.plugins.map((entry) => ({
|
||||
type: "plugin" as const,
|
||||
key: entry.key,
|
||||
displayName: entry.key,
|
||||
required: entry.requirement,
|
||||
metadata: { sourceRefs: entry.sourceRefs, note: entry.note },
|
||||
})),
|
||||
...manifest.dependencies.skills.map((entry) => ({
|
||||
type: "skill" as const,
|
||||
key: entry.key,
|
||||
displayName: entry.slug,
|
||||
required: entry.requirement,
|
||||
metadata: { sourceRefs: entry.sourceRefs },
|
||||
})),
|
||||
...manifest.dependencies.secrets.map((entry) => ({
|
||||
type: "secret" as const,
|
||||
key: entry.key,
|
||||
displayName: entry.key,
|
||||
required: entry.requirement,
|
||||
metadata: { sourceRefs: entry.sourceRefs, description: entry.description },
|
||||
})),
|
||||
...manifest.dependencies.permissions.map((entry) => ({
|
||||
type: "permission" as const,
|
||||
key: entry.capability,
|
||||
displayName: entry.capability,
|
||||
required: "required" as const,
|
||||
metadata: { sourceRefs: entry.sourceRefs, reason: entry.reason },
|
||||
})),
|
||||
...manifest.dependencies.workspaces.map((entry) => ({
|
||||
type: "workspace" as const,
|
||||
key: entry.key,
|
||||
displayName: entry.key,
|
||||
required: entry.repoUrlRequired ? "required" as const : "optional" as const,
|
||||
metadata: { sourceRefs: entry.sourceRefs, pinnedRefRecommended: entry.pinnedRefRecommended },
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function clipFilesFromPayload(payload: Record<string, unknown>) {
|
||||
const artifact = payload.artifact;
|
||||
const artifactPayload =
|
||||
artifact && typeof artifact === "object"
|
||||
? (artifact as Record<string, unknown>).payload
|
||||
: null;
|
||||
if (!artifactPayload || typeof artifactPayload !== "object") return null;
|
||||
const files = (artifactPayload as Record<string, unknown>).files;
|
||||
if (!files || typeof files !== "object" || Array.isArray(files)) return null;
|
||||
return files as Record<string, CompanyPortabilityFileEntry>;
|
||||
}
|
||||
|
||||
async function buildClipImportPreview(companyId: string, input: { url: string; collisionStrategy?: "rename" | "skip" }) {
|
||||
const slug = clipSlugFromUrl(input.url);
|
||||
const clip = await svc.getPublicDetail(slug);
|
||||
if (!clip?.currentRevision) {
|
||||
throw unprocessable("Clip was not found or is not importable.");
|
||||
}
|
||||
const manifestPayload = clip.currentRevision.manifestPayload;
|
||||
const parsed = clipManifestSchema.safeParse(manifestPayload);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable("Clip revision does not contain a valid clip manifest.");
|
||||
}
|
||||
const files = clipFilesFromPayload(manifestPayload);
|
||||
if (!files) {
|
||||
throw unprocessable("Clip revision does not include portable files. Publish it again from the app before importing.");
|
||||
}
|
||||
const preview = await portability.previewImport({
|
||||
source: { type: "inline", rootPath: parsed.data.clip.slug, files },
|
||||
include: { company: false, agents: true, projects: true, issues: true, skills: true },
|
||||
target: { mode: "existing_company", companyId },
|
||||
collisionStrategy: input.collisionStrategy ?? "rename",
|
||||
}, {
|
||||
mode: "agent_safe",
|
||||
sourceCompanyId: companyId,
|
||||
});
|
||||
return {
|
||||
clip,
|
||||
parsedManifest: parsed.data,
|
||||
files,
|
||||
preview,
|
||||
source: {
|
||||
url: input.url,
|
||||
revisionNumber: clip.currentRevision.revisionNumber,
|
||||
manifestChecksum: clip.currentRevision.manifestChecksum,
|
||||
artifactChecksum: clip.currentRevision.artifactChecksum,
|
||||
},
|
||||
safety: {
|
||||
dangerousCapabilities: parsed.data.security.dangerousCapabilities,
|
||||
requiredSecrets: parsed.data.dependencies.secrets.map((entry) => entry.key),
|
||||
permissions: parsed.data.dependencies.permissions.map((entry) => entry.capability),
|
||||
routineTriggersEnabledByDefault: parsed.data.security.routinePolicy.importedTriggersEnabledByDefault,
|
||||
webhookSecretsRegenerated: parsed.data.security.routinePolicy.webhookSecretsRegenerated,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/public/clips", async (req, res) => {
|
||||
const q = typeof req.query.q === "string" ? req.query.q : null;
|
||||
@@ -155,6 +297,185 @@ export function clipRoutes(db: Db) {
|
||||
res.status(201).json(profile);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/clips/share-preview", validate(clipSharePreviewSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await companies.getById(companyId);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
source: { type: "team" | "agent" | "skill" | "routine" | "bundle"; id: string };
|
||||
title?: string;
|
||||
summary?: string;
|
||||
slug?: string;
|
||||
visibility?: "private_share" | "unlisted" | "public";
|
||||
revisionNote?: string | null;
|
||||
};
|
||||
const include = { company: false, agents: false, projects: false, issues: false, skills: false };
|
||||
const exportRequest: {
|
||||
include: typeof include;
|
||||
agents?: string[];
|
||||
skills?: string[];
|
||||
issues?: string[];
|
||||
expandReferencedSkills?: boolean;
|
||||
} = { include };
|
||||
|
||||
if (body.source.type === "agent") {
|
||||
exportRequest.include.agents = true;
|
||||
exportRequest.include.skills = true;
|
||||
exportRequest.agents = [body.source.id];
|
||||
} else if (body.source.type === "team") {
|
||||
exportRequest.include.agents = true;
|
||||
exportRequest.include.skills = true;
|
||||
exportRequest.agents = body.source.id === "company" ? undefined : [body.source.id];
|
||||
} else if (body.source.type === "skill") {
|
||||
exportRequest.include.skills = true;
|
||||
exportRequest.skills = [body.source.id];
|
||||
} else if (body.source.type === "routine") {
|
||||
exportRequest.include.agents = true;
|
||||
exportRequest.include.projects = true;
|
||||
exportRequest.include.issues = true;
|
||||
exportRequest.include.skills = true;
|
||||
exportRequest.issues = [body.source.id];
|
||||
} else {
|
||||
exportRequest.include.agents = true;
|
||||
exportRequest.include.projects = true;
|
||||
exportRequest.include.issues = true;
|
||||
exportRequest.include.skills = true;
|
||||
}
|
||||
exportRequest.expandReferencedSkills = true;
|
||||
|
||||
const exportPreview = await portability.previewExport(companyId, exportRequest);
|
||||
const sourceLabel =
|
||||
exportPreview.manifest.agents[0]?.name
|
||||
?? exportPreview.manifest.skills[0]?.name
|
||||
?? exportPreview.manifest.issues[0]?.title
|
||||
?? exportPreview.manifest.company?.name
|
||||
?? company.name;
|
||||
const title = body.title ?? `${sourceLabel} ${body.source.type === "bundle" ? "bundle" : "clip"}`;
|
||||
const summary = body.summary ?? `Portable ${body.source.type} package shared from ${company.name}.`;
|
||||
const visibility = body.visibility ?? "unlisted";
|
||||
const slug = body.slug ?? slugify(title, `clip-${body.source.type}`);
|
||||
const creator = {
|
||||
profileId: null,
|
||||
handle: slugify(company.issuePrefix || company.name, "paperclip-company"),
|
||||
displayName: company.name,
|
||||
};
|
||||
const snapshotInput = {
|
||||
clip: {
|
||||
slug,
|
||||
revisionNumber: 1,
|
||||
title,
|
||||
summary,
|
||||
visibility,
|
||||
creator,
|
||||
},
|
||||
artifact: {
|
||||
manifest: exportPreview.manifest,
|
||||
files: exportPreview.files,
|
||||
},
|
||||
source: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
paperclipCompatibility: null,
|
||||
},
|
||||
};
|
||||
const snapshot =
|
||||
body.source.type === "agent"
|
||||
? buildAgentClipSnapshot(snapshotInput)
|
||||
: body.source.type === "team"
|
||||
? buildTeamClipSnapshot(snapshotInput)
|
||||
: body.source.type === "skill"
|
||||
? buildSkillClipSnapshot(snapshotInput)
|
||||
: body.source.type === "routine"
|
||||
? buildRoutineClipSnapshot(snapshotInput)
|
||||
: buildBundleClipSnapshot(snapshotInput);
|
||||
const manifestPayload = {
|
||||
...snapshot,
|
||||
artifact: {
|
||||
...snapshot.artifact,
|
||||
payload: {
|
||||
...snapshot.artifact.payload,
|
||||
files: exportPreview.files,
|
||||
},
|
||||
},
|
||||
};
|
||||
const parsedManifest = clipManifestSchema.parse(manifestPayload);
|
||||
const publishRequest = {
|
||||
creatorProfile: {
|
||||
handle: creator.handle,
|
||||
displayName: creator.displayName,
|
||||
},
|
||||
slug,
|
||||
type: body.source.type,
|
||||
title,
|
||||
summary,
|
||||
description: summary,
|
||||
visibility,
|
||||
tags: [body.source.type],
|
||||
categories: [],
|
||||
useCases: [],
|
||||
requiredProviders: parsedManifest.dependencies.adapters.map((entry) => entry.type),
|
||||
compatibility: parsedManifest.publication.compatibility,
|
||||
sourceKind: "paperclip_company_object",
|
||||
sourceObjectType: body.source.type,
|
||||
sourceObjectId: body.source.id,
|
||||
revision: {
|
||||
manifestVersion: parsedManifest.schema,
|
||||
manifestChecksum: parsedManifest.checksums.manifest,
|
||||
artifactChecksum: parsedManifest.checksums.artifact,
|
||||
manifestPayload,
|
||||
artifactRef: null,
|
||||
dependencyGraph: parsedManifest.dependencies,
|
||||
dependencies: dependencyInputsFromManifest(parsedManifest),
|
||||
permissions: parsedManifest.dependencies.permissions.map((entry) => ({
|
||||
capability: entry.capability,
|
||||
reason: entry.reason,
|
||||
})),
|
||||
secretsSchema: parsedManifest.dependencies.secrets.map((entry) => ({
|
||||
key: entry.key,
|
||||
description: entry.description,
|
||||
requirement: entry.requirement,
|
||||
})),
|
||||
budgetEstimate: {
|
||||
monthlyCents: parsedManifest.dependencies.budgetHints.monthlyCents,
|
||||
},
|
||||
redactionReport: parsedManifest.security.redactionReport,
|
||||
dangerousCapabilities: parsedManifest.security.dangerousCapabilities,
|
||||
securityReviewState: parsedManifest.security.reviewState,
|
||||
verificationState: parsedManifest.verification.validationStatus,
|
||||
compatibility: parsedManifest.publication.compatibility,
|
||||
changeSummary: body.revisionNote ?? null,
|
||||
breakingChanges: null,
|
||||
migrationNotes: null,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
source: {
|
||||
type: body.source.type,
|
||||
id: body.source.id,
|
||||
label: sourceLabel,
|
||||
},
|
||||
publishRequest,
|
||||
exportPreview,
|
||||
manifest: parsedManifest,
|
||||
dependencyCounts: {
|
||||
adapters: parsedManifest.dependencies.adapters.length,
|
||||
plugins: parsedManifest.dependencies.plugins.length,
|
||||
skills: parsedManifest.dependencies.skills.length,
|
||||
secrets: parsedManifest.dependencies.secrets.length,
|
||||
permissions: parsedManifest.dependencies.permissions.length,
|
||||
workspaces: parsedManifest.dependencies.workspaces.length,
|
||||
},
|
||||
redactionSummary: parsedManifest.security.redactionReport.summary,
|
||||
dangerousCapabilities: parsedManifest.security.dangerousCapabilities,
|
||||
warnings: exportPreview.warnings,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/clips/publish", validate(publishClipSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -181,6 +502,71 @@ export function clipRoutes(db: Db) {
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/clips/import-preview", validate(clipImportPreviewSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await buildClipImportPreview(companyId, req.body);
|
||||
res.json({
|
||||
clip: result.clip,
|
||||
preview: result.preview,
|
||||
safety: result.safety,
|
||||
source: result.source,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/clips/import", validate(clipImportApplySchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await buildClipImportPreview(companyId, req.body);
|
||||
const importResult = await portability.importBundle({
|
||||
source: { type: "inline", rootPath: result.parsedManifest.clip.slug, files: result.files },
|
||||
include: { company: false, agents: true, projects: true, issues: true, skills: true },
|
||||
target: { mode: "existing_company", companyId },
|
||||
collisionStrategy: req.body.collisionStrategy ?? "rename",
|
||||
}, req.actor.type === "board" ? req.actor.userId : null, {
|
||||
mode: "agent_safe",
|
||||
sourceCompanyId: companyId,
|
||||
});
|
||||
await svc.recordImportTelemetry(result.clip.slug, {
|
||||
revisionNumber: result.source.revisionNumber,
|
||||
destinationCompanyId: companyId,
|
||||
status: "applied",
|
||||
sourceUrl: result.source.url.startsWith("http") ? result.source.url : null,
|
||||
revisionUrl: result.clip.currentRevision
|
||||
? `/clips/${result.clip.slug}/revisions/${result.clip.currentRevision.revisionNumber}`
|
||||
: null,
|
||||
metadata: {
|
||||
selectedOptions: req.body.selectedOptions ?? {},
|
||||
agentCount: importResult.agents.length,
|
||||
projectCount: importResult.projects.length,
|
||||
warningCount: importResult.warnings.length,
|
||||
},
|
||||
}, actorForClip(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "clip.imported",
|
||||
entityType: "clip",
|
||||
entityId: result.clip.id,
|
||||
details: {
|
||||
slug: result.clip.slug,
|
||||
revisionNumber: result.source.revisionNumber,
|
||||
agentCount: importResult.agents.length,
|
||||
projectCount: importResult.projects.length,
|
||||
warningCount: importResult.warnings.length,
|
||||
},
|
||||
});
|
||||
res.json({
|
||||
importResult,
|
||||
clip: result.clip,
|
||||
source: result.source,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/clips/:clipId/revisions", validate(createClipRevisionSchema), async (req, res) => {
|
||||
const clip = await svc.getClipById(req.params.clipId as string);
|
||||
if (!clip) {
|
||||
|
||||
@@ -123,6 +123,10 @@ export function clipService(db: Db) {
|
||||
return first(await db.select().from(clipCreatorProfiles).where(eq(clipCreatorProfiles.id, id)).limit(1));
|
||||
}
|
||||
|
||||
async function getCreatorProfileByHandle(handle: string) {
|
||||
return first(await db.select().from(clipCreatorProfiles).where(eq(clipCreatorProfiles.handle, handle)).limit(1));
|
||||
}
|
||||
|
||||
async function getClipById(id: string) {
|
||||
return first(await db.select().from(clips).where(eq(clips.id, id)).limit(1));
|
||||
}
|
||||
@@ -252,6 +256,13 @@ export function clipService(db: Db) {
|
||||
return profile;
|
||||
}
|
||||
if (!input.creatorProfile) throw unprocessable("creatorProfileId or creatorProfile is required");
|
||||
const existing = await getCreatorProfileByHandle(input.creatorProfile.handle);
|
||||
if (existing) {
|
||||
if (existing.companyId !== companyId) {
|
||||
throw conflict("Creator profile handle already belongs to another company");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return createCreatorProfile(companyId, input.creatorProfile);
|
||||
}
|
||||
|
||||
@@ -670,6 +681,7 @@ export function clipService(db: Db) {
|
||||
return {
|
||||
createCreatorProfile,
|
||||
getCreatorProfileById,
|
||||
getCreatorProfileByHandle,
|
||||
getClipById,
|
||||
getClipBySlug,
|
||||
listPublic,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Search } from "./pages/Search";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
|
||||
import { Routines } from "./pages/Routines";
|
||||
import { Clips } from "./pages/Clips";
|
||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||
@@ -110,6 +111,7 @@ function boardRoutes() {
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
) : null}
|
||||
<Route path="routines" element={<Routines />} />
|
||||
<Route path="clips" element={<Clips />} />
|
||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
ClipImportApplyRequest,
|
||||
ClipImportApplyResult,
|
||||
ClipImportPreviewRequest,
|
||||
ClipImportPreviewResult,
|
||||
ClipSharePreviewRequest,
|
||||
ClipSharePreviewResult,
|
||||
PublicClip,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const clipsApi = {
|
||||
listPublic: (params: { q?: string; type?: string; tag?: string; limit?: number } = {}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.q) searchParams.set("q", params.q);
|
||||
if (params.type) searchParams.set("type", params.type);
|
||||
if (params.tag) searchParams.set("tag", params.tag);
|
||||
if (params.limit) searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
return api.get<PublicClip[]>(`/public/clips${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
getPublic: (slug: string) =>
|
||||
api.get<PublicClip>(`/public/clips/${encodeURIComponent(slug)}`),
|
||||
sharePreview: (companyId: string, payload: ClipSharePreviewRequest) =>
|
||||
api.post<ClipSharePreviewResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/clips/share-preview`,
|
||||
payload,
|
||||
),
|
||||
publish: (companyId: string, payload: Record<string, unknown>) =>
|
||||
api.post<{ clip: PublicClip; revision: Record<string, unknown>; creatorProfile: Record<string, unknown> }>(
|
||||
`/companies/${encodeURIComponent(companyId)}/clips/publish`,
|
||||
payload,
|
||||
),
|
||||
importPreview: (companyId: string, payload: ClipImportPreviewRequest) =>
|
||||
api.post<ClipImportPreviewResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/clips/import-preview`,
|
||||
payload,
|
||||
),
|
||||
importClip: (companyId: string, payload: ClipImportApplyRequest) =>
|
||||
api.post<ClipImportApplyResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/clips/import`,
|
||||
payload,
|
||||
),
|
||||
};
|
||||
@@ -18,3 +18,4 @@ export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
export { sidebarPreferencesApi } from "./sidebarPreferences";
|
||||
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||
export { companySkillsApi } from "./companySkills";
|
||||
export { clipsApi } from "./clips";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Network,
|
||||
Boxes,
|
||||
Repeat,
|
||||
Paperclip,
|
||||
GitBranch,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
@@ -101,6 +102,7 @@ export function Sidebar() {
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/clips" label="Clips" icon={Paperclip} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
|
||||
@@ -12,6 +12,12 @@ export const queryKeys = {
|
||||
file: (companyId: string, skillId: string, relativePath: string) =>
|
||||
["company-skills", companyId, skillId, "file", relativePath] as const,
|
||||
},
|
||||
clips: {
|
||||
publicList: (params?: { q?: string; type?: string; tag?: string; limit?: number }) =>
|
||||
["clips", "public", params ?? {}] as const,
|
||||
publicDetail: (slug: string) => ["clips", "public", slug] as const,
|
||||
sharePreview: (companyId: string) => ["clips", companyId, "share-preview"] as const,
|
||||
},
|
||||
agents: {
|
||||
list: (companyId: string) => ["agents", companyId] as const,
|
||||
detail: (id: string) => ["agents", "detail", id] as const,
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ClipboardCheck,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileSearch,
|
||||
Package,
|
||||
Repeat,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ClipSharePreviewResult,
|
||||
ClipType,
|
||||
ClipVisibility,
|
||||
PublicClip,
|
||||
} from "@paperclipai/shared";
|
||||
import { clipsApi } from "../api/clips";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type ShareSource = {
|
||||
id: string;
|
||||
type: ClipType;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const visibilityLabels: Record<ClipVisibility, string> = {
|
||||
private_share: "Private link",
|
||||
unlisted: "Unlisted",
|
||||
public: "Public review",
|
||||
};
|
||||
|
||||
function asPreviewRecord(value: unknown) {
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function getPreviewPlan(preview: unknown) {
|
||||
const record = asPreviewRecord(preview);
|
||||
return asPreviewRecord(record.plan);
|
||||
}
|
||||
|
||||
function getPreviewArray<T = Record<string, unknown>>(preview: unknown, key: string): T[] {
|
||||
const value = getPreviewPlan(preview)[key];
|
||||
return Array.isArray(value) ? value as T[] : [];
|
||||
}
|
||||
|
||||
function formatCount(value: number, singular: string, plural = `${singular}s`) {
|
||||
return `${value} ${value === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function clipRevisionLabel(clip: PublicClip) {
|
||||
const revision = clip.currentRevision?.revisionNumber;
|
||||
return revision ? `revision ${revision}` : "no public revision";
|
||||
}
|
||||
|
||||
function safetyTone(clip: PublicClip) {
|
||||
if (clip.moderationState === "warning" || clip.moderationState === "under_review") return "text-amber-600";
|
||||
if (clip.moderationState === "limited" || clip.moderationState === "blocked") return "text-destructive";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function Clips() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToastActions();
|
||||
const queryClient = useQueryClient();
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [shareSource, setShareSource] = useState<ShareSource | null>(null);
|
||||
const [shareDraft, setShareDraft] = useState({
|
||||
title: "",
|
||||
summary: "",
|
||||
slug: "",
|
||||
visibility: "unlisted" as ClipVisibility,
|
||||
revisionNote: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Clips" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: publicClips, isLoading: clipsLoading } = useQuery({
|
||||
queryKey: queryKeys.clips.publicList({ limit: 18 }),
|
||||
queryFn: () => clipsApi.listPublic({ limit: 18 }),
|
||||
});
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: routines } = useQuery({
|
||||
queryKey: queryKeys.routines.list(selectedCompanyId!),
|
||||
queryFn: () => routinesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: skills } = useQuery({
|
||||
queryKey: queryKeys.companySkills.list(selectedCompanyId!),
|
||||
queryFn: () => companySkillsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const shareSources = useMemo<ShareSource[]>(() => {
|
||||
const sources: ShareSource[] = [];
|
||||
if (selectedCompany) {
|
||||
sources.push({
|
||||
id: "company",
|
||||
type: "bundle",
|
||||
label: `${selectedCompany.name} bundle`,
|
||||
description: "Share the current company as a bundled starter package.",
|
||||
});
|
||||
sources.push({
|
||||
id: "company",
|
||||
type: "team",
|
||||
label: `${selectedCompany.name} team`,
|
||||
description: "Share the org team and selected skills as a team clip.",
|
||||
});
|
||||
}
|
||||
for (const agent of agents ?? []) {
|
||||
sources.push({
|
||||
id: agent.id,
|
||||
type: "agent",
|
||||
label: agent.name,
|
||||
description: agent.capabilities ?? agent.title ?? agent.role,
|
||||
});
|
||||
}
|
||||
for (const skill of skills ?? []) {
|
||||
sources.push({
|
||||
id: skill.id,
|
||||
type: "skill",
|
||||
label: skill.name,
|
||||
description: skill.description ?? skill.key,
|
||||
});
|
||||
}
|
||||
for (const routine of routines ?? []) {
|
||||
sources.push({
|
||||
id: routine.id,
|
||||
type: "routine",
|
||||
label: routine.title,
|
||||
description: routine.description ?? "Recurring workflow with safe trigger review on import.",
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}, [agents, routines, selectedCompany, skills]);
|
||||
|
||||
const sharePreviewMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedCompanyId || !shareSource) throw new Error("Select a source first.");
|
||||
return clipsApi.sharePreview(selectedCompanyId, {
|
||||
source: { type: shareSource.type, id: shareSource.id },
|
||||
title: shareDraft.title || undefined,
|
||||
summary: shareDraft.summary || undefined,
|
||||
slug: shareDraft.slug || undefined,
|
||||
visibility: shareDraft.visibility,
|
||||
revisionNote: shareDraft.revisionNote || null,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Share preview failed",
|
||||
body: error instanceof Error ? error.message : "Paperclip could not build the clip preview.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (preview: ClipSharePreviewResult) => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company first.");
|
||||
return clipsApi.publish(selectedCompanyId, preview.publishRequest);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
pushToast({
|
||||
title: "Clip published",
|
||||
body: `${result.clip.title} is available as ${result.clip.visibility}.`,
|
||||
tone: "success",
|
||||
});
|
||||
setShareOpen(false);
|
||||
sharePreviewMutation.reset();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.clips.publicList({ limit: 18 }) });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Publish failed",
|
||||
body: error instanceof Error ? error.message : "Paperclip could not publish this clip.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const importPreviewMutation = useMutation({
|
||||
mutationFn: (urlOverride?: string) => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company first.");
|
||||
const url = (urlOverride ?? importUrl).trim();
|
||||
return clipsApi.importPreview(selectedCompanyId, {
|
||||
url,
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Import preview failed",
|
||||
body: error instanceof Error ? error.message : "Paperclip could not resolve that clip.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company first.");
|
||||
return clipsApi.importClip(selectedCompanyId, {
|
||||
url: importUrl.trim(),
|
||||
collisionStrategy: "rename",
|
||||
selectedOptions: { routineTriggers: "review_required" },
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
pushToast({
|
||||
title: "Clip imported",
|
||||
body: `${result.clip.title} was imported with routine triggers still review-gated.`,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Import failed",
|
||||
body: error instanceof Error ? error.message : "Paperclip could not import this clip.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function openShare(source: ShareSource) {
|
||||
setShareSource(source);
|
||||
setShareDraft({
|
||||
title: source.label,
|
||||
summary: source.description.slice(0, 240) || `Portable ${source.type} shared from Paperclip.`,
|
||||
slug: source.label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80),
|
||||
visibility: "unlisted",
|
||||
revisionNote: "Initial app share.",
|
||||
});
|
||||
sharePreviewMutation.reset();
|
||||
publishMutation.reset();
|
||||
setShareOpen(true);
|
||||
}
|
||||
|
||||
const activePreview = importPreviewMutation.data;
|
||||
const importAgentPlans = getPreviewArray(activePreview?.preview, "agentPlans");
|
||||
const importProjectPlans = getPreviewArray(activePreview?.preview, "projectPlans");
|
||||
const importIssuePlans = getPreviewArray(activePreview?.preview, "issuePlans");
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Package} message="Select a company to browse and import clips." />;
|
||||
}
|
||||
|
||||
if (clipsLoading) {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Clips</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Publish reusable teams, agents, skills, and routines, or import a clip after a safety preview.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => shareSources[0] && openShare(shareSources[0])} disabled={shareSources.length === 0}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
Import from URL
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={importUrl}
|
||||
onChange={(event) => setImportUrl(event.target.value)}
|
||||
placeholder="https://paperclip.ing/clips/support-triage or support-triage"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => importPreviewMutation.mutate(undefined)}
|
||||
disabled={!importUrl.trim() || importPreviewMutation.isPending}
|
||||
>
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
Dry Run
|
||||
</Button>
|
||||
</div>
|
||||
{activePreview ? (
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{activePreview.clip.title}</div>
|
||||
<div className="mt-1 break-all text-xs text-muted-foreground">
|
||||
{clipRevisionLabel(activePreview.clip)} · {activePreview.source.manifestChecksum}
|
||||
</div>
|
||||
</div>
|
||||
<PlanRows title="Creates and updates" rows={[...importAgentPlans, ...importProjectPlans, ...importIssuePlans]} />
|
||||
{(asPreviewRecord(activePreview.preview).warnings as string[] | undefined)?.length ? (
|
||||
<WarningList warnings={asPreviewRecord(activePreview.preview).warnings as string[]} />
|
||||
) : null}
|
||||
</div>
|
||||
<SafetyPanel preview={activePreview} />
|
||||
<div className="xl:col-span-2 flex flex-col gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Import is blocked if preview errors exist. Recurring and webhook triggers stay disabled until reviewed.
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={importMutation.isPending || ((asPreviewRecord(activePreview.preview).errors as unknown[] | undefined)?.length ?? 0) > 0}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Import Clip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ClipboardCheck className="h-4 w-4 text-muted-foreground" />
|
||||
Share from app
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
{shareSources.slice(0, 6).map((source) => (
|
||||
<button
|
||||
key={`${source.type}:${source.id}`}
|
||||
className="flex w-full items-start gap-3 border-b border-border px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
|
||||
onClick={() => openShare(source)}
|
||||
>
|
||||
<SourceIcon type={source.type} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{source.label}</span>
|
||||
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">{source.description}</span>
|
||||
</span>
|
||||
<Copy className="mt-0.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
Public catalog
|
||||
</div>
|
||||
{publicClips && publicClips.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{publicClips.map((clip) => (
|
||||
<ClipCard key={clip.id} clip={clip} onImport={() => {
|
||||
setImportUrl(clip.slug);
|
||||
importPreviewMutation.mutate(clip.slug);
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={Package} message="No public clips are available yet. Publish an unlisted clip from this app to start." />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Dialog open={shareOpen} onOpenChange={setShareOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share clip</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review dependencies, redaction, visibility, and the public preview before publishing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<div className="space-y-3">
|
||||
<LabeledInput label="Title" value={shareDraft.title} onChange={(value) => setShareDraft((prev) => ({ ...prev, title: value }))} />
|
||||
<LabeledInput label="Slug" value={shareDraft.slug} onChange={(value) => setShareDraft((prev) => ({ ...prev, slug: value }))} />
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Summary</span>
|
||||
<Textarea value={shareDraft.summary} onChange={(event) => setShareDraft((prev) => ({ ...prev, summary: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Revision note</span>
|
||||
<Textarea value={shareDraft.revisionNote} onChange={(event) => setShareDraft((prev) => ({ ...prev, revisionNote: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Visibility</span>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={shareDraft.visibility}
|
||||
onChange={(event) => setShareDraft((prev) => ({ ...prev, visibility: event.target.value as ClipVisibility }))}
|
||||
>
|
||||
{Object.entries(visibilityLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => sharePreviewMutation.mutate()}
|
||||
disabled={sharePreviewMutation.isPending || !shareSource}
|
||||
>
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
Build Preview
|
||||
</Button>
|
||||
{sharePreviewMutation.data ? <ShareReview preview={sharePreviewMutation.data} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShareOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => sharePreviewMutation.data && publishMutation.mutate(sharePreviewMutation.data)}
|
||||
disabled={!sharePreviewMutation.data || publishMutation.isPending}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Publish
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceIcon({ type }: { type: ClipType }) {
|
||||
const className = "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground";
|
||||
if (type === "routine") return <Repeat className={className} />;
|
||||
if (type === "skill") return <Sparkles className={className} />;
|
||||
if (type === "agent" || type === "team") return <Package className={className} />;
|
||||
return <ClipboardCheck className={className} />;
|
||||
}
|
||||
|
||||
function LabeledInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<Input value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareReview({ preview }: { preview: ClipSharePreviewResult }) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border px-3 py-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{preview.source.label}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{preview.source.type} · immutable revision</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<Metric label="Dependencies" value={preview.dependencyCounts.adapters + preview.dependencyCounts.skills + preview.dependencyCounts.permissions} />
|
||||
<Metric label="Redactions" value={preview.redactionSummary.redacted + preview.redactionSummary.omitted} />
|
||||
</div>
|
||||
{preview.dangerousCapabilities.length > 0 ? (
|
||||
<div className="flex gap-2 text-xs text-amber-600">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{preview.dangerousCapabilities.join(", ")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>No dangerous capabilities detected in the manifest preview.</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.warnings.length > 0 ? <WarningList warnings={preview.warnings.slice(0, 3)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafetyPanel({ preview }: { preview: { safety: { dangerousCapabilities: string[]; requiredSecrets: string[]; permissions: string[]; routineTriggersEnabledByDefault: boolean; webhookSecretsRegenerated: boolean } } }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-3 py-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
Safety review
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-xs">
|
||||
<SafetyRow label="Secrets" value={preview.safety.requiredSecrets.length ? preview.safety.requiredSecrets.join(", ") : "none required"} warn={preview.safety.requiredSecrets.length > 0} />
|
||||
<SafetyRow label="Permissions" value={preview.safety.permissions.length ? preview.safety.permissions.join(", ") : "none requested"} warn={preview.safety.permissions.length > 0} />
|
||||
<SafetyRow label="Dangerous" value={preview.safety.dangerousCapabilities.length ? preview.safety.dangerousCapabilities.join(", ") : "none detected"} warn={preview.safety.dangerousCapabilities.length > 0} />
|
||||
<SafetyRow label="Routines" value={preview.safety.routineTriggersEnabledByDefault ? "triggers would enable" : "triggers disabled for review"} warn={preview.safety.routineTriggersEnabledByDefault} />
|
||||
<SafetyRow label="Webhooks" value={preview.safety.webhookSecretsRegenerated ? "secrets regenerated" : "manual review required"} warn={!preview.safety.webhookSecretsRegenerated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafetyRow({ label, value, warn }: { label: string; value: string; warn: boolean }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={cn("min-w-0", warn ? "text-amber-600" : "text-foreground")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-2 py-1.5">
|
||||
<div className="font-medium tabular-nums">{value}</div>
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanRows({ title, rows }: { title: string; rows: Record<string, unknown>[] }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">{title}</div>
|
||||
{rows.length > 0 ? (
|
||||
<div className="rounded-md border border-border">
|
||||
{rows.slice(0, 8).map((row, index) => (
|
||||
<div key={`${String(row.slug ?? index)}:${index}`} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3 border-b border-border px-3 py-2 text-sm last:border-b-0">
|
||||
<span className="text-xs uppercase text-muted-foreground">{String(row.action ?? "create")}</span>
|
||||
<span className="truncate">{String(row.plannedName ?? row.plannedTitle ?? row.slug ?? "Imported object")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-2 text-sm text-muted-foreground">
|
||||
No creates or updates in this preview.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningList({ warnings }: { warnings: string[] }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{warnings.map((warning, index) => (
|
||||
<div key={`${warning}:${index}`} className="flex gap-2 text-xs text-amber-600">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipCard({ clip, onImport }: { clip: PublicClip; onImport: () => void }) {
|
||||
return (
|
||||
<div className="flex min-h-[12rem] flex-col rounded-md border border-border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{clip.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{clip.type} · @{clip.creator.handle} · {clipRevisionLabel(clip)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("text-xs", safetyTone(clip))}>{clip.moderationState}</span>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-3 text-sm text-muted-foreground">{clip.summary}</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-xs">
|
||||
<Metric label="imports" value={clip.metrics.importCount} />
|
||||
<Metric label="votes" value={clip.metrics.voteScore} />
|
||||
<Metric label="proof" value={clip.metrics.successfulFirstRunCount} />
|
||||
</div>
|
||||
<div className="mt-auto flex items-center justify-between pt-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCount(clip.dependencies?.length ?? 0, "dependency", "dependencies")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onImport}>
|
||||
<FileSearch className="mr-1.5 h-3.5 w-3.5" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<a href={`/api/public/clips/${encodeURIComponent(clip.slug)}/manifest`} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user