diff --git a/src/app/(dashboard)/calendar/page.tsx b/src/app/(dashboard)/calendar/page.tsx new file mode 100644 index 0000000..31362c3 --- /dev/null +++ b/src/app/(dashboard)/calendar/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { ContentCalendar } from '@/components/features/content-calendar'; + +export const metadata: Metadata = { + title: 'Content Calendar — ozskr.ai', +}; + +export default function CalendarPage() { + return ; +} diff --git a/src/app/(dashboard)/content/page.tsx b/src/app/(dashboard)/content/page.tsx new file mode 100644 index 0000000..9572764 --- /dev/null +++ b/src/app/(dashboard)/content/page.tsx @@ -0,0 +1,15 @@ +/** + * Content Library Page + * Placeholder — full content browsing/management UI coming in a later sprint. + */ + +export default function ContentPage() { + return ( +
+

Content Library

+

+ Coming soon — ask your agent to create content, or browse your published posts here. +

+
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 20a5e95..6103c78 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,15 +1,13 @@ -'use client'; - /** * Dashboard Layout - * Authenticated layout with sidebar navigation and top bar + * Full-width tab navigation layout with YellowBrick command bar. + * Replaces sidebar-based layout with horizontal tab navigation. */ -import { useState } from 'react'; import { AuthGuard } from '@/components/features/auth-guard'; -import { Sidebar } from '@/components/features/sidebar'; -import { TopBar } from '@/components/features/top-bar'; -import { CommandBar } from '@/components/features/command-bar'; +import { DashboardTopBar } from '@/components/features/dashboard-top-bar'; +import { TabNavigation } from '@/components/features/tab-navigation'; +import { YellowBrick } from '@/components/features/yellow-brick'; import { AchievementToastProvider } from '@/features/gamification/components/achievement-toast'; import { FeedbackWidget } from '@/features/feedback/feedback-widget'; @@ -18,30 +16,27 @@ export default function DashboardLayout({ }: { children: React.ReactNode; }) { - const [commandBarOpen, setCommandBarOpen] = useState(false); - return ( -
- {/* Sidebar */} - +
+ - {/* Main Content Area */} -
- {/* Top Bar */} - setCommandBarOpen(true)} /> +
+ {/* YellowBrick — centered, max-w-[672px], full-width on mobile */} +
+ +
- {/* Page Content */} -
-
{children}
+ {/* Horizontal tab navigation */} + + + {/* Page content */} +
+ {children}
- {/* Command Bar */} - - - {/* Feedback Widget */}
diff --git a/src/app/api/v1/agent/chat/route.ts b/src/app/api/v1/agent/chat/route.ts new file mode 100644 index 0000000..fb52a6d --- /dev/null +++ b/src/app/api/v1/agent/chat/route.ts @@ -0,0 +1,274 @@ +/** + * POST /api/v1/agent/chat + * + * SSE streaming chat endpoint for the YellowBrick command bar. + * + * Auth: Bearer JWT verified against JWT_SECRET (same pattern as Hono authMiddleware). + * Supabase auth-helpers cookies are NOT used — the app issues its own JWTs. + * + * Streams text/event-stream with events: + * data: {"type":"chunk","content":""}\n\n + * data: {"type":"done"}\n\n + * data: {"type":"error","error":""}\n\n + * + * Langfuse tracing is enabled when LANGFUSE_SECRET_KEY is present — the endpoint + * does not fail if the key is absent. + */ + +import { NextRequest } from 'next/server'; +import { streamText } from 'ai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { jwtVerify } from 'jose'; +import { z } from 'zod'; +import { logger } from '@/lib/utils/logger'; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +const ChatRequestSchema = z.object({ + message: z.string().min(1).max(10000), + context: z + .enum(['dashboard', 'calendar', 'content', 'analytics', 'social', 'settings']) + .optional() + .default('dashboard'), +}); + +// --------------------------------------------------------------------------- +// System prompts by context +// --------------------------------------------------------------------------- + +const SYSTEM_PROMPTS: Record = { + dashboard: + 'You are a helpful AI assistant for the ozskr.ai platform dashboard. ' + + 'Help users understand their agent activity, navigate the platform, and get the most out of their AI influencer agents. ' + + 'Be concise, friendly, and practical.', + + content: + 'You are a creative AI content assistant for the ozskr.ai platform. ' + + 'Help users craft compelling social media content, brainstorm ideas, refine captions, and develop content strategies for their AI influencer agents. ' + + 'Be creative, engaging, and platform-aware.', + + social: + 'You are a social media strategy assistant for the ozskr.ai platform. ' + + 'Help users build and grow their AI influencer agents\' social presence, optimize engagement, and develop audience growth strategies. ' + + 'Be data-informed, strategic, and actionable.', + + calendar: + 'You are a scheduling and planning assistant for the ozskr.ai platform. ' + + 'Help users plan content calendars, schedule posts for optimal timing, and manage their AI influencer agent publishing workflows. ' + + 'Be organized, time-aware, and efficient.', + + analytics: + 'You are a data analysis assistant for the ozskr.ai platform. ' + + 'Help users interpret agent performance metrics, understand engagement trends, and derive actionable insights from their analytics data. ' + + 'Be precise, analytical, and insight-focused.', + + settings: + 'You are a configuration assistant for the ozskr.ai platform. ' + + 'Help users set up and adjust their AI influencer agent settings, understand configuration options, and troubleshoot setup issues. ' + + 'Be clear, accurate, and step-by-step.', +}; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +function sseChunk(content: string): string { + const payload = JSON.stringify({ type: 'chunk', content }); + return `data: ${payload}\n\n`; +} + +function sseDone(): string { + return `data: ${JSON.stringify({ type: 'done' })}\n\n`; +} + +function sseError(error: string): string { + return `data: ${JSON.stringify({ type: 'error', error })}\n\n`; +} + +// --------------------------------------------------------------------------- +// Auth helper +// --------------------------------------------------------------------------- + +/** + * Extract and verify the JWT from the Authorization header. + * Returns the wallet address on success, or null if auth fails. + */ +async function verifyAuth(req: NextRequest): Promise { + const authHeader = req.headers.get('Authorization'); + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.substring(7); + const jwtSecret = process.env.JWT_SECRET; + + if (!jwtSecret) { + logger.error('[agent-chat] JWT_SECRET not configured'); + return null; + } + + try { + const secret = new TextEncoder().encode(jwtSecret); + const { payload } = await jwtVerify(token, secret); + + const walletAddress = payload.wallet_address; + if (typeof walletAddress !== 'string' || !walletAddress) { + return null; + } + + return walletAddress; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Optional Langfuse tracing +// --------------------------------------------------------------------------- + +/** + * Attempt to create a Langfuse trace for the chat request. + * Returns null if Langfuse is not configured — never throws. + */ +async function tryCreateTrace( + walletAddress: string, + context: string +): Promise<{ end: (outputLength: number) => void } | null> { + const secretKey = process.env.LANGFUSE_SECRET_KEY; + if (!secretKey) { + return null; + } + + try { + const { getLangfuse } = await import('@/lib/ai/telemetry'); + const langfuse = getLangfuse(); + const startTime = Date.now(); + const trace = langfuse.trace({ + name: 'agent-chat', + userId: walletAddress, + metadata: { context, model: 'claude-haiku-4-5-20251001' }, + }); + + return { + end: (outputLength: number) => { + trace.update({ + metadata: { + latencyMs: Date.now() - startTime, + outputChars: outputLength, + }, + }); + // Fire-and-forget flush + void langfuse.flushAsync().catch(() => {}); + }, + }; + } catch { + // Langfuse is optional — never let telemetry failures surface to users + return null; + } +} + +// --------------------------------------------------------------------------- +// Route Handler +// --------------------------------------------------------------------------- + +export async function POST(req: NextRequest): Promise { + // 1. Auth + const walletAddress = await verifyAuth(req); + if (!walletAddress) { + return new Response(JSON.stringify({ error: 'Unauthorized', code: 'UNAUTHORIZED' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 2. Parse and validate request body + let rawBody: unknown; + try { + rawBody = await req.json(); + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON body', code: 'BAD_REQUEST' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const parsed = ChatRequestSchema.safeParse(rawBody); + if (!parsed.success) { + return new Response( + JSON.stringify({ + error: 'Invalid request body', + code: 'BAD_REQUEST', + details: parsed.error.flatten(), + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const { message, context } = parsed.data; + const systemPrompt = SYSTEM_PROMPTS[context] ?? SYSTEM_PROMPTS.dashboard; + + logger.info('[agent-chat] request received', { + walletAddress, + context, + messageLength: message.length, + }); + + // 3. Start optional Langfuse trace + const trace = await tryCreateTrace(walletAddress, context); + + // 4. Build the streaming SSE response + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + let outputLength = 0; + + try { + const result = streamText({ + model: anthropic('claude-haiku-4-5-20251001'), + system: systemPrompt, + messages: [{ role: 'user', content: message }], + // Apply prompt caching to the system prompt for character context + providerOptions: { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }); + + for await (const delta of result.textStream) { + controller.enqueue(encoder.encode(sseChunk(delta))); + outputLength += delta.length; + } + + controller.enqueue(encoder.encode(sseDone())); + trace?.end(outputLength); + + logger.info('[agent-chat] stream complete', { + walletAddress, + context, + outputLength, + }); + } catch (err) { + const errMessage = err instanceof Error ? err.message : 'Stream error'; + logger.error('[agent-chat] stream error', { walletAddress, context, error: errMessage }); + controller.enqueue(encoder.encode(sseError('An error occurred. Please try again.'))); + trace?.end(outputLength); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/src/components/features/content-calendar/ContentCalendar.tsx b/src/components/features/content-calendar/ContentCalendar.tsx new file mode 100644 index 0000000..8b301e2 --- /dev/null +++ b/src/components/features/content-calendar/ContentCalendar.tsx @@ -0,0 +1,571 @@ +'use client'; + +/** + * ContentCalendar + * Weekly calendar view for scheduled agent posts. + * Fetches from /api/ai/schedules and gracefully handles empty/error states. + */ + +import { useEffect, useState, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ChevronLeft, ChevronRight, Plus, X, Twitter, Instagram, Linkedin } from 'lucide-react'; +import { useYellowBrickStore } from '@/components/features/yellow-brick'; +import { useAuthStore } from '@/features/wallet/store'; +import { cn } from '@/lib/utils'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ScheduledPost { + id: string; + content: string; + platform: 'twitter' | 'instagram' | 'linkedin'; + scheduledAt: string; // ISO 8601 + status: 'pending' | 'published' | 'failed'; +} + +type Platform = ScheduledPost['platform']; + +interface SchedulePostPayload { + content: string; + platform: Platform; + scheduledAt: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +/** Returns Monday of the week containing `date` */ +function getWeekStart(date: Date): Date { + const d = new Date(date); + const day = d.getDay(); // 0 = Sun, 1 = Mon … + const diff = (day === 0 ? -6 : 1 - day); // shift so Monday = 0 + d.setDate(d.getDate() + diff); + d.setHours(0, 0, 0, 0); + return d; +} + +function addDays(date: Date, days: number): Date { + const d = new Date(date); + d.setDate(d.getDate() + days); + return d; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function isoDatePart(date: Date): string { + return date.toISOString().split('T')[0]!; +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +const PLATFORM_LABELS: Record = { + twitter: 'Twitter', + instagram: 'Instagram', + linkedin: 'LinkedIn', +}; + +function PlatformIcon({ platform, className }: { platform: Platform; className?: string }) { + switch (platform) { + case 'twitter': + return ; + case 'instagram': + return ; + case 'linkedin': + return ; + } +} + +// --------------------------------------------------------------------------- +// API fetcher — tolerates 404 / malformed responses +// --------------------------------------------------------------------------- + +const API_BASE = '/api/ai/schedules'; + +async function fetchScheduledPosts(token: string | null): Promise { + if (!token) return []; + + try { + const res = await fetch(API_BASE, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) return []; + + const raw: unknown = await res.json(); + + // The existing API returns { data: [...], pagination: {...} } + // We map the existing schedule shape onto ScheduledPost + if ( + raw !== null && + typeof raw === 'object' && + 'data' in raw && + Array.isArray((raw as { data: unknown }).data) + ) { + const items = (raw as { data: unknown[] }).data; + return items.flatMap((item): ScheduledPost[] => { + if ( + item !== null && + typeof item === 'object' && + 'id' in item && + 'nextRunAt' in item && + 'promptTemplate' in item + ) { + const s = item as Record; + const scheduledAt = typeof s['nextRunAt'] === 'string' ? s['nextRunAt'] : ''; + const content = typeof s['promptTemplate'] === 'string' ? s['promptTemplate'] : ''; + const id = typeof s['id'] === 'string' ? s['id'] : String(s['id']); + + // Map contentType to platform (best-effort) + let platform: Platform = 'twitter'; + if (typeof s['contentType'] === 'string') { + if (s['contentType'] === 'image') platform = 'instagram'; + else if (s['contentType'] === 'video') platform = 'linkedin'; + } + + const status: ScheduledPost['status'] = + s['isActive'] === false ? 'failed' : 'pending'; + + if (!scheduledAt) return []; + + return [{ id, content, platform, scheduledAt, status }]; + } + return []; + }); + } + + return []; + } catch { + return []; + } +} + +async function postSchedule( + payload: SchedulePostPayload, + token: string | null +): Promise { + if (!token) throw new Error('Not authenticated'); + + const res = await fetch(API_BASE, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + // Map to existing API shape + scheduleType: 'one_time', + nextRunAt: payload.scheduledAt, + contentType: payload.platform === 'instagram' ? 'image' : 'text', + promptTemplate: payload.content, + characterId: '00000000-0000-0000-0000-000000000000', // placeholder — real form would select character + autoPublish: false, + }), + }); + + if (!res.ok) { + const err: unknown = await res.json().catch(() => ({})); + const message = + err !== null && typeof err === 'object' && 'error' in err + ? String((err as { error: unknown }).error) + : 'Failed to schedule post'; + throw new Error(message); + } +} + +// --------------------------------------------------------------------------- +// Schedule Post Modal +// --------------------------------------------------------------------------- + +interface SchedulePostModalProps { + open: boolean; + onClose: () => void; + defaultDate?: Date; + onSuccess: () => void; +} + +function SchedulePostModal({ open, onClose, defaultDate, onSuccess }: SchedulePostModalProps) { + const dialogRef = useRef(null); + const token = useAuthStore((s) => s.token); + const queryClient = useQueryClient(); + + const [content, setContent] = useState(''); + const [platform, setPlatform] = useState('twitter'); + const [date, setDate] = useState( + defaultDate ? isoDatePart(defaultDate) : isoDatePart(new Date()) + ); + const [time, setTime] = useState('09:00'); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const mutation = useMutation({ + mutationFn: (payload: SchedulePostPayload) => postSchedule(payload, token), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['calendar-posts'] }); + setSuccess(true); + setContent(''); + setTimeout(() => { + setSuccess(false); + onSuccess(); + onClose(); + }, 1200); + }, + onError: (err: Error) => { + setError(err.message); + }, + }); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open) { + dialog.showModal(); + } else { + dialog.close(); + } + }, [open]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const scheduledAt = new Date(`${date}T${time}:00`).toISOString(); + mutation.mutate({ content, platform, scheduledAt }); + }; + + return ( + + {/* Header */} +
+

Schedule Post

+ +
+ +
+ {/* Content textarea */} +
+ +