From d49b4ea2dc963f050ceb32d09a6cf02bc093a8a0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 16 Apr 2026 08:45:22 -0400 Subject: [PATCH] feat(rsocials): graph-based campaign creation with brief-to-canvas pipeline Add campaign planner as primary creation tool. Brief inputs in collapsible sidebar generate editable node graph via Gemini 2.5 Flash. New input node types (goal, message, tone) with feeds edges to downstream post/thread nodes. Stale tracking propagates when inputs change; batch regen + single-node AI fill. - schemas: goal/message/tone node types, feeds edge, stale tracking, schema v8 - mod: 3 API endpoints (from-brief, regen-nodes, ai-fill-node) + /campaign-flow page - planner: ports, wiring, rendering, inline config, brief sidebar, context menu - css: brief panel, platform chips, stale badges, feeds edge styles Co-Authored-By: Claude Opus 4.6 --- .../rsocials/components/campaign-planner.css | 202 +++++++++ .../components/folk-campaign-planner.ts | 370 +++++++++++++++- modules/rsocials/mod.ts | 407 +++++++++++++++++- modules/rsocials/schemas.ts | 35 +- 4 files changed, 1004 insertions(+), 10 deletions(-) diff --git a/modules/rsocials/components/campaign-planner.css b/modules/rsocials/components/campaign-planner.css index 5c2ac407..13a100fc 100644 --- a/modules/rsocials/components/campaign-planner.css +++ b/modules/rsocials/components/campaign-planner.css @@ -859,3 +859,205 @@ folk-campaign-planner { max-width: 150px; } } + +/* ── Brief panel (collapsible left sidebar) ── */ +.cp-brief-panel { + width: 0; + overflow: hidden; + border-right: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + display: flex; + flex-direction: column; + transition: width 0.3s ease; + flex-shrink: 0; +} + +.cp-brief-panel.open { + width: 300px; +} + +.cp-brief-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + min-height: 44px; +} + +.cp-brief-title { + font-size: 13px; + font-weight: 600; + flex: 1; +} + +.cp-brief-close { + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + font-size: 16px; + cursor: pointer; + padding: 4px; +} + +.cp-brief-close:hover { + color: var(--rs-text-primary, #e2e8f0); +} + +.cp-brief-body { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.cp-brief-body label { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + font-weight: 600; +} + +.cp-brief-body textarea { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + font-family: inherit; + box-sizing: border-box; + resize: vertical; + min-height: 120px; +} + +/* ── Platform selector chips ── */ +.cp-platform-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.cp-platform-check { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 6px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + cursor: pointer; + font-size: 11px; + color: var(--rs-text-primary, #e2e8f0); + transition: border-color 0.15s, background 0.15s; +} + +.cp-platform-check:has(input:checked) { + border-color: #6366f1; + background: #6366f122; +} + +.cp-platform-check input { + display: none; +} + +/* ── Brief / Generate buttons ── */ +.cp-btn--brief { + background: #8b5cf622; + border-color: #8b5cf655; + color: #a78bfa; +} + +.cp-btn--brief:hover { + background: #8b5cf633; + border-color: #8b5cf6; +} + +.cp-btn--brief.active { + background: #8b5cf633; + border-color: #8b5cf6; +} + +.cp-btn--generate { + width: 100%; + padding: 10px; + border-radius: 8px; + border: none; + background: #6366f1; + color: white; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.cp-btn--generate:hover { + background: #4f46e5; +} + +.cp-btn--generate:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Stale badge ── */ +.cp-stale-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border-radius: 4px; + background: #f59e0b22; + color: #f59e0b; + font-size: 9px; + font-weight: 600; + white-space: nowrap; +} + +/* ── Regen button (amber) ── */ +.cp-btn--regen { + background: #f59e0b22; + border-color: #f59e0b55; + color: #fbbf24; +} + +.cp-btn--regen:hover { + background: #f59e0b33; + border-color: #f59e0b; +} + +/* ── AI fill button (indigo) ── */ +.cp-icp-btn-ai { + background: #6366f1; + color: white; + flex: 1; +} + +.cp-icp-btn-ai:hover { + background: #4f46e5; +} + +.cp-icp-btn-ai:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Feeds edge style ── */ +.edge-path-feeds { + stroke-dasharray: 6 4; + filter: drop-shadow(0 0 2px #6366f1); +} + +/* ── Mobile: brief panel ── */ +@media (max-width: 768px) { + .cp-brief-panel.open { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 20; + } +} diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index 91e1204c..c0110042 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -20,6 +20,9 @@ import type { PlatformNodeData, AudienceNodeData, PhaseNodeData, + GoalNodeData, + MessageNodeData, + ToneNodeData, } from '../schemas'; import { SocialsLocalFirstClient } from '../local-first-client'; import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; @@ -41,11 +44,13 @@ const CAMPAIGN_PORT_DEFS: Record = { { kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] }, { kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] }, { kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' }, + { kind: 'feeds-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#6366f1' }, ], thread: [ { kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] }, { kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] }, { kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' }, + { kind: 'feeds-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#6366f1' }, ], platform: [ { kind: 'content-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#10b981' }, @@ -54,6 +59,15 @@ const CAMPAIGN_PORT_DEFS: Record = { { kind: 'target-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#f59e0b' }, ], phase: [], + goal: [ + { kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] }, + ], + message: [ + { kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] }, + ], + tone: [ + { kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] }, + ], }; // ── Edge type → visual config ── @@ -62,12 +76,14 @@ const EDGE_STYLES: Record = { publish: '#10b981', sequence: '#8b5cf6', target: '#f59e0b', + feeds: '#6366f1', }; // ── Node sizes ── @@ -82,6 +98,9 @@ function getNodeSize(node: CampaignPlannerNode): { w: number; h: number } { const d = node.data as PhaseNodeData; return { w: d.size.w, h: d.size.h }; } + case 'goal': return { w: 240, h: 130 }; + case 'message': return { w: 220, h: 100 }; + case 'tone': return { w: 220, h: 110 }; default: return { w: 200, h: 100 }; } } @@ -179,6 +198,12 @@ class FolkCampaignPlanner extends HTMLElement { // Postiz private postizOpen = false; + // Brief panel + private briefPanelOpen = false; + private briefText = ''; + private briefPlatforms: string[] = ['x', 'linkedin', 'instagram', 'threads', 'bluesky']; + private briefLoading = false; + // Persistence private localFirstClient: SocialsLocalFirstClient | null = null; private saveTimer: ReturnType | null = null; @@ -535,7 +560,9 @@ class FolkCampaignPlanner extends HTMLElement { // Determine edge type from port kinds let edgeType: CampaignEdgeType = 'publish'; - if (this.wiringSourcePortKind === 'sequence-out') { + if (this.wiringSourcePortKind === 'feeds-out') { + edgeType = 'feeds'; + } else if (this.wiringSourcePortKind === 'sequence-out') { edgeType = 'sequence'; } else { // Check what kind of input the target has @@ -626,6 +653,15 @@ class FolkCampaignPlanner extends HTMLElement { case 'phase': data = { label: 'New Phase', dateRange: '', color: '#6366f1', progress: 0, childNodeIds: [], size: { w: 400, h: 300 } }; break; + case 'goal': + data = { label: 'Campaign Goal', objective: '', startDate: '', endDate: '' }; + break; + case 'message': + data = { label: 'Key Message', text: '', priority: 'medium' }; + break; + case 'tone': + data = { label: 'Tone & Style', tone: 'professional', style: 'awareness', audience: '', sizeEstimate: '' }; + break; } const node: CampaignPlannerNode = { id, type, position: { x, y }, data }; this.nodes.push(node); @@ -673,7 +709,7 @@ class FolkCampaignPlanner extends HTMLElement { overlay.classList.add('inline-edit-overlay'); const panelW = 260; - const panelH = node.type === 'post' ? 340 : node.type === 'thread' ? 200 : 220; + const panelH = node.type === 'post' ? 380 : node.type === 'thread' ? 240 : node.type === 'goal' ? 260 : node.type === 'tone' ? 300 : 220; const panelX = s.w + 12; const panelY = 0; @@ -804,10 +840,68 @@ class FolkCampaignPlanner extends HTMLElement { `; break; } + case 'goal': { + const d = node.data as GoalNodeData; + body = ` +
+ + + + + + + + +
`; + break; + } + case 'message': { + const d = node.data as MessageNodeData; + body = ` +
+ + + + +
`; + break; + } + case 'tone': { + const d = node.data as ToneNodeData; + body = ` +
+ + + + + + + + +
`; + break; + } } + // AI fill button for post/thread nodes + const aiFillBtn = (node.type === 'post' || node.type === 'thread') + ? `` : ''; + const toolbar = `
+ ${aiFillBtn}
`; @@ -855,6 +949,13 @@ class FolkCampaignPlanner extends HTMLElement { if (field === 'content' && node.type === 'post') { (node.data as PostNodeData).label = val.split('\n')[0].substring(0, 40); } + if (field === 'text' && node.type === 'message') { + (node.data as MessageNodeData).label = val.substring(0, 40); + } + // Mark downstream nodes stale when input nodes change + if (node.type === 'goal' || node.type === 'message' || node.type === 'tone') { + this.markDownstreamStale(node.id); + } this.scheduleSave(); }; el.addEventListener('input', handler); @@ -876,6 +977,8 @@ class FolkCampaignPlanner extends HTMLElement { if (d.threadId) { window.location.href = `${this.basePath}thread-editor/${d.threadId}/edit`; } + } else if (action === 'ai-fill') { + this.aiFillNode(node.id); } }); }); @@ -888,6 +991,9 @@ class FolkCampaignPlanner extends HTMLElement { case 'platform': return '📡'; case 'audience': return '🎯'; case 'phase': return '📅'; + case 'goal': return '🎯'; + case 'message': return '💬'; + case 'tone': return '🎭'; default: return ''; } } @@ -916,6 +1022,9 @@ class FolkCampaignPlanner extends HTMLElement { { icon: '\u{1f4e1}', label: 'Add Platform', type: 'platform' }, { icon: '\u{1f3af}', label: 'Add Audience', type: 'audience' }, { icon: '\u{1f4c5}', label: 'Add Phase', type: 'phase' }, + { icon: '\u{1f3af}', label: 'Add Goal', type: 'goal' }, + { icon: '\u{1f4ac}', label: 'Add Message', type: 'message' }, + { icon: '\u{1f3ad}', label: 'Add Tone', type: 'tone' }, ]; menu.innerHTML = items.map(it => @@ -1288,6 +1397,8 @@ class FolkCampaignPlanner extends HTMLElement {
+ + @@ -1296,6 +1407,26 @@ class FolkCampaignPlanner extends HTMLElement {
+
+
+ \u2728 Generate from Brief + +
+
+ + + +
+ ${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p => + `` + ).join('')} +
+ +
+
@@ -1369,6 +1500,9 @@ class FolkCampaignPlanner extends HTMLElement { case 'thread': inner = this.renderThreadNodeInner(node); break; case 'platform': inner = this.renderPlatformNodeInner(node); break; case 'audience': inner = this.renderAudienceNodeInner(node); break; + case 'goal': inner = this.renderGoalNodeInner(node); break; + case 'message': inner = this.renderMessageNodeInner(node); break; + case 'tone': inner = this.renderToneNodeInner(node); break; } const ports = this.renderPortsSvg(node); @@ -1393,13 +1527,15 @@ class FolkCampaignPlanner extends HTMLElement { const charMax = platform === 'x' ? 280 : 2200; const charPct = Math.min(1, charCount / charMax) * 100; const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Unscheduled'; + const staleBadge = node.stale ? `\u26a0 stale` : ''; - return `
+ return `
${icon} ${esc(d.label || preview || 'New Post')} + ${staleBadge}
${esc(preview)}
@@ -1464,6 +1600,197 @@ class FolkCampaignPlanner extends HTMLElement {
`; } + private renderGoalNodeInner(node: CampaignPlannerNode): string { + const d = node.data as GoalNodeData; + const staleBadge = node.stale ? '' : ''; // goals don't show stale + return `
+
+ 🎯 + ${esc(d.label || 'Campaign Goal')} +
+
${esc(d.objective || '').substring(0, 60)}
+
${d.startDate ? esc(d.startDate) + ' \u2192 ' + esc(d.endDate) : 'No dates set'}
+
`; + } + + private renderMessageNodeInner(node: CampaignPlannerNode): string { + const d = node.data as MessageNodeData; + const priorityColor = d.priority === 'high' ? '#ef4444' : d.priority === 'medium' ? '#f59e0b' : '#94a3b8'; + return `
+
+
+
+ 💬 + ${esc(d.label || 'Key Message')} + ${d.priority} +
+
${esc(d.text || '').substring(0, 60)}
+
+
`; + } + + private renderToneNodeInner(node: CampaignPlannerNode): string { + const d = node.data as ToneNodeData; + return `
+
+ 🎭 + ${esc(d.label || 'Tone')} +
+
${esc(d.tone)} \u00b7 ${esc(d.style.replace('-', ' '))}
+
${esc(d.audience || '').substring(0, 50)}
+
`; + } + + private renderStaleBadge(node: CampaignPlannerNode): string { + if (!node.stale) return ''; + return `\u26a0 stale`; + } + + // ── Stale tracking ── + + private markDownstreamStale(nodeId: string) { + // Follow outgoing edges from this node and mark downstream post/thread nodes stale + const outEdges = this.edges.filter(e => e.from === nodeId); + for (const edge of outEdges) { + const target = this.nodes.find(n => n.id === edge.to); + if (!target) continue; + if (target.type === 'post' || target.type === 'thread') { + target.stale = true; + target.staleReason = `Input node changed`; + } + // Recurse for phase→post chains + if (target.type === 'phase') { + this.markDownstreamStale(target.id); + } + } + this.drawCanvasContent(); + } + + private getStaleCount(): number { + return this.nodes.filter(n => n.stale).length; + } + + // ── Brief panel methods ── + + private async generateFromBrief() { + if (this.briefLoading || !this.briefText.trim()) return; + this.briefLoading = true; + this.updateBriefPanel(); + + try { + const res = await fetch(`${this.basePath}api/campaign/flow/from-brief`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rawBrief: this.briefText, + platforms: this.briefPlatforms, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as any).error || `HTTP ${res.status}`); + } + + const flow: CampaignFlow = await res.json(); + this.currentFlowId = flow.id; + this.flowName = flow.name; + this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })); + this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })); + this.briefPanelOpen = false; + + this.render(); + requestAnimationFrame(() => this.fitView()); + } catch (e: any) { + console.error('[CampaignPlanner] Generate from brief error:', e.message); + alert('Failed to generate: ' + e.message); + } finally { + this.briefLoading = false; + } + } + + private async regenStaleNodes() { + const staleIds = this.nodes.filter(n => n.stale).map(n => n.id); + if (staleIds.length === 0) return; + + try { + const res = await fetch(`${this.basePath}api/campaign/flow/regen-nodes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowId: this.currentFlowId, + nodeIds: staleIds, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as any).error || `HTTP ${res.status}`); + } + + const data = await res.json(); + if (data.flow) { + this.nodes = data.flow.nodes.map((n: any) => ({ ...n, position: { ...n.position }, data: { ...n.data } })); + this.edges = data.flow.edges.map((e: any) => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })); + this.drawCanvasContent(); + this.updateToolbarStaleCount(); + } + } catch (e: any) { + console.error('[CampaignPlanner] Regen stale error:', e.message); + alert('Failed to regenerate: ' + e.message); + } + } + + private async aiFillNode(nodeId: string) { + try { + const res = await fetch(`${this.basePath}api/campaign/flow/ai-fill-node`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowId: this.currentFlowId, + nodeId, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as any).error || `HTTP ${res.status}`); + } + + const data = await res.json(); + if (data.node) { + const idx = this.nodes.findIndex(n => n.id === nodeId); + if (idx >= 0) { + this.nodes[idx] = { ...data.node, position: { ...data.node.position }, data: { ...data.node.data } }; + this.exitInlineEdit(); + this.drawCanvasContent(); + this.scheduleSave(); + } + } + } catch (e: any) { + console.error('[CampaignPlanner] AI fill error:', e.message); + alert('Failed to AI fill: ' + e.message); + } + } + + private updateBriefPanel() { + const panel = this.shadow.getElementById('brief-panel'); + const genBtn = panel?.querySelector('#brief-generate') as HTMLButtonElement | null; + if (genBtn) { + genBtn.disabled = this.briefLoading; + genBtn.textContent = this.briefLoading ? 'Generating...' : 'Generate Graph'; + } + } + + private updateToolbarStaleCount() { + const btn = this.shadow.getElementById('regen-stale-btn'); + const count = this.getStaleCount(); + if (btn) { + btn.style.display = count > 0 ? '' : 'none'; + btn.textContent = `\u26a0 Regen ${count} Stale`; + } + } + // ── Port rendering ── private renderPortsSvg(node: CampaignPlannerNode): string { @@ -1509,6 +1836,10 @@ class FolkCampaignPlanner extends HTMLElement { sourcePortKind = 'publish'; targetPortKind = 'target-in'; break; + case 'feeds': + sourcePortKind = 'feeds-out'; + targetPortKind = 'feeds-in'; + break; default: sourcePortKind = 'publish'; targetPortKind = 'content-in'; @@ -1522,6 +1853,7 @@ class FolkCampaignPlanner extends HTMLElement { const style = EDGE_STYLES[edge.type] || { width: 2, dash: '', animated: false }; const cssClass = edge.type === 'publish' ? 'edge-path-publish' : edge.type === 'sequence' ? 'edge-path-sequence' + : edge.type === 'feeds' ? 'edge-path-feeds' : 'edge-path-target'; // Build bezier path @@ -1602,6 +1934,38 @@ class FolkCampaignPlanner extends HTMLElement { this.addNode('audience', cx, cy); }); + // Brief panel + this.shadow.getElementById('toggle-brief')?.addEventListener('click', () => { + this.briefPanelOpen = !this.briefPanelOpen; + const panel = this.shadow.getElementById('brief-panel'); + if (panel) panel.classList.toggle('open', this.briefPanelOpen); + }); + this.shadow.getElementById('close-brief')?.addEventListener('click', () => { + this.briefPanelOpen = false; + const panel = this.shadow.getElementById('brief-panel'); + if (panel) panel.classList.remove('open'); + }); + this.shadow.getElementById('brief-text')?.addEventListener('input', (e) => { + this.briefText = (e.target as HTMLTextAreaElement).value; + }); + this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + const checked: string[] = []; + this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]:checked').forEach(c => { + checked.push((c as HTMLInputElement).value); + }); + this.briefPlatforms = checked; + }); + }); + this.shadow.getElementById('brief-generate')?.addEventListener('click', () => { + this.generateFromBrief(); + }); + + // Regen stale + this.shadow.getElementById('regen-stale-btn')?.addEventListener('click', () => { + this.regenStaleNodes(); + }); + // Postiz panel this.shadow.getElementById('toggle-postiz')?.addEventListener('click', () => { this.postizOpen = !this.postizOpen; diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index d62e06f4..63822fa7 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -19,7 +19,7 @@ import type { RSpaceModule } from "../../shared/module"; import type { SyncServer } from "../../server/local-first/sync-server"; import { renderLanding } from "./landing"; import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data"; -import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft } from "./schemas"; +import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft, type CampaignPlannerNode, type CampaignEdge, type CampaignFlow, type GoalNodeData, type MessageNodeData, type ToneNodeData, type PostNodeData, type PhaseNodeData, type PlatformNodeData } from "./schemas"; import { generateImageFromPrompt, downloadAndSaveImage, @@ -1913,8 +1913,413 @@ routes.delete("/api/campaign/wizard/:id", (c) => { return c.json({ ok: true }); }); +// ── Campaign Flow (graph-based) API ── + +routes.post("/api/campaign/flow/from-brief", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; + if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); + + const body = await c.req.json(); + const { rawBrief, platforms } = body; + if (!rawBrief || typeof rawBrief !== "string" || rawBrief.trim().length < 10) { + return c.json({ error: "rawBrief required (min 10 characters)" }, 400); + } + const selectedPlatforms: string[] = Array.isArray(platforms) && platforms.length > 0 + ? platforms + : ["x", "linkedin", "instagram", "threads", "bluesky"]; + + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { responseMimeType: "application/json" } as any, + }); + + const prompt = `You are a social media campaign strategist. Analyze this campaign brief and return structured campaign data. + +Brief: +""" +${rawBrief.trim()} +""" + +Platforms to target: ${selectedPlatforms.join(', ')} + +Return JSON: +{ + "goal": { "title": "Campaign title", "objective": "What we want to achieve", "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD" }, + "messages": [ + { "text": "Key message 1", "priority": "high" }, + { "text": "Key message 2", "priority": "medium" } + ], + "tone": { "tone": "professional | casual | urgent | inspirational", "style": "event-promo | product-launch | awareness | community", "audience": "Target audience description" }, + "phases": [ + { "name": "phase-slug", "label": "Phase Label", "days": "Day 1-3", "color": "#hex" } + ], + "posts": [ + { "platform": "x", "postType": "text", "content": "Post content", "scheduledAt": "2026-04-20T09:00:00", "hashtags": ["tag1"], "phase": 0, "phaseLabel": "Phase Label" } + ] +} + +Rules: +- 2-4 key messages, 3-5 phases +- Generate posts matching cadence for each phase × platform +- Use today (${new Date().toISOString().split('T')[0]}) as reference +- Respect platform char limits (x=280, linkedin=1300, threads=500, bluesky=300) +- Phase colors: use distinct hex colors (#6366f1, #10b981, #f59e0b, #ef4444, #8b5cf6)`; + + try { + const result = await model.generateContent(prompt); + const text = result.response.text(); + const parsed = JSON.parse(text); + + const now = Date.now(); + const flowId = `flow-${now}-${Math.random().toString(36).slice(2, 8)}`; + const nodes: CampaignPlannerNode[] = []; + const edges: CampaignEdge[] = []; + + // Col 1: Input nodes (x=50) + const goalId = `goal-${now}-g`; + nodes.push({ + id: goalId, + type: 'goal', + position: { x: 50, y: 50 }, + data: { + label: parsed.goal?.title || 'Campaign Goal', + objective: parsed.goal?.objective || '', + startDate: parsed.goal?.startDate || '', + endDate: parsed.goal?.endDate || '', + } as GoalNodeData, + }); + + const messageIds: string[] = []; + (parsed.messages || []).forEach((msg: any, i: number) => { + const mid = `message-${now}-${i}`; + messageIds.push(mid); + nodes.push({ + id: mid, + type: 'message', + position: { x: 50, y: 220 + i * 130 }, + data: { + label: (msg.text || '').substring(0, 40), + text: msg.text || '', + priority: msg.priority || 'medium', + } as MessageNodeData, + }); + }); + + const toneId = `tone-${now}-t`; + nodes.push({ + id: toneId, + type: 'tone', + position: { x: 50, y: 220 + messageIds.length * 130 }, + data: { + label: `${parsed.tone?.tone || 'Professional'} tone`, + tone: parsed.tone?.tone || 'professional', + style: parsed.tone?.style || 'awareness', + audience: parsed.tone?.audience || '', + sizeEstimate: '', + } as ToneNodeData, + }); + + // Col 2: Phase containers (x=350) + const phaseIds: string[] = []; + (parsed.phases || []).forEach((phase: any, i: number) => { + const pid = `phase-${now}-${i}`; + phaseIds.push(pid); + nodes.push({ + id: pid, + type: 'phase', + position: { x: 350, y: 50 + i * 320 }, + data: { + label: phase.label || `Phase ${i + 1}`, + dateRange: phase.days || '', + color: phase.color || '#6366f1', + progress: 0, + childNodeIds: [], + size: { w: 400, h: 280 }, + } as PhaseNodeData, + }); + + // goal → phase (feeds) + edges.push({ + id: `e-${now}-goal-phase-${i}`, + from: goalId, + to: pid, + type: 'feeds', + }); + }); + + // Col 3: Post nodes (inside phases) + platform nodes (x=850) + const platformNodeMap = new Map(); + let postIdx = 0; + (parsed.posts || []).forEach((post: any) => { + const postId = `post-${now}-${postIdx++}`; + const phaseIdx = typeof post.phase === 'number' ? post.phase : 0; + const phaseY = 50 + phaseIdx * 320; + const postY = phaseY + 60 + (postIdx % 5) * 140; + + nodes.push({ + id: postId, + type: 'post', + position: { x: 400 + (postIdx % 3) * 260, y: postY }, + data: { + label: (post.content || '').split('\n')[0].substring(0, 40) || 'Post', + platform: post.platform || 'x', + postType: post.postType || 'text', + content: post.content || '', + scheduledAt: post.scheduledAt || '', + status: 'draft', + hashtags: post.hashtags || [], + } as PostNodeData, + }); + + // message → post (feeds) + if (messageIds.length > 0) { + const msgIdx = postIdx % messageIds.length; + edges.push({ + id: `e-${now}-msg-post-${postIdx}`, + from: messageIds[msgIdx], + to: postId, + type: 'feeds', + }); + } + + // tone → post (feeds) + edges.push({ + id: `e-${now}-tone-post-${postIdx}`, + from: toneId, + to: postId, + type: 'feeds', + }); + + // Ensure platform node exists + post → platform (publish) + const plat = post.platform || 'x'; + if (!platformNodeMap.has(plat)) { + const platId = `platform-${now}-${plat}`; + platformNodeMap.set(plat, platId); + const platIdx = platformNodeMap.size - 1; + nodes.push({ + id: platId, + type: 'platform', + position: { x: 950, y: 50 + platIdx * 120 }, + data: { + label: plat.charAt(0).toUpperCase() + plat.slice(1), + platform: plat, + handle: '', + } as PlatformNodeData, + }); + } + edges.push({ + id: `e-${now}-post-plat-${postIdx}`, + from: postId, + to: platformNodeMap.get(plat)!, + type: 'publish', + }); + }); + + // Save flow to Automerge + const flow: CampaignFlow = { + id: flowId, + name: parsed.goal?.title || 'Campaign Flow', + nodes, + edges, + createdAt: now, + updatedAt: now, + createdBy: null, + }; + + const docId = socialsDocId(dataSpace); + ensureDoc(dataSpace); + _syncServer!.changeDoc(docId, `create campaign flow ${flowId}`, (d) => { + if (!d.campaignFlows) d.campaignFlows = {} as any; + (d.campaignFlows as any)[flowId] = flow; + d.activeFlowId = flowId; + }); + + return c.json(flow, 201); + } catch (e: any) { + console.error("[rSocials] Flow from-brief error:", e.message); + return c.json({ error: "Failed to generate flow: " + (e.message || "unknown") }, 502); + } +}); + +routes.post("/api/campaign/flow/regen-nodes", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; + if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); + + const body = await c.req.json(); + const { flowId, nodeIds } = body; + if (!flowId || !Array.isArray(nodeIds) || nodeIds.length === 0) { + return c.json({ error: "flowId and nodeIds[] required" }, 400); + } + + const docId = socialsDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const flow = doc.campaignFlows?.[flowId]; + if (!flow) return c.json({ error: "Flow not found" }, 404); + + // Gather input context from upstream nodes via feeds edges + const inputNodes = flow.nodes.filter(n => n.type === 'goal' || n.type === 'message' || n.type === 'tone'); + const inputContext = { + goals: inputNodes.filter(n => n.type === 'goal').map(n => n.data), + messages: inputNodes.filter(n => n.type === 'message').map(n => n.data), + tones: inputNodes.filter(n => n.type === 'tone').map(n => n.data), + }; + + const targetNodes = flow.nodes.filter(n => nodeIds.includes(n.id)); + if (targetNodes.length === 0) return c.json({ error: "No matching nodes found" }, 404); + + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { responseMimeType: "application/json" } as any, + }); + + const prompt = `Regenerate content for these social media posts based on updated campaign inputs. + +Campaign inputs: +${JSON.stringify(inputContext, null, 2)} + +Posts to regenerate (keep platform, postType, scheduledAt — only update content and hashtags): +${JSON.stringify(targetNodes.map(n => ({ id: n.id, type: n.type, data: n.data })), null, 2)} + +Return JSON array with same structure, updated content: +[{ "id": "node-id", "content": "new content", "hashtags": ["tag"], "label": "short label" }] + +Rules: +- Respect platform char limits (x=280, linkedin=1300, threads=500, bluesky=300) +- Incorporate the campaign goal, key messages, and tone +- Keep scheduling and platform unchanged`; + + try { + const result = await model.generateContent(prompt); + const text = result.response.text(); + const updates = JSON.parse(text); + + _syncServer!.changeDoc(docId, `regen stale nodes in ${flowId}`, (d) => { + const f = d.campaignFlows?.[flowId]; + if (!f) return; + for (const upd of updates) { + const node = f.nodes.find((n: any) => n.id === upd.id); + if (!node) continue; + if (node.type === 'post') { + const pd = node.data as any; + if (upd.content) pd.content = upd.content; + if (upd.hashtags) pd.hashtags = upd.hashtags; + if (upd.label) pd.label = upd.label; + } + (node as any).stale = false; + (node as any).staleReason = ''; + } + f.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json({ flow: updated.campaignFlows[flowId] }); + } catch (e: any) { + console.error("[rSocials] Flow regen error:", e.message); + return c.json({ error: "Failed to regenerate: " + (e.message || "unknown") }, 502); + } +}); + +routes.post("/api/campaign/flow/ai-fill-node", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; + if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); + + const body = await c.req.json(); + const { flowId, nodeId } = body; + if (!flowId || !nodeId) return c.json({ error: "flowId and nodeId required" }, 400); + + const docId = socialsDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const flow = doc.campaignFlows?.[flowId]; + if (!flow) return c.json({ error: "Flow not found" }, 404); + + const node = flow.nodes.find(n => n.id === nodeId); + if (!node) return c.json({ error: "Node not found" }, 404); + + // Read upstream input nodes connected via feeds edges + const upstreamIds = flow.edges + .filter(e => e.to === nodeId && e.type === 'feeds') + .map(e => e.from); + const upstreamNodes = flow.nodes.filter(n => upstreamIds.includes(n.id)); + + const inputContext = { + goals: upstreamNodes.filter(n => n.type === 'goal').map(n => n.data), + messages: upstreamNodes.filter(n => n.type === 'message').map(n => n.data), + tones: upstreamNodes.filter(n => n.type === 'tone').map(n => n.data), + }; + + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { responseMimeType: "application/json" } as any, + }); + + const prompt = `Generate content for this social media post based on campaign context. + +Campaign inputs: +${JSON.stringify(inputContext, null, 2)} + +Target node to fill: +${JSON.stringify({ id: node.id, type: node.type, data: node.data }, null, 2)} + +Return JSON: { "content": "post content", "hashtags": ["tag"], "label": "short label" } + +Platform limits: x=280, linkedin=1300, threads=500, bluesky=300. Incorporate goal, messages, and tone.`; + + try { + const result = await model.generateContent(prompt); + const text = result.response.text(); + const generated = JSON.parse(text); + + _syncServer!.changeDoc(docId, `ai fill node ${nodeId}`, (d) => { + const f = d.campaignFlows?.[flowId]; + if (!f) return; + const n = f.nodes.find((nd: any) => nd.id === nodeId); + if (!n || n.type !== 'post') return; + const pd = n.data as any; + if (generated.content) pd.content = generated.content; + if (generated.hashtags) pd.hashtags = generated.hashtags; + if (generated.label) pd.label = generated.label; + (n as any).stale = false; + (n as any).staleReason = ''; + f.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + const updatedNode = updated.campaignFlows[flowId]?.nodes.find(n => n.id === nodeId); + return c.json({ node: updatedNode }); + } catch (e: any) { + console.error("[rSocials] AI fill error:", e.message); + return c.json({ error: "Failed to generate: " + (e.message || "unknown") }, 502); + } +}); + // ── Page routes (inject web components) ── +routes.get("/campaign-flow", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `Campaign Flow — rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); +}); + routes.get("/campaign-wizard/:id", (c) => { const space = c.req.param("space") || "demo"; const wizardId = c.req.param("id"); diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index 6ffad318..a40133f8 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -56,7 +56,7 @@ export interface Campaign { // ── Campaign planner (flow canvas) types ── -export type CampaignNodeType = 'post' | 'thread' | 'platform' | 'audience' | 'phase'; +export type CampaignNodeType = 'post' | 'thread' | 'platform' | 'audience' | 'phase' | 'goal' | 'message' | 'tone'; export interface PostNodeData { label: string; @@ -97,14 +97,37 @@ export interface PhaseNodeData { size: { w: number; h: number }; } +export interface GoalNodeData { + label: string; + objective: string; + startDate: string; + endDate: string; +} + +export interface MessageNodeData { + label: string; + text: string; + priority: 'high' | 'medium' | 'low'; +} + +export interface ToneNodeData { + label: string; + tone: string; + style: string; + audience: string; + sizeEstimate: string; +} + export interface CampaignPlannerNode { id: string; type: CampaignNodeType; position: { x: number; y: number }; - data: PostNodeData | ThreadNodeData | PlatformNodeData | AudienceNodeData | PhaseNodeData; + data: PostNodeData | ThreadNodeData | PlatformNodeData | AudienceNodeData | PhaseNodeData | GoalNodeData | MessageNodeData | ToneNodeData; + stale?: boolean; + staleReason?: string; } -export type CampaignEdgeType = 'publish' | 'sequence' | 'target'; +export type CampaignEdgeType = 'publish' | 'sequence' | 'target' | 'feeds'; export interface CampaignEdge { id: string; @@ -455,12 +478,12 @@ export interface SocialsDoc { export const socialsSchema: DocSchema = { module: 'socials', collection: 'data', - version: 7, + version: 8, init: (): SocialsDoc => ({ meta: { module: 'socials', collection: 'data', - version: 7, + version: 8, spaceSlug: '', createdAt: Date.now(), }, @@ -480,7 +503,7 @@ export const socialsSchema: DocSchema = { if (!doc.pendingApprovals) (doc as any).pendingApprovals = {}; if (!doc.campaignWizards) (doc as any).campaignWizards = {}; if (!doc.newsletterDrafts) (doc as any).newsletterDrafts = {}; - if (doc.meta) doc.meta.version = 7; + if (doc.meta) doc.meta.version = 8; return doc; }, };