added ability to share workflows (#240)

This commit is contained in:
arkml 2025-09-11 21:47:32 +05:30 committed by GitHub
parent ad7a0d313b
commit 1aaf5929f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 283 additions and 35 deletions

View file

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { Workflow } from '@/app/lib/types/workflow_types';
import { nanoid } from 'nanoid';
import { db } from '@/app/lib/mongodb';
import { SHARED_WORKFLOWS_COLLECTION } from '@/src/infrastructure/repositories/mongodb.shared-workflows.indexes';
const DEFAULT_TTL_SECONDS = 60 * 60 * 24; // 24 hours
interface SharedWorkflowDoc {
_id: string;
workflow: unknown;
createdAt: Date;
expiresAt: Date;
}
function validateWorkflowJson(obj: unknown) {
const parsed = Workflow.safeParse(obj);
if (!parsed.success) {
const message = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
throw new Error(`Invalid workflow JSON: ${message}`);
}
return parsed.data;
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
const url = searchParams.get('url');
if (id) {
const coll = db.collection<SharedWorkflowDoc>(SHARED_WORKFLOWS_COLLECTION);
const doc = await coll.findOne(
{ _id: id },
{ projection: { workflow: 1, expiresAt: 1 } }
);
if (!doc) {
return NextResponse.json({ error: 'Not found or expired' }, { status: 404 });
}
// Optional safeguard if TTL not yet cleaned up
if (doc.expiresAt && doc.expiresAt.getTime() <= Date.now()) {
return NextResponse.json({ error: 'Not found or expired' }, { status: 404 });
}
return NextResponse.json(doc.workflow);
}
if (!url) {
return NextResponse.json({ error: 'Missing "id" or "url" query param' }, { status: 400 });
}
if (url.startsWith('blob:')) {
return NextResponse.json({ error: 'Blob URLs are not accessible from the server. Use POST /api/shared-workflow to upload the workflow and share its id.' }, { status: 400 });
}
const isHttp = url.startsWith('http://') || url.startsWith('https://');
if (!isHttp) {
return NextResponse.json({ error: 'Only http(s) URLs are supported in the "url" param' }, { status: 400 });
}
const resp = await fetch(url, { cache: 'no-store' });
if (!resp.ok) {
return NextResponse.json({ error: `Failed to fetch URL: ${resp.status} ${resp.statusText}` }, { status: 400 });
}
const text = await resp.text();
const obj = JSON.parse(text);
const workflow = validateWorkflowJson(obj);
return NextResponse.json(workflow);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 400 });
}
}
export async function POST(req: NextRequest) {
try {
const contentType = req.headers.get('content-type') || '';
let body: any;
if (contentType.includes('application/json')) {
body = await req.json();
} else {
const text = await req.text();
body = JSON.parse(text);
}
const workflowCandidate = typeof body?.workflow === 'object' ? body.workflow : body;
const workflow = validateWorkflowJson(workflowCandidate);
const id = nanoid();
const coll = db.collection<SharedWorkflowDoc>(SHARED_WORKFLOWS_COLLECTION);
const now = new Date();
const expiresAt = new Date(now.getTime() + DEFAULT_TTL_SECONDS * 1000);
await coll.insertOne({ _id: id, workflow, createdAt: now, expiresAt });
const origin = new URL(req.url).origin;
const href = `${origin}/api/shared-workflow?id=${id}`;
return NextResponse.json({ id, href, ttlSeconds: DEFAULT_TTL_SECONDS });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 400 });
}
}

View file

@ -2,7 +2,7 @@
import React from "react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input } from "@heroui/react";
import { Button as CustomButton } from "@/components/ui/button";
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon } from "lucide-react";
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon, ShareIcon } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { ProgressBar, ProgressStep } from "@/components/ui/progress-bar";
@ -35,6 +35,9 @@ interface TopBarProps {
onStartTestTour?: () => void;
onStartPublishTour?: () => void;
onStartUseTour?: () => void;
onShareWorkflow: () => void;
shareUrl: string | null;
onCopyShareUrl: () => void;
}
export function TopBar({
@ -66,6 +69,9 @@ export function TopBar({
onStartTestTour,
onStartPublishTour,
onStartUseTour,
onShareWorkflow,
shareUrl,
onCopyShareUrl,
}: TopBarProps) {
const router = useRouter();
const params = useParams();
@ -240,6 +246,27 @@ export function TopBar({
<RadioIcon size={16} />
Live workflow
</div>
<Tooltip content="Share Assistant">
<button
onClick={onShareWorkflow}
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
aria-label="Share Assistant"
type="button"
>
<ShareIcon size={20} />
</button>
</Tooltip>
{shareUrl && (
<Tooltip content="Copy share URL">
<button
onClick={onCopyShareUrl}
className="px-2 py-1 text-xs bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:hover:bg-indigo-900/50 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-700 rounded-md transition-colors"
type="button"
>
Copy URL
</button>
</Tooltip>
)}
<Tooltip content="Download Assistant JSON">
<button
onClick={onDownloadJSON}
@ -306,6 +333,27 @@ export function TopBar({
<PenLine size={16} />
Draft workflow
</div>}
<Tooltip content="Share Assistant">
<button
onClick={onShareWorkflow}
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
aria-label="Share Assistant"
type="button"
>
<ShareIcon size={20} />
</button>
</Tooltip>
{shareUrl && (
<Tooltip content="Copy share URL">
<button
onClick={onCopyShareUrl}
className="px-2 py-1 text-xs bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:hover:bg-indigo-900/50 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-700 rounded-md transition-colors"
type="button"
>
Copy URL
</button>
</Tooltip>
)}
<Tooltip content="Download Assistant JSON">
<button
onClick={onDownloadJSON}

View file

@ -1389,15 +1389,12 @@ export function WorkflowEditor({
onRevertModalClose();
}
// Remove handleCopyJSON and add handleDownloadJSON
function handleDownloadJSON() {
// Helper: build exported JSON with masked prompt variables
function buildWorkflowExportJson() {
const workflow = state.present.workflow;
// Create a copy of the workflow and replace variable values with dummy text
const workflowCopy = {
...workflow,
prompts: workflow.prompts.map(prompt => {
// If this is a variable (base_prompt type), replace its value with dummy text
if (prompt.type === 'base_prompt') {
return {
...prompt,
@ -1407,8 +1404,12 @@ export function WorkflowEditor({
return prompt;
})
};
const json = JSON.stringify(workflowCopy, null, 2);
return JSON.stringify(workflowCopy, null, 2);
}
// Download workflow as JSON file
function handleDownloadJSON() {
const json = buildWorkflowExportJson();
const blob = new Blob([json], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@ -1420,6 +1421,39 @@ export function WorkflowEditor({
document.body.removeChild(a);
}
// Share: upload JSON to server to get a share ID and reveal copy button
const [shareUrl, setShareUrl] = useState<string | null>(null);
async function handleShareWorkflow() {
try {
// POST to server to create a share token
const json = buildWorkflowExportJson();
const resp = await fetch('/api/shared-workflow', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: json,
});
if (!resp.ok) {
console.error('Failed to create share link');
return;
}
const data = await resp.json();
const createUrl = `${window.location.origin}/projects?shared=${encodeURIComponent(data.id)}`;
setShareUrl(createUrl);
} catch (e) {
console.error('Error sharing workflow:', e);
}
}
function handleCopyShareUrl() {
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl);
setShowCopySuccess(true);
setTimeout(() => setShowCopySuccess(false), 2000);
}
// Cleanup blob URL on unmount
// No-op cleanup; shareUrl is a normal URL now
const processQueue = useCallback(async (state: State, dispatch: React.Dispatch<Action>) => {
if (saving.current || saveQueue.current.length === 0) return;
@ -1689,6 +1723,9 @@ export function WorkflowEditor({
onUndo={() => dispatchGuarded({ type: "undo" })}
onRedo={() => dispatchGuarded({ type: "redo" })}
onDownloadJSON={handleDownloadJSON}
onShareWorkflow={handleShareWorkflow}
shareUrl={shareUrl}
onCopyShareUrl={handleCopyShareUrl}
onPublishWorkflow={handlePublishWorkflow}
onChangeMode={onChangeMode}
onRevertToLive={handleRevertToLive}

View file

@ -156,23 +156,54 @@ export function BuildAssistantSection() {
useEffect(() => {
const urlPrompt = searchParams.get('prompt');
const urlTemplate = searchParams.get('template');
const sharedId = searchParams.get('shared');
const importUrl = searchParams.get('importUrl');
if (urlPrompt || urlTemplate) {
setAutoCreateLoading(true);
createProjectWithOptions({
template: urlTemplate || undefined,
prompt: urlPrompt || undefined,
router,
onError: (error) => {
console.error('Error auto-creating project:', error);
setAutoCreateLoading(false);
// Fall back to showing the form with the prompt pre-filled
if (urlPrompt) {
setUserPrompt(urlPrompt);
const run = async () => {
if (sharedId || importUrl) {
try {
setAutoCreateLoading(true);
const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`;
const resp = await fetch(`/api/shared-workflow?${qs}`, { cache: 'no-store' });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Failed to load shared workflow (${resp.status})`);
}
const workflowObj = await resp.json();
await createProjectFromJsonWithOptions({
workflowJson: JSON.stringify(workflowObj),
router,
onError: (error) => {
console.error('Error creating project from shared workflow:', error);
setAutoCreateLoading(false);
}
});
return;
} catch (err) {
console.error('Error auto-importing shared workflow:', err);
setAutoCreateLoading(false);
}
});
}
}
if (urlPrompt || urlTemplate) {
setAutoCreateLoading(true);
createProjectWithOptions({
template: urlTemplate || undefined,
prompt: urlPrompt || undefined,
router,
onError: (error) => {
console.error('Error auto-creating project:', error);
setAutoCreateLoading(false);
// Fall back to showing the form with the prompt pre-filled
if (urlPrompt) {
setUserPrompt(urlPrompt);
}
}
});
}
};
run();
}, [searchParams, router]);
const handleCreateAssistant = async () => {

View file

@ -149,6 +149,8 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
const searchParams = useSearchParams();
const urlPrompt = searchParams.get('prompt');
const urlTemplate = searchParams.get('template');
const sharedId = searchParams.get('shared');
const importUrl = searchParams.get('importUrl');
// Add this effect to update name when defaultName changes
useEffect(() => {
@ -165,29 +167,48 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
// Add effect to handle URL parameters for auto-creation
useEffect(() => {
const handleAutoCreate = async () => {
// Only auto-create if we have either a prompt or template, and we're not already loading
if ((urlPrompt || urlTemplate) && !importLoading && !autoCreateLoading) {
// Auto-create from template/prompt, or import from shared/id/url
if ((urlPrompt || urlTemplate || sharedId || importUrl) && !importLoading && !autoCreateLoading) {
setAutoCreateLoading(true);
try {
await createProjectWithOptions({
template: urlTemplate || undefined,
prompt: urlPrompt || undefined,
router,
onError: (error) => {
// Auto-creation failed, show the form instead
setBillingError(error instanceof Error ? error.message : String(error));
setAutoCreateLoading(false);
if (sharedId || importUrl) {
// Fetch workflow JSON via our API route
const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`;
const resp = await fetch(`/api/shared-workflow?${qs}`, { cache: 'no-store' });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Failed to load shared workflow (${resp.status})`);
}
});
const workflowObj = await resp.json();
await createProjectFromJsonWithOptions({
workflowJson: JSON.stringify(workflowObj),
router,
onError: (error) => {
setBillingError(error instanceof Error ? error.message : String(error));
}
});
} else {
await createProjectWithOptions({
template: urlTemplate || undefined,
prompt: urlPrompt || undefined,
router,
onError: (error) => {
// Auto-creation failed, show the form instead
setBillingError(error instanceof Error ? error.message : String(error));
setAutoCreateLoading(false);
}
});
}
} catch (error) {
console.error('Error auto-creating project:', error);
setBillingError(error instanceof Error ? error.message : String(error));
setAutoCreateLoading(false);
}
}
};
handleAutoCreate();
}, [urlPrompt, urlTemplate, importLoading, autoCreateLoading, router]);
}, [urlPrompt, urlTemplate, sharedId, importUrl, importLoading, autoCreateLoading, router]);
// Inject glow animation styles
useEffect(() => {

View file

@ -10,6 +10,7 @@ import { RECURRING_JOB_RULES_COLLECTION, RECURRING_JOB_RULES_INDEXES } from "../
import { SCHEDULED_JOB_RULES_COLLECTION, SCHEDULED_JOB_RULES_INDEXES } from "../repositories/mongodb.scheduled-job-rules.indexes";
import { COMPOSIO_TRIGGER_DEPLOYMENTS_COLLECTION, COMPOSIO_TRIGGER_DEPLOYMENTS_INDEXES } from "../repositories/mongodb.composio-trigger-deployments.indexes";
import { USERS_COLLECTION, USERS_INDEXES } from "../repositories/mongodb.users.indexes";
import { SHARED_WORKFLOWS_COLLECTION, SHARED_WORKFLOWS_INDEXES } from "../repositories/mongodb.shared-workflows.indexes";
export async function ensureAllIndexes(database: Db): Promise<void> {
await database.collection(API_KEYS_COLLECTION).createIndexes(API_KEYS_INDEXES);
@ -23,4 +24,5 @@ export async function ensureAllIndexes(database: Db): Promise<void> {
await database.collection(SCHEDULED_JOB_RULES_COLLECTION).createIndexes(SCHEDULED_JOB_RULES_INDEXES);
await database.collection(COMPOSIO_TRIGGER_DEPLOYMENTS_COLLECTION).createIndexes(COMPOSIO_TRIGGER_DEPLOYMENTS_INDEXES);
await database.collection(USERS_COLLECTION).createIndexes(USERS_INDEXES);
}
await database.collection(SHARED_WORKFLOWS_COLLECTION).createIndexes(SHARED_WORKFLOWS_INDEXES);
}

View file

@ -0,0 +1,8 @@
import { IndexDescription } from "mongodb";
export const SHARED_WORKFLOWS_COLLECTION = "shared_workflows";
export const SHARED_WORKFLOWS_INDEXES: IndexDescription[] = [
{ key: { expiresAt: 1 }, name: "expiresAt_ttl", expireAfterSeconds: 0 },
];