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

@ -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(() => {