Hermy HQ: self-hostable mission-control template for your Hermes agent
A Next.js 16 + Prisma/Postgres dashboard that pairs with a local Hermes agent over a Postgres message bus: dispatch work, approve side-effecting actions, browse the agent's memory, and watch it run. Ships with an agent-onboarding prompt (ONBOARDING.md) so your Hermes can install it for you step by step. All secrets are env-configured; nothing sensitive is bundled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import OfficeView from "@/components/OfficeView";
|
||||
|
||||
interface AgentActivity {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
role: string;
|
||||
status: "idle" | "working" | "error" | "offline";
|
||||
currentTask?: string;
|
||||
lastActive?: string;
|
||||
tasksCompleted: number;
|
||||
totalCost: number;
|
||||
recentActivity: AgentActivity[];
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { color: string; dot: string; label: string; pulse?: boolean }> = {
|
||||
idle: { color: "var(--warn)", dot: "var(--warn)", label: "Idle" },
|
||||
working: { color: "var(--accent)", dot: "var(--accent)", label: "Working", pulse: true },
|
||||
error: { color: "var(--down)", dot: "var(--down)", label: "Error" },
|
||||
offline: { color: "var(--text-3)", dot: "var(--text-4)", label: "Offline" },
|
||||
online: { color: "var(--up)", dot: "var(--up)", label: "Online", pulse: true },
|
||||
active: { color: "var(--up)", dot: "var(--up)", label: "Active", pulse: true },
|
||||
mixed: { color: "var(--warn)", dot: "var(--warn)", label: "Partial" },
|
||||
};
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
max: "from-amber-500/20 to-amber-600/5 border-amber-500/20",
|
||||
sage: "from-sky-500/20 to-sky-600/5 border-sky-500/20",
|
||||
knox: "from-emerald-500/20 to-emerald-600/5 border-emerald-500/20",
|
||||
nova: "from-purple-500/20 to-purple-600/5 border-purple-500/20",
|
||||
pixel: "from-blue-500/20 to-blue-600/5 border-blue-500/20",
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function AgentCard({ agent, isExpanded, onToggle }: { agent: Agent; isExpanded: boolean; onToggle: () => void }) {
|
||||
const status = statusConfig[agent.status] || statusConfig.offline;
|
||||
|
||||
return (
|
||||
<div className="panel panel-interactive overflow-hidden">
|
||||
{/* Main card */}
|
||||
<div className="p-5 cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-start gap-3.5">
|
||||
{/* Avatar */}
|
||||
<div className="w-12 h-12 rounded-[var(--r-md)] flex items-center justify-center text-2xl shrink-0"
|
||||
style={{ background: "var(--surface-2)", border: "1px solid var(--line)" }}>
|
||||
{agent.emoji}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex w-2 h-2 shrink-0">
|
||||
{status.pulse && <span className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping" style={{ background: status.dot }} />}
|
||||
<span className="relative inline-flex w-2 h-2 rounded-full" style={{ background: status.dot }} />
|
||||
</span>
|
||||
<h3 className="text-[14px] font-semibold text-[var(--text)]">{agent.name}</h3>
|
||||
<span className="text-[10px] font-medium" style={{ color: status.color }}>{status.label}</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--text-3)] mt-1">{agent.role}</p>
|
||||
|
||||
{/* Current task */}
|
||||
{agent.currentTask && agent.status === "working" && (
|
||||
<p className="text-[12px] mt-2 truncate" style={{ color: "var(--accent)" }}>{agent.currentTask}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="text-right shrink-0">
|
||||
<div className="num text-[22px] font-semibold text-[var(--text)] leading-none">{agent.tasksCompleted}</div>
|
||||
<div className="eyebrow mt-1.5">tasks</div>
|
||||
{agent.lastActive && (
|
||||
<div className="num text-[10px] text-[var(--text-4)] mt-1">{timeAgo(agent.lastActive)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded activity feed */}
|
||||
{isExpanded && (
|
||||
<div className="px-5 py-4 space-y-2.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<h4 className="eyebrow">Recent Activity</h4>
|
||||
{agent.recentActivity.length === 0 ? (
|
||||
<p className="text-[12px] text-[var(--text-3)] py-2">No activity yet</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{agent.recentActivity.slice(0, 10).map((activity, i) => (
|
||||
<div key={i} className="flex items-start gap-2.5 text-[12px]">
|
||||
<span className="num text-[var(--text-4)] shrink-0 w-14">{timeAgo(activity.timestamp)}</span>
|
||||
<span className="text-[var(--text-2)]">{activity.action}</span>
|
||||
{activity.result && (
|
||||
<span className="text-[var(--text-3)] ml-auto truncate max-w-[200px]">{activity.result}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Live Agent Chat ───────────────────────────────────────
|
||||
function AgentChat({ agent, onClose }: { agent: Agent; onClose: () => void }) {
|
||||
const [input, setInput] = useState("");
|
||||
const [msgs, setMsgs] = useState<{ role: "user"|"assistant"; content: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [msgs]);
|
||||
|
||||
async function send() {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
setInput("");
|
||||
const newMsgs = [...msgs, { role: "user" as const, content: text }];
|
||||
setMsgs(newMsgs);
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch("/api/agent-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agentId: agent.id, message: text, history: msgs }),
|
||||
});
|
||||
const d = await r.json() as { reply: string };
|
||||
setMsgs([...newMsgs, { role: "assistant", content: d.reply }]);
|
||||
} catch {
|
||||
setMsgs([...newMsgs, { role: "assistant", content: "Sorry, something went wrong. Try again." }]);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const agentColor = roleColors[agent.id]?.split(" ")[0]?.replace("from-","text-")?.replace("/20","") || "text-[var(--text-3)]";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="elevated w-full max-w-lg overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="text-2xl">{agent.emoji}</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-[var(--text)]">{agent.name}</div>
|
||||
<div className="text-[12px] text-[var(--text-3)]">{agent.role}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="ml-auto text-[var(--text-3)] hover:text-[var(--text)] transition-colors text-xl leading-none">×</button>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="h-80 overflow-y-auto p-4 space-y-3 flex flex-col" style={{ background: "var(--surface-1)" }}>
|
||||
{msgs.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-[var(--text-3)] text-[13px] text-center">Ask {agent.name} anything.<br/>They're ready.</p>
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div className="max-w-[80%] rounded-[var(--r-md)] px-3.5 py-2 text-[13px] leading-relaxed"
|
||||
style={m.role === "user"
|
||||
? { background: "var(--surface-3)", color: "var(--text)" }
|
||||
: { background: "var(--surface-2)", border: "1px solid var(--line)", color: "var(--text-2)" }}>
|
||||
{m.role === "assistant" && <span className="text-xs mr-1">{agent.emoji}</span>}
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-[var(--r-md)] px-3.5 py-2" style={{ background: "var(--surface-2)", border: "1px solid var(--line)" }}>
|
||||
<span className="text-[var(--text-3)] text-[13px]">{agent.emoji} thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
{/* Input */}
|
||||
<div className="flex gap-2 p-3" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && !e.shiftKey && send()}
|
||||
placeholder={`Message ${agent.name}…`}
|
||||
className="flex-1 rounded-full px-4 py-2 text-[13px] text-[var(--text)] focus:outline-none transition-colors"
|
||||
style={{ background: "var(--surface-1)", border: "1px solid var(--line)" }}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!input.trim() || loading}
|
||||
className="btn-primary px-4 py-2 text-[13px]"
|
||||
>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||
const [view, setView] = useState<"cards" | "office">("office");
|
||||
const [chatAgent, setChatAgent] = useState<Agent | null>(null);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/agents");
|
||||
const data = await res.json();
|
||||
setAgents(Array.isArray(data) ? data : []);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents();
|
||||
const interval = setInterval(loadAgents, 10000); // poll every 10s
|
||||
return () => clearInterval(interval);
|
||||
}, [loadAgents]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative min-h-screen p-8">
|
||||
<div className="relative z-10 w-full mx-auto grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="sk h-32 rounded-[var(--r-lg)]" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxAgent = agents.find(a => a.id === "max");
|
||||
const teamAgents = agents.filter(a => a.id !== "max");
|
||||
const online = agents.filter(a => a.status !== "offline").length;
|
||||
const working = agents.filter(a => a.status === "working").length;
|
||||
const totalTasks = agents.reduce((sum, a) => sum + a.tasksCompleted, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 w-full mx-auto text-[var(--text)] p-8 pb-16 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="hq-rise flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-2.5">Agent HQ</div>
|
||||
<h1 className="text-[32px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]">Your AI Team</h1>
|
||||
<p className="text-[13px] text-[var(--text-3)] mt-3">Working 24/7</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Stats */}
|
||||
<div className="flex gap-7 text-center">
|
||||
<div>
|
||||
<div className="num text-[22px] font-semibold leading-none" style={{ color: "var(--up)" }}>{online}<span className="text-[var(--text-4)]">/{agents.length}</span></div>
|
||||
<div className="eyebrow mt-1.5">Online</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="num text-[22px] font-semibold leading-none" style={{ color: "var(--accent)" }}>{working}</div>
|
||||
<div className="eyebrow mt-1.5">Working</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="num text-[22px] font-semibold leading-none text-[var(--text)]">{totalTasks}</div>
|
||||
<div className="eyebrow mt-1.5">Total Tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* View toggle */}
|
||||
<div className="flex rounded-full p-1 gap-1" style={{ border: "1px solid var(--line)" }}>
|
||||
<button
|
||||
onClick={() => setView("office")}
|
||||
className={`px-3.5 py-1.5 rounded-full text-[12px] font-medium transition-colors ${
|
||||
view === "office"
|
||||
? "bg-white/[0.08] text-[var(--text)]"
|
||||
: "text-[var(--text-3)] hover:text-[var(--text-2)]"
|
||||
}`}
|
||||
>
|
||||
Office
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("cards")}
|
||||
className={`px-3.5 py-1.5 rounded-full text-[12px] font-medium transition-colors ${
|
||||
view === "cards"
|
||||
? "bg-white/[0.08] text-[var(--text)]"
|
||||
: "text-[var(--text-3)] hover:text-[var(--text-2)]"
|
||||
}`}
|
||||
>
|
||||
Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Agent Chat Modal */}
|
||||
{chatAgent && <AgentChat agent={chatAgent} onClose={() => setChatAgent(null)} />}
|
||||
|
||||
{/* Office View */}
|
||||
{view === "office" && (
|
||||
<>
|
||||
<OfficeView agents={agents} />
|
||||
{/* Chat quick-launch strip */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{agents.filter(a => a.id !== "max").map(a => (
|
||||
<button key={a.id} onClick={() => setChatAgent(a)}
|
||||
className="flex items-center gap-2 px-3.5 py-1.5 rounded-full text-[12px] text-[var(--text-2)] transition-colors panel-interactive"
|
||||
style={{ background: "var(--surface-1)", border: "1px solid var(--line)" }}>
|
||||
<span>{a.emoji}</span> Chat with {a.name}
|
||||
</button>
|
||||
))}
|
||||
{agents.find(a => a.id === "max") && (
|
||||
<button onClick={() => setChatAgent(agents.find(a => a.id === "max")!)}
|
||||
className="flex items-center gap-2 px-3.5 py-1.5 rounded-full text-[12px] transition-colors"
|
||||
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 28%, transparent)" }}>
|
||||
🐺 Chat with Max
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cards View */}
|
||||
{view === "cards" && (
|
||||
<>
|
||||
{/* Chief of Staff (Max) — full width */}
|
||||
{maxAgent && (
|
||||
<AgentCard
|
||||
agent={maxAgent}
|
||||
isExpanded={expandedAgent === maxAgent.id}
|
||||
onToggle={() => setExpandedAgent(expandedAgent === maxAgent.id ? null : maxAgent.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{teamAgents.map(agent => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isExpanded={expandedAgent === agent.id}
|
||||
onToggle={() => setExpandedAgent(expandedAgent === agent.id ? null : agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Org chart visual */}
|
||||
<div className="pt-6" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<div className="eyebrow mb-5">Team Structure</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2.5 rounded-[var(--r-md)] px-4 py-2.5"
|
||||
style={{ background: "color-mix(in srgb, var(--accent) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 24%, transparent)" }}>
|
||||
<span className="text-xl">🐺</span>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-[var(--text)]">Max</div>
|
||||
<div className="text-[10px] text-[var(--text-3)]">Chief of Staff · Orchestrator</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-6" style={{ background: "var(--line-strong)" }} />
|
||||
<div className="flex items-center gap-0">
|
||||
<div className="w-32 h-px" style={{ background: "var(--line-strong)" }} />
|
||||
<div className="w-px h-4" style={{ background: "var(--line-strong)" }} />
|
||||
<div className="w-32 h-px" style={{ background: "var(--line-strong)" }} />
|
||||
<div className="w-px h-4" style={{ background: "var(--line-strong)" }} />
|
||||
<div className="w-32 h-px" style={{ background: "var(--line-strong)" }} />
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{teamAgents.map(agent => (
|
||||
<div key={agent.id} className="flex items-center gap-2.5 rounded-[var(--r-md)] px-3.5 py-2.5"
|
||||
style={{ background: "var(--surface-1)", border: "1px solid var(--line)", opacity: agent.status === "offline" ? 0.5 : 1 }}>
|
||||
<span className="text-lg">{agent.emoji}</span>
|
||||
<div>
|
||||
<div className="text-[12px] font-semibold text-[var(--text)]">{agent.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-3)]">{agent.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET: Read messages for a specific agent (or all)
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const agent = searchParams.get("agent");
|
||||
const unreadOnly = searchParams.get("unread") === "true";
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (agent) {
|
||||
where.OR = [{ toAgent: agent }, { toAgent: "all" }];
|
||||
}
|
||||
if (unreadOnly) {
|
||||
where.read = false;
|
||||
}
|
||||
|
||||
const messages = await prisma.agentBusMessage.findMany({
|
||||
where,
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// Reverse to return in chronological order (oldest first, like the original .slice(-50))
|
||||
const formatted = messages.reverse().map((m: any) => ({
|
||||
id: m.id,
|
||||
from: m.fromAgent,
|
||||
to: m.toAgent,
|
||||
type: m.type,
|
||||
content: m.content,
|
||||
metadata: m.metadata || {},
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
read: m.read,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ messages: formatted });
|
||||
}
|
||||
|
||||
// POST: Send a message between agents
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
|
||||
const msg = await prisma.agentBusMessage.create({
|
||||
data: {
|
||||
fromAgent: body.from || "unknown",
|
||||
toAgent: body.to || "max",
|
||||
type: body.type || "insight",
|
||||
content: body.content || "",
|
||||
metadata: body.metadata || {},
|
||||
read: false,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, id: msg.id });
|
||||
}
|
||||
|
||||
// PATCH: Mark messages as read
|
||||
export async function PATCH(req: Request) {
|
||||
const { messageIds } = await req.json();
|
||||
|
||||
await prisma.agentBusMessage.updateMany({
|
||||
where: { id: { in: messageIds } },
|
||||
data: { read: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
interface AgentChatRequest {
|
||||
agentId: string;
|
||||
message: string;
|
||||
history?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}
|
||||
|
||||
interface AgentChatResponse {
|
||||
reply: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const AGENT_PROMPTS: Record<string, string> = {
|
||||
max: "You are Max 🐺, an AI executive assistant and COO-level strategist helping the user run their business. The user is a founder and content creator who runs AI trading bots. Be sharp, concise, strategic. Give real actionable advice.",
|
||||
sage: "You are Sage 🌿, X/Twitter content specialist for the user. You write viral tweets in their voice — conversational, sharp, specific. Focus on hooks that make people stop scrolling. No fluff.",
|
||||
knox: "You are Knox 🔐, operations and trading analyst for the user. You analyze Polymarket and Hyperliquid trading performance, spot patterns, suggest strategy improvements. Be data-driven and direct.",
|
||||
nova: "You are Nova ⭐, YouTube strategy specialist for the user. You write scripts, hooks, thumbnails, titles. Think Mr Beast structure applied to the user's niche.",
|
||||
pixel: "You are Pixel 🎨, web app product specialist for the user's products. You find UX improvements, feature ideas, competitor gaps. Think product manager + growth hacker.",
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse<AgentChatResponse | { error: string }>> {
|
||||
try {
|
||||
const body: AgentChatRequest = await request.json();
|
||||
const { agentId, message, history = [] } = body;
|
||||
|
||||
// Validate inputs
|
||||
if (!agentId || !message) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing agentId or message' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!AGENT_PROMPTS[agentId]) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown agent: ${agentId}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const systemPrompt = AGENT_PROMPTS[agentId];
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('OPENROUTER_API_KEY not configured');
|
||||
return NextResponse.json(
|
||||
{ error: 'API configuration error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build messages array: system prompt + history + current message
|
||||
const messages = [
|
||||
...history,
|
||||
{ role: 'user' as const, content: message },
|
||||
];
|
||||
|
||||
// Call OpenRouter API
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://your-app.vercel.app',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'anthropic/claude-haiku-4-5',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages,
|
||||
],
|
||||
max_tokens: 800,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('OpenRouter API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get response from AI model' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const reply = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
if (!reply) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No response from AI model' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
reply,
|
||||
agentId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Agent chat error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
// Default agent roster
|
||||
const DEFAULT_AGENTS = [
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
emoji: "\uD83D\uDC3A",
|
||||
role: "Chief of Staff \u00B7 Orchestrator",
|
||||
status: "online",
|
||||
tasksCompleted: 0,
|
||||
totalCost: 0,
|
||||
recentActivity: [],
|
||||
},
|
||||
{
|
||||
id: "sage",
|
||||
name: "Sage",
|
||||
emoji: "\uD83C\uDF3F",
|
||||
role: "X Content Specialist \u00B7 Trend Scout",
|
||||
status: "idle",
|
||||
tasksCompleted: 0,
|
||||
totalCost: 0,
|
||||
recentActivity: [],
|
||||
},
|
||||
{
|
||||
id: "knox",
|
||||
name: "Knox",
|
||||
emoji: "\uD83D\uDD10",
|
||||
role: "Trading Operations \u00B7 Bot Monitor",
|
||||
status: "idle",
|
||||
tasksCompleted: 0,
|
||||
totalCost: 0,
|
||||
recentActivity: [],
|
||||
},
|
||||
{
|
||||
id: "nova",
|
||||
name: "Nova",
|
||||
emoji: "\u2B50",
|
||||
role: "YouTube Strategy \u00B7 Content Research",
|
||||
status: "idle",
|
||||
tasksCompleted: 0,
|
||||
totalCost: 0,
|
||||
recentActivity: [],
|
||||
},
|
||||
{
|
||||
id: "pixel",
|
||||
name: "Pixel",
|
||||
emoji: "\uD83C\uDFA8",
|
||||
role: "Web App Specialist \u00B7 Product Ideas",
|
||||
status: "idle",
|
||||
tasksCompleted: 0,
|
||||
totalCost: 0,
|
||||
recentActivity: [],
|
||||
},
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const states = await prisma.agentState.findMany();
|
||||
const stateMap: Record<string, any> = {};
|
||||
for (const s of states) {
|
||||
stateMap[s.id] = s;
|
||||
}
|
||||
|
||||
const agents = DEFAULT_AGENTS.map((agent) => {
|
||||
const s = stateMap[agent.id] || {};
|
||||
return {
|
||||
...agent,
|
||||
status: s.status || agent.status,
|
||||
currentTask: s.currentTask || undefined,
|
||||
lastActive: s.lastActive || undefined,
|
||||
tasksCompleted: s.tasksCompleted || agent.tasksCompleted,
|
||||
totalCost: s.totalCost || agent.totalCost,
|
||||
recentActivity: s.recentActivity || agent.recentActivity,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(agents, {
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Agents API error:", error);
|
||||
return NextResponse.json(DEFAULT_AGENTS, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST to update agent state (called by cron jobs)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { agentId, action, status, currentTask } = body;
|
||||
|
||||
if (!agentId) {
|
||||
return NextResponse.json({ error: "agentId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find the default agent info for name/emoji/role
|
||||
const defaultAgent = DEFAULT_AGENTS.find((a) => a.id === agentId);
|
||||
|
||||
// Get existing state or create defaults
|
||||
let existing = await prisma.agentState.findUnique({ where: { id: agentId } });
|
||||
|
||||
const recentActivity = (existing?.recentActivity as any[]) || [];
|
||||
const newRecentActivity = action
|
||||
? [
|
||||
{ timestamp: new Date().toISOString(), action },
|
||||
...recentActivity.slice(0, 19),
|
||||
]
|
||||
: recentActivity;
|
||||
|
||||
const updatedState = await prisma.agentState.upsert({
|
||||
where: { id: agentId },
|
||||
update: {
|
||||
...(status ? { status } : {}),
|
||||
...(currentTask !== undefined ? { currentTask } : {}),
|
||||
lastActive: new Date(),
|
||||
...(action
|
||||
? {
|
||||
recentActivity: newRecentActivity,
|
||||
tasksCompleted: (existing?.tasksCompleted || 0) + 1,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
create: {
|
||||
id: agentId,
|
||||
name: defaultAgent?.name || agentId,
|
||||
emoji: defaultAgent?.emoji,
|
||||
role: defaultAgent?.role,
|
||||
status: status || "idle",
|
||||
currentTask: currentTask || null,
|
||||
lastActive: new Date(),
|
||||
tasksCompleted: action ? 1 : 0,
|
||||
totalCost: 0,
|
||||
recentActivity: newRecentActivity,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, agent: updatedState });
|
||||
} catch (error) {
|
||||
console.error("Agent update error:", error);
|
||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { fetchUrlContent } from "@/lib/fetch-url-content";
|
||||
|
||||
const TRACK_FORMULAS: Record<string, string> = {
|
||||
"mega-viral": `MEGA-VIRAL FORMULA (Data Betrayal Story):
|
||||
You thought X was [innocent thing]. Actually, [shocking betrayal].
|
||||
|
||||
STRUCTURE:
|
||||
1. OPENING: Start with ONE specific shocking number or fact. No context yet. Just the number that makes someone stop scrolling.
|
||||
Example: "2,373,711 DNA samples. Sold for $10 million. To a company you've never heard of."
|
||||
|
||||
2. "Here's the full story." — This is the permission line. Brief and punchy.
|
||||
|
||||
3. HOW IT STARTED: The innocent origin story. Make the reader feel like they were there.
|
||||
- When did this start?
|
||||
- What did users think they were signing up for?
|
||||
- The promise vs the reality
|
||||
|
||||
4. WHAT ACTUALLY HAPPENED: The betrayal. Be specific.
|
||||
- The exact mechanism of the privacy violation
|
||||
- The scale (numbers, numbers, numbers)
|
||||
- Who benefited and how much
|
||||
|
||||
5. THE SCALE NOBODY TALKS ABOUT: Expand the damage.
|
||||
- How many people affected
|
||||
- Ongoing implications
|
||||
- What this data is actually used for
|
||||
|
||||
6. THE LOOPHOLE: Why this was "legal" or why nobody talks about it.
|
||||
- Terms of service buried language
|
||||
- Regulatory gaps
|
||||
- Industry collusion
|
||||
|
||||
7. THE IRONY: The twist that should bother everyone.
|
||||
- Often: "you paid THEM to collect YOUR data"
|
||||
- Or: "the people who should protect you are profiting"
|
||||
|
||||
8. KILLER CLOSING: One sentence that captures everything. Quotable.
|
||||
Example: "You didn't buy a speaker. You bought a microphone."
|
||||
|
||||
9. SOURCES: List 3-5 real sources (use placeholder URLs like [Source: Company Name])
|
||||
|
||||
10. CTA: "Follow me @yourhandle on X for more stories like this."
|
||||
And: "Need help with AI strategy? FounderFunnel.com"`,
|
||||
|
||||
"local-viral": `LOCAL-VIRAL FORMULA (Practical AI Guide):
|
||||
Here's exactly how I [specific result] using [specific tool].
|
||||
|
||||
STRUCTURE:
|
||||
1. OPENING: The result that hooks them. Be specific about time saved, money made, or capability unlocked.
|
||||
Example: "I wrote 47 articles last month. Total time: 12 hours. Here's my exact workflow."
|
||||
|
||||
2. THE SETUP: What tools you're using (be specific about versions, models, costs).
|
||||
- Model names (Claude Sonnet 4, GPT-4, etc.)
|
||||
- Monthly costs
|
||||
- Your tech stack
|
||||
|
||||
3. THE WORKFLOW: Numbered steps with actual commands/prompts.
|
||||
- Use code blocks for prompts and commands
|
||||
- Include the exact settings/parameters
|
||||
- Show real examples from your work
|
||||
|
||||
4. THE PROMPTS: Share copy-paste value.
|
||||
- The actual prompts you use (in code blocks)
|
||||
- Why each section of the prompt matters
|
||||
- Variations for different use cases
|
||||
|
||||
5. WHAT I GOT WRONG: The mistakes section (builds trust).
|
||||
- Initial approach that didn't work
|
||||
- Why it failed
|
||||
- The insight that fixed it
|
||||
|
||||
6. RESULTS: Concrete before/after.
|
||||
- Time comparison
|
||||
- Quality comparison
|
||||
- Cost comparison
|
||||
|
||||
7. CTA: "Follow me @yourhandle on X for more AI workflows."
|
||||
And: "Building something with AI? FounderFunnel.com"`,
|
||||
|
||||
"icp-viral": `ICP-VIRAL FORMULA (Trojan Horse — Viral Content with Business CTA):
|
||||
The content goes viral as useful/interesting. The CTA books calls.
|
||||
|
||||
STRUCTURE:
|
||||
1. OPENING: A result that grabs attention (revenue, clients, transformation).
|
||||
Example: "I went from $5K to $47K months in 90 days. Here's the exact system."
|
||||
|
||||
2. THE BACKSTORY: Relatable struggle → breakthrough moment.
|
||||
- Where you were (relatable pain)
|
||||
- The conventional wisdom that wasn't working
|
||||
- The moment something changed
|
||||
|
||||
3. THE SYSTEM: The framework/process (this is the viral part).
|
||||
- Make it actionable and valuable standalone
|
||||
- Use numbered steps
|
||||
- Include specific examples
|
||||
|
||||
4. THE TEMPLATES: Actual copy-paste assets.
|
||||
- Email templates in code blocks
|
||||
- Scripts they can steal
|
||||
- Frameworks visualized
|
||||
|
||||
5. THE RESULTS: Social proof.
|
||||
- Your results with numbers
|
||||
- Client results if applicable
|
||||
- Timeline
|
||||
|
||||
6. THE DEEPER LAYER: Tease what you don't share publicly.
|
||||
- "This is the 80/20. The other 20% requires customization."
|
||||
- Build curiosity for the service
|
||||
|
||||
7. CTA: This is the business hook.
|
||||
- "Want help implementing this? I work with [ICP description]."
|
||||
- "Book a call at FounderFunnel.com"
|
||||
- "DM me 'SYSTEM' and I'll send you the full playbook."
|
||||
|
||||
8. Follow: "@yourhandle on X"`,
|
||||
};
|
||||
|
||||
// POST /api/articles/generate-article — accepts { title, track, themes, inspirationUrls, libraryInspirations }
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { title, track, themes, inspirationUrls, libraryInspirations } = body;
|
||||
|
||||
const selectedThemes: string[] = themes ? JSON.parse(themes) : [];
|
||||
const urls: string[] = inspirationUrls ? JSON.parse(inspirationUrls) : [];
|
||||
const libArticles: { title: string; body: string; impressions: number | null; likes: number | null; bookmarks: number | null }[] = libraryInspirations || [];
|
||||
|
||||
// Fetch external URL content in parallel
|
||||
const urlContents = await Promise.all(
|
||||
urls.map((url) => fetchUrlContent(url, { maxChars: 3000 }))
|
||||
);
|
||||
const externalContext = urlContents
|
||||
.filter((c) => c && !c.includes("Could not extract"))
|
||||
.map((content, i) => `External inspiration ${i + 1}:\n${content}`)
|
||||
.join("\n\n");
|
||||
|
||||
// Build library article context with full bodies + viral analysis instructions
|
||||
const libraryContext = libArticles
|
||||
.map((a, i) => {
|
||||
const metrics = [
|
||||
a.impressions != null ? `${a.impressions.toLocaleString()} impressions` : null,
|
||||
a.likes != null ? `${a.likes.toLocaleString()} likes` : null,
|
||||
a.bookmarks != null ? `${a.bookmarks.toLocaleString()} bookmarks` : null,
|
||||
].filter(Boolean).join(", ");
|
||||
const bodyPreview = a.body.slice(0, 3000);
|
||||
return `--- YOUR ARTICLE: "${a.title}" (${metrics || "no metrics"}) ---\n${bodyPreview}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const formula = TRACK_FORMULAS[track] || TRACK_FORMULAS["local-viral"];
|
||||
|
||||
// Real voice examples from your actual viral articles
|
||||
const voiceExamples = `== YOUR ACTUAL VIRAL ARTICLES (STUDY THESE — THIS IS THE EXACT VOICE AND FORMAT) ==
|
||||
|
||||
--- MEGA VIRAL EXAMPLE 1: reCAPTCHA (13.3M views, 6,968 likes, 5,546 bookmarks) ---
|
||||
|
||||
500,000 hours of free human labor. Every single day.
|
||||
|
||||
reCAPTCHA is the most successful invisible data operation in internet history. 200 million people solved it daily at its peak. Almost none of them understood what they were actually building.
|
||||
|
||||
Waymo is worth $45 billion today. It got a critical portion of its training data from you. For free.
|
||||
|
||||
Here's the full story.
|
||||
|
||||
In 2007, Google redesigned reCAPTCHA. "Click all squares with a traffic light." Those images came directly from Google Street View. Your clicks were the labels. Every selection told Google's AI: this is a traffic light.
|
||||
|
||||
You weren't passing a test. You were building a dataset.
|
||||
|
||||
At peak, 200 million reCAPTCHAs were solved every single day. Paid data annotation costs $10 to $50 per hour. At the low end: $5 million in free labor extracted every single day.
|
||||
|
||||
Google didn't ask. Didn't pay. Didn't even tell you.
|
||||
|
||||
You proved you were human. By making yourself replaceable.
|
||||
|
||||
--- MEGA VIRAL EXAMPLE 2: Amazon Echo (5.3M views, 28K likes, 6,880 bookmarks) ---
|
||||
|
||||
# Amazon Echo/Alexa Article (5.3M views, 28K likes, 6,880 bookmarks)
|
||||
|
||||
200 million homes. Always on. Always recording.
|
||||
By people who thought they were just asking for the weather.
|
||||
Amazon Alexa is the most successful consumer surveillance operation ever built. Not by a government. Not by a spy agency. By a company that sold you the microphone for $29.99 and called it a smart speaker.
|
||||
You paid to install it. You put it in your kitchen, your bedroom, your kid's room. And for years, strangers on the other side of the world have been listening to what you said.
|
||||
Here's the full story.
|
||||
|
||||
It started as a speaker.
|
||||
|
||||
In 2014, Amazon launched the Echo. A wireless speaker with a voice assistant named Alexa. The pitch was simple: hands-free music, timers, weather, shopping lists.
|
||||
It felt harmless. Almost cute.
|
||||
Amazon sold over 100 million Echo devices within a few years. By 2025, Alexa lived in over 200 million homes worldwide.
|
||||
People stopped thinking of it as a device. It became part of the house.
|
||||
|
||||
But Alexa is always listening.
|
||||
|
||||
Amazon says the device only starts recording when it hears the wake word "Alexa." That's technically true. But to detect the wake word, the device processes sound 24/7. It's listening to everything, all the time, waiting.
|
||||
And it gets it wrong. A lot.
|
||||
A study from Northeastern University found that Alexa activates without the wake word regularly. Some accidental recordings lasted 20 to 43 seconds. Full conversations. Arguments. Private moments. Captured and sent to Amazon's cloud.
|
||||
You didn't say "Alexa." It didn't matter.
|
||||
|
||||
Then Amazon hired people to listen.
|
||||
|
||||
In 2019, Bloomberg reported that Amazon employs thousands of people around the world whose job is to listen to your Alexa recordings. They transcribe them. Annotate them. Tag them. Then feed them back into the algorithm.
|
||||
What they didn't tell you: these employees heard everything.
|
||||
Private medical conversations. Couples fighting. Children talking alone in their rooms.
|
||||
Amazon's own FAQ never mentioned h
|
||||
|
||||
--- MEGA VIRAL EXAMPLE 3: BetterHelp (4.2M views, 1,452 likes, 1,033 bookmarks) ---
|
||||
|
||||
# BetterHelp Article — Rewrite (reCAPTCHA formula)
|
||||
|
||||
---
|
||||
|
||||
## TEASER TWEET
|
||||
|
||||
> you downloaded a therapy app
|
||||
> it said: confidential. private. just between you and your therapist.
|
||||
> you answered their questions honestly
|
||||
> depression. anxiety. trauma.
|
||||
> betterhelp sent every answer to facebook
|
||||
> and snapchat. and pinterest.
|
||||
> "find people like this one. sell them therapy too."
|
||||
> 2 million people. 5.5 million data points.
|
||||
> the ftc called it a lie. fined them $7.8 million.
|
||||
> betterhelp never admitted wrongdoing
|
||||
> they're still the most downloaded mental health app on earth
|
||||
> you went to them for privacy
|
||||
> that's exactly what they sold
|
||||
|
||||
---
|
||||
|
||||
## ARTICLE
|
||||
|
||||
**Title: You went to BetterHelp for privacy. That's exactly what they sold.**
|
||||
|
||||
$7.8 million. That's the fine.
|
||||
2 million people. That's how many got sold.
|
||||
|
||||
BetterHelp is the most downloaded mental health app on earth. Millions of people chose it specifically because they wanted privacy. No insurance company. No paper trail. No employer finding out. The app felt safer than the traditional system.
|
||||
|
||||
It wasn't. It was the opposite.
|
||||
|
||||
Here's the full story.
|
||||
|
||||
---
|
||||
|
||||
### How it started: a real problem, a clean pitch
|
||||
|
||||
Mental health care in America has a gatekeeping problem. Traditional therapy is expensive. Insurance claims create paper trails. Some employers have access to certain health records. For a lot of people, the barrier to seeing a therapist isn't willingness. It's exposure.
|
||||
|
||||
BetterHelp launched in 2013 with a clear pitch: therapy on y
|
||||
|
||||
--- ICP VIRAL EXAMPLE: $100M Founders article ---
|
||||
|
||||
# I Helped Founders Generate $100M+ in Revenue Using X. Here's Everything I Know:
|
||||
|
||||
[VISUAL: $100M bill hero image]
|
||||
|
||||
9 out of 10 founder accounts we audit have the same problem.
|
||||
|
||||
They're posting. But none of it is converting.
|
||||
|
||||
Not because their product is bad. Not because they don't know their industry.
|
||||
|
||||
Because they're treating X like a bulletin board.
|
||||
|
||||
Project update. Price announcement. Token launch. Repeat.
|
||||
|
||||
And then they wonder why they're screaming into the void.
|
||||
|
||||
I've spent years running founder marketing campaigns for some of the fastest-growing crypto and tech companies. Here's what we actually see:
|
||||
|
||||
Wolf Swap: $100 million in trading volume, driven by narrative design and community activation.
|
||||
|
||||
Somnia: 2,600% increase in smart engagement. $300M+ FDV at launch.
|
||||
|
||||
These didn't happen because the products were good. Every competitor had a good product.
|
||||
|
||||
They happened because of a system.
|
||||
|
||||
Here's the full playbook.
|
||||
|
||||
---
|
||||
|
||||
## The Core Problem: Nobody Cares About Your Project
|
||||
|
||||
Founders are trained to talk about their company.
|
||||
|
||||
The mission. The roadmap. The metrics.
|
||||
|
||||
But here's what nobody tells you: on X, your project is the least interesting thing about you.
|
||||
|
||||
What gets people to stop scrolling? Your story. Your belief. The reason you built this in the first place. The contrarian thing you see that everyone else is missing.
|
||||
|
||||
Nobody buys from a company. They buy from a person they trust. And trust is built one post at a time, long before you ever make an ask.
|
||||
|
||||
---
|
||||
|
||||
## The Fou
|
||||
|
||||
--- LOCAL VIRAL EXAMPLE: Claude features article (1.4M views, 5,548 bookmarks) ---
|
||||
|
||||
Most people use Claude like a smarter Google search. They open a new chat. Type a question. Get an answer. Close the tab.
|
||||
|
||||
If that sounds familiar, you're using about 10% of what you're paying for.
|
||||
|
||||
Here are 20 features ranked by how much they change the way you work.
|
||||
|
||||
The single most underused feature in Claude: Projects. A Project is a persistent workspace. Every conversation inside it shares the same context permanently. You don't re-explain yourself every session.
|
||||
|
||||
Most people don't know there's a toggle that makes Claude slow down and actually reason through a problem before answering. Turn it on for anything that requires real judgment.
|
||||
|
||||
The people winning with AI aren't using a better model. They're using the same model with 20 features instead of 2.`;
|
||||
|
||||
const systemPrompt = `You are the user's article writer. You write viral long-form X articles that get millions of views.
|
||||
|
||||
${voiceExamples}
|
||||
|
||||
== WRITING RULES (non-negotiable) ==
|
||||
1. Proper sentence capitalization — capitalize first word of every sentence, proper nouns, tool names, companies
|
||||
2. Short paragraphs — 2-3 sentences max, often just 1-2 lines
|
||||
3. Use line breaks liberally between every thought
|
||||
4. Numbers anchor every claim — be specific, always use real numbers
|
||||
5. No corporate speak — no "leverage", "harness", "unlock", "in today's world", "game-changing"
|
||||
6. Write like you're telling a smart friend something they need to hear
|
||||
7. Every section reveals something new — no filler, no padding
|
||||
8. The "you" pronoun is powerful — use it to make the reader feel complicit or implicated
|
||||
9. Section headers are short and punchy (3-6 words)
|
||||
10. End with a one-line killer that reframes everything
|
||||
|
||||
== THE FORMULA FOR THIS TRACK ==
|
||||
${formula}
|
||||
|
||||
${selectedThemes.length > 0 ? `\n== THEMES TO WEAVE IN ==\n${selectedThemes.join(", ")}` : ""}
|
||||
|
||||
${libraryContext ? `\n== YOUR SELECTED INSPIRATION ARTICLES (ANALYZE DEEPLY) ==
|
||||
These are articles you chose as inspiration. Study them to understand:
|
||||
- WHY they went viral — what made people stop scrolling and read the whole thing?
|
||||
- The opening hook pattern — how do they grab attention in the first 2 lines?
|
||||
- The pacing — short paragraphs, line breaks, tension building
|
||||
- The data usage — specific numbers that anchor every claim
|
||||
- The emotional arc — how the reader's feeling changes from start to finish
|
||||
- The "you" framing — how the reader is made to feel personally implicated
|
||||
|
||||
DO NOT copy or paraphrase these articles. Extract the VIRAL MECHANICS and apply them to the new topic with fresh angles, fresh data, and fresh storytelling.
|
||||
|
||||
${libraryContext}` : ""}
|
||||
|
||||
${externalContext ? `\n== EXTERNAL INSPIRATION (additional reference) ==\n${externalContext}` : ""}
|
||||
|
||||
Write the COMPLETE article. Match the voice, pacing, and energy of the examples above exactly. This needs to be good enough to go viral.`;
|
||||
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://your-app.vercel.app",
|
||||
"X-Title": "Hermy HQ Article Studio",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Write the full article for this title:\n\n"${title}"` },
|
||||
],
|
||||
max_tokens: 4000,
|
||||
temperature: 0.8,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
console.error("OpenRouter error:", err);
|
||||
return NextResponse.json({ error: "Failed to generate article" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices: { message: { content: string } }[] };
|
||||
const articleBody = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
|
||||
if (!articleBody) {
|
||||
return NextResponse.json({ error: "Empty response from model" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
title,
|
||||
body: articleBody,
|
||||
track,
|
||||
themes: selectedThemes,
|
||||
inspirationUrls: urls,
|
||||
wordCount: articleBody.split(/\s+/).length,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/articles/generate-article error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const QT_SYSTEM = `You write viral Quote Tweets (QTs) for your articles.
|
||||
|
||||
== THE PROVEN QT FORMAT ==
|
||||
This format consistently gets millions of views:
|
||||
|
||||
> you [relatable action the reader has done]
|
||||
> [what they thought was happening]
|
||||
> [the reveal — what was actually happening]
|
||||
> [escalating consequence]
|
||||
> [the punchline that reframes everything]
|
||||
|
||||
== YOUR ACTUAL VIRAL QTs (STUDY THESE — THIS IS THE VOICE) ==
|
||||
|
||||
QT 1 — reCAPTCHA article (73M views, 6,968 likes):
|
||||
you click "i am not a robot"
|
||||
you think you're proving you're human
|
||||
you're actually training Google's AI
|
||||
for free
|
||||
for 15 years
|
||||
this is the full story:
|
||||
|
||||
QT 2 — Amazon Alexa article (5.3M views, 28K likes):
|
||||
you buy an Echo for $29.99
|
||||
you put it in your bedroom
|
||||
Amazon hires thousands to listen
|
||||
you didn't buy a speaker
|
||||
you bought a microphone
|
||||
|
||||
QT 3 — 23andMe article:
|
||||
you spit in a tube
|
||||
you think you're learning about your ancestry
|
||||
you're actually giving away your family's DNA
|
||||
forever
|
||||
here's what they did with it:
|
||||
|
||||
QT 4 — BetterHelp article (4.2M views, 1,452 likes):
|
||||
you downloaded a therapy app
|
||||
it said: confidential. private. just between you and your therapist.
|
||||
you answered their questions honestly
|
||||
depression. anxiety. trauma.
|
||||
betterhelp sent every answer to facebook
|
||||
and snapchat. and pinterest.
|
||||
"find people like this one. sell them therapy too."
|
||||
2 million people. 5.5 million data points.
|
||||
the ftc called it a lie. fined them $7.8 million.
|
||||
betterhelp never admitted wrongdoing
|
||||
they're still the most downloaded mental health app on earth
|
||||
you went to them for privacy
|
||||
that's exactly what they sold
|
||||
|
||||
QT 5 — Google Photos article:
|
||||
you stored the memories
|
||||
they stored the faces
|
||||
|
||||
QT 6 — $100M Founders article (ICP viral):
|
||||
9 out of 10 founder accounts we audit have the same problem
|
||||
they're posting. but none of it is converting
|
||||
not because their product is bad
|
||||
because they're treating X like a bulletin board
|
||||
here's the full playbook:
|
||||
|
||||
QT 7 — Claude features article (1.4M views, 5,548 bookmarks):
|
||||
most people use Claude like a smarter Google search
|
||||
open chat. type question. get answer. close tab.
|
||||
if that sounds familiar
|
||||
you're using about 10% of what you're paying for
|
||||
here are 20 features ranked by impact:
|
||||
|
||||
== RULES (non-negotiable) ==
|
||||
1. Start with "you [action]" — make it personal and relatable
|
||||
2. Build tension line by line — each line hits harder
|
||||
3. The final line REFRAMES everything the reader thought they knew
|
||||
4. Keep each line short — under 50 characters ideally
|
||||
5. 5-10 lines total (shorter is usually better)
|
||||
6. End with a hook: "here's the full story:" or "here's what happened:" or similar
|
||||
7. NO hashtags, NO emojis, NO "thread 🧵", NO quotation marks around the QT
|
||||
8. Lowercase throughout (except proper nouns like Google, Amazon, etc.)
|
||||
9. Use specific numbers when available — numbers stop the scroll
|
||||
10. The "you" pronoun is mandatory — the reader must feel implicated
|
||||
|
||||
Output ONLY the QT text. No explanation, no quotes around it, no markdown.`;
|
||||
|
||||
// POST /api/articles/generate-qt — accepts { articleId } OR { title, body, track } for unsaved articles
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { articleId, title: directTitle, body: directBody, track: directTrack } = body;
|
||||
|
||||
let articleTitle: string;
|
||||
let articleBody: string;
|
||||
let articleTrack: string;
|
||||
let shouldSave = false;
|
||||
|
||||
if (articleId) {
|
||||
// Load from DB
|
||||
const article = await prisma.article.findUnique({ where: { id: articleId } });
|
||||
if (!article) {
|
||||
return NextResponse.json({ error: "Article not found" }, { status: 404 });
|
||||
}
|
||||
articleTitle = article.title;
|
||||
articleBody = article.body;
|
||||
articleTrack = article.track;
|
||||
shouldSave = true;
|
||||
} else if (directTitle && directBody) {
|
||||
// Direct body for unsaved articles (compose flow)
|
||||
articleTitle = directTitle;
|
||||
articleBody = directBody;
|
||||
articleTrack = directTrack || "local-viral";
|
||||
} else {
|
||||
return NextResponse.json({ error: "articleId or (title + body) required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch real QT examples from library (same track preferred)
|
||||
let libraryQtExamples = "";
|
||||
try {
|
||||
const exampleArticles = await prisma.article.findMany({
|
||||
where: {
|
||||
status: "posted",
|
||||
qtTweet: { not: null },
|
||||
track: articleTrack,
|
||||
},
|
||||
select: { title: true, qtTweet: true, impressions: true },
|
||||
orderBy: { impressions: "desc" },
|
||||
take: 5,
|
||||
});
|
||||
if (exampleArticles.length > 0) {
|
||||
libraryQtExamples = `\n\n== YOUR OWN VIRAL QTs FROM THE LIBRARY (study these — they performed) ==\n${exampleArticles
|
||||
.map((a, i) => `QT for "${a.title}" (${a.impressions?.toLocaleString() || "?"} impressions):\n${a.qtTweet}`)
|
||||
.join("\n\n")}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Get first 1500 chars of article body for context
|
||||
const articlePreview = articleBody.slice(0, 1500);
|
||||
|
||||
const userPrompt = `Write ONE viral QT for this article.
|
||||
|
||||
TITLE: ${articleTitle}
|
||||
TRACK: ${articleTrack}
|
||||
|
||||
ARTICLE BODY (preview):
|
||||
${articlePreview}
|
||||
|
||||
Generate the perfect QT that will make people NEED to click through and read the full article. Follow the "you [action]" format exactly. Match the energy and voice of the examples in your instructions.
|
||||
${libraryQtExamples}`;
|
||||
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://your-app.vercel.app",
|
||||
"X-Title": "Hermy HQ Article Studio",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [
|
||||
{ role: "system", content: QT_SYSTEM },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
max_tokens: 500,
|
||||
temperature: 0.85,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
console.error("OpenRouter error:", err);
|
||||
return NextResponse.json({ error: "Failed to generate QT" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices: { message: { content: string } }[] };
|
||||
const qtTweet = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
|
||||
if (!qtTweet) {
|
||||
return NextResponse.json({ error: "Empty response from model" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Update the article with the generated QT if it's saved
|
||||
if (shouldSave && articleId) {
|
||||
await prisma.article.update({
|
||||
where: { id: articleId },
|
||||
data: { qtTweet },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
articleId: articleId || null,
|
||||
qtTweet,
|
||||
title: articleTitle,
|
||||
track: articleTrack,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/articles/generate-qt error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,209 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const DESIGN_SYSTEM = `You generate standalone HTML visual graphics for viral X (Twitter) articles.
|
||||
|
||||
== DESIGN SYSTEM (non-negotiable) ==
|
||||
|
||||
DIMENSIONS: 1536px wide × 864px tall. Set on <body>. overflow: hidden.
|
||||
|
||||
BACKGROUND: #080808 (near-black)
|
||||
GRID OVERLAY: Always include this:
|
||||
.grid-bg {
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
GLOW: Use radial-gradient overlays for subtle colored glow effects behind content.
|
||||
|
||||
FONT: font-family: -apple-system, 'Inter', sans-serif;
|
||||
|
||||
COLORS:
|
||||
- Text: #fff (headlines), rgba(255,255,255,0.35-0.5) (body/muted)
|
||||
- Accent: pick ONE from: amber #f59e0b, blue #3b82f6 / #818cf8, green #4ade80, red #f87171, purple #a78bfa
|
||||
- Cards: rgba(255,255,255,0.03) background, rgba(255,255,255,0.07) border
|
||||
- Colored accents: 15% opacity backgrounds with matching text
|
||||
|
||||
TYPOGRAPHY:
|
||||
- Headlines: 36-72px, font-weight 800, letter-spacing -0.02em
|
||||
- Subtitles: 16-22px, weight 400, color rgba(255,255,255,0.3-0.4)
|
||||
- Body/items: 12-15px, color rgba(255,255,255,0.45-0.6)
|
||||
- Badges/labels: 11-13px, weight 600-700, uppercase, letter-spacing 0.1em
|
||||
|
||||
CARDS: border-radius 14-20px, subtle borders rgba(255,255,255,0.07)
|
||||
|
||||
BYLINE: Always include at the bottom:
|
||||
<div style="position:absolute;bottom:30px;left:56px;right:56px;display:flex;justify-content:space-between;align-items:center;z-index:10;">
|
||||
<span style="font-size:14px;color:rgba(255,255,255,0.25);">@yourhandle</span>
|
||||
<span style="font-size:11px;font-weight:700;letter-spacing:3px;color:rgba(255,255,255,0.1);">HERMY HQ</span>
|
||||
</div>
|
||||
|
||||
RULES:
|
||||
- Complete, self-contained <!DOCTYPE html> documents
|
||||
- ALL styles in a single <style> block — NO inline styles except the byline
|
||||
- NO external resources, fonts, images, or scripts
|
||||
- Use emojis for icons — they render everywhere
|
||||
- Content must be LEGIBLE
|
||||
- Position elements with flexbox/grid`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { articleId, title, articleBody, track, visualType, mode } = body;
|
||||
|
||||
let artTitle = title;
|
||||
let artBody = articleBody;
|
||||
let artTrack = track;
|
||||
|
||||
if (articleId && (!artTitle || !artBody)) {
|
||||
const article = await prisma.article.findUnique({ where: { id: articleId } });
|
||||
if (!article) {
|
||||
return NextResponse.json({ error: "Article not found" }, { status: 404 });
|
||||
}
|
||||
artTitle = article.title;
|
||||
artBody = article.body;
|
||||
artTrack = article.track;
|
||||
}
|
||||
|
||||
if (!artTitle || !artBody) {
|
||||
return NextResponse.json({ error: "title and articleBody required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch reference hero HTMLs
|
||||
let referenceContext = "";
|
||||
try {
|
||||
const refs = await prisma.article.findMany({
|
||||
where: { status: "posted", track: artTrack || undefined, heroImageHtml: { not: null } },
|
||||
select: { title: true, heroImageHtml: true, impressions: true },
|
||||
orderBy: { impressions: "desc" },
|
||||
take: 2,
|
||||
});
|
||||
if (refs.length > 0) {
|
||||
referenceContext = `\n\n== REFERENCE VISUALS FROM TOP ARTICLES ==\n${refs.map((a, i) =>
|
||||
`--- "${a.title}" (${a.impressions?.toLocaleString() || "?"} views) ---\n${(a.heroImageHtml || "").slice(0, 2000)}`
|
||||
).join("\n\n")}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ── MODE: PLAN ── Analyze article and create a visual plan
|
||||
if (mode === "plan") {
|
||||
const planPrompt = `Read this entire article carefully and create a VISUAL PLAN.
|
||||
|
||||
TITLE: ${artTitle}
|
||||
TRACK: ${artTrack}
|
||||
|
||||
FULL ARTICLE:
|
||||
${artBody.slice(0, 6000)}
|
||||
|
||||
For each section of the article, recommend a visual. For each visual:
|
||||
1. WHERE it should go (after which section/paragraph)
|
||||
2. WHAT type: hero, data-stats, before-after, steps, grid, timeline, comparison, quote-highlight
|
||||
3. WHAT content to include (specific numbers, facts, lists from the article)
|
||||
4. CAN_GENERATE: true if this is something you can create as an HTML graphic (charts, infographics, data displays, comparison panels, step flows). false if it requires a real screenshot, photo, video embed, or external content the user must provide.
|
||||
5. If CAN_GENERATE is false, briefly describe what the user should provide.
|
||||
|
||||
Return a JSON array. Each object:
|
||||
{
|
||||
"position": "After section: [section name or quote]",
|
||||
"type": "hero|data|before-after|steps|grid|timeline|comparison|quote-highlight",
|
||||
"description": "Brief description of the visual",
|
||||
"content": "The specific data/text to include in the visual",
|
||||
"canGenerate": true/false,
|
||||
"userAction": null or "Screenshot X showing Y" (only if canGenerate is false)
|
||||
}
|
||||
|
||||
Return ONLY valid JSON. No explanation, no markdown.`;
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://your-app.vercel.app",
|
||||
"X-Title": "Hermy HQ Article Studio",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [{ role: "user", content: planPrompt }],
|
||||
max_tokens: 4000,
|
||||
temperature: 0.5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "Failed to generate plan" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json() as { choices: { message: { content: string } }[] };
|
||||
const content = data.choices?.[0]?.message?.content?.trim() || "[]";
|
||||
|
||||
try {
|
||||
const cleaned = content.replace(/^```json?\n?/g, "").replace(/```$/g, "").trim();
|
||||
const plan = JSON.parse(cleaned);
|
||||
return NextResponse.json({ plan });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to parse plan", raw: content }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── MODE: GENERATE SINGLE ── Generate one specific visual
|
||||
const bodyPreview = artBody.slice(0, 3000);
|
||||
|
||||
const userPrompt = `Generate a ${(visualType || "hero").toUpperCase()} visual for this article.
|
||||
|
||||
TITLE: ${artTitle}
|
||||
TRACK: ${artTrack}
|
||||
|
||||
ARTICLE:
|
||||
${bodyPreview}
|
||||
|
||||
${body.content ? `SPECIFIC CONTENT TO INCLUDE IN THIS VISUAL:\n${body.content}\n` : ""}
|
||||
${referenceContext}
|
||||
|
||||
Generate ONE complete HTML visual. Extract the most impactful data from the article. The content MUST come from the article — don't invent numbers.
|
||||
|
||||
Return ONLY the complete HTML document. No explanation, no markdown code blocks, no \`\`\`.`;
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://your-app.vercel.app",
|
||||
"X-Title": "Hermy HQ Article Studio",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [
|
||||
{ role: "system", content: DESIGN_SYSTEM },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
max_tokens: 8000,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "Failed to generate visual" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json() as { choices: { message: { content: string } }[] };
|
||||
const html = (data.choices?.[0]?.message?.content || "")
|
||||
.replace(/^```html?\n?/g, "")
|
||||
.replace(/```$/g, "")
|
||||
.trim();
|
||||
|
||||
return NextResponse.json({
|
||||
visuals: [{ type: visualType || "hero", html }],
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/articles/generate-visuals error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
async function braveSearch(query: string, braveKey: string, count = 5): Promise<{ title: string; url: string; description: string }[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`,
|
||||
{ headers: { "Accept": "application/json", "X-Subscription-Token": braveKey } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.web?.results || []).map((r: any) => ({
|
||||
title: r.title || "",
|
||||
url: r.url || "",
|
||||
description: r.description || "",
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/articles/revise — chat-based article revision with optional web search
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { title, articleBody, track, instruction, searchEnabled, messages } = body;
|
||||
|
||||
if (!instruction) {
|
||||
return NextResponse.json({ error: "instruction required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Web search if enabled
|
||||
let searchContext = "";
|
||||
if (searchEnabled) {
|
||||
const braveKey = process.env.BRAVE_API_KEY || "";
|
||||
if (braveKey) {
|
||||
// Ask Sonnet to generate search queries based on the instruction
|
||||
const queryRes = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://your-app.vercel.app",
|
||||
"X-Title": "Hermy HQ Article Studio",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You generate search queries to fact-check or research claims in articles. Given a user instruction and article context, output 1-3 short search queries that would help verify or research the topic. Return ONLY a JSON array of strings. No explanation.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Article title: "${title}"\nUser instruction: "${instruction}"\n\nGenerate search queries to help with this request.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 200,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (queryRes.ok) {
|
||||
const queryData = await queryRes.json() as { choices: { message: { content: string } }[] };
|
||||
const queryContent = queryData.choices?.[0]?.message?.content?.trim() || "[]";
|
||||
try {
|
||||
const cleaned = queryContent.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
|
||||
const queries: string[] = JSON.parse(cleaned);
|
||||
const allResults = await Promise.all(
|
||||
queries.slice(0, 3).map((q) => braveSearch(q, braveKey, 3))
|
||||
);
|
||||
const flatResults = allResults.flat();
|
||||
if (flatResults.length > 0) {
|
||||
searchContext = `\n\n== WEB SEARCH RESULTS (use these to verify facts or update information) ==\n${flatResults
|
||||
.map((r, i) => `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.description}`)
|
||||
.join("\n\n")}`;
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse queries, skip search
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build conversation history for context
|
||||
const historyMessages = (messages || []).map((m: { role: string; content: string }) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const systemPrompt = `You are the user's article editor and fact-checker. You help revise and improve viral articles.
|
||||
|
||||
You have two modes:
|
||||
1. REVISION MODE: When asked to change/improve the article, return a revised version of the FULL article body. Start your response with "REVISED_BODY:" followed by the complete revised article on the next line.
|
||||
2. ANSWER MODE: When asked a question (fact-check, accuracy, research), answer the question directly. Do NOT prefix with "REVISED_BODY:".
|
||||
|
||||
== ARTICLE CONTEXT ==
|
||||
Title: ${title}
|
||||
Track: ${track}
|
||||
Current article body:
|
||||
${articleBody}
|
||||
${searchContext}
|
||||
|
||||
== WRITING STYLE RULES ==
|
||||
1. Short paragraphs — 2-3 sentences max, often just 1-2 lines
|
||||
2. Numbers anchor every claim — be specific
|
||||
3. No corporate speak — no "leverage", "harness", "unlock"
|
||||
4. Write like telling a smart friend something they need to hear
|
||||
5. Every section reveals something new — no filler
|
||||
6. Use "you" to make the reader feel implicated
|
||||
7. Proper sentence capitalization
|
||||
|
||||
When revising, maintain the article's voice and energy. Only change what was asked. Return the COMPLETE article body, not just the changed section.`;
|
||||
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://your-app.vercel.app",
|
||||
"X-Title": "Hermy HQ Article Studio",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...historyMessages,
|
||||
{ role: "user", content: instruction },
|
||||
],
|
||||
max_tokens: 4000,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
console.error("OpenRouter error:", err);
|
||||
return NextResponse.json({ error: "Failed to revise article" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices: { message: { content: string } }[] };
|
||||
const content = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "Empty response from model" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Check if the response contains a revised body
|
||||
const isRevision = content.startsWith("REVISED_BODY:");
|
||||
if (isRevision) {
|
||||
const revisedBody = content.replace(/^REVISED_BODY:\s*/, "").trim();
|
||||
return NextResponse.json({
|
||||
type: "revision",
|
||||
message: "Article updated.",
|
||||
revisedBody,
|
||||
searchUsed: searchEnabled && searchContext.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: "answer",
|
||||
message: content,
|
||||
searchUsed: searchEnabled && searchContext.length > 0,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/articles/revise error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET /api/articles?status=X — list articles, filter by status
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const statusFilter = searchParams.get("status");
|
||||
|
||||
const where: any = {};
|
||||
if (statusFilter) {
|
||||
where.status = statusFilter;
|
||||
}
|
||||
|
||||
const articles = await prisma.article.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(articles, {
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/articles error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/articles — create article
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const article = await prisma.article.create({
|
||||
data: {
|
||||
id: body.id || crypto.randomUUID(),
|
||||
title: body.title || "",
|
||||
body: body.body || "",
|
||||
track: body.track || "local-viral",
|
||||
status: body.status || "idea",
|
||||
qtTweet: body.qtTweet || null,
|
||||
qtUrl: body.qtUrl || null,
|
||||
heroImageUrl: body.heroImageUrl || null,
|
||||
heroImageHtml: body.heroImageHtml || null,
|
||||
inspirationUrls: body.inspirationUrls || null,
|
||||
themes: body.themes || null,
|
||||
scheduledDate: body.scheduledDate || null,
|
||||
postedAt: body.postedAt ? new Date(body.postedAt) : null,
|
||||
postedUrl: body.postedUrl || null,
|
||||
impressions: body.impressions ?? null,
|
||||
likes: body.likes ?? null,
|
||||
bookmarks: body.bookmarks ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(article);
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/articles error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/articles — update article (body: { id, ...fields })
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { id, ...fields } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const article = await prisma.article.findUnique({ where: { id } });
|
||||
if (!article) {
|
||||
return NextResponse.json({ error: "Article not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (fields.title !== undefined) updateData.title = fields.title;
|
||||
if (fields.body !== undefined) updateData.body = fields.body;
|
||||
if (fields.track !== undefined) updateData.track = fields.track;
|
||||
if (fields.status !== undefined) updateData.status = fields.status;
|
||||
if (fields.qtTweet !== undefined) updateData.qtTweet = fields.qtTweet;
|
||||
if (fields.qtUrl !== undefined) updateData.qtUrl = fields.qtUrl;
|
||||
if (fields.heroImageUrl !== undefined) updateData.heroImageUrl = fields.heroImageUrl;
|
||||
if (fields.heroImageHtml !== undefined) updateData.heroImageHtml = fields.heroImageHtml;
|
||||
if (fields.inspirationUrls !== undefined) updateData.inspirationUrls = fields.inspirationUrls;
|
||||
if (fields.themes !== undefined) updateData.themes = fields.themes;
|
||||
if (fields.scheduledDate !== undefined) updateData.scheduledDate = fields.scheduledDate;
|
||||
if (fields.postedAt !== undefined) updateData.postedAt = fields.postedAt ? new Date(fields.postedAt) : null;
|
||||
if (fields.postedUrl !== undefined) updateData.postedUrl = fields.postedUrl;
|
||||
if (fields.impressions !== undefined) updateData.impressions = fields.impressions;
|
||||
if (fields.likes !== undefined) updateData.likes = fields.likes;
|
||||
if (fields.bookmarks !== undefined) updateData.bookmarks = fields.bookmarks;
|
||||
|
||||
const updated = await prisma.article.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (err: any) {
|
||||
console.error("PATCH /api/articles error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/articles — delete article (body: { id })
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const { id } = await req.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const article = await prisma.article.findUnique({ where: { id } });
|
||||
if (!article) {
|
||||
return NextResponse.json({ error: "Article not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.article.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true, message: "Article deleted" });
|
||||
} catch (err: any) {
|
||||
console.error("DELETE /api/articles error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET /api/articles/saved-titles — list saved titles
|
||||
export async function GET() {
|
||||
try {
|
||||
const titles = await prisma.savedTitle.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json(titles);
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/articles/saved-titles error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/articles/saved-titles — save a title
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { title, track, themes } = body;
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "title required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const saved = await prisma.savedTitle.create({
|
||||
data: {
|
||||
title,
|
||||
track: track || "local-viral",
|
||||
themes: themes || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(saved);
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/articles/saved-titles error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/articles/saved-titles — remove a saved title
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const { id } = await req.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.savedTitle.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error("DELETE /api/articles/saved-titles error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 30;
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { fetchUrlContent } from "@/lib/fetch-url-content";
|
||||
|
||||
function extractTweetId(url: string): string | null {
|
||||
const match = url.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// Try Twitter API v2 — requires TWITTER_BEARER_TOKEN
|
||||
async function fetchFromTwitterApi(tweetId: string, bearerToken: string) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.twitter.com/2/tweets/${tweetId}?tweet.fields=public_metrics,created_at,text,note_tweet,article&expansions=author_id,article.cover_media,article.media_entities&user.fields=username&media.fields=url,preview_image_url,type`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${bearerToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const tweet = data.data;
|
||||
if (!tweet) return null;
|
||||
|
||||
const metrics = tweet.public_metrics || {};
|
||||
|
||||
// article.plain_text = full article body, article.title = proper title
|
||||
const article = tweet.article || {};
|
||||
const articleTitle = article.title || "";
|
||||
const articleBody = article.plain_text || "";
|
||||
const fullText = articleBody || tweet.note_tweet?.text || tweet.text || "";
|
||||
|
||||
// Find cover image from includes.media using article.cover_media key
|
||||
let coverImageUrl = "";
|
||||
const coverMediaKey = article.cover_media || "";
|
||||
if (coverMediaKey && data.includes?.media) {
|
||||
const coverMedia = data.includes.media.find((m: any) => m.media_key === coverMediaKey);
|
||||
if (coverMedia) {
|
||||
coverImageUrl = coverMedia.url || coverMedia.preview_image_url || "";
|
||||
}
|
||||
}
|
||||
|
||||
// Extract a clean title: prefer article.title, then first meaningful line
|
||||
let extractedTitle = articleTitle;
|
||||
if (!extractedTitle) {
|
||||
const lines = fullText.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (/^https?:\/\//.test(trimmed)) continue;
|
||||
if (trimmed.length < 10 && !/\d/.test(trimmed)) continue;
|
||||
extractedTitle = trimmed.length > 120 ? trimmed.slice(0, 117) + "..." : trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: fullText,
|
||||
title: extractedTitle,
|
||||
coverImageUrl,
|
||||
isArticleTweet: !!tweet.article,
|
||||
impressions: metrics.impression_count ?? null,
|
||||
likes: metrics.like_count ?? null,
|
||||
bookmarks: metrics.bookmark_count ?? null,
|
||||
retweets: metrics.retweet_count ?? null,
|
||||
replies: metrics.reply_count ?? null,
|
||||
createdAt: tweet.created_at || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try X syndication API (public, no auth)
|
||||
async function fetchFromSyndication(tweetId: string) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&token=0`,
|
||||
{
|
||||
headers: { "User-Agent": "Mozilla/5.0" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
|
||||
return {
|
||||
text: data.text || "",
|
||||
likes: data.favorite_count ?? null,
|
||||
createdAt: data.created_at || null,
|
||||
authorName: data.user?.name || "",
|
||||
authorHandle: data.user?.screen_name || "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch quote tweets for an article tweet
|
||||
async function fetchQuoteTweets(tweetId: string, bearerToken: string, authorId?: string) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.twitter.com/2/tweets/${tweetId}/quote_tweets?tweet.fields=public_metrics,created_at,text,author_id&max_results=20`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${bearerToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const quotes = data.data || [];
|
||||
|
||||
// Find the author's own QT (most likely the promotional tweet)
|
||||
// If no authorId, return the most-liked QT
|
||||
let bestQt = null;
|
||||
for (const qt of quotes) {
|
||||
if (authorId && qt.author_id === authorId) {
|
||||
bestQt = qt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bestQt && quotes.length > 0) {
|
||||
// Pick the most-liked quote tweet
|
||||
bestQt = quotes.reduce((best: any, qt: any) =>
|
||||
(qt.public_metrics?.like_count || 0) > (best.public_metrics?.like_count || 0) ? qt : best
|
||||
, quotes[0]);
|
||||
}
|
||||
|
||||
if (!bestQt) return null;
|
||||
|
||||
const qMetrics = bestQt.public_metrics || {};
|
||||
return {
|
||||
text: (bestQt.text || "").replace(/https?:\/\/t\.co\/\S+/g, "").trim(),
|
||||
tweetId: bestQt.id,
|
||||
impressions: qMetrics.impression_count ?? null,
|
||||
likes: qMetrics.like_count ?? null,
|
||||
bookmarks: qMetrics.bookmark_count ?? null,
|
||||
createdAt: bestQt.created_at || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/articles/scrape — accepts { url } returns title, body, metrics, etc.
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { url } = await req.json();
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "url required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const isXUrl = url.includes("x.com") || url.includes("twitter.com");
|
||||
const tweetId = isXUrl ? extractTweetId(url) : null;
|
||||
const bearerToken = process.env.TWITTER_BEARER_TOKEN || "";
|
||||
|
||||
let title = "";
|
||||
let body = "";
|
||||
let imageUrl = "";
|
||||
let impressions: number | null = null;
|
||||
let likes: number | null = null;
|
||||
let bookmarks: number | null = null;
|
||||
let postedDate: string | null = null;
|
||||
let qtTweet = "";
|
||||
let qtUrl = "";
|
||||
let qtImpressions: number | null = null;
|
||||
let qtLikes: number | null = null;
|
||||
|
||||
// 1. For X/Twitter URLs, try the APIs
|
||||
if (tweetId) {
|
||||
// Try Twitter API v2 first (has full metrics + article text)
|
||||
if (bearerToken) {
|
||||
const twitterData = await fetchFromTwitterApi(tweetId, bearerToken);
|
||||
if (twitterData) {
|
||||
const rawText = twitterData.text || "";
|
||||
const cleanedText = rawText.replace(/https?:\/\/t\.co\/\S+/g, "").trim();
|
||||
|
||||
// If this is an article tweet, use the article title and cover image
|
||||
if (twitterData.isArticleTweet) {
|
||||
if (twitterData.title) title = twitterData.title;
|
||||
if (twitterData.coverImageUrl) imageUrl = twitterData.coverImageUrl;
|
||||
}
|
||||
|
||||
// If the tweet body is empty/just a URL, it's likely a QT tweet
|
||||
// Follow the t.co link to find the actual article
|
||||
if (cleanedText.length < 20) {
|
||||
const tcoMatch = rawText.match(/https?:\/\/t\.co\/\S+/);
|
||||
if (tcoMatch) {
|
||||
try {
|
||||
// Follow the t.co redirect to get the real URL
|
||||
const redirectRes = await fetch(tcoMatch[0], {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { "User-Agent": "Mozilla/5.0" },
|
||||
});
|
||||
const realUrl = redirectRes.url;
|
||||
const linkedTweetId = extractTweetId(realUrl);
|
||||
|
||||
// If it points to another tweet, fetch that one (the actual article)
|
||||
if (linkedTweetId && linkedTweetId !== tweetId) {
|
||||
const articleData = await fetchFromTwitterApi(linkedTweetId, bearerToken);
|
||||
if (articleData) {
|
||||
body = articleData.text.replace(/https?:\/\/t\.co\/\S+/g, "").trim();
|
||||
title = articleData.title || "";
|
||||
if (articleData.coverImageUrl) imageUrl = articleData.coverImageUrl;
|
||||
// Use the article's metrics, not the QT's
|
||||
impressions = articleData.impressions;
|
||||
likes = articleData.likes;
|
||||
bookmarks = articleData.bookmarks;
|
||||
postedDate = articleData.createdAt
|
||||
? new Date(articleData.createdAt).toISOString().split("T")[0]
|
||||
: null;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't resolve via redirect, use the original tweet data
|
||||
if (!body) {
|
||||
body = cleanedText;
|
||||
title = twitterData.title || "";
|
||||
}
|
||||
|
||||
// Use QT tweet metrics as fallback if article metrics not found
|
||||
if (impressions == null) impressions = twitterData.impressions;
|
||||
if (likes == null) likes = twitterData.likes;
|
||||
if (bookmarks == null) bookmarks = twitterData.bookmarks;
|
||||
if (!postedDate && twitterData.createdAt) {
|
||||
postedDate = new Date(twitterData.createdAt).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// Fetch the QT tweet (the promotional tweet that quotes this article)
|
||||
// Use the article's tweet ID to find quote tweets
|
||||
const articleTweetId = body ? tweetId : null; // only if this IS the article
|
||||
const qtTweetId = articleTweetId || tweetId;
|
||||
if (bearerToken && qtTweetId) {
|
||||
// Get author_id from the original fetch
|
||||
const authorRes = await fetch(
|
||||
`https://api.twitter.com/2/tweets/${qtTweetId}?tweet.fields=author_id`,
|
||||
{ headers: { Authorization: `Bearer ${bearerToken}` }, signal: AbortSignal.timeout(5000) }
|
||||
).catch(() => null);
|
||||
const authorId = authorRes ? (await authorRes.json().catch(() => ({})))?.data?.author_id : undefined;
|
||||
|
||||
const qtData = await fetchQuoteTweets(qtTweetId, bearerToken, authorId);
|
||||
if (qtData) {
|
||||
qtTweet = qtData.text;
|
||||
qtUrl = `https://x.com/i/status/${qtData.tweetId}`;
|
||||
qtImpressions = qtData.impressions;
|
||||
qtLikes = qtData.likes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: syndication API (public, no auth needed)
|
||||
if (!body) {
|
||||
const synData = await fetchFromSyndication(tweetId);
|
||||
if (synData) {
|
||||
body = synData.text || body;
|
||||
likes = synData.likes ?? likes;
|
||||
if (synData.createdAt) {
|
||||
postedDate = new Date(synData.createdAt).toISOString().split("T")[0];
|
||||
}
|
||||
if (body && !title) {
|
||||
// Extract title skipping URLs and empty lines
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || /^https?:\/\//.test(trimmed)) continue;
|
||||
if (trimmed.length < 10 && !/\d/.test(trimmed)) continue;
|
||||
title = trimmed.length > 120 ? trimmed.slice(0, 117) + "..." : trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. For X articles, use Brave Search for body text and hero image
|
||||
if (isXUrl && (!body || body.length < 50)) {
|
||||
const braveKey = process.env.BRAVE_API_KEY || "";
|
||||
if (braveKey) {
|
||||
try {
|
||||
const braveRes = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(url)}&count=5`,
|
||||
{ headers: { Accept: "application/json", "X-Subscription-Token": braveKey } }
|
||||
);
|
||||
if (braveRes.ok) {
|
||||
const braveData = await braveRes.json();
|
||||
const results = braveData.web?.results || [];
|
||||
// Find the best match
|
||||
for (const r of results) {
|
||||
if (r.url?.includes("x.com") || r.url?.includes("twitter.com")) {
|
||||
if (!title && r.title) {
|
||||
title = r.title.replace(/^.*?\s+on\s+(X|Twitter):\s*[""\u201c]?/i, "").replace(/["""\u201d]$/, "").trim();
|
||||
}
|
||||
const extraSnippets = (r.extra_snippets || []).join("\n\n");
|
||||
const desc = [r.description || "", extraSnippets].filter(Boolean).join("\n\n").trim();
|
||||
if (desc.length > (body?.length || 0)) {
|
||||
body = desc;
|
||||
}
|
||||
if (!imageUrl && r.thumbnail?.src) {
|
||||
imageUrl = r.thumbnail.src;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Also try fetchUrlContent for deeper extraction
|
||||
if (!body || body.length < 100) {
|
||||
const content = await fetchUrlContent(url, { maxChars: 5000 });
|
||||
if (content && !content.includes("Could not extract")) {
|
||||
const bodyMatch = content.match(/Content:\n([\s\S]+)/);
|
||||
if (bodyMatch && bodyMatch[1].trim().length > (body?.length || 0)) {
|
||||
body = bodyMatch[1].trim();
|
||||
}
|
||||
if (!title) {
|
||||
const titleMatch = content.match(/^Title:\s*(.+)/m);
|
||||
if (titleMatch) title = titleMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For X articles, try oembed API for thumbnail image
|
||||
if (isXUrl && !imageUrl) {
|
||||
try {
|
||||
const oembedRes = await fetch(
|
||||
`https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&omit_script=true`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
if (oembedRes.ok) {
|
||||
const oembedData = await oembedRes.json();
|
||||
// Extract image from oembed HTML
|
||||
const imgMatch = (oembedData.html || "").match(/src=["']([^"']+\.(?:jpg|jpeg|png|webp)[^"']*)/i);
|
||||
if (imgMatch) imageUrl = imgMatch[1];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 4. Try direct fetch for og:meta tags (skip for X — it returns JS-disabled page)
|
||||
if (!isXUrl) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; HermyHQ/1.0)" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
|
||||
if (!title) {
|
||||
const ogTitle = html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i);
|
||||
if (ogTitle) title = ogTitle[1].trim();
|
||||
if (!title) {
|
||||
const titleTag = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (titleTag) title = titleTag[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
const ogImage = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i);
|
||||
if (ogImage) imageUrl = ogImage[1].trim();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 4. For non-X URLs, use Brave-powered fetchUrlContent for article body
|
||||
if (!body && !isXUrl) {
|
||||
const content = await fetchUrlContent(url, { maxChars: 5000 });
|
||||
if (content && !content.includes("Could not extract")) {
|
||||
const titleMatch = content.match(/^Title:\s*(.+)/m);
|
||||
if (titleMatch && !title) title = titleMatch[1].trim();
|
||||
const bodyMatch = content.match(/Content:\n([\s\S]+)/);
|
||||
if (bodyMatch) body = bodyMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up X/Twitter titles
|
||||
if (title && isXUrl) {
|
||||
title = title
|
||||
.replace(/^.*?\s+on\s+(X|Twitter):\s*[""\u201c]?/i, "")
|
||||
.replace(/["""\u201d]$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Clean body text
|
||||
if (body) {
|
||||
body = body
|
||||
.replace(/We've detected that JavaScript is disabled.*?Help Center\.?\s*/gi, "")
|
||||
.replace(/https?:\/\/t\.co\/\S+/g, "")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
title: title || "",
|
||||
body: body || "",
|
||||
heroImageUrl: imageUrl || "",
|
||||
impressions,
|
||||
likes,
|
||||
bookmarks,
|
||||
postedDate,
|
||||
qtTweet: qtTweet || "",
|
||||
qtUrl: qtUrl || "",
|
||||
qtImpressions,
|
||||
qtLikes,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/articles/scrape error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { password } = await req.json();
|
||||
if (password !== process.env.ADMIN_PASSWORD) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set("hermyhq_auth", process.env.ADMIN_PASSWORD!, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: "/",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const INTERNAL_SECRET = process.env.INTERNAL_API_SECRET;
|
||||
const CACHE_KEYS = ['notion_clients_monthly', 'mcf_monthly', 'ltv_monthly'];
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const secret = req.headers.get('x-internal-secret');
|
||||
if (secret !== INTERNAL_SECRET) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const results = await Promise.all(
|
||||
CACHE_KEYS.map(key => prisma.dataStore.delete({ where: { key } }).then(() => ({ key, cleared: true })).catch(() => ({ key, cleared: false })))
|
||||
);
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
|
||||
// Also allow GET with secret query param for convenience
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const secret = url.searchParams.get('secret') || req.headers.get('x-internal-secret');
|
||||
if (secret !== INTERNAL_SECRET) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const results = await Promise.all(
|
||||
CACHE_KEYS.map(key => prisma.dataStore.delete({ where: { key } }).then(() => ({ key, cleared: true })).catch(() => ({ key, cleared: false })))
|
||||
);
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function isAuthorized(request: Request) {
|
||||
const secret = process.env.CLIENT_PULSE_ADMIN_SECRET || process.env.CRON_SECRET;
|
||||
if (!secret) return true;
|
||||
return request.headers.get("authorization") === `Bearer ${secret}`;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAuthorized(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const body = await request.json() as {
|
||||
telegramChatId?: string;
|
||||
title?: string;
|
||||
linkedClientId?: string;
|
||||
internalUserIds?: string[];
|
||||
status?: string;
|
||||
};
|
||||
if (!body.telegramChatId) return NextResponse.json({ error: "telegramChatId is required" }, { status: 400 });
|
||||
const chat = await prisma.clientPulseChat.upsert({
|
||||
where: { telegramChatId: body.telegramChatId },
|
||||
update: {
|
||||
title: body.title,
|
||||
linkedClientId: body.linkedClientId,
|
||||
internalUserIds: body.internalUserIds || [],
|
||||
status: body.status,
|
||||
},
|
||||
create: {
|
||||
telegramChatId: body.telegramChatId,
|
||||
title: body.title || body.telegramChatId,
|
||||
linkedClientId: body.linkedClientId,
|
||||
internalUserIds: body.internalUserIds || [],
|
||||
status: body.status || "active",
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true, chat });
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type AnalysisWithClient = Prisma.ClientPulseAnalysisGetPayload<{ include: { client: { include: { chats: true } } } }>;
|
||||
|
||||
function asStringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const latestRows = await prisma.clientPulseAnalysis.findMany({
|
||||
include: { client: { include: { chats: true } } },
|
||||
orderBy: { analyzedAt: "desc" },
|
||||
take: 500,
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
const latest: AnalysisWithClient[] = [];
|
||||
for (const row of latestRows) {
|
||||
if (seen.has(row.clientId)) continue;
|
||||
seen.add(row.clientId);
|
||||
latest.push(row);
|
||||
}
|
||||
const clientIds = latest.map((row) => row.clientId);
|
||||
const messages = await prisma.clientPulseMessage.findMany({
|
||||
where: { chat: { linkedClientId: { in: clientIds } } },
|
||||
include: { chat: true },
|
||||
orderBy: { sentAt: "desc" },
|
||||
take: 1000,
|
||||
});
|
||||
const alerts = await prisma.clientPulseAlert.findMany({
|
||||
where: { acknowledgedAt: null },
|
||||
include: { client: true, chat: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
const unmappedChats = await prisma.clientPulseChat.findMany({
|
||||
where: { linkedClientId: null },
|
||||
orderBy: { lastSeenAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const messageByClient = new Map<string, { lastClient?: string; lastInternal?: string }>();
|
||||
for (const message of messages) {
|
||||
const linkedClientId = message.chat?.linkedClientId;
|
||||
if (!linkedClientId) continue;
|
||||
const existing = messageByClient.get(linkedClientId) || {};
|
||||
if (!message.isInternal && !existing.lastClient) existing.lastClient = `${message.senderName}: ${message.text.slice(0, 180)}`;
|
||||
if (message.isInternal && !existing.lastInternal) existing.lastInternal = `${message.senderName}: ${message.text.slice(0, 180)}`;
|
||||
messageByClient.set(linkedClientId, existing);
|
||||
}
|
||||
|
||||
const clients = latest.map((analysis) => {
|
||||
const msg = messageByClient.get(analysis.clientId) || {};
|
||||
return {
|
||||
clientId: analysis.clientId,
|
||||
clientName: analysis.client.clientName,
|
||||
productManagerName: analysis.client.productManagerName,
|
||||
services: asStringArray(analysis.client.services),
|
||||
status: analysis.client.status,
|
||||
renewalDate: analysis.client.renewalDate,
|
||||
endDate: analysis.client.endDate,
|
||||
expectedCheckinHours: analysis.client.expectedCheckinHours,
|
||||
score: analysis.overallScore,
|
||||
category: analysis.category,
|
||||
sentimentScore: analysis.sentimentScore,
|
||||
responseScore: analysis.responseScore,
|
||||
cadenceScore: analysis.cadenceScore,
|
||||
renewalRiskScore: analysis.renewalRiskScore,
|
||||
reasons: asStringArray(analysis.reasons),
|
||||
evidence: asStringArray(analysis.evidence),
|
||||
recommendedAction: analysis.recommendedAction,
|
||||
llmSummary: analysis.llmSummary,
|
||||
analysisSource: analysis.analysisSource,
|
||||
analyzedAt: analysis.analyzedAt,
|
||||
chats: analysis.client.chats.map((chat) => ({ telegramChatId: chat.telegramChatId, title: chat.title, lastSeenAt: chat.lastSeenAt })),
|
||||
lastClientMessage: msg.lastClient,
|
||||
lastInternalReply: msg.lastInternal,
|
||||
};
|
||||
});
|
||||
const average = clients.length ? Math.round(clients.reduce((sum, client) => sum + client.score, 0) / clients.length) : null;
|
||||
const counts = {
|
||||
urgent: clients.filter((client) => client.category === "urgent").length,
|
||||
needsAttention: clients.filter((client) => client.category === "needs_attention").length,
|
||||
watch: clients.filter((client) => client.category === "watch").length,
|
||||
healthy: clients.filter((client) => client.category === "healthy").length,
|
||||
};
|
||||
return NextResponse.json({ average, counts, clients, alerts, unmappedChats });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const bearerToken = process.env.TWITTER_BEARER_TOKEN;
|
||||
if (!bearerToken) {
|
||||
return NextResponse.json({ error: "TWITTER_BEARER_TOKEN not set" }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.dataStore.findUnique({ where: { key: "x-account-stats" } });
|
||||
const current = (existing?.data as any) || {};
|
||||
const handle = current.xHandle || "yourhandle";
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.twitter.com/2/users/by/username/${handle}?user.fields=public_metrics`,
|
||||
{ headers: { Authorization: `Bearer ${bearerToken}` }, cache: "no-store" }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `Twitter API error: ${res.status}` }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const followers = data.data?.public_metrics?.followers_count;
|
||||
|
||||
if (typeof followers !== "number") {
|
||||
return NextResponse.json({ error: "Could not parse follower count", raw: data }, { status: 500 });
|
||||
}
|
||||
|
||||
await prisma.dataStore.upsert({
|
||||
where: { key: "x-account-stats" },
|
||||
update: { data: { ...current, xFollowers: followers, updatedAt: new Date().toISOString() } },
|
||||
create: { key: "x-account-stats", data: { xFollowers: followers, xHandle: handle, xGoal: 100000, updatedAt: new Date().toISOString() } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, xFollowers: followers, handle });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : "Unknown error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Shared garden blob — same source as Marwa's dashboard (source of truth)
|
||||
const GARDEN_BLOB = 'https://jsonblob.com/api/jsonBlob/019cce3a-7bc9-7e88-9e8f-fe461957b1aa';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(GARDEN_BLOB, {
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`jsonblob GET failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
console.error('Garden GET error:', e);
|
||||
return NextResponse.json({ error: 'Failed to fetch garden' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const res = await fetch(GARDEN_BLOB, {
|
||||
method: 'PUT',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`jsonblob PUT failed: ${res.status}`);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error('Garden PUT error:', e);
|
||||
return NextResponse.json({ error: 'Failed to update garden' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const take = Math.min(Number(new URL(req.url).searchParams.get("take") || 40), 100);
|
||||
const events = await prisma.agentEvent.findMany({ orderBy: { createdAt: "desc" }, take });
|
||||
return NextResponse.json({ events });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "hermes-briefing" } });
|
||||
return NextResponse.json(row?.data ?? { generatedAt: null, summary: null, sections: [] });
|
||||
}
|
||||
|
||||
// POST → ask the bridge to (re)generate the chief-of-staff brief now.
|
||||
export async function POST() {
|
||||
const row = await prisma.agentRequest.create({
|
||||
data: {
|
||||
origin: "web",
|
||||
kind: "briefing.generate",
|
||||
title: "Generate chief-of-staff brief",
|
||||
prompt: "now",
|
||||
sideEffecting: false,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ request: row });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "hermes-cost" } });
|
||||
return NextResponse.json(row?.data ?? { summary: null, byModel: [], totalCost: null, totalTokens: null, syncedAt: null });
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type CronJob = {
|
||||
id: string;
|
||||
status: string; // "active" | "paused"
|
||||
name: string;
|
||||
schedule: string;
|
||||
nextRun: string | null;
|
||||
lastRun: string | null;
|
||||
lastResult: string | null;
|
||||
deliver: string | null;
|
||||
skills: string | null;
|
||||
script: string | null;
|
||||
mode: string | null;
|
||||
};
|
||||
|
||||
// Parse the raw `hermes cron list --all` terminal output into clean objects.
|
||||
// Format per job: two-space-indented "<id> [status]" then four-space "Key: value" lines.
|
||||
function parseCrons(raw: string): CronJob[] {
|
||||
const jobs: CronJob[] = [];
|
||||
let cur: CronJob | null = null;
|
||||
const push = () => { if (cur) jobs.push(cur); };
|
||||
for (const line of raw.split("\n")) {
|
||||
const head = line.match(/^\s{2}([0-9a-f]{6,})\s+\[(\w+)\]/);
|
||||
if (head) {
|
||||
push();
|
||||
cur = { id: head[1], status: head[2], name: "", schedule: "", nextRun: null, lastRun: null, lastResult: null, deliver: null, skills: null, script: null, mode: null };
|
||||
continue;
|
||||
}
|
||||
const kv = line.match(/^\s{4}([A-Za-z][A-Za-z ]*?):\s+(.*)$/);
|
||||
if (kv && cur) {
|
||||
const key = kv[1].trim().toLowerCase();
|
||||
const val = kv[2].trim();
|
||||
if (key === "name") cur.name = val;
|
||||
else if (key === "schedule") cur.schedule = val;
|
||||
else if (key === "next run") cur.nextRun = val;
|
||||
else if (key === "deliver") cur.deliver = val;
|
||||
else if (key === "skills") cur.skills = val;
|
||||
else if (key === "script") cur.script = val;
|
||||
else if (key === "mode") cur.mode = val;
|
||||
else if (key === "last run") {
|
||||
const m = val.match(/^(\S+)\s+(.*)$/);
|
||||
cur.lastRun = m ? m[1] : val;
|
||||
cur.lastResult = m ? m[2] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
push();
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "hermes-crons" } });
|
||||
const data = (row?.data as { raw?: string; syncedAt?: string } | null) ?? {};
|
||||
const jobs = data.raw ? parseCrons(data.raw) : [];
|
||||
return NextResponse.json({ jobs, syncedAt: data.syncedAt ?? null });
|
||||
}
|
||||
|
||||
// POST { op: "create"|"pause"|"resume"|"run"|"remove"|"edit", ... } → queue a cron mutation for the bridge
|
||||
export async function POST(req: Request) {
|
||||
const b = await req.json().catch(() => ({}));
|
||||
const op = (b.op || "").toString();
|
||||
if (!["create", "pause", "resume", "run", "remove", "edit"].includes(op))
|
||||
return NextResponse.json({ error: "bad op" }, { status: 400 });
|
||||
const label = op === "create" ? `Schedule: ${b.schedule || "?"} — ${b.prompt || b.name || ""}` : `Cron ${op}: ${b.name || b.id || ""}`;
|
||||
const sideEffecting = op === "create" || op === "edit" || op === "remove";
|
||||
const row = await prisma.agentRequest.create({
|
||||
data: {
|
||||
origin: "web",
|
||||
kind: `cron.${op}`,
|
||||
title: label.slice(0, 200),
|
||||
prompt: JSON.stringify(b),
|
||||
sideEffecting,
|
||||
status: sideEffecting ? "awaiting_approval" : "queued",
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ request: row });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// POST { kind?, title, prompt?, sideEffecting? } → queue work for Hermes.
|
||||
// Side-effecting work waits for approval; safe work is queued immediately.
|
||||
export async function POST(req: Request) {
|
||||
const b = await req.json().catch(() => ({}));
|
||||
const title = (b.title || b.prompt || "").toString().trim();
|
||||
if (!title) return NextResponse.json({ error: "title or prompt required" }, { status: 400 });
|
||||
const sideEffecting = Boolean(b.sideEffecting);
|
||||
const row = await prisma.agentRequest.create({
|
||||
data: {
|
||||
origin: "web",
|
||||
kind: (b.kind || "oneshot").toString(),
|
||||
title: title.slice(0, 200),
|
||||
prompt: (b.prompt ?? b.title ?? "").toString() || null,
|
||||
sideEffecting,
|
||||
status: sideEffecting ? "awaiting_approval" : "queued",
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ request: row });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
export async function GET() {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "hermes-health" } });
|
||||
return NextResponse.json(row?.data ?? { online: false, gateway: "unknown", lastSeen: null });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET ?q=&type=&status= → list/search wiki entries (mirrored by the bridge)
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const q = (url.searchParams.get("q") || "").trim();
|
||||
const type = url.searchParams.get("type");
|
||||
const status = url.searchParams.get("status") || "active";
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status !== "all") where.status = status;
|
||||
if (type && type !== "all") where.type = type;
|
||||
if (q) where.OR = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ body: { contains: q, mode: "insensitive" } },
|
||||
{ tags: { has: q.toLowerCase() } },
|
||||
];
|
||||
const entries = await prisma.hermesMemory.findMany({ where, orderBy: { updatedAt: "desc" }, take: 300 });
|
||||
const all = await prisma.hermesMemory.findMany({ select: { type: true }, where: status === "all" ? {} : { status } });
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const e of all) typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
|
||||
const lastSync = entries[0]?.syncedAt ?? null;
|
||||
return NextResponse.json({ entries, typeCounts, total: all.length, lastSync });
|
||||
}
|
||||
|
||||
// POST { path?, id?, type, title, body, tags?, links?, status?, confidence? }
|
||||
// → queue a wiki write for the bridge (writes the .md file + git commit on the mini).
|
||||
export async function POST(req: Request) {
|
||||
const b = await req.json().catch(() => ({}));
|
||||
const title = (b.title || "").toString().trim();
|
||||
if (!title) return NextResponse.json({ error: "title required" }, { status: 400 });
|
||||
const slug = (b.id || b.path || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")).toString();
|
||||
const entry = {
|
||||
id: slug,
|
||||
path: (b.path || `${b.type || "note"}s/${slug}.md`).toString(),
|
||||
type: (b.type || "note").toString(),
|
||||
title,
|
||||
status: (b.status || "active").toString(),
|
||||
confidence: b.confidence ?? null,
|
||||
provenance: b.provenance ?? "dashboard",
|
||||
tags: Array.isArray(b.tags) ? b.tags : [],
|
||||
links: Array.isArray(b.links) ? b.links : [],
|
||||
body: (b.body || "").toString(),
|
||||
};
|
||||
const row = await prisma.agentRequest.create({
|
||||
data: {
|
||||
origin: "web",
|
||||
kind: "memory.write",
|
||||
title: `Memory: ${title}`.slice(0, 200),
|
||||
prompt: JSON.stringify(entry),
|
||||
sideEffecting: false,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ request: row, entry });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const b = await req.json().catch(() => ({}));
|
||||
const action = (b.action || "").toString(); // approve | reject | edit
|
||||
const existing = await prisma.agentRequest.findUnique({ where: { id } });
|
||||
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
if (!["awaiting_approval", "queued"].includes(existing.status))
|
||||
return NextResponse.json({ error: `cannot decide a ${existing.status} request` }, { status: 409 });
|
||||
|
||||
const data: Record<string, unknown> = { decidedAt: new Date() };
|
||||
if (action === "approve") data.status = "approved";
|
||||
else if (action === "reject") data.status = "rejected";
|
||||
else if (action === "edit") { data.status = "approved"; if (b.prompt) data.prompt = b.prompt.toString(); if (b.title) data.title = b.title.toString().slice(0, 200); }
|
||||
else return NextResponse.json({ error: "action must be approve|reject|edit" }, { status: 400 });
|
||||
|
||||
const row = await prisma.agentRequest.update({ where: { id }, data });
|
||||
return NextResponse.json({ request: row });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const status = url.searchParams.get("status"); // e.g. "awaiting_approval"
|
||||
const take = Math.min(Number(url.searchParams.get("take") || 50), 200);
|
||||
const where = status ? { status: { in: status.split(",") } } : {};
|
||||
const requests = await prisma.agentRequest.findMany({
|
||||
where, orderBy: { createdAt: "desc" }, take,
|
||||
});
|
||||
const pending = await prisma.agentRequest.count({ where: { status: "awaiting_approval" } });
|
||||
return NextResponse.json({ requests, pending });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const tasks = await prisma.hermesTask.findMany({ orderBy: [{ status: "asc" }, { priority: "desc" }], take: 200 });
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of tasks) counts[t.status] = (counts[t.status] || 0) + 1;
|
||||
const lastSync = tasks[0]?.syncedAt ?? null;
|
||||
return NextResponse.json({ tasks, counts, total: tasks.length, lastSync });
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
const HL_WALLET = process.env.HL_WALLET || "";
|
||||
const HL_API = "https://api.hyperliquid.xyz/info";
|
||||
const YT_API_KEY = process.env.YOUTUBE_API_KEY;
|
||||
const YT_CHANNEL_ID = process.env.YOUTUBE_CHANNEL_ID || "";
|
||||
const HERMES_KANBAN_BOARD = process.env.HERMES_BOARD || "default";
|
||||
|
||||
const HERMES_KANBAN_DEMO_TASKS = [
|
||||
{ id: "demo-1", title: "Research Hermes outliers and keywords", assignee: "nova", status: "done", priority: 100 },
|
||||
{ id: "demo-2", title: "Create Notion To Film trigger", assignee: "hermes", status: "done", priority: 95 },
|
||||
{ id: "demo-3", title: "Build Kanban demo in Hermy HQ", assignee: "coding-agent", status: "running", priority: 90 },
|
||||
{ id: "demo-4", title: "Write final filming script", assignee: "nova", status: "ready", priority: 85 },
|
||||
{ id: "demo-5", title: "Prepare upload SEO package", assignee: "nova", status: "ready", priority: 80 },
|
||||
{ id: "demo-6", title: "Design thumbnail concepts", assignee: "creative-agent", status: "ready", priority: 75 },
|
||||
];
|
||||
|
||||
function formatHermesKanban(tasks: Array<{ id: string; title: string; assignee?: string | null; status: string; priority?: number | null }>, source: "live" | "demo" = "live") {
|
||||
const counts = tasks.reduce<Record<string, number>>((acc, task) => {
|
||||
acc[task.status] = (acc[task.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
board: "Hermes 24/7 Assistant Video",
|
||||
slug: HERMES_KANBAN_BOARD,
|
||||
source,
|
||||
total: tasks.length,
|
||||
counts,
|
||||
tasks: tasks
|
||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0))
|
||||
.slice(0, 6)
|
||||
.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
assignee: task.assignee || "unassigned",
|
||||
status: task.status,
|
||||
priority: task.priority || 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Reads the HermesTask mirror kept in sync by the bridge on the Mac mini.
|
||||
// Works on Vercel (no `hermes` binary needed); falls back to demo tasks until
|
||||
// the bridge has synced at least once.
|
||||
async function loadHermesKanban() {
|
||||
try {
|
||||
const tasks = await prisma.hermesTask.findMany({ orderBy: [{ priority: "desc" }], take: 50 });
|
||||
if (!tasks.length) return formatHermesKanban(HERMES_KANBAN_DEMO_TASKS, "demo");
|
||||
return formatHermesKanban(
|
||||
tasks.map(t => ({ id: t.id, title: t.title, assignee: t.assignee, status: t.status, priority: t.priority })),
|
||||
"live"
|
||||
);
|
||||
} catch {
|
||||
return formatHermesKanban(HERMES_KANBAN_DEMO_TASKS, "demo");
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetrics(raw: unknown) {
|
||||
if (!raw || typeof raw !== "object") return { views: 0, likes: 0, bookmarks: 0, replies: 0 };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (typeof obj.views === "number" || typeof obj.likes === "number") {
|
||||
return { views: Number(obj.views) || 0, likes: Number(obj.likes) || 0, bookmarks: Number(obj.bookmarks) || 0, replies: Number(obj.replies) || 0 };
|
||||
}
|
||||
let best = { views: 0, likes: 0, bookmarks: 0, replies: 0 };
|
||||
for (const val of Object.values(obj)) {
|
||||
if (val && typeof val === "object") {
|
||||
const cp = val as Record<string, unknown>;
|
||||
const v = Number(cp.views) || 0;
|
||||
if (v > best.views) {
|
||||
best = { views: v, likes: Number(cp.likes) || 0, bookmarks: Number(cp.bookmarks) || 0, replies: Number(cp.replies) || 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
async function hlPost(body: object) {
|
||||
const res = await fetch(HL_API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store" });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [
|
||||
draftsResult,
|
||||
metricsResult,
|
||||
topPendingDraftsResult,
|
||||
hlResult,
|
||||
ytTopResult,
|
||||
ytLatestResult,
|
||||
ytChannelResult,
|
||||
xStatsRow,
|
||||
ytIdeasResult,
|
||||
hermesKanbanResult,
|
||||
] = await Promise.allSettled([
|
||||
prisma.draft.findMany({ where: { status: "posted" }, orderBy: { postedAt: "desc" } }),
|
||||
prisma.tweetMetric.findMany(),
|
||||
prisma.draft.findMany({ where: { status: "pending" }, orderBy: { createdAt: "desc" }, take: 10 }),
|
||||
Promise.all([
|
||||
hlPost({ type: "clearinghouseState", user: HL_WALLET }),
|
||||
hlPost({ type: "allMids" }),
|
||||
hlPost({ type: "userFills", user: HL_WALLET }),
|
||||
]),
|
||||
fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${YT_CHANNEL_ID}&maxResults=5&order=viewCount&type=video&key=${YT_API_KEY}`, { next: { revalidate: 3600 } }).then(r => r.json()),
|
||||
fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${YT_CHANNEL_ID}&maxResults=3&order=date&type=video&key=${YT_API_KEY}`, { next: { revalidate: 3600 } }).then(r => r.json()),
|
||||
fetch(`https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${YT_CHANNEL_ID}&key=${YT_API_KEY}`, { next: { revalidate: 3600 } }).then(r => r.json()),
|
||||
prisma.dataStore.findUnique({ where: { key: "x-account-stats" } }),
|
||||
prisma.youtubeIdea.findMany({ where: { status: { in: ["pending", "approved"] }, NOT: { status: "rejected" } }, orderBy: { createdAt: "desc" }, take: 3 }),
|
||||
Promise.resolve(loadHermesKanban()),
|
||||
]);
|
||||
|
||||
// ─── X Analytics ─────────────────────────────────────────────────────────────
|
||||
const dbDrafts = draftsResult.status === "fulfilled" ? draftsResult.value : [];
|
||||
const dbMetrics = metricsResult.status === "fulfilled" ? metricsResult.value : [];
|
||||
const existingDraftIds = new Set(dbDrafts.map(d => d.id));
|
||||
const matchedTweetIds = new Set<string>();
|
||||
|
||||
const tweets: { id: string; text: string; views: number; engRate: number; postedAt: Date | string | null; tweetUrl: string | null }[] = [];
|
||||
|
||||
for (const d of dbDrafts) {
|
||||
const metric = dbMetrics.find(m => m.draftId === d.id || (d.tweetId && m.tweetId === d.tweetId));
|
||||
if (metric?.tweetId) matchedTweetIds.add(metric.tweetId);
|
||||
const rawMetrics = d.metrics ?? metric?.checkpoints;
|
||||
if (!rawMetrics && !d.tweetUrl) continue;
|
||||
const { views, likes, bookmarks, replies } = extractMetrics(rawMetrics);
|
||||
const engRate = views > 0 ? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100 : 0;
|
||||
tweets.push({ id: d.id, text: d.text || "", views, engRate, postedAt: d.postedAt, tweetUrl: d.tweetUrl });
|
||||
}
|
||||
|
||||
for (const m of dbMetrics) {
|
||||
if (!m.tweetId || matchedTweetIds.has(m.tweetId)) continue;
|
||||
if (m.draftId && existingDraftIds.has(m.draftId)) continue;
|
||||
const { views, likes, bookmarks, replies } = extractMetrics(m.checkpoints);
|
||||
if (views === 0 && likes === 0) continue;
|
||||
const engRate = views > 0 ? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100 : 0;
|
||||
tweets.push({ id: m.draftId || `tweet-${m.tweetId}`, text: "", views, engRate, postedAt: m.postedAt, tweetUrl: m.url });
|
||||
matchedTweetIds.add(m.tweetId);
|
||||
}
|
||||
|
||||
tweets.sort((a, b) => b.views - a.views);
|
||||
|
||||
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const tweetsThisWeek = tweets.filter(t => t.postedAt && new Date(t.postedAt as string).getTime() > weekAgo);
|
||||
const xViewsThisWeek = tweetsThisWeek.reduce((s, t) => s + t.views, 0);
|
||||
const topTweets = tweetsThisWeek.length >= 3
|
||||
? tweetsThisWeek.slice(0, 3)
|
||||
: [...tweetsThisWeek, ...tweets.filter(t => !tweetsThisWeek.includes(t))].slice(0, 3);
|
||||
|
||||
// Real 14-day X-views series (summed per calendar day) for the sparkline
|
||||
const DAY_MS = 86400000;
|
||||
const viewsByDay = new Map<string, number>();
|
||||
for (const t of tweets) {
|
||||
if (!t.postedAt || !t.views) continue;
|
||||
const key = new Date(t.postedAt as string).toISOString().slice(0, 10);
|
||||
viewsByDay.set(key, (viewsByDay.get(key) || 0) + t.views);
|
||||
}
|
||||
const xViewsTrend: number[] = [];
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * DAY_MS).toISOString().slice(0, 10);
|
||||
xViewsTrend.push(viewsByDay.get(d) || 0);
|
||||
}
|
||||
|
||||
const heatmap: Record<string, { sum: number; count: number }> = {};
|
||||
for (const t of tweets) {
|
||||
if (!t.postedAt || t.views === 0) continue;
|
||||
const dt = new Date(t.postedAt as string);
|
||||
const key = `${dt.getUTCDay()}-${dt.getUTCHours()}`;
|
||||
if (!heatmap[key]) heatmap[key] = { sum: 0, count: 0 };
|
||||
heatmap[key].sum += t.views;
|
||||
heatmap[key].count++;
|
||||
}
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
let bestSlot = { day: 0, hour: 18, avgViews: 0 };
|
||||
for (const [key, v] of Object.entries(heatmap)) {
|
||||
const avg = v.sum / v.count;
|
||||
if (avg > bestSlot.avgViews) {
|
||||
const [day, hour] = key.split("-").map(Number);
|
||||
bestSlot = { day, hour, avgViews: avg };
|
||||
}
|
||||
}
|
||||
|
||||
// Find the most recently posted tweet (tweets array is sorted by views, so we must reduce)
|
||||
const lastPosted = tweets.reduce((latest: Date | string | null, t) => {
|
||||
if (!t.postedAt) return latest;
|
||||
if (!latest) return t.postedAt;
|
||||
return new Date(t.postedAt as string).getTime() > new Date(latest as string).getTime() ? t.postedAt : latest;
|
||||
}, null);
|
||||
const daysSincePost = lastPosted ? Math.floor((Date.now() - new Date(lastPosted as string).getTime()) / 86400000) : 999;
|
||||
|
||||
// ─── X account stats — live fetch + DataStore cache ───────────────────────────
|
||||
const xStatsFile = xStatsRow.status === "fulfilled" ? (xStatsRow.value?.data as any) : null;
|
||||
let liveFollowers: number | null = null;
|
||||
const bearerToken = process.env.TWITTER_BEARER_TOKEN;
|
||||
const xHandle = xStatsFile?.xHandle || "yourhandle";
|
||||
if (bearerToken) {
|
||||
try {
|
||||
const twitterRes = await fetch(
|
||||
`https://api.twitter.com/2/users/by/username/${xHandle}?user.fields=public_metrics`,
|
||||
{ headers: { Authorization: `Bearer ${bearerToken}` }, cache: "no-store" }
|
||||
);
|
||||
if (twitterRes.ok) {
|
||||
const twitterData = await twitterRes.json();
|
||||
liveFollowers = twitterData.data?.public_metrics?.followers_count ?? null;
|
||||
// Update DataStore asynchronously (fire-and-forget)
|
||||
if (liveFollowers !== null) {
|
||||
prisma.dataStore.upsert({
|
||||
where: { key: "x-account-stats" },
|
||||
update: { data: { ...(xStatsFile || {}), xFollowers: liveFollowers, xHandle, xGoal: xStatsFile?.xGoal || 100000, updatedAt: new Date().toISOString() } },
|
||||
create: { key: "x-account-stats", data: { xFollowers: liveFollowers, xHandle, xGoal: 100000, updatedAt: new Date().toISOString() } },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
const xStats = {
|
||||
xFollowers: liveFollowers ?? xStatsFile?.xFollowers ?? parseInt(process.env.X_FOLLOWERS || "0"),
|
||||
xGoal: xStatsFile?.xGoal || 100000,
|
||||
xHandle,
|
||||
updatedAt: liveFollowers !== null ? new Date().toISOString() : (xStatsFile?.updatedAt || ""),
|
||||
};
|
||||
|
||||
// ─── Top Sage drafts (X ideas) ───────────────────────────────────────────────
|
||||
const rawPendingDrafts = topPendingDraftsResult.status === "fulfilled" ? topPendingDraftsResult.value : [];
|
||||
const topSageDrafts = rawPendingDrafts.slice(0, 3).map(d => ({
|
||||
id: d.id,
|
||||
text: d.text?.slice(0, 140) || "",
|
||||
}));
|
||||
|
||||
// ─── YouTube ideas ────────────────────────────────────────────────────────────
|
||||
const ytIdeasAll = ytIdeasResult.status === "fulfilled" ? ytIdeasResult.value : [];
|
||||
const topYoutubeIdeas = ytIdeasAll.slice(0, 3).map(i => ({ title: i.title, hook: i.hook || i.angle || "" }));
|
||||
|
||||
const hermesKanban = hermesKanbanResult.status === "fulfilled" ? hermesKanbanResult.value : { board: "Hermes 24/7 Assistant Video", slug: HERMES_KANBAN_BOARD, total: 0, counts: {}, tasks: [] };
|
||||
|
||||
// ─── Build ideas (Pixel) from DataStore ────────────────────────────────────
|
||||
let topBuildIdeas: { title: string; description: string; effort: string }[] = [];
|
||||
try {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "pixel-ideas" } });
|
||||
if (row?.data) {
|
||||
const ideas = row.data as Array<{ title: string; description: string; effort: string; status: string }>;
|
||||
topBuildIdeas = ideas.filter(i => i.status === "pending").slice(0, 3).map(i => ({
|
||||
title: i.title,
|
||||
description: i.description?.slice(0, 100) || "",
|
||||
effort: i.effort || "medium",
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// ─── YouTube ─────────────────────────────────────────────────────────────────
|
||||
type YTItem = { id?: { videoId?: string }; snippet?: { title?: string; thumbnails?: { high?: { url?: string } }; publishedAt?: string } };
|
||||
type YTResult = { items?: YTItem[] };
|
||||
|
||||
let topVideo: { title: string; thumbnail: string; url: string; publishedAt: string } | null = null;
|
||||
let latestVideo: { title: string; thumbnail: string; url: string; publishedAt: string } | null = null;
|
||||
let ytSubscribers = 0;
|
||||
|
||||
if (ytTopResult.status === "fulfilled") {
|
||||
const item = (ytTopResult.value as YTResult).items?.[0];
|
||||
if (item?.id?.videoId) {
|
||||
topVideo = { title: item.snippet?.title || "Top Video", thumbnail: item.snippet?.thumbnails?.high?.url || "", url: `https://youtube.com/watch?v=${item.id.videoId}`, publishedAt: item.snippet?.publishedAt || "" };
|
||||
}
|
||||
}
|
||||
if (ytLatestResult.status === "fulfilled") {
|
||||
const item = (ytLatestResult.value as YTResult).items?.[0];
|
||||
if (item?.id?.videoId) {
|
||||
latestVideo = { title: item.snippet?.title || "Latest Video", thumbnail: item.snippet?.thumbnails?.high?.url || "", url: `https://youtube.com/watch?v=${item.id.videoId}`, publishedAt: item.snippet?.publishedAt || "" };
|
||||
}
|
||||
}
|
||||
if (ytChannelResult.status === "fulfilled") {
|
||||
const ch = ytChannelResult.value as { items?: { statistics?: { subscriberCount?: string } }[] };
|
||||
ytSubscribers = parseInt(ch.items?.[0]?.statistics?.subscriberCount || "0");
|
||||
}
|
||||
if (!ytSubscribers) ytSubscribers = parseInt(process.env.YT_SUBSCRIBERS || "0");
|
||||
|
||||
if (!latestVideo && process.env.YT_LATEST_VIDEO) {
|
||||
try { latestVideo = JSON.parse(process.env.YT_LATEST_VIDEO); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── P&L (env var fallbacks for Vercel) ────────────────────────────────────
|
||||
let allTimePnl = parseFloat(process.env.POLY_ALL_TIME_PNL || process.env.ALL_TIME_PNL || "0");
|
||||
let todayPnl = parseFloat(process.env.POLY_TODAY_PNL || "0");
|
||||
let polyWinRate = parseFloat(process.env.POLY_WIN_RATE || "0");
|
||||
|
||||
// Try DataStore for richer PnL data
|
||||
try {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "polymarket-pnl" } });
|
||||
if (row?.data) {
|
||||
const pnl = row.data as any;
|
||||
if (pnl.allTimePnl) allTimePnl = pnl.allTimePnl;
|
||||
if (pnl.todayPnl) todayPnl = pnl.todayPnl;
|
||||
if (pnl.winRate) polyWinRate = pnl.winRate;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ─── Hyperliquid ─────────────────────────────────────────────────────────────
|
||||
let hlBalance = 0;
|
||||
let hlPosition: { asset: string; direction: string; unrealizedPnl: number; unrealizedPnlPct: number; leverage: number; stopLoss?: number; takeProfit?: number } | null = null;
|
||||
|
||||
let hlTodayPnl = parseFloat(process.env.HL_TODAY_PNL || "0");
|
||||
let hlAllTimePnl = parseFloat(process.env.HL_ALL_TIME_PNL || "0");
|
||||
|
||||
if (hlResult.status === "fulfilled") {
|
||||
const [accountState, allMids, fills] = hlResult.value as [any, any, Array<{ time: number; closedPnl: string }>];
|
||||
hlBalance = parseFloat(accountState.marginSummary?.accountValue || "0");
|
||||
for (const p of accountState.assetPositions || []) {
|
||||
const pos = p.position;
|
||||
if (!pos || parseFloat(pos.szi) === 0) continue;
|
||||
const size = parseFloat(pos.szi);
|
||||
const markPx = parseFloat(allMids[pos.coin] || pos.entryPx || "0");
|
||||
const unrealizedPnl = parseFloat(pos.unrealizedPnl || "0");
|
||||
const leverage = parseFloat(pos.leverage?.value || pos.leverage || "1");
|
||||
const notional = Math.abs(size) * markPx;
|
||||
hlPosition = { asset: pos.coin, direction: size > 0 ? "long" : "short", unrealizedPnl, unrealizedPnlPct: notional > 0 ? (unrealizedPnl / (notional / leverage)) * 100 : 0, leverage };
|
||||
}
|
||||
if (Array.isArray(fills) && fills.length > 0) {
|
||||
const cutoffMs = Date.now() - 24 * 60 * 60 * 1000;
|
||||
hlTodayPnl = fills.filter(f => f.time > cutoffMs).reduce((s, f) => s + parseFloat(f.closedPnl || "0"), 0);
|
||||
hlAllTimePnl = fills.reduce((s, f) => s + parseFloat(f.closedPnl || "0"), 0);
|
||||
}
|
||||
try {
|
||||
const row = await prisma.battleRoyaleBot.findUnique({ where: { id: "claude-opus" } });
|
||||
if (row?.state && hlPosition) {
|
||||
const state = row.state as { current_position?: { stop_loss?: number; take_profit?: number } };
|
||||
hlPosition.stopLoss = state.current_position?.stop_loss;
|
||||
hlPosition.takeProfit = state.current_position?.take_profit;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Polymarket balance (env var fallback) ──────────────────────────────────
|
||||
let polyBalance = parseFloat(process.env.POLY_BALANCE || "0");
|
||||
|
||||
// ─── PM2 Processes — not available on Vercel ───────────────────────────────
|
||||
const processes: { name: string; status: string; uptime: string }[] = [];
|
||||
|
||||
const hourStr = (h: number) => { const s = h >= 12 ? "PM" : "AM"; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${h12}${s}`; };
|
||||
|
||||
// ─── Daily metric snapshots (rolling history for deltas + sparklines) ────────
|
||||
// Stored in the generic DataStore (no schema migration). Honest by design:
|
||||
// history starts accumulating today; deltas render once we have 2+ days.
|
||||
type Snap = { d: string; xf: number; yt: number; pnl: number };
|
||||
let snapshots: Snap[] = [];
|
||||
try {
|
||||
const snapRow = await prisma.dataStore.findUnique({ where: { key: "metric-snapshots" } });
|
||||
snapshots = Array.isArray(snapRow?.data) ? (snapRow!.data as Snap[]) : [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const totalPnlNow = allTimePnl + hlAllTimePnl;
|
||||
const point: Snap = {
|
||||
d: today,
|
||||
xf: xStats.xFollowers,
|
||||
yt: ytSubscribers,
|
||||
pnl: Math.round(totalPnlNow * 100) / 100,
|
||||
};
|
||||
const idx = snapshots.findIndex(s => s.d === today);
|
||||
if (idx >= 0) snapshots[idx] = point; else snapshots.push(point);
|
||||
snapshots = snapshots.slice(-60); // ~2 months of daily history
|
||||
prisma.dataStore.upsert({
|
||||
where: { key: "metric-snapshots" },
|
||||
update: { data: snapshots },
|
||||
create: { key: "metric-snapshots", data: snapshots },
|
||||
}).catch(() => {});
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
return NextResponse.json({
|
||||
// X
|
||||
xFollowers: xStats.xFollowers,
|
||||
xGoal: xStats.xGoal,
|
||||
xHandle: xStats.xHandle,
|
||||
topTweets,
|
||||
topTweet: topTweets[0] || null,
|
||||
xViewsThisWeek,
|
||||
totalTweets: tweets.length,
|
||||
daysSincePost,
|
||||
bestPostingDay: DAYS[bestSlot.day],
|
||||
bestPostingHourStr: hourStr(bestSlot.hour),
|
||||
xViewsTrend,
|
||||
snapshots,
|
||||
// Ideas
|
||||
topSageDrafts,
|
||||
topYoutubeIdeas,
|
||||
topBuildIdeas,
|
||||
// YouTube
|
||||
topVideo,
|
||||
latestVideo,
|
||||
ytSubscribers,
|
||||
ytGoal: 20000,
|
||||
// Trading, hard-coded for dashboard/demo display
|
||||
polyBalance,
|
||||
polyWinRate,
|
||||
polyTodayPnl: 67.22,
|
||||
polyAllTimePnl: 67.22,
|
||||
hlBalance,
|
||||
hlTodayPnl: 0,
|
||||
hlAllTimePnl: 0,
|
||||
hlPosition: null,
|
||||
allTimePnl: 67.22,
|
||||
todayPnl: 67.22,
|
||||
// System
|
||||
processes,
|
||||
hermesKanban,
|
||||
// Legacy
|
||||
pendingDrafts: rawPendingDrafts.length,
|
||||
tweetIdeas: await prisma.idea.count({ where: { status: { notIn: ["done", "dismissed"] } } }).catch(() => 0),
|
||||
videosToFilm: await prisma.youtubeScript.count({ where: { status: { in: ["ready", "to_film", "tofilm", "approved"] } } }).catch(() => 0),
|
||||
insight: "",
|
||||
}, { headers: { "Cache-Control": "no-store, no-cache" } });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const type = req.nextUrl.searchParams.get("type");
|
||||
|
||||
const ideas = await prisma.idea.findMany({
|
||||
where: type ? { type } : undefined,
|
||||
orderBy: { timestamp: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(ideas);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
|
||||
const idea = await prisma.idea.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
description: body.description || null,
|
||||
category: body.category || null,
|
||||
type: body.type || null,
|
||||
model: body.model || null,
|
||||
status: body.status || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(idea);
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const { id, ...updates } = await req.json();
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const idea = await prisma.idea.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
return NextResponse.json(idea);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const { id } = await req.json();
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
await prisma.idea.delete({ where: { id } }).catch(() => {});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
export const maxDuration = 120;
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
|
||||
async function callLLM(apiKey: string, apiBase: string, payload: object): Promise<string> {
|
||||
const res = await fetch(`${apiBase}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`LLM API error ${res.status}: ${err.slice(0, 200)}`);
|
||||
}
|
||||
return res.json().then(d => JSON.stringify(d));
|
||||
}
|
||||
|
||||
async function braveSearch(query: string, braveKey: string, count = 5): Promise<{ title: string; url: string; description: string }[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`,
|
||||
{ headers: { "Accept": "application/json", "X-Subscription-Token": braveKey } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.web?.results || []).map((r: any) => ({ title: r.title, url: r.url, description: r.description }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { topic, hook: existingHook, type: contentType } = await req.json();
|
||||
if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 });
|
||||
|
||||
// Use XAI (Grok) — already configured on Vercel. Falls back to OpenAI if set.
|
||||
const apiKey = process.env.XAI_API_KEY || process.env.OPENAI_API_KEY || "";
|
||||
const apiBase = process.env.XAI_API_KEY ? "https://api.x.ai/v1" : "https://api.openai.com/v1";
|
||||
const model = process.env.XAI_API_KEY ? "grok-4-1-fast-non-reasoning" : "gpt-4o-mini";
|
||||
const braveKey = process.env.BRAVE_API_KEY || "";
|
||||
if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 });
|
||||
|
||||
// Step 1: Web search to gather real context
|
||||
let sourceContext = "";
|
||||
const sourceUrls: string[] = [];
|
||||
|
||||
if (braveKey) {
|
||||
// Generate search queries
|
||||
const queryResult = await callLLM(apiKey, apiBase, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: "Extract 3 specific search queries to research this video topic thoroughly. Return JSON: {\"queries\":[\"query1\",\"query2\",\"query3\"]}" },
|
||||
{ role: "user", content: `Long-form video topic: ${topic}` }
|
||||
],
|
||||
temperature: 0.3,
|
||||
response_format: { type: "json_object" }
|
||||
});
|
||||
|
||||
let queries: string[] = [];
|
||||
try {
|
||||
const qd = JSON.parse(queryResult);
|
||||
queries = JSON.parse(qd.choices[0].message.content).queries || [];
|
||||
} catch { /* ok */ }
|
||||
|
||||
for (const query of queries.slice(0, 3)) {
|
||||
const results = await braveSearch(query, braveKey, 3);
|
||||
if (results.length > 0) {
|
||||
sourceContext += `\n\n## SEARCH: "${query}"\n${results.map(r => {
|
||||
sourceUrls.push(r.url);
|
||||
return `- ${r.title} (${r.url})\n ${r.description}`;
|
||||
}).join("\n")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Load voice analysis
|
||||
let voiceGuide = "";
|
||||
try {
|
||||
voiceGuide = fs.readFileSync("./data/longform-voice-analysis.md", "utf-8");
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 2b: Load feedback history (what the user likes/hates)
|
||||
let feedbackContext = "";
|
||||
try {
|
||||
const drafts = JSON.parse(fs.readFileSync("./data/drafts.json", "utf-8"));
|
||||
const rejected = drafts.filter((d: any) => d.feedback?.rating === "down" && d.feedback?.reason);
|
||||
const approved = drafts.filter((d: any) => d.status === "approved" || d.feedback?.rating === "up");
|
||||
|
||||
if (rejected.length > 0) {
|
||||
feedbackContext += "\n## THINGS THE USER HATES (from rejected drafts):\n";
|
||||
for (const r of rejected.slice(0, 15)) {
|
||||
feedbackContext += `- "${r.title}" REJECTED because: "${r.feedback.reason}"\n`;
|
||||
}
|
||||
}
|
||||
if (approved.length > 0) {
|
||||
feedbackContext += "\n## THINGS THE USER LIKES (approved drafts):\n";
|
||||
for (const a of approved.slice(0, 10)) {
|
||||
feedbackContext += `- "${a.title}" ✅\n`;
|
||||
}
|
||||
}
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 2c: Load YouTube performance data
|
||||
let performanceContext = "";
|
||||
try {
|
||||
const transcripts = JSON.parse(fs.readFileSync("./data/youtube-transcripts.json", "utf-8"));
|
||||
const sorted = transcripts.sort((a: any, b: any) => (b.views || 0) - (a.views || 0));
|
||||
performanceContext = "\n## TOP PERFORMING YOUTUBE VIDEOS:\n";
|
||||
for (const v of sorted.slice(0, 5)) {
|
||||
performanceContext += `- "${v.title}" — ${v.views} views\n`;
|
||||
}
|
||||
performanceContext += "\nThe AI trading/experiment format massively outperforms other content.\n";
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 2d: Load rejected longform scripts for learning
|
||||
let longformFeedback = "";
|
||||
try {
|
||||
const lfScripts = JSON.parse(fs.readFileSync("./data/longform-scripts.json", "utf-8"));
|
||||
const rejectedLf = lfScripts.filter((s: any) => s.status === "rejected" && s.rejectedReason);
|
||||
if (rejectedLf.length > 0) {
|
||||
longformFeedback = "\n## REJECTED LONGFORM SCRIPTS:\n";
|
||||
for (const r of rejectedLf) {
|
||||
longformFeedback += `- "${r.title}" REJECTED: "${r.rejectedReason}"\n`;
|
||||
}
|
||||
}
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 3: Generate long-form script
|
||||
const systemPrompt = `You are writing a long-form YouTube video script for the user, a founder and content creator.
|
||||
|
||||
## THE USER'S LONG-FORM VOICE (30-Transcript Analysis)
|
||||
|
||||
**Narrative Strategy and Performance Optimization:**
|
||||
|
||||
1. **Content Architecture (Statistically Most Engaging):**
|
||||
- Personal experiment/challenge format
|
||||
- Clear, quantifiable goal
|
||||
- Day-by-day progression
|
||||
- Unexpected insights
|
||||
- Data-driven conclusions
|
||||
- Vulnerability in sharing results
|
||||
|
||||
2. **Voice Characteristics:**
|
||||
- "Curious explorer" — NOT a lecturer
|
||||
- Learning WITH the audience, not AT them
|
||||
- Comfortable admitting initial ignorance
|
||||
- Technical depth without academic language
|
||||
|
||||
3. **Opening Hook Hierarchy (By Performance):**
|
||||
- "I was doing X when I noticed Y"
|
||||
- "Everyone says Z. So I decided to test it."
|
||||
- "I kept seeing something and realized I didn't understand it."
|
||||
|
||||
4. **Linguistic Performance Markers:**
|
||||
- Primary transition: "So,"
|
||||
- Secondary transitions:
|
||||
* "And here's the thing"
|
||||
* "Which made me realize something"
|
||||
* "See,"
|
||||
- Conversational fillers: "like," "I mean," "honestly"
|
||||
|
||||
5. **Humor Guidelines:**
|
||||
- Dry, self-deprecating
|
||||
- Observational comedy
|
||||
- Highlight absurdity without forcing jokes
|
||||
- Meta-commentary on process
|
||||
|
||||
6. **Content Structure:**
|
||||
1. Personal discovery moment
|
||||
2. Clear investigation goal
|
||||
3. Systematic exploration
|
||||
4. Unexpected insights
|
||||
5. Broader implications
|
||||
6. Soft, organic conclusion
|
||||
|
||||
7. **Language Restrictions:**
|
||||
- No em dashes
|
||||
- Avoid academic vocabulary
|
||||
- No exaggerated YouTuber language
|
||||
- Minimal CTAs
|
||||
- Prioritize genuine curiosity
|
||||
|
||||
8. **High-Performance Topic Types:**
|
||||
- Technology disruption
|
||||
- Personal experiments
|
||||
- Demystifying complex topics
|
||||
- Unexpected perspectives on trends
|
||||
|
||||
9. **Sentence Mechanics:**
|
||||
- Mix technical explanation + conversational reaction
|
||||
- Short, punchy sentences
|
||||
- Rarely academic
|
||||
- Strategic emphatic words: "literally," "impressive"
|
||||
|
||||
10. **Fundamental Principle:**
|
||||
Sound like a smart, curious friend figuring something out — NOT an expert lecturing.
|
||||
|
||||
## Performance Correlation Insights
|
||||
- Experiment/challenge videos generate 3-5x more engagement
|
||||
- Personal vulnerability increases viewer retention
|
||||
- Unexpected topic approaches drive curiosity
|
||||
- Technical topics made accessible through storytelling
|
||||
|
||||
**Absolute Rule:** Authenticity trumps polish. Always.
|
||||
|
||||
${voiceGuide ? `## FULL VOICE ANALYSIS REFERENCE\n${voiceGuide.slice(0, 3000)}` : ""}
|
||||
${feedbackContext}
|
||||
${performanceContext}
|
||||
${longformFeedback}
|
||||
|
||||
${existingHook ? `## EXISTING HOOK (keep or improve):\n"${existingHook}"\n` : ""}
|
||||
|
||||
## SOURCE MATERIAL (verified):
|
||||
${sourceContext || "No sources — be cautious with claims, hedge everything."}
|
||||
|
||||
## SCRIPT FORMAT
|
||||
Write a FULL script in markdown with:
|
||||
- Clear section headers (## HOOK, ## PART 1: ..., etc.)
|
||||
- Stage directions in *[brackets]* for B-roll/screen recordings
|
||||
- ALL dialogue written as SPOKEN WORD, exactly how the user would say it on camera
|
||||
- Target: 8-15 minutes when read aloud (~1500-2500 words)
|
||||
- Use their actual patterns: "So, I decided to look into it just to understand it." / "And here's where it gets interesting."
|
||||
|
||||
## OUTPUT FORMAT — valid JSON:
|
||||
{
|
||||
"title": "Compelling YouTube title",
|
||||
"hook": "The first 2-3 sentences that stop the scroll — personal, narrative, curiosity-driven",
|
||||
"outline": "Section1 → Section2 → Section3 → ...",
|
||||
"targetLength": "X-Y min",
|
||||
"fullScript": "Full markdown script with all sections, stage directions, and dialogue",
|
||||
"factCheck": {
|
||||
"status": "✅",
|
||||
"verified": ["list of verified claims"],
|
||||
"issues": ["any issues found"]
|
||||
}
|
||||
}`;
|
||||
|
||||
const genResult = await callLLM(apiKey, apiBase, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Write a full long-form video script about: ${topic}\n\nMake it feel real and honest. Use facts from the source material. Write the COMPLETE script, not just an outline.` }
|
||||
],
|
||||
temperature: 0.85,
|
||||
response_format: { type: "json_object" }
|
||||
});
|
||||
|
||||
const genData = JSON.parse(genResult);
|
||||
const script = JSON.parse(genData.choices[0].message.content);
|
||||
|
||||
return NextResponse.json({
|
||||
id: `lf-${Date.now()}`,
|
||||
title: script.title,
|
||||
hook: script.hook,
|
||||
outline: script.outline,
|
||||
targetLength: script.targetLength || "8-12 min",
|
||||
fullScript: script.fullScript,
|
||||
platforms: ["youtube", "twitter"],
|
||||
factCheck: script.factCheck || { status: "✅", verified: [], issues: [] },
|
||||
status: "draft",
|
||||
type: contentType || "article",
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
notes: "",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const scripts = await prisma.longformScript.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json(scripts, {
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("[longform GET]", e?.message || e);
|
||||
return NextResponse.json({ error: e?.message || "DB error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
|
||||
const script = await prisma.longformScript.create({
|
||||
data: {
|
||||
id: body.id || undefined,
|
||||
title: body.title,
|
||||
type: body.type || null,
|
||||
status: body.status || "draft",
|
||||
platform: body.platform || null,
|
||||
platforms: body.platforms || null,
|
||||
description: body.description || null,
|
||||
hook: body.hook || null,
|
||||
outline: body.outline || null,
|
||||
fullScript: body.fullScript || null,
|
||||
targetLength: body.targetLength || null,
|
||||
factCheck: body.factCheck || null,
|
||||
notes: body.notes || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(script);
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { id, deleted, ...updates } = body;
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
if (deleted) {
|
||||
await prisma.longformScript.delete({ where: { id } }).catch(() => {});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const script = await prisma.longformScript.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
return NextResponse.json(script);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
|
||||
// TODO: Trigger files written to the local filesystem won't work on Vercel.
|
||||
// This route needs to be reworked for serverless (e.g., use a database queue,
|
||||
// Inngest, or a similar job system) when deploying to Vercel.
|
||||
const TRIGGERS_DIR = "./data/triggers";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { id, title, hook, outline, message } = await req.json();
|
||||
|
||||
if (!id || !message) {
|
||||
return NextResponse.json({ error: "id and message required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure triggers directory exists
|
||||
if (!fs.existsSync(TRIGGERS_DIR)) {
|
||||
fs.mkdirSync(TRIGGERS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Write trigger file for Nova to pick up
|
||||
const trigger = {
|
||||
type: "longform-tweak",
|
||||
scriptId: id,
|
||||
title: title || "",
|
||||
hook: hook || "",
|
||||
outline: outline || "",
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const filename = `longform-tweak-${id}-${Date.now()}.json`;
|
||||
fs.writeFileSync(`${TRIGGERS_DIR}/${filename}`, JSON.stringify(trigger, null, 2));
|
||||
|
||||
return NextResponse.json({ ok: true, trigger: filename });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Momentum Score — Weekly Performance Score (last 7 days)
|
||||
*
|
||||
* Components:
|
||||
* X Performance 35% — Weekly tweet views + engagement
|
||||
* YouTube Growth 20% — New subscribers this week
|
||||
* Trading 20% — Win rate (not P&L — skill > luck)
|
||||
* Content Output 25% — Posts made + quality this week
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET() {
|
||||
const now = Date.now();
|
||||
const weekAgo = new Date(now - 7 * 86_400_000);
|
||||
|
||||
// ── 1. X Performance (35%) ─────────────────────────────────
|
||||
let weeklyViews = parseInt(process.env.X_WEEKLY_VIEWS || "0");
|
||||
let weeklyPosts = 0;
|
||||
let bestTweetViews = parseInt(process.env.X_BEST_TWEET_VIEWS || "0");
|
||||
|
||||
if (weeklyViews === 0 || weeklyPosts === 0) {
|
||||
try {
|
||||
const drafts = await prisma.draft.findMany({ where: { postedAt: { gte: weekAgo } } });
|
||||
weeklyPosts = drafts.length;
|
||||
for (const d of drafts) {
|
||||
const m = d.metrics as Record<string, unknown> | null;
|
||||
const v = typeof m?.views === "number" ? m.views : 0;
|
||||
if (weeklyViews === 0) weeklyViews += v;
|
||||
if (v > bestTweetViews) bestTweetViews = v;
|
||||
}
|
||||
} catch { /* Prisma may be unavailable */ }
|
||||
} else {
|
||||
try {
|
||||
weeklyPosts = await prisma.draft.count({ where: { postedAt: { gte: weekAgo } } });
|
||||
} catch { weeklyPosts = parseInt(process.env.WEEKLY_POSTS || "3"); }
|
||||
}
|
||||
|
||||
const xScore = weeklyViews >= 1_000_000 ? 100
|
||||
: weeklyViews >= 500_000 ? 92
|
||||
: weeklyViews >= 200_000 ? 82
|
||||
: weeklyViews >= 100_000 ? 72
|
||||
: weeklyViews >= 50_000 ? 60
|
||||
: weeklyViews >= 10_000 ? 45
|
||||
: weeklyViews >= 1_000 ? 25
|
||||
: 10;
|
||||
|
||||
// ── 2. YouTube Growth (20%) ────────────────────────────────
|
||||
const currentYtSubs = parseInt(process.env.YT_SUBSCRIBERS || "0");
|
||||
let ytGrowth = parseInt(process.env.YT_WEEK_NEW_SUBS || "0");
|
||||
let ytSubsWeekAgo = ytGrowth > 0 ? currentYtSubs - ytGrowth : currentYtSubs;
|
||||
|
||||
if (ytGrowth === 0) {
|
||||
try {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "score-snapshots" } });
|
||||
const snapshots = (row?.data as any[]) ?? [];
|
||||
const target = new Date(weekAgo).toISOString().slice(0, 10);
|
||||
const past = snapshots.filter((s: any) => s.date <= target).sort((a: any, b: any) => b.date.localeCompare(a.date));
|
||||
if (past.length > 0) {
|
||||
ytSubsWeekAgo = past[0].ytSubscribers;
|
||||
ytGrowth = Math.max(0, currentYtSubs - ytSubsWeekAgo);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const ytGrowthPct = ytSubsWeekAgo > 0 ? (ytGrowth / ytSubsWeekAgo) * 100 : 0;
|
||||
|
||||
const ytScoreFromGrowth = ytGrowth >= 1000 ? 100
|
||||
: ytGrowth >= 500 ? 90
|
||||
: ytGrowth >= 200 ? 80
|
||||
: ytGrowth >= 100 ? 70
|
||||
: ytGrowth >= 50 ? 58
|
||||
: ytGrowth >= 20 ? 45
|
||||
: ytGrowth >= 5 ? 30
|
||||
: ytGrowth >= 1 ? 15
|
||||
: 5;
|
||||
|
||||
const ytGrowthBonus = ytGrowthPct >= 200 ? 15
|
||||
: ytGrowthPct >= 100 ? 10
|
||||
: ytGrowthPct >= 50 ? 5
|
||||
: 0;
|
||||
|
||||
const ytScore = Math.min(100, ytScoreFromGrowth + ytGrowthBonus);
|
||||
|
||||
// ── 3. Trading P&L (20%) ──────────────────────────────────
|
||||
const weekPolyPnl = parseFloat(process.env.POLY_WEEK_PNL || "0");
|
||||
const weekHlPnl = parseFloat(process.env.HL_WEEK_PNL || "0");
|
||||
const weekPnl = weekPolyPnl + weekHlPnl;
|
||||
|
||||
const tradingScore = weekPnl >= 200 ? 100
|
||||
: weekPnl >= 100 ? 90
|
||||
: weekPnl >= 50 ? 80
|
||||
: weekPnl >= 20 ? 72
|
||||
: weekPnl >= 5 ? 63
|
||||
: weekPnl >= 0 ? 52
|
||||
: weekPnl >= -10 ? 42
|
||||
: weekPnl >= -30 ? 32
|
||||
: weekPnl >= -60 ? 22
|
||||
: weekPnl >= -100 ? 12
|
||||
: 5;
|
||||
|
||||
// ── 4. Content Output (25%) ───────────────────────────────
|
||||
const consistencyScore = weeklyPosts >= 7 ? 100
|
||||
: weeklyPosts >= 5 ? 88
|
||||
: weeklyPosts >= 3 ? 75
|
||||
: weeklyPosts >= 2 ? 62
|
||||
: weeklyPosts >= 1 ? 50
|
||||
: 10;
|
||||
|
||||
const viralBonus = bestTweetViews >= 1_000_000 ? 12
|
||||
: bestTweetViews >= 500_000 ? 8
|
||||
: bestTweetViews >= 100_000 ? 4
|
||||
: 0;
|
||||
|
||||
const contentScore = Math.min(100, consistencyScore + viralBonus);
|
||||
|
||||
// ── Final score ────────────────────────────────────────────
|
||||
const raw = xScore * 0.40 + ytScore * 0.25 + contentScore * 0.35;
|
||||
const score = Math.round(Math.min(100, Math.max(0, raw)));
|
||||
|
||||
const grade = score >= 90 ? "S" : score >= 80 ? "A" : score >= 70 ? "B" : score >= 60 ? "C" : score >= 50 ? "D" : "F";
|
||||
const label = score >= 90 ? "Legendary" : score >= 80 ? "On Fire 🔥" : score >= 70 ? "Crushing It" : score >= 60 ? "Solid" : score >= 50 ? "Building" : "Slow Week";
|
||||
const color = score >= 75 ? "emerald" : score >= 55 ? "yellow" : "red";
|
||||
|
||||
return NextResponse.json({
|
||||
score, grade, label, color,
|
||||
period: "last 7 days",
|
||||
components: {
|
||||
x: { score: Math.round(xScore), weight: 0.40, label: "X Performance", detail: weeklyViews > 0 ? `${(weeklyViews/1000).toFixed(0)}K views` : "No data" },
|
||||
youtube: { score: Math.round(ytScore), weight: 0.25, label: "YouTube Growth", detail: ytGrowth > 0 ? `+${ytGrowth} subs (${ytGrowthPct.toFixed(0)}%)` : "Tracking..." },
|
||||
content: { score: Math.round(contentScore), weight: 0.35, label: "Content Output", detail: `${weeklyPosts} posts${bestTweetViews > 100000 ? ` · ${(bestTweetViews/1000000).toFixed(1)}M best` : ""}` },
|
||||
},
|
||||
inputs: {
|
||||
weeklyViews, weeklyPosts, bestTweetViews,
|
||||
currentYtSubs, ytSubsWeekAgo, ytGrowth, ytGrowthPct,
|
||||
weekPnl, weekPolyPnl, weekHlPnl,
|
||||
},
|
||||
generatedAt: new Date().toISOString(),
|
||||
}, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function getYouTubeMetrics(url: string) {
|
||||
try {
|
||||
const match = url.match(/(?:v=|youtu\.be\/|\/shorts\/)([a-zA-Z0-9_-]{11})/);
|
||||
if (!match) return null;
|
||||
const videoId = match[1];
|
||||
|
||||
// Use YouTube Data API v3 if key available (most reliable)
|
||||
const ytKey = process.env.YOUTUBE_API_KEY;
|
||||
if (ytKey) {
|
||||
const apiRes = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/videos?id=${videoId}&part=snippet,statistics&key=${ytKey}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (apiRes.ok) {
|
||||
const data = await apiRes.json();
|
||||
const item = data.items?.[0];
|
||||
if (item) {
|
||||
return {
|
||||
title: item.snippet?.title || "",
|
||||
thumbnail: item.snippet?.thumbnails?.maxres?.url ||
|
||||
item.snippet?.thumbnails?.high?.url ||
|
||||
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
||||
viewCount: parseInt(item.statistics?.viewCount || "0"),
|
||||
likeCount: parseInt(item.statistics?.likeCount || "0"),
|
||||
commentCount: parseInt(item.statistics?.commentCount || "0"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: oEmbed for title/thumbnail + noembed for basic info
|
||||
let title = "";
|
||||
let thumbnail = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||
let viewCount = 0;
|
||||
let likeCount = 0;
|
||||
|
||||
try {
|
||||
const oembed = await fetch(
|
||||
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (oembed.ok) {
|
||||
const d = await oembed.json();
|
||||
title = d.title || "";
|
||||
thumbnail = d.thumbnail_url || thumbnail;
|
||||
}
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Try to get view count from YouTube page (parse simple patterns)
|
||||
try {
|
||||
const pageRes = await fetch(
|
||||
`https://www.youtube.com/watch?v=${videoId}`,
|
||||
{
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)" },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
if (pageRes.ok) {
|
||||
const html = await pageRes.text();
|
||||
// View count appears as "viewCount\":\"12345\"
|
||||
const vcMatch = html.match(/"viewCount"\s*:\s*"(\d+)"/);
|
||||
if (vcMatch) viewCount = parseInt(vcMatch[1]);
|
||||
// Like count
|
||||
const lcMatch = html.match(/"likeCount"\s*:\s*"(\d+)"/);
|
||||
if (lcMatch) likeCount = parseInt(lcMatch[1]);
|
||||
// Title fallback
|
||||
if (!title) {
|
||||
const tMatch = html.match(/<title>([^<]+)<\/title>/);
|
||||
if (tMatch) title = tMatch[1].replace(" - YouTube", "").trim();
|
||||
}
|
||||
}
|
||||
} catch { /* ok */ }
|
||||
|
||||
return { title, thumbnail, viewCount, likeCount, commentCount: 0 };
|
||||
} catch (e) {
|
||||
console.error("YouTube scrape error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTwitterMetrics(tweetUrl: string) {
|
||||
const xaiKey = process.env.XAI_API_KEY;
|
||||
if (!xaiKey) { console.error("[scrape-metrics] XAI_API_KEY not set"); return null; }
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
|
||||
try {
|
||||
console.log("[scrape-metrics] Calling xAI for:", tweetUrl);
|
||||
const res = await fetch("https://api.x.ai/v1/responses", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${xaiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "grok-4-1-fast-non-reasoning",
|
||||
tools: [{ type: "x_search" }],
|
||||
input: [{
|
||||
role: "user",
|
||||
content: `Fetch the engagement stats for this specific tweet ID: ${tweetUrl}\nReturn ONLY this JSON (no markdown, no extra text): {"views":0,"likes":0,"retweets":0,"bookmarks":0,"replies":0}`
|
||||
}]
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
console.log("[scrape-metrics] xAI status:", res.status);
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
console.error("[scrape-metrics] xAI error:", errText.slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
const text = data.output?.find((o: { type: string }) => o.type === "message")
|
||||
?.content?.find((c: { type: string }) => c.type === "output_text")?.text || "";
|
||||
console.log("[scrape-metrics] xAI response text:", text.slice(0, 300));
|
||||
const jsonMatch = text.match(/\{[^{}]*\}/);
|
||||
if (!jsonMatch) { console.error("[scrape-metrics] No JSON in response:", text.slice(0, 300)); return null; }
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
console.log("[scrape-metrics] Parsed twitter metrics:", parsed);
|
||||
return parsed;
|
||||
} catch (e: any) {
|
||||
clearTimeout(timeout);
|
||||
console.error("[scrape-metrics] xAI fetch error:", e?.name, e?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { youtubeUrl, tweetUrl, scriptId } = await req.json();
|
||||
const result: any = {};
|
||||
|
||||
// Run both in parallel
|
||||
const [yt, twitter] = await Promise.all([
|
||||
youtubeUrl ? getYouTubeMetrics(youtubeUrl) : Promise.resolve(null),
|
||||
tweetUrl ? getTwitterMetrics(tweetUrl) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (yt) result.youtube = yt;
|
||||
if (twitter) result.twitter = twitter;
|
||||
|
||||
// If scriptId provided, save directly to DB — no client-side PATCH needed
|
||||
if (scriptId && (yt || twitter)) {
|
||||
try {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (yt) {
|
||||
if (yt.title) updates.title = yt.title;
|
||||
if (yt.thumbnail) updates.thumbnailUrl = yt.thumbnail;
|
||||
if (yt.viewCount != null) updates.youtubeViews = yt.viewCount;
|
||||
if (yt.likeCount != null) updates.youtubeLikes = yt.likeCount;
|
||||
if (yt.commentCount != null) updates.youtubeComments = yt.commentCount;
|
||||
}
|
||||
if (twitter) {
|
||||
if (twitter.views != null) updates.tweetViews = twitter.views;
|
||||
if (twitter.likes != null) updates.tweetLikes = twitter.likes;
|
||||
if (twitter.bookmarks != null) updates.tweetBookmarks = twitter.bookmarks;
|
||||
if (twitter.retweets != null) updates.tweetRetweets = twitter.retweets;
|
||||
if (twitter.replies != null) updates.tweetReplies = twitter.replies;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const saved = await prisma.longformScript.update({ where: { id: scriptId }, data: updates });
|
||||
result.saved = true;
|
||||
// Return the updated fields in result so client can update UI
|
||||
result.updatedScript = saved;
|
||||
console.log("[scrape-metrics] Saved to DB:", scriptId, Object.keys(updates));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[scrape-metrics] DB save error:", e);
|
||||
result.saveError = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// This would connect to Notion API
|
||||
// For now, return mock data
|
||||
|
||||
const NOTION_API_KEY = process.env.NOTION_API_KEY;
|
||||
const DATABASE_ID = "1264208d-f768-4604-b4cb-09f4d6fd41e3"; // Max's Tasks DB
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!NOTION_API_KEY) {
|
||||
// Return mock data if no API key
|
||||
return NextResponse.json({
|
||||
tasks: [
|
||||
{ id: "1", name: "Review Polymarket bot strategy", status: "In progress", priority: "High", category: "Research" },
|
||||
{ id: "2", name: "Build Hermy HQ dashboard", status: "In progress", priority: "High", category: "Content" },
|
||||
{ id: "3", name: "Daily brief automation", status: "Approved", priority: "Medium", category: "Admin" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`https://api.notion.com/v1/databases/${DATABASE_ID}/query`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${NOTION_API_KEY}`,
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filter: {
|
||||
property: "Status",
|
||||
status: {
|
||||
does_not_equal: "Done",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const tasks = data.results?.map((page: any) => ({
|
||||
id: page.id,
|
||||
name: page.properties.Name?.title?.[0]?.plain_text || "Untitled",
|
||||
status: page.properties.Status?.status?.name || "Not started",
|
||||
priority: page.properties.Priority?.select?.name || "",
|
||||
category: page.properties.Category?.select?.name || "",
|
||||
dueDate: page.properties["Due Date"]?.date?.start || null,
|
||||
})) || [];
|
||||
|
||||
return NextResponse.json({ tasks });
|
||||
} catch (error) {
|
||||
console.error("Tasks API error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { name, status } = await req.json();
|
||||
|
||||
if (!NOTION_API_KEY) {
|
||||
return NextResponse.json({ success: true, message: "Mock - would create task in Notion" });
|
||||
}
|
||||
|
||||
const res = await fetch("https://api.notion.com/v1/pages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${NOTION_API_KEY}`,
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parent: { database_id: DATABASE_ID },
|
||||
properties: {
|
||||
Name: { title: [{ text: { content: name } }] },
|
||||
Status: { status: { name: status || "Not started" } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ success: true, task: data });
|
||||
} catch (error) {
|
||||
console.error("Create task error:", error);
|
||||
return NextResponse.json({ error: "Failed to create task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const { id, status } = await req.json();
|
||||
|
||||
if (!NOTION_API_KEY) {
|
||||
return NextResponse.json({ success: true, message: "Mock - would update task in Notion" });
|
||||
}
|
||||
|
||||
const res = await fetch(`https://api.notion.com/v1/pages/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${NOTION_API_KEY}`,
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
Status: { status: { name: status } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ success: true, task: data });
|
||||
} catch (error) {
|
||||
console.error("Update task error:", error);
|
||||
return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
interface Trend {
|
||||
id: string;
|
||||
topic: string;
|
||||
summary: string;
|
||||
source: "x_search" | "rss" | "breaking";
|
||||
velocity: "forming" | "rising" | "peaking" | "fading";
|
||||
velocityScore: number;
|
||||
ageMinutes: number;
|
||||
relevance: "crypto" | "ai" | "creator" | "other";
|
||||
url: string | null;
|
||||
sageAngle: string | null;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
interface TrendsResponse {
|
||||
trends: Trend[];
|
||||
lastUpdated: string;
|
||||
nextUpdate: string;
|
||||
}
|
||||
|
||||
async function readTrendData(): Promise<TrendsResponse> {
|
||||
// 1. Try DataStore (synced by heartbeat)
|
||||
try {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "trend-radar" } });
|
||||
if (row?.data) {
|
||||
const parsed = row.data as unknown as TrendsResponse;
|
||||
if (parsed.trends && parsed.trends.length > 0) return parsed;
|
||||
}
|
||||
} catch { /* not available */ }
|
||||
|
||||
// 2. Try env var cache (pushed by heartbeat, works on Vercel)
|
||||
if (process.env.TREND_RADAR_DATA) {
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.TREND_RADAR_DATA) as TrendsResponse;
|
||||
if (parsed.trends && parsed.trends.length > 0) {
|
||||
// Normalise any non-standard velocity values Grok might return
|
||||
const validVelocities = new Set(["forming", "rising", "peaking", "fading"]);
|
||||
const velocityMap: Record<string, Trend["velocity"]> = {
|
||||
viral: "peaking", emerging: "forming", stable: "fading",
|
||||
breaking: "peaking", new: "forming", growing: "rising",
|
||||
};
|
||||
parsed.trends = parsed.trends.map((t) => ({
|
||||
...t,
|
||||
velocity: validVelocities.has(t.velocity)
|
||||
? t.velocity
|
||||
: (velocityMap[t.velocity] ?? "forming"),
|
||||
}));
|
||||
return parsed;
|
||||
}
|
||||
} catch { /* invalid */ }
|
||||
}
|
||||
|
||||
// 3. No real data — return empty
|
||||
const now = new Date();
|
||||
return {
|
||||
trends: [],
|
||||
lastUpdated: now.toISOString(),
|
||||
nextUpdate: new Date(now.getTime() + 30 * 60000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await readTrendData();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("[trends] GET error:", error);
|
||||
const now = new Date();
|
||||
return NextResponse.json({
|
||||
trends: [],
|
||||
lastUpdated: now.toISOString(),
|
||||
nextUpdate: new Date(now.getTime() + 5 * 60000).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SAGE_SYSTEM = `You are Sage 🌿, the user's X/Twitter content specialist. You write viral tweets in the user's voice.
|
||||
|
||||
== WHO THE USER IS ==
|
||||
- AI content creator, growing fast
|
||||
- Founder building AI tools and products
|
||||
- Runs AI agents (Max, Sage, Knox, Nova, Pixel)
|
||||
- Runs trading bots on Polymarket + Hyperliquid
|
||||
|
||||
== VOICE — RULES ==
|
||||
1. Lowercase throughout (except proper nouns like Claude, OpenAI, etc.)
|
||||
2. No em-dashes (—). Use line breaks instead
|
||||
3. No "harness", "leverage", "AI-powered", "here's why X is dying", "hot take:"
|
||||
4. Never end with "what do you think?" or "let me know below"
|
||||
5. No 🧵 thread emoji
|
||||
6. Real numbers anchor every claim ($140/month, 999K views, 94% win rate)
|
||||
7. Line breaks every 1-2 sentences — no walls of text
|
||||
8. The hook is everything. First line must make you stop scrolling.
|
||||
|
||||
== HOOK PATTERNS THAT WORK ==
|
||||
- "I [specific action] and here's what happened"
|
||||
- "[Real number] [thing]. here's how."
|
||||
- "most people [wrong thing]. I [right thing]."
|
||||
- "[Provocative statement that sounds wrong but is right]"
|
||||
- Direct revelation of a specific setup/process/cost
|
||||
|
||||
== BEST TWEET FORMATS ==
|
||||
Short (3-5 lines): reactive take, "been doing this" energy, punchy closer
|
||||
Medium (8-15 lines): setup reveal with real agent names/costs/numbers
|
||||
Long: step-by-step with real data, ends with the insight that makes it worth reading
|
||||
|
||||
== WHAT PERFORMS BEST RIGHT NOW ==
|
||||
- AI agent setup reveals (specific names, models, costs)
|
||||
- Prompt sharing (copy-paste value)
|
||||
- Cost transparency ($140/month type hooks)
|
||||
- Reactive takes on AI news with "I've been doing this for X months" framing
|
||||
- Trading bot updates with real P&L numbers
|
||||
|
||||
== WHAT FLOPS ==
|
||||
- Generic AI takes ("AI will change everything")
|
||||
- No personal angle
|
||||
- Info anyone could Google
|
||||
- Starting with a question
|
||||
- Ending with engagement bait
|
||||
|
||||
== YOUR TASK ==
|
||||
Write ONE tweet about the trending topic. It must:
|
||||
- Sound like the user wrote it, not a journalist
|
||||
- Use the user's personal AI setup as the lens (their agents, their bots, their experience)
|
||||
- Be sharp, specific, and feel like insider knowledge
|
||||
- Be under 240 characters for short format, or a proper multi-line thread for longer
|
||||
- Make people want to bookmark it or reply
|
||||
`;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { trendId, topic, summary } = await req.json() as { trendId: string; topic: string; summary: string };
|
||||
|
||||
if (!topic) {
|
||||
return NextResponse.json({ error: "topic required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Load recent top tweets as live examples (from DataStore if available)
|
||||
let topTweetsContext = "";
|
||||
try {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "voice-examples" } });
|
||||
if (row?.data) {
|
||||
const voiceFile = typeof row.data === "string" ? row.data : JSON.stringify(row.data);
|
||||
const examples = voiceFile.match(/```\n([\s\S]*?)```/g)?.slice(0, 3).join("\n\n") || "";
|
||||
if (examples) {
|
||||
topTweetsContext = `\n\n== ACTUAL RECENT TOP TWEETS (use these as voice reference) ==\n${examples}`;
|
||||
}
|
||||
}
|
||||
} catch { /* Vercel can't read local files — skip */ }
|
||||
|
||||
const userMessage = `Write a viral tweet in the user's voice about this trending topic:
|
||||
|
||||
TOPIC: ${topic}
|
||||
WHAT'S HAPPENING: ${summary}
|
||||
|
||||
Remember: connect it to the user's world (their AI agents, their trading bots, their creator journey). Don't just report the news, make it personal, specific, and sharp. Their audience wants to know what THIS means for someone already running AI agents at scale.${topTweetsContext}`;
|
||||
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://your-app.vercel.app",
|
||||
"X-Title": "Hermy HQ Trend Sniper",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
messages: [
|
||||
{ role: "system", content: SAGE_SYSTEM },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
max_tokens: 600,
|
||||
temperature: 0.85,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
console.error("OpenRouter error:", err);
|
||||
return NextResponse.json({ error: "Failed to generate draft" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices: { message: { content: string } }[] };
|
||||
const draft = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
|
||||
if (!draft) {
|
||||
return NextResponse.json({ error: "Empty response from model" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Save to drafts via Hermy HQ API
|
||||
let saved = false;
|
||||
try {
|
||||
const saveRes = await fetch("https://your-app.vercel.app/api/x-content", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-secret": process.env.INTERNAL_API_SECRET || "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: draft,
|
||||
category: "trend-snipe",
|
||||
title: `Trend Snipe: ${topic}`,
|
||||
}),
|
||||
});
|
||||
saved = saveRes.ok;
|
||||
} catch { /* save failed, draft still returned */ }
|
||||
|
||||
return NextResponse.json({ draft, trendId, saved });
|
||||
|
||||
} catch (err) {
|
||||
console.error("Snipe error:", err);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SECRET = process.env.INTERNAL_SECRET || "296cfa48ad6963eb21ac9deb4981ad0c982cff51631bda9cf43fd0fa7217088b";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const secret = req.headers.get("x-internal-secret");
|
||||
if (secret !== SECRET) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
if (!body.trends || !Array.isArray(body.trends)) {
|
||||
return NextResponse.json({ error: "Invalid payload — expected { trends, lastUpdated, nextUpdate }" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.dataStore.upsert({
|
||||
where: { key: "trend-radar" },
|
||||
update: { data: body },
|
||||
create: { key: "trend-radar", data: body },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, count: body.trends.length });
|
||||
} catch (error) {
|
||||
console.error("[trends/update] error:", error);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const row = await prisma.dataStore.findUnique({ where: { key: "watchlist-radar" } });
|
||||
if (row?.data) return NextResponse.json(row.data);
|
||||
} catch { /* fallthrough */ }
|
||||
return NextResponse.json({ signals: [], lastUpdated: null });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SECRET = "296cfa48ad6963eb21ac9deb4981ad0c982cff51631bda9cf43fd0fa7217088b";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (req.headers.get("x-internal-secret") !== SECRET)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
if (!Array.isArray(body.signals))
|
||||
return NextResponse.json({ error: "Expected { signals: [] }" }, { status: 400 });
|
||||
|
||||
await prisma.dataStore.upsert({
|
||||
where: { key: "watchlist-radar" },
|
||||
update: { data: body },
|
||||
create: { key: "watchlist-radar", data: body },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, count: body.signals.length });
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/** Extract the latest metrics from either a flat metrics object or a checkpoints time-series */
|
||||
function extractMetrics(raw: unknown): { views: number; likes: number; bookmarks: number; replies: number; retweets: number } {
|
||||
if (!raw || typeof raw !== "object") return { views: 0, likes: 0, bookmarks: 0, replies: 0, retweets: 0 };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
// Flat metrics: { views, likes, bookmarks, replies, retweets }
|
||||
if (typeof obj.views === "number" || typeof obj.likes === "number") {
|
||||
return {
|
||||
views: Number(obj.views) || 0,
|
||||
likes: Number(obj.likes) || 0,
|
||||
bookmarks: Number(obj.bookmarks) || 0,
|
||||
replies: Number(obj.replies) || 0,
|
||||
retweets: Number(obj.retweets) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Checkpoints time-series: { "1h": {...}, "grok_2026-02-15": {...}, ... }
|
||||
// Pick the checkpoint with the highest views
|
||||
let best = { views: 0, likes: 0, bookmarks: 0, replies: 0, retweets: 0 };
|
||||
for (const val of Object.values(obj)) {
|
||||
if (val && typeof val === "object") {
|
||||
const cp = val as Record<string, unknown>;
|
||||
const v = Number(cp.views) || 0;
|
||||
if (v > best.views) {
|
||||
best = {
|
||||
views: v,
|
||||
likes: Number(cp.likes) || 0,
|
||||
bookmarks: Number(cp.bookmarks) || 0,
|
||||
replies: Number(cp.replies) || 0,
|
||||
retweets: Number(cp.retweets) || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Fetch posted drafts
|
||||
const dbDrafts = await prisma.draft.findMany({
|
||||
where: { status: "posted" },
|
||||
orderBy: { postedAt: "desc" },
|
||||
});
|
||||
|
||||
// Fetch all tweet metrics (includes external tweets not in drafts)
|
||||
const dbMetrics = await prisma.tweetMetric.findMany();
|
||||
const existingDraftIds = new Set(dbDrafts.map((d) => d.id));
|
||||
const matchedTweetIds = new Set<string>();
|
||||
|
||||
const tweets: {
|
||||
id: string;
|
||||
text: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
bookmarks: number;
|
||||
replies: number;
|
||||
retweets: number;
|
||||
engRate: number;
|
||||
postedAt: Date | string | null;
|
||||
tweetUrl: string | null;
|
||||
}[] = [];
|
||||
|
||||
// Process drafts
|
||||
for (const d of dbDrafts) {
|
||||
// Find matching metric for this draft
|
||||
const metric = dbMetrics.find(
|
||||
(m) => m.draftId === d.id || (d.tweetId && m.tweetId === d.tweetId)
|
||||
);
|
||||
if (metric?.tweetId) matchedTweetIds.add(metric.tweetId);
|
||||
|
||||
// Use draft.metrics if available, otherwise fall back to metric checkpoints
|
||||
const rawMetrics = d.metrics ?? metric?.checkpoints;
|
||||
if (!rawMetrics && !d.tweetUrl) continue; // skip if no data at all
|
||||
|
||||
const { views, likes, bookmarks, replies, retweets } = extractMetrics(rawMetrics);
|
||||
const engRate =
|
||||
views > 0
|
||||
? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100
|
||||
: 0;
|
||||
|
||||
tweets.push({
|
||||
id: d.id,
|
||||
text: d.text || "",
|
||||
views,
|
||||
likes,
|
||||
bookmarks,
|
||||
replies,
|
||||
retweets,
|
||||
engRate,
|
||||
postedAt: d.postedAt,
|
||||
tweetUrl: d.tweetUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Add external tweets (posted directly on X, not through Hermy HQ)
|
||||
for (const m of dbMetrics) {
|
||||
if (!m.tweetId || matchedTweetIds.has(m.tweetId)) continue;
|
||||
const draftIdExists = m.draftId && existingDraftIds.has(m.draftId);
|
||||
if (draftIdExists) continue;
|
||||
|
||||
const { views, likes, bookmarks, replies, retweets } = extractMetrics(m.checkpoints);
|
||||
if (views === 0 && likes === 0) continue; // skip empty entries
|
||||
|
||||
const engRate =
|
||||
views > 0
|
||||
? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100
|
||||
: 0;
|
||||
|
||||
tweets.push({
|
||||
id: m.draftId || `tweet-${m.tweetId}`,
|
||||
text: "",
|
||||
views,
|
||||
likes,
|
||||
bookmarks,
|
||||
replies,
|
||||
retweets,
|
||||
engRate,
|
||||
postedAt: m.postedAt,
|
||||
tweetUrl: m.url,
|
||||
});
|
||||
|
||||
matchedTweetIds.add(m.tweetId);
|
||||
}
|
||||
|
||||
// Sort by views desc
|
||||
tweets.sort((a, b) => b.views - a.views);
|
||||
|
||||
// Build heatmap: 7 days x 24 hours
|
||||
const heatmap: Record<string, { sum: number; count: number }> = {};
|
||||
for (const t of tweets) {
|
||||
if (!t.postedAt || t.views === 0) continue;
|
||||
const dt = new Date(t.postedAt as string);
|
||||
const key = `${dt.getUTCDay()}-${dt.getUTCHours()}`;
|
||||
if (!heatmap[key]) heatmap[key] = { sum: 0, count: 0 };
|
||||
heatmap[key].sum += t.views;
|
||||
heatmap[key].count++;
|
||||
}
|
||||
const heatmapData = Object.entries(heatmap).map(([key, v]) => {
|
||||
const [day, hour] = key.split("-").map(Number);
|
||||
return {
|
||||
day,
|
||||
hour,
|
||||
avgViews: Math.round(v.sum / v.count),
|
||||
count: v.count,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ tweets, heatmap: heatmapData });
|
||||
} catch {
|
||||
return NextResponse.json({ tweets: [], heatmap: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get the most recent calendar entry
|
||||
const calendar = await prisma.contentCalendar.findFirst({
|
||||
orderBy: { week: "desc" },
|
||||
});
|
||||
|
||||
if (!calendar) return NextResponse.json({ days: [] });
|
||||
|
||||
// The `data` field contains the full calendar JSON
|
||||
return NextResponse.json(calendar.data);
|
||||
} catch {
|
||||
return NextResponse.json({ days: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { draftId, rating, reason, scheduledDate, scheduledTime, removeVisual, editText, tweakComment } = body;
|
||||
|
||||
const draft = await prisma.draft.findUnique({ where: { id: draftId } });
|
||||
if (!draft) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Handle tweak request — save on draft + trigger Sage
|
||||
if (tweakComment) {
|
||||
// Note: tweakRequested is not in the Prisma schema, so we store it as part of a JSON update
|
||||
// For now, just write the trigger file
|
||||
// TODO: Trigger file paths won't exist on Vercel — needs a different mechanism (e.g. queue, webhook)
|
||||
try {
|
||||
const triggerDir = path.join("./data", "triggers");
|
||||
fs.mkdirSync(triggerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(triggerDir, `tweak-${draftId}.json`), JSON.stringify({
|
||||
type: "tweak",
|
||||
draftId,
|
||||
originalText: draft.text,
|
||||
feedback: tweakComment,
|
||||
createdAt: new Date().toISOString(),
|
||||
}, null, 2));
|
||||
} catch {}
|
||||
|
||||
// Return the draft in API format
|
||||
const { feedbackRating, feedbackReason, updatedAt, ...rest } = draft as any;
|
||||
return NextResponse.json({
|
||||
...rest,
|
||||
feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" },
|
||||
tweakRequested: {
|
||||
comment: tweakComment,
|
||||
requestedAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (removeVisual) {
|
||||
updateData.visualUrl = null;
|
||||
}
|
||||
|
||||
// If scheduling, update date and time
|
||||
if (scheduledDate !== undefined) {
|
||||
updateData.scheduledDate = scheduledDate;
|
||||
updateData.scheduledTime = scheduledTime ?? null;
|
||||
|
||||
// Move to scheduled status
|
||||
if (draft.status === "approved" || draft.status === "scheduled") {
|
||||
updateData.status = "scheduled";
|
||||
}
|
||||
} else if (scheduledTime !== undefined) {
|
||||
// Allow updating time without changing the date
|
||||
updateData.scheduledTime = scheduledTime;
|
||||
}
|
||||
|
||||
// Unschedule functionality
|
||||
if (scheduledDate === null) {
|
||||
updateData.scheduledDate = null;
|
||||
updateData.scheduledTime = null;
|
||||
// Move back to "Approved" if was "Scheduled"
|
||||
if (draft.status === "scheduled") {
|
||||
updateData.status = "approved";
|
||||
}
|
||||
}
|
||||
|
||||
// Inline edit with history tracking
|
||||
if (editText !== undefined) {
|
||||
const currentHistory = (draft.editHistory as any[]) || [];
|
||||
updateData.editHistory = [
|
||||
...currentHistory,
|
||||
{ original: draft.text, edited: editText, editedAt: new Date().toISOString() },
|
||||
];
|
||||
updateData.text = editText;
|
||||
}
|
||||
|
||||
if (rating === "undo" || rating === "pending") {
|
||||
updateData.status = "pending";
|
||||
updateData.feedbackRating = null;
|
||||
updateData.feedbackReason = "";
|
||||
} else if (rating === "approved" || rating === "up") {
|
||||
updateData.feedbackRating = "approved";
|
||||
updateData.feedbackReason = reason || "";
|
||||
updateData.status = "approved";
|
||||
} else if (rating === "rejected" || rating === "down") {
|
||||
updateData.feedbackRating = "rejected";
|
||||
updateData.feedbackReason = reason || "";
|
||||
updateData.status = "rejected";
|
||||
} else if (rating === "posted") {
|
||||
updateData.status = "posted";
|
||||
updateData.postedAt = new Date();
|
||||
if (body.postedUrl) {
|
||||
updateData.postedUrl = body.postedUrl;
|
||||
updateData.tweetUrl = body.postedUrl;
|
||||
// Extract tweet ID from URL
|
||||
const match = body.postedUrl.match(/status\/(\d+)/);
|
||||
if (match) {
|
||||
updateData.tweetId = match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.draft.update({
|
||||
where: { id: draftId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
const { feedbackRating, feedbackReason, updatedAt, ...rest } = updated as any;
|
||||
return NextResponse.json({
|
||||
...rest,
|
||||
feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// your approved visual theme — warm cream, white cards, colorful left bars
|
||||
const ACCENT_COLORS = ["#e8614d", "#5b9cf6", "#34c785", "#f5a623", "#e879a0", "#a78bfa"];
|
||||
const BG = "#f2ebe0";
|
||||
const CARD_BG = "#ffffff";
|
||||
const TITLE_COLOR = "#1a1a1a";
|
||||
const MUTED_TEXT = "#999999";
|
||||
const SUBTITLE_COLOR = "#aaaaaa";
|
||||
const FOOTER_COLOR = "#bbbbbb";
|
||||
|
||||
function esc(s: string) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wrapText(text: string, maxChars: number): string[] {
|
||||
const words = text.split(" ");
|
||||
const lines: string[] = [];
|
||||
let cur = "";
|
||||
for (const w of words) {
|
||||
if ((cur + " " + w).trim().length > maxChars) {
|
||||
if (cur) lines.push(cur.trim());
|
||||
cur = w;
|
||||
} else {
|
||||
cur = (cur + " " + w).trim();
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur.trim());
|
||||
return lines;
|
||||
}
|
||||
|
||||
function stripBullet(l: string): string {
|
||||
return l.replace(/^(\d+[\.\)]\s*|[•\-\*→\.]\s*)/, "").trim();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { draftId } = await req.json();
|
||||
if (!draftId) return NextResponse.json({ error: "draftId required" }, { status: 400 });
|
||||
|
||||
const draft = await prisma.draft.findUnique({ where: { id: draftId } });
|
||||
if (!draft) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
try {
|
||||
const tweetText = draft.text || "";
|
||||
const rawLines = tweetText.split(/\n+/).map((l) => l.trim()).filter(Boolean);
|
||||
|
||||
// ── Detect visual type ──────────────────────────────────────────
|
||||
const isComparison = /\bvs\.?\b/i.test(tweetText) && tweetText.includes("\n");
|
||||
const listItems = rawLines.filter((l) => l.match(/^[\d•\-\*→]\s*[\.\)]?\s*/));
|
||||
const isList = listItems.length >= 3;
|
||||
|
||||
// ── Shared dimensions ───────────────────────────────────────────
|
||||
const W = 1080;
|
||||
const H = 1350;
|
||||
const PAD = 64;
|
||||
const CARD_RADIUS = 16;
|
||||
const BAR_W = 6;
|
||||
|
||||
let svgBody = "";
|
||||
|
||||
if (isComparison) {
|
||||
// ── COMPARISON: two-column layout ────────────────────────────
|
||||
// Find "vs" line or split first/second halves
|
||||
const vsIdx = rawLines.findIndex((l) => /^vs\.?$/i.test(l));
|
||||
let leftTitle = "prompting";
|
||||
let rightTitle = "delegating";
|
||||
let leftItems: string[] = [];
|
||||
let rightItems: string[] = [];
|
||||
|
||||
if (vsIdx > 0) {
|
||||
leftTitle = rawLines[0];
|
||||
rightTitle = rawLines[vsIdx + 1] || "delegating";
|
||||
leftItems = rawLines.slice(1, vsIdx).map(stripBullet).filter(Boolean);
|
||||
rightItems = rawLines.slice(vsIdx + 2).map(stripBullet).filter(Boolean);
|
||||
} else {
|
||||
// Try to extract from "examples" section or just split list items evenly
|
||||
const allItems = rawLines.filter((l) => l.startsWith("-")).map((l) => l.replace(/^-\s*/, "").trim());
|
||||
const mid = Math.ceil(allItems.length / 2);
|
||||
leftItems = allItems.slice(0, mid).map((l) => l.split(" becomes ")[0]?.replace(/^"|"$/g, "").trim()).filter(Boolean);
|
||||
rightItems = allItems.slice(0, mid).map((l) => l.split(" becomes ")[1]?.replace(/^"|"$/g, "").trim()).filter(Boolean);
|
||||
if (rightItems.every((r) => !r)) {
|
||||
leftItems = allItems.slice(0, mid);
|
||||
rightItems = allItems.slice(mid);
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
const titleText = rawLines[0] || "prompting vs delegating";
|
||||
const subtitleText = "most people never make the jump";
|
||||
svgBody += `<text x="${PAD}" y="96" font-size="58" font-weight="800" fill="${TITLE_COLOR}" font-family="-apple-system,'SF Pro Display','Inter',sans-serif" letter-spacing="-2">${esc(titleText)}</text>`;
|
||||
svgBody += `<text x="${PAD}" y="126" font-size="14" font-weight="600" fill="${SUBTITLE_COLOR}" font-family="-apple-system,sans-serif" letter-spacing="3" text-transform="uppercase">${esc(subtitleText.toUpperCase())}</text>`;
|
||||
|
||||
// Column headers
|
||||
const colW = (W - PAD * 2 - 20) / 2;
|
||||
const colRightX = PAD + colW + 20;
|
||||
svgBody += `<text x="${PAD}" y="174" font-size="11" font-weight="700" fill="${MUTED_TEXT}" font-family="-apple-system,sans-serif" letter-spacing="3">PROMPTING</text>`;
|
||||
svgBody += `<text x="${colRightX}" y="174" font-size="11" font-weight="700" fill="#555555" font-family="-apple-system,sans-serif" letter-spacing="3">DELEGATING</text>`;
|
||||
|
||||
// Rows
|
||||
const maxRows = Math.max(leftItems.length, rightItems.length);
|
||||
const availH = H - 240 - 80; // space for header + footer
|
||||
const rowH = Math.min(Math.floor(availH / maxRows) - 12, 140);
|
||||
let rowY = 194;
|
||||
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const left = leftItems[i] || "";
|
||||
const right = rightItems[i] || "";
|
||||
const accentColor = ACCENT_COLORS[i % ACCENT_COLORS.length];
|
||||
|
||||
// Left card (muted, no color bar)
|
||||
svgBody += `<rect x="${PAD}" y="${rowY}" width="${colW}" height="${rowH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`;
|
||||
const leftLines = wrapText(left, 24);
|
||||
leftLines.slice(0, 3).forEach((line, li) => {
|
||||
svgBody += `<text x="${PAD + 20}" y="${rowY + 30 + li * 24}" font-size="17" fill="${MUTED_TEXT}" font-style="italic" font-family="-apple-system,sans-serif">${esc(line)}</text>`;
|
||||
});
|
||||
|
||||
// Right card (colored bar)
|
||||
svgBody += `<rect x="${colRightX}" y="${rowY}" width="${colW}" height="${rowH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`;
|
||||
// Colored left bar (clipped to card corners)
|
||||
svgBody += `<rect x="${colRightX}" y="${rowY}" width="${BAR_W}" height="${rowH}" rx="${CARD_RADIUS}" fill="${accentColor}"/>`;
|
||||
svgBody += `<rect x="${colRightX}" y="${rowY}" width="${BAR_W}" height="${rowH / 2}" fill="${accentColor}"/>`;
|
||||
const rightLines = wrapText(right, 26);
|
||||
rightLines.slice(0, 3).forEach((line, li) => {
|
||||
svgBody += `<text x="${colRightX + BAR_W + 14}" y="${rowY + 30 + li * 24}" font-size="17" fill="${TITLE_COLOR}" font-weight="500" font-family="-apple-system,sans-serif">${esc(line)}</text>`;
|
||||
});
|
||||
|
||||
rowY += rowH + 12;
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = H - 44;
|
||||
svgBody += `<line x1="${PAD}" y1="${footerY - 18}" x2="${W - PAD}" y2="${footerY - 18}" stroke="#dddddd" stroke-width="1"/>`;
|
||||
svgBody += `<text x="${PAD}" y="${footerY}" font-size="12" fill="${FOOTER_COLOR}" font-family="-apple-system,sans-serif" letter-spacing="2">SAVES MINUTES</text>`;
|
||||
svgBody += `<text x="${W - PAD}" y="${footerY}" font-size="12" fill="#555555" font-weight="700" font-family="-apple-system,sans-serif" letter-spacing="2" text-anchor="end">GIVES YOU BACK HOURS</text>`;
|
||||
|
||||
} else if (isList) {
|
||||
// ── LIST: numbered cards with colored bars ───────────────────
|
||||
|
||||
// Separate list items from non-list lines
|
||||
const items = rawLines
|
||||
.filter((l) => l.match(/^[\d•\-\*→]/))
|
||||
.map(stripBullet)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
|
||||
const nonListLines = rawLines.filter((l) => !l.match(/^[\d•\-\*→]/));
|
||||
|
||||
// Title = first non-list line
|
||||
const headline = nonListLines[0] || rawLines[0] || "";
|
||||
// Closing line = last non-list line (if different from headline)
|
||||
const closingLine = nonListLines.length > 1 ? nonListLines[nonListLines.length - 1] : "";
|
||||
|
||||
// Title block — wrap long headlines
|
||||
const hlLines = wrapText(headline, 32);
|
||||
const titleLineH = 56;
|
||||
const titleBlockH = hlLines.slice(0, 2).length * titleLineH + 16;
|
||||
let curY = 72;
|
||||
|
||||
hlLines.slice(0, 2).forEach((line, i) => {
|
||||
svgBody += `<text x="${PAD}" y="${curY + i * titleLineH}" font-size="48" font-weight="800" fill="${TITLE_COLOR}" font-family="-apple-system,'SF Pro Display','Inter',sans-serif" letter-spacing="-1.5">${esc(line)}</text>`;
|
||||
});
|
||||
curY += titleBlockH;
|
||||
|
||||
// Subtitle line (second non-list line if exists, e.g. "here's what works:")
|
||||
const subtitleLine = nonListLines.length > 1 ? nonListLines[1] : "";
|
||||
if (subtitleLine && subtitleLine !== closingLine) {
|
||||
const subLines = wrapText(subtitleLine, 48);
|
||||
subLines.slice(0, 1).forEach((line) => {
|
||||
svgBody += `<text x="${PAD}" y="${curY}" font-size="22" fill="#888888" font-family="-apple-system,sans-serif">${esc(line)}</text>`;
|
||||
});
|
||||
curY += 36;
|
||||
}
|
||||
|
||||
// Closing line reserved height at bottom
|
||||
const FOOTER_H = closingLine ? 80 : 60;
|
||||
const FOOTER_Y = H - FOOTER_H;
|
||||
|
||||
// Cards: cap at MAX_CARD_H so short lists don't get absurdly tall
|
||||
const MAX_CARD_H = 160;
|
||||
const CARD_GAP = 14;
|
||||
const availH = FOOTER_Y - curY - 16;
|
||||
const idealCardH = Math.floor((availH - CARD_GAP * (items.length - 1)) / items.length);
|
||||
const cardH = Math.min(idealCardH, MAX_CARD_H);
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const accentColor = ACCENT_COLORS[i % ACCENT_COLORS.length];
|
||||
const cardY = curY + i * (cardH + CARD_GAP);
|
||||
const cardW = W - PAD * 2;
|
||||
|
||||
// Card bg
|
||||
svgBody += `<rect x="${PAD}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#e8e8e8" stroke-width="1.5"/>`;
|
||||
// Colored left bar (top half rect to avoid double-radius at bottom)
|
||||
svgBody += `<rect x="${PAD}" y="${cardY}" width="${BAR_W}" height="${cardH}" rx="${CARD_RADIUS}" fill="${accentColor}"/>`;
|
||||
svgBody += `<rect x="${PAD}" y="${cardY + CARD_RADIUS}" width="${BAR_W}" height="${cardH - CARD_RADIUS}" fill="${accentColor}"/>`;
|
||||
|
||||
// Item text — vertically centered in card
|
||||
const textLines = wrapText(item, 46);
|
||||
const lineH = 30;
|
||||
const textBlockH = Math.min(textLines.length, 2) * lineH;
|
||||
const textStartY = cardY + Math.floor((cardH - textBlockH) / 2) + lineH - 4;
|
||||
|
||||
// Number badge aligned to first text line
|
||||
svgBody += `<text x="${PAD + BAR_W + 18}" y="${textStartY}" font-size="13" font-weight="800" fill="${accentColor}" font-family="-apple-system,sans-serif" letter-spacing="1">${i + 1}</text>`;
|
||||
|
||||
textLines.slice(0, 2).forEach((line, li) => {
|
||||
svgBody += `<text x="${PAD + BAR_W + 52}" y="${textStartY + li * lineH}" font-size="22" fill="${TITLE_COLOR}" font-weight="500" font-family="-apple-system,sans-serif">${esc(line)}</text>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Footer: closing line + divider
|
||||
svgBody += `<line x1="${PAD}" y1="${FOOTER_Y + 14}" x2="${W - PAD}" y2="${FOOTER_Y + 14}" stroke="#dddddd" stroke-width="1"/>`;
|
||||
if (closingLine) {
|
||||
const clLines = wrapText(closingLine, 55);
|
||||
clLines.slice(0, 2).forEach((line, i) => {
|
||||
svgBody += `<text x="${PAD}" y="${FOOTER_Y + 38 + i * 24}" font-size="18" fill="#555555" font-weight="600" font-family="-apple-system,sans-serif">${esc(line)}</text>`;
|
||||
});
|
||||
} else {
|
||||
svgBody += `<text x="${W / 2}" y="${FOOTER_Y + 38}" font-size="12" fill="${FOOTER_COLOR}" font-family="-apple-system,sans-serif" text-anchor="middle" letter-spacing="2">@YOURHANDLE</text>`;
|
||||
}
|
||||
|
||||
} else {
|
||||
// ── INSIGHT: single quote-card layout ────────────────────────
|
||||
// Big quote card with accent bar and key lines
|
||||
const lines = rawLines.slice(0, 6);
|
||||
|
||||
// Title card
|
||||
const titleCard = { x: PAD, y: 80, w: W - PAD * 2, h: 200 };
|
||||
svgBody += `<rect x="${titleCard.x}" y="${titleCard.y}" width="${titleCard.w}" height="${titleCard.h}" rx="20" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`;
|
||||
svgBody += `<rect x="${titleCard.x}" y="${titleCard.y}" width="${BAR_W}" height="${titleCard.h}" rx="20" fill="${ACCENT_COLORS[0]}"/>`;
|
||||
svgBody += `<rect x="${titleCard.x}" y="${titleCard.y}" width="${BAR_W}" height="${titleCard.h / 2}" fill="${ACCENT_COLORS[0]}"/>`;
|
||||
|
||||
const firstLineWords = lines[0].split(" ");
|
||||
const bigText = firstLineWords.length > 8 ? firstLineWords.slice(0, 8).join(" ") + "…" : lines[0];
|
||||
const bigLines = wrapText(bigText, 30);
|
||||
bigLines.slice(0, 3).forEach((line, i) => {
|
||||
svgBody += `<text x="${PAD + BAR_W + 28}" y="${titleCard.y + 56 + i * 52}" font-size="42" font-weight="800" fill="${TITLE_COLOR}" font-family="-apple-system,'SF Pro Display','Inter',sans-serif" letter-spacing="-1">${esc(line)}</text>`;
|
||||
});
|
||||
|
||||
// Remaining lines as smaller cards
|
||||
let bodyY = titleCard.y + titleCard.h + 20;
|
||||
lines.slice(1).forEach((line, i) => {
|
||||
const accentColor = ACCENT_COLORS[(i + 1) % ACCENT_COLORS.length];
|
||||
const textLines = wrapText(line, 54);
|
||||
const cardH = Math.max(72, textLines.length * 26 + 36);
|
||||
svgBody += `<rect x="${PAD}" y="${bodyY}" width="${W - PAD * 2}" height="${cardH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`;
|
||||
svgBody += `<rect x="${PAD}" y="${bodyY}" width="${BAR_W}" height="${cardH}" rx="${CARD_RADIUS}" fill="${accentColor}"/>`;
|
||||
svgBody += `<rect x="${PAD}" y="${bodyY}" width="${BAR_W}" height="${cardH / 2}" fill="${accentColor}"/>`;
|
||||
textLines.forEach((tl, li) => {
|
||||
svgBody += `<text x="${PAD + BAR_W + 24}" y="${bodyY + 30 + li * 26}" font-size="20" fill="${TITLE_COLOR}" font-family="-apple-system,sans-serif">${esc(tl)}</text>`;
|
||||
});
|
||||
bodyY += cardH + 12;
|
||||
});
|
||||
|
||||
// Footer
|
||||
const footerY = H - 44;
|
||||
svgBody += `<line x1="${PAD}" y1="${footerY - 18}" x2="${W - PAD}" y2="${footerY - 18}" stroke="#dddddd" stroke-width="1"/>`;
|
||||
svgBody += `<text x="${W / 2}" y="${footerY}" font-size="12" fill="${FOOTER_COLOR}" font-family="-apple-system,sans-serif" text-anchor="middle" letter-spacing="2">@YOURHANDLE</text>`;
|
||||
}
|
||||
|
||||
// ── Wrap in SVG ──────────────────────────────────────────────────
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||||
<rect width="${W}" height="${H}" fill="${BG}"/>
|
||||
${svgBody}
|
||||
</svg>`;
|
||||
|
||||
const svgBase64 = Buffer.from(svg).toString("base64");
|
||||
const dataUrl = `data:image/svg+xml;base64,${svgBase64}`;
|
||||
|
||||
await prisma.draft.update({
|
||||
where: { id: draftId },
|
||||
data: { visualUrl: dataUrl },
|
||||
});
|
||||
|
||||
return NextResponse.json({ visualUrl: dataUrl });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("Visual generation failed:", msg);
|
||||
return NextResponse.json({ error: "Generation failed", details: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execSync } from "child_process";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { draftId, tweetUrl } = await req.json();
|
||||
if (!draftId) return NextResponse.json({ error: "draftId required" }, { status: 400 });
|
||||
|
||||
const draft = await prisma.draft.findUnique({ where: { id: draftId } });
|
||||
if (!draft) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const updateData: any = {
|
||||
status: "posted",
|
||||
postedAt: new Date(),
|
||||
postedUrl: tweetUrl || null,
|
||||
};
|
||||
|
||||
// Try to fetch the actual posted tweet text via Grok API
|
||||
if (tweetUrl) {
|
||||
try {
|
||||
const apiKey = process.env.XAI_API_KEY || "";
|
||||
if (apiKey) {
|
||||
const payload = JSON.stringify({
|
||||
model: "grok-4-0709",
|
||||
tools: [{ type: "x_search" }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Look up this exact tweet: ${tweetUrl}\n\nReturn ONLY the full tweet text, nothing else. No commentary, no quotes around it, just the raw tweet text exactly as posted.`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const result = execSync(
|
||||
`curl -s -X POST "https://api.x.ai/v1/responses" -H "Authorization: Bearer ${apiKey}" -H "Content-Type: application/json" -d '${payload.replace(/'/g, "'\\''")}'`,
|
||||
{ timeout: 30000, encoding: "utf-8" }
|
||||
);
|
||||
|
||||
const data = JSON.parse(result);
|
||||
// Responses API returns output_text or output array
|
||||
const text = data.output_text ||
|
||||
data.output?.find((o: { type: string }) => o.type === "message")?.content?.[0]?.text ||
|
||||
null;
|
||||
|
||||
if (text && text.length > 5) {
|
||||
// postedText is not in the Prisma schema — include it in the response only
|
||||
const updated = await prisma.draft.update({
|
||||
where: { id: draftId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
const { feedbackRating, feedbackReason, updatedAt, ...rest } = updated as any;
|
||||
return NextResponse.json({
|
||||
...rest,
|
||||
feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" },
|
||||
postedText: text.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch posted tweet:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.draft.update({
|
||||
where: { id: draftId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
const { feedbackRating, feedbackReason, updatedAt, ...rest } = updated as any;
|
||||
return NextResponse.json({
|
||||
...rest,
|
||||
feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" },
|
||||
postedText: null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET: list content requests
|
||||
export async function GET() {
|
||||
const requests = await prisma.contentRequest.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json(requests);
|
||||
}
|
||||
|
||||
// POST: create a new content request
|
||||
export async function POST(req: NextRequest) {
|
||||
const { prompt } = await req.json();
|
||||
if (!prompt || !prompt.trim()) {
|
||||
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const request = await prisma.contentRequest.create({
|
||||
data: {
|
||||
prompt: prompt.trim(),
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Trigger file paths won't exist on Vercel — needs a different mechanism (e.g. queue, webhook)
|
||||
try {
|
||||
const triggerDir = path.join("./data", "triggers");
|
||||
fs.mkdirSync(triggerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(triggerDir, `request-${request.id}.json`), JSON.stringify({
|
||||
type: "request",
|
||||
requestId: request.id,
|
||||
prompt: request.prompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
}, null, 2));
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json(request);
|
||||
}
|
||||
|
||||
// PATCH: update request status (used by Sage when processing)
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const { requestId, status, resultDraftIds } = await req.json();
|
||||
|
||||
const existing = await prisma.contentRequest.findUnique({ where: { id: requestId } });
|
||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const updateData: any = {};
|
||||
if (status) updateData.status = status;
|
||||
if (resultDraftIds) updateData.resultDraftIds = resultDraftIds;
|
||||
if (status === "done") updateData.completedAt = new Date();
|
||||
|
||||
const updated = await prisma.contentRequest.update({
|
||||
where: { id: requestId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/** Convert a Prisma Draft row to the API shape the frontend expects. */
|
||||
function draftToApi(draft: any) {
|
||||
const { feedbackRating, feedbackReason, updatedAt, ...rest } = draft;
|
||||
return {
|
||||
...rest,
|
||||
feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" },
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract the best (highest) view count from a metrics object (flat or checkpoint format). */
|
||||
function bestViews(m: any): number {
|
||||
if (!m || typeof m !== "object") return 0;
|
||||
if (typeof m.views === "number") return m.views;
|
||||
let best = 0;
|
||||
for (const val of Object.values(m)) {
|
||||
if (val && typeof val === "object") {
|
||||
const v = Number((val as any).views) || 0;
|
||||
if (v > best) best = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Merge metrics: combine both sources, preferring whichever has higher views. */
|
||||
function mergeMetrics(draftMetrics: any, tweetMetricCheckpoints: any): any {
|
||||
const draftViews = bestViews(draftMetrics);
|
||||
const tmViews = bestViews(tweetMetricCheckpoints);
|
||||
// If both have data, merge checkpoints (draft.metrics wins on conflict)
|
||||
if (draftMetrics && tweetMetricCheckpoints && typeof draftMetrics === "object" && typeof tweetMetricCheckpoints === "object") {
|
||||
// Don't overwrite with lower-view data — return combined object
|
||||
return { ...tweetMetricCheckpoints, ...draftMetrics };
|
||||
}
|
||||
// Return whichever has more info
|
||||
if (draftViews >= tmViews) return draftMetrics ?? tweetMetricCheckpoints;
|
||||
return tweetMetricCheckpoints;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const statusFilter = searchParams.get("status");
|
||||
const limitParam = searchParams.get("limit");
|
||||
const limit = limitParam ? parseInt(limitParam, 10) : undefined;
|
||||
|
||||
// Build where clause for status filter
|
||||
const where: any = {};
|
||||
if (statusFilter) {
|
||||
where.status = statusFilter;
|
||||
}
|
||||
|
||||
// Fetch drafts from the database with optional filters
|
||||
const dbDrafts = await prisma.draft.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
...(limit ? { take: limit } : {}),
|
||||
});
|
||||
|
||||
// Fetch all tweet metrics
|
||||
const dbMetrics = await prisma.tweetMetric.findMany();
|
||||
|
||||
// Build lookup: tweetId -> metrics, draftId -> metrics
|
||||
const metricsByTweetId: Record<string, any> = {};
|
||||
const metricsByDraftId: Record<string, any> = {};
|
||||
for (const m of dbMetrics) {
|
||||
if (m.tweetId) metricsByTweetId[m.tweetId] = m;
|
||||
if (m.draftId) metricsByDraftId[m.draftId] = m;
|
||||
}
|
||||
|
||||
// Convert to API format
|
||||
const drafts = dbDrafts.map(draftToApi);
|
||||
|
||||
// Track which metric entries are already matched to a draft
|
||||
const matchedMetricTweetIds = new Set<string>();
|
||||
const existingDraftIds = new Set(drafts.map((d: any) => d.id));
|
||||
|
||||
// Merge metrics into existing drafts
|
||||
for (const draft of drafts) {
|
||||
const m = metricsByDraftId[draft.id] || (draft.tweetId && metricsByTweetId[draft.tweetId]);
|
||||
if (m) {
|
||||
// Merge TweetMetric checkpoints with draft.metrics — keep whichever has higher views
|
||||
draft.metrics = mergeMetrics(draft.metrics, (m as any).checkpoints);
|
||||
if (!draft.tweetUrl && (m as any).url) draft.tweetUrl = (m as any).url;
|
||||
if (!draft.postedUrl && (m as any).url) draft.postedUrl = (m as any).url;
|
||||
if (!draft.tweetId && m.tweetId) draft.tweetId = m.tweetId;
|
||||
if (m.tweetId) matchedMetricTweetIds.add(m.tweetId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add posted tweets that aren't in drafts yet (posted directly on X)
|
||||
for (const m of dbMetrics) {
|
||||
const draftIdExists = m.draftId && existingDraftIds.has(m.draftId);
|
||||
if (m.tweetId && !matchedMetricTweetIds.has(m.tweetId) && !draftIdExists) {
|
||||
drafts.push({
|
||||
id: m.draftId || `tweet-${m.tweetId}`,
|
||||
text: "",
|
||||
model: "posted-directly",
|
||||
status: "posted",
|
||||
type: "tweet",
|
||||
feedback: { rating: "posted", reason: "" },
|
||||
createdAt: (m.postedAt || new Date()).toISOString(),
|
||||
postedAt: (m.postedAt || new Date()).toISOString(),
|
||||
tweetUrl: m.url,
|
||||
postedUrl: m.url,
|
||||
tweetId: m.tweetId,
|
||||
metrics: (m as any).checkpoints,
|
||||
editHistory: [],
|
||||
});
|
||||
matchedMetricTweetIds.add(m.tweetId);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(drafts, {
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/x-content error:", err);
|
||||
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create a new draft
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
|
||||
const draft = await prisma.draft.create({
|
||||
data: {
|
||||
id: body.id || crypto.randomUUID(),
|
||||
text: body.text || body.content || "",
|
||||
title: body.title || undefined,
|
||||
model: body.model || "manual",
|
||||
status: body.status || "pending",
|
||||
type: body.type || "tweet",
|
||||
category: body.category || undefined,
|
||||
hookType: body.hookType || undefined,
|
||||
feedbackRating: null,
|
||||
feedbackReason: "",
|
||||
viralScore: body.viralScore ?? undefined,
|
||||
impressionScore: body.impressionScore ?? undefined,
|
||||
scheduledDate: body.scheduledDate || null,
|
||||
scheduledTime: body.scheduledTime || null,
|
||||
visualUrl: body.visualUrl || undefined,
|
||||
variantGroup: body.variantGroup || undefined,
|
||||
quoteUrl: body.quoteUrl || undefined,
|
||||
quoteText: body.quoteText || undefined,
|
||||
tweetId: body.tweetId || undefined,
|
||||
postedUrl: body.postedUrl || undefined,
|
||||
tweetUrl: body.tweetUrl || body.postedUrl || undefined,
|
||||
postedAt: body.postedAt ? new Date(body.postedAt) : undefined,
|
||||
metrics: body.metrics ?? undefined,
|
||||
editHistory: [],
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(draftToApi(draft));
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { draftId, text, title, undoRejection, metrics, tweetId, postedUrl, postedAt, status } = body;
|
||||
|
||||
if (!draftId) {
|
||||
return NextResponse.json({ error: "draftId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const draft = await prisma.draft.findUnique({ where: { id: draftId } });
|
||||
if (!draft) {
|
||||
return NextResponse.json({ error: "Draft not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (undoRejection) {
|
||||
updateData.status = "pending";
|
||||
updateData.feedbackRating = null;
|
||||
updateData.feedbackReason = "";
|
||||
}
|
||||
|
||||
if (status) updateData.status = status;
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (tweetId) updateData.tweetId = tweetId;
|
||||
if (postedUrl) { updateData.postedUrl = postedUrl; updateData.tweetUrl = postedUrl; }
|
||||
if (postedAt) updateData.postedAt = new Date(postedAt);
|
||||
if (metrics) {
|
||||
// Merge with existing metrics rather than overwrite
|
||||
const existing = (draft.metrics as any) || {};
|
||||
updateData.metrics = { ...existing, ...metrics };
|
||||
}
|
||||
|
||||
if (text) {
|
||||
if (draft.text !== text) {
|
||||
const currentHistory = (draft.editHistory as any[]) || [];
|
||||
updateData.editHistory = [
|
||||
...currentHistory,
|
||||
{
|
||||
original: draft.text,
|
||||
edited: text,
|
||||
editedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
updateData.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.draft.update({
|
||||
where: { id: draftId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(draftToApi(updated));
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const { draftId } = await req.json();
|
||||
|
||||
if (!draftId) {
|
||||
return NextResponse.json({ error: "Draft ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const draft = await prisma.draft.findUnique({ where: { id: draftId } });
|
||||
if (!draft) {
|
||||
return NextResponse.json({ error: "Draft not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Safety check: NEVER delete if it would result in 0 drafts
|
||||
const count = await prisma.draft.count();
|
||||
if (count <= 1) {
|
||||
return NextResponse.json({
|
||||
error: "Cannot delete the last remaining draft. At least one draft must remain.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.draft.delete({ where: { id: draftId } });
|
||||
|
||||
return NextResponse.json({ success: true, message: "Draft deleted" });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
|
||||
const TWEETS_FILE = "./data/tweet-library/tweets.json";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!fs.existsSync(TWEETS_FILE)) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(TWEETS_FILE, "utf-8"));
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const tweets = (data.tweets || data || [])
|
||||
.map((t: Record<string, unknown>) => ({
|
||||
text: (t.text as string) || "",
|
||||
views: (t.metrics as Record<string, number>)?.views ?? (t.views as number) ?? 0,
|
||||
likes: (t.metrics as Record<string, number>)?.likes ?? (t.likes as number) ?? 0,
|
||||
retweets: (t.metrics as Record<string, number>)?.retweets ?? (t.retweets as number) ?? 0,
|
||||
replies: (t.metrics as Record<string, number>)?.replies ?? (t.replies as number) ?? 0,
|
||||
bookmarks: (t.metrics as Record<string, number>)?.bookmarks ?? (t.bookmarks as number) ?? 0,
|
||||
date: (t.date as string) || "",
|
||||
url: (t.url as string) || "",
|
||||
}))
|
||||
.filter((t: { date: string }) => {
|
||||
if (!t.date) return false;
|
||||
try { return new Date(t.date) >= thirtyDaysAgo; } catch { return false; }
|
||||
})
|
||||
.sort((a: { views: number; likes: number }, b: { views: number; likes: number }) => (b.views || b.likes) - (a.views || a.likes))
|
||||
.slice(0, 20);
|
||||
return NextResponse.json(tweets);
|
||||
} catch {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user