diff --git a/.gitignore b/.gitignore index dbe532a..f2f257f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.env +.env* __pycache__/ *.pyc .venv/ diff --git a/app/app/api/auth/github/callback/route.ts b/app/app/api/auth/github/callback/route.ts new file mode 100644 index 0000000..30645bc --- /dev/null +++ b/app/app/api/auth/github/callback/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; +const CLIENT_ID = process.env.GITHUB_CLIENT_ID!; +const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET!; + +export async function GET(req: NextRequest) { + const code = req.nextUrl.searchParams.get("code"); + const error = req.nextUrl.searchParams.get("error"); + + if (error || !code) { + return NextResponse.redirect( + `${BASE_URL}/test?github_error=${error || "missing_code"}` + ); + } + + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: `${BASE_URL}/api/auth/github/callback`, + }), + }); + + const data = await tokenRes.json(); + + if (data.error || !data.access_token) { + return NextResponse.redirect( + `${BASE_URL}/test?github_error=${data.error || "token_failed"}` + ); + } + + const response = NextResponse.redirect(`${BASE_URL}/test?github_connected=true`); + response.cookies.set("github_token", data.access_token, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: 60 * 60 * 24 * 30, // GitHub tokens don't expire unless revoked + path: "/", + }); + return response; +} diff --git a/app/app/api/auth/github/route.ts b/app/app/api/auth/github/route.ts new file mode 100644 index 0000000..35bf961 --- /dev/null +++ b/app/app/api/auth/github/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; +const CLIENT_ID = process.env.GITHUB_CLIENT_ID!; + +const SCOPES = ["notifications", "read:user", "repo"].join(" "); + +export async function GET() { + const url = new URL("https://github.com/login/oauth/authorize"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", `${BASE_URL}/api/auth/github/callback`); + url.searchParams.set("scope", SCOPES); + return NextResponse.redirect(url.toString()); +} diff --git a/app/app/api/auth/gmail/callback/route.ts b/app/app/api/auth/gmail/callback/route.ts new file mode 100644 index 0000000..0f74433 --- /dev/null +++ b/app/app/api/auth/gmail/callback/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; +const CLIENT_ID = process.env.GOOGLE_CLIENT_ID!; +const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET!; +const REDIRECT_URI = `${BASE_URL}/api/auth/gmail/callback`; + +export async function GET(req: NextRequest) { + const code = req.nextUrl.searchParams.get("code"); + const error = req.nextUrl.searchParams.get("error"); + + if (error || !code) { + return NextResponse.redirect(`${BASE_URL}/test?gmail_error=${error || "missing_code"}`); + } + + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + grant_type: "authorization_code", + }).toString(), + }); + + const data = await tokenRes.json(); + + if (data.error || !data.access_token) { + return NextResponse.redirect(`${BASE_URL}/test?gmail_error=${data.error || "token_failed"}`); + } + + const response = NextResponse.redirect(`${BASE_URL}/test?gmail_connected=true`); + response.cookies.set("gmail_token", data.access_token, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: data.expires_in || 3600, + path: "/", + }); + // Store refresh token separately (long-lived) + if (data.refresh_token) { + response.cookies.set("gmail_refresh_token", data.refresh_token, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: 60 * 60 * 24 * 30, + path: "/", + }); + } + return response; +} diff --git a/app/app/api/auth/gmail/route.ts b/app/app/api/auth/gmail/route.ts new file mode 100644 index 0000000..7fde363 --- /dev/null +++ b/app/app/api/auth/gmail/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; +const CLIENT_ID = process.env.GOOGLE_CLIENT_ID!; + +const SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/userinfo.email", +].join(" "); + +export async function GET() { + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", `${BASE_URL}/api/auth/gmail/callback`); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", SCOPES); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return NextResponse.redirect(url.toString()); +} diff --git a/app/app/api/auth/slack/callback/route.ts b/app/app/api/auth/slack/callback/route.ts new file mode 100644 index 0000000..462696a --- /dev/null +++ b/app/app/api/auth/slack/callback/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { storeCredential } from "@/lib/backend-client"; + +const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID!; +const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET!; +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; +const REDIRECT_URI = `${BASE_URL}/api/auth/slack/callback`; + +export async function GET(req: NextRequest) { + const code = req.nextUrl.searchParams.get("code"); + const error = req.nextUrl.searchParams.get("error"); + + if (error || !code) { + return NextResponse.redirect( + `${BASE_URL}/test?error=${error || "missing_code"}` + ); + } + + // Exchange code for token + const params = new URLSearchParams({ + client_id: SLACK_CLIENT_ID, + client_secret: SLACK_CLIENT_SECRET, + code, + redirect_uri: REDIRECT_URI, + }); + + const tokenRes = await fetch("https://slack.com/api/oauth.v2.access", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + const data = await tokenRes.json(); + + if (!data.ok) { + return NextResponse.redirect( + `${BASE_URL}/test?error=${data.error}` + ); + } + + const userToken = data.authed_user?.access_token; + if (!userToken) { + return NextResponse.redirect( + `${BASE_URL}/test?error=no_user_token` + ); + } + + const scopes = (data.authed_user?.scope || "").split(",").filter(Boolean); + + // Store in backend credential vault (source of truth) + try { + const backendRes = await storeCredential("slack", userToken, scopes); + if (!backendRes.ok) { + console.error("Backend credential store failed:", await backendRes.text()); + } + } catch (err) { + console.error("Could not reach backend, falling back to cookie:", err); + // Fallback: store in cookie so the app still works without the backend + const fallback = NextResponse.redirect( + `${BASE_URL}/test?connected=true&mode=local` + ); + fallback.cookies.set("slack_token", userToken, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: 60 * 60 * 24 * 30, + path: "/", + }); + return fallback; + } + + return NextResponse.redirect(`${BASE_URL}/test?connected=true`); +} diff --git a/app/app/api/auth/slack/route.ts b/app/app/api/auth/slack/route.ts new file mode 100644 index 0000000..e157c13 --- /dev/null +++ b/app/app/api/auth/slack/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { getSlackOAuthUrl } from "@/lib/backend-client"; + +const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID!; +const BACKEND_API_KEY = process.env.BACKEND_API_KEY; +const REDIRECT_URI = `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"}/api/auth/slack/callback`; + +const SCOPES = [ + "channels:history", + "channels:read", + "groups:history", + "groups:read", + "users:read", + "reactions:read", +].join(","); + +export async function GET() { + // If backend is configured, route through it (tokens stored in encrypted vault) + if (BACKEND_API_KEY) { + return NextResponse.redirect(getSlackOAuthUrl()); + } + + // No backend — do OAuth directly in the frontend (cookie fallback) + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.searchParams.set("client_id", SLACK_CLIENT_ID); + url.searchParams.set("user_scope", SCOPES); + url.searchParams.set("redirect_uri", REDIRECT_URI); + return NextResponse.redirect(url.toString()); +} diff --git a/app/app/api/chat/route.ts b/app/app/api/chat/route.ts new file mode 100644 index 0000000..41400e5 --- /dev/null +++ b/app/app/api/chat/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from "next/server"; +import Anthropic from "@anthropic-ai/sdk"; + +const client = new Anthropic(); + +const SYSTEM_PROMPTS: Record = { + slack: `You are a Slack Agent. Summarize discussions, extract action items, flag blockers. Be concise and action-oriented. Never just describe — always recommend next steps.`, + gmail: `You are an Inbox Agent. Summarize emails, detect urgency, suggest replies and actions. Be concise. Always recommend what to do next.`, + github: `You are a GitHub Agent. Track PRs, issues, and code review status. Flag blockers, suggest review priorities. Be direct.`, + global: `You are an AI assistant with visibility across Slack, Gmail, and GitHub. Cross-reference information across tools, identify patterns and blockers, and suggest concrete next steps.`, +}; + +export async function POST(req: NextRequest) { + const { messages, agentId, context } = await req.json(); + + const system = context + ? `${SYSTEM_PROMPTS[agentId] || SYSTEM_PROMPTS.global}\n\nCurrent data context:\n${context}` + : SYSTEM_PROMPTS[agentId] || SYSTEM_PROMPTS.global; + + const stream = client.messages.stream({ + model: "claude-sonnet-4-6", + max_tokens: 1024, + system, + messages, + }); + + const readable = new ReadableStream({ + async start(controller) { + for await (const chunk of stream) { + if ( + chunk.type === "content_block_delta" && + chunk.delta.type === "text_delta" + ) { + controller.enqueue(new TextEncoder().encode(chunk.delta.text)); + } + } + controller.close(); + }, + }); + + return new Response(readable, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/app/app/api/employees/route.ts b/app/app/api/employees/route.ts new file mode 100644 index 0000000..f403f04 --- /dev/null +++ b/app/app/api/employees/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hireEmployee, listEmployees, fireEmployee } from "@/lib/backend-client"; + +export async function GET() { + try { + const res = await listEmployees(); + if (res.ok) { + const data = await res.json(); + return NextResponse.json({ ok: true, employees: data }); + } + return NextResponse.json({ ok: false, employees: [] }); + } catch { + return NextResponse.json({ ok: false, employees: [] }); + } +} + +export async function POST(req: NextRequest) { + const { role, config } = await req.json(); + try { + const res = await hireEmployee(role, config); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 201 : 500 }); + } catch { + return NextResponse.json({ error: "Backend unavailable" }, { status: 503 }); + } +} + +export async function DELETE(req: NextRequest) { + const { agentId } = await req.json(); + try { + await fireEmployee(agentId); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Backend unavailable" }, { status: 503 }); + } +} diff --git a/app/app/api/github/activity/route.ts b/app/app/api/github/activity/route.ts new file mode 100644 index 0000000..fae87fd --- /dev/null +++ b/app/app/api/github/activity/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; + +interface GithubNotification { + id: string; + reason: string; + subject: { + title: string; + url: string; + type: string; + latest_comment_url: string | null; + }; + repository: { + full_name: string; + }; + updated_at: string; + unread: boolean; +} + +interface PullRequest { + id: number; + number: number; + title: string; + state: string; + user: { login: string }; + created_at: string; + updated_at: string; + draft: boolean; + html_url: string; + base: { repo: { full_name: string } }; + requested_reviewers: { login: string }[]; +} + +function formatDate(isoDate: string): string { + const date = new Date(isoDate); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return date.toLocaleDateString([], { weekday: "short" }); + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function reasonLabel(reason: string): string { + const map: Record = { + assign: "assigned", + author: "author", + comment: "commented", + mention: "mentioned", + review_requested: "review requested", + subscribed: "subscribed", + team_mention: "team mention", + ci_activity: "CI", + }; + return map[reason] || reason; +} + +export async function GET(req: NextRequest) { + const token = req.cookies.get("github_token")?.value; + + if (!token) { + return NextResponse.json({ connected: false, items: [] }); + } + + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + // Fetch notifications and PRs awaiting review in parallel + const [notifRes, prRes] = await Promise.all([ + fetch("https://api.github.com/notifications?all=false&per_page=15", { headers }), + fetch("https://api.github.com/search/issues?q=is:pr+is:open+review-requested:@me&per_page=10", { headers }), + ]); + + if (!notifRes.ok && notifRes.status === 401) { + return NextResponse.json({ connected: false, items: [] }); + } + + const items: { + id: string; + repo: string; + title: string; + type: string; + reason: string; + time: string; + unread: boolean; + url?: string; + author?: string; + state?: string; + }[] = []; + + // Process notifications + if (notifRes.ok) { + const notifications: GithubNotification[] = await notifRes.json(); + for (const n of notifications) { + items.push({ + id: `notif-${n.id}`, + repo: n.repository.full_name, + title: n.subject.title, + type: n.subject.type.replace("PullRequest", "PR").replace("Issue", "Issue"), + reason: reasonLabel(n.reason), + time: formatDate(n.updated_at), + unread: n.unread, + }); + } + } + + // Process PRs awaiting your review + if (prRes.ok) { + const prData = await prRes.json(); + const prs: PullRequest[] = prData.items || []; + for (const pr of prs) { + // Avoid duplicating items already in notifications + const alreadyListed = items.some( + (i) => i.title === pr.title && i.repo === pr.base.repo.full_name + ); + if (!alreadyListed) { + items.push({ + id: `pr-${pr.id}`, + repo: pr.base.repo.full_name, + title: pr.title, + type: "PR", + reason: "review requested", + time: formatDate(pr.updated_at), + unread: true, + url: pr.html_url, + author: pr.user.login, + state: pr.draft ? "draft" : pr.state, + }); + } + } + } + + // Sort: unread first, then by original order (already time-sorted from API) + items.sort((a, b) => (b.unread ? 1 : 0) - (a.unread ? 1 : 0)); + + return NextResponse.json({ connected: true, items: items.slice(0, 20) }); +} + +export async function DELETE() { + const response = NextResponse.json({ ok: true }); + response.cookies.delete("github_token"); + return response; +} diff --git a/app/app/api/gmail/messages/route.ts b/app/app/api/gmail/messages/route.ts new file mode 100644 index 0000000..3db620b --- /dev/null +++ b/app/app/api/gmail/messages/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; + +interface GmailMessage { + id: string; + payload: { + headers: { name: string; value: string }[]; + body?: { data?: string }; + parts?: { mimeType: string; body?: { data?: string } }[]; + }; + snippet: string; + labelIds: string[]; + internalDate: string; +} + +function getHeader(headers: { name: string; value: string }[], name: string) { + return headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || ""; +} + +function decodeBody(data?: string): string { + if (!data) return ""; + try { + return atob(data.replace(/-/g, "+").replace(/_/g, "/")); + } catch { + return ""; + } +} + +function formatDate(internalDate: string): string { + const date = new Date(parseInt(internalDate)); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + if (diffHours < 1) return `${Math.floor(diffMs / 60000)}m ago`; + if (diffHours < 24) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return date.toLocaleDateString([], { weekday: "short" }); + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +async function refreshAccessToken(refreshToken: string): Promise { + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID!, + client_secret: process.env.GOOGLE_CLIENT_SECRET!, + refresh_token: refreshToken, + grant_type: "refresh_token", + }).toString(), + }); + const data = await res.json(); + return data.access_token || null; +} + +export async function GET(req: NextRequest) { + let token = req.cookies.get("gmail_token")?.value; + const refreshToken = req.cookies.get("gmail_refresh_token")?.value; + + if (!token && !refreshToken) { + return NextResponse.json({ connected: false, emails: [] }); + } + + // Try to refresh if no access token + if (!token && refreshToken) { + token = (await refreshAccessToken(refreshToken)) || undefined; + if (!token) { + return NextResponse.json({ connected: false, emails: [] }); + } + } + + // Fetch list of recent messages + const listRes = await fetch( + "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10&labelIds=INBOX", + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (!listRes.ok) { + // Token might be expired — try refresh + if (listRes.status === 401 && refreshToken) { + token = (await refreshAccessToken(refreshToken)) || undefined; + if (!token) return NextResponse.json({ connected: false, emails: [] }); + } else { + return NextResponse.json({ connected: false, error: "gmail_api_error" }); + } + } + + const listData = await listRes.json(); + const messageIds: string[] = (listData.messages || []).map((m: { id: string }) => m.id); + + // Fetch each message in parallel (up to 10) + const messages = await Promise.all( + messageIds.map(async (id) => { + const msgRes = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${id}?format=full`, + { headers: { Authorization: `Bearer ${token}` } } + ); + return msgRes.ok ? (msgRes.json() as Promise) : null; + }) + ); + + const emails = messages + .filter((m): m is GmailMessage => m !== null) + .map((msg) => { + const headers = msg.payload.headers; + const from = getHeader(headers, "from"); + const fromName = from.includes("<") ? from.split("<")[0].trim().replace(/"/g, "") : from; + const subject = getHeader(headers, "subject") || "(no subject)"; + + // Get plain text body + let body = msg.snippet; + const textPart = msg.payload.parts?.find(p => p.mimeType === "text/plain"); + if (textPart?.body?.data) { + body = decodeBody(textPart.body.data).slice(0, 300); + } else if (msg.payload.body?.data) { + body = decodeBody(msg.payload.body.data).slice(0, 300); + } + + const isUnread = msg.labelIds.includes("UNREAD"); + const labels = msg.labelIds + .filter(l => !["INBOX", "UNREAD", "IMPORTANT", "CATEGORY_PERSONAL"].includes(l)) + .map(l => l.toLowerCase().replace("category_", "")) + .slice(0, 2); + + return { + id: msg.id, + from: fromName, + subject, + body: body.trim(), + time: formatDate(msg.internalDate), + priority: isUnread ? "high" as const : "low" as const, + read: !isUnread, + labels, + }; + }); + + return NextResponse.json({ connected: true, emails }); +} + +export async function DELETE() { + const response = NextResponse.json({ ok: true }); + response.cookies.delete("gmail_token"); + response.cookies.delete("gmail_refresh_token"); + return response; +} diff --git a/app/app/api/slack/messages/route.ts b/app/app/api/slack/messages/route.ts new file mode 100644 index 0000000..b2837bc --- /dev/null +++ b/app/app/api/slack/messages/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSlackMessages, disconnectSlack } from "@/lib/backend-client"; + +export async function GET(req: NextRequest) { + // Try backend first + try { + const backendRes = await getSlackMessages(); + if (backendRes.ok) { + const data = await backendRes.json(); + return NextResponse.json(data); + } + } catch { + // Backend unreachable — fall through to cookie fallback + } + + // Fallback: use token from cookie (set when backend was unavailable at OAuth time) + const cookieToken = req.cookies.get("slack_token")?.value; + if (!cookieToken) { + return NextResponse.json({ connected: false, messages: [] }); + } + + return fetchSlackDirect(cookieToken); +} + +export async function DELETE() { + // Try to delete from backend + try { + await disconnectSlack(); + } catch { + // Backend unreachable, best effort + } + + // Also clear cookie fallback + const response = NextResponse.json({ ok: true }); + response.cookies.delete("slack_token"); + return response; +} + +async function fetchSlackDirect(token: string) { + const userCache = new Map(); + + // Use only public_channel to avoid needing groups:read scope + const channelsRes = await fetch( + "https://slack.com/api/conversations.list?types=public_channel&limit=20&exclude_archived=true", + { headers: { Authorization: `Bearer ${token}` } } + ); + const channelsData = await channelsRes.json(); + + if (!channelsData.ok) { + console.error("[Slack] conversations.list error:", channelsData.error, channelsData); + return NextResponse.json({ connected: false, error: channelsData.error }); + } + + const channels = (channelsData.channels || []).filter( + (c: { is_member: boolean }) => c.is_member + ); + const allMessages = []; + + for (const channel of channels.slice(0, 5)) { + const histRes = await fetch( + `https://slack.com/api/conversations.history?channel=${channel.id}&limit=3`, + { headers: { Authorization: `Bearer ${token}` } } + ); + const histData = await histRes.json(); + if (!histData.ok || !histData.messages) continue; + + for (const msg of histData.messages) { + if (!msg.user || !msg.text) continue; + + if (!userCache.has(msg.user)) { + const uRes = await fetch( + `https://slack.com/api/users.info?user=${msg.user}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + const uData = await uRes.json(); + const name = uData.ok + ? uData.user.profile.display_name || uData.user.profile.real_name + : msg.user; + userCache.set(msg.user, name); + } + + const name = userCache.get(msg.user)!; + const ts = parseFloat(msg.ts); + const diffMs = Date.now() - ts * 1000; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + + allMessages.push({ + id: msg.ts, + channel: `#${channel.name}`, + user: name, + avatar: name.split(" ").map((w: string) => w[0]).join("").toUpperCase().slice(0, 2), + text: msg.text, + time: diffMins < 60 ? `${diffMins}m ago` : diffHours < 24 ? `${diffHours}h ago` : new Date(ts * 1000).toLocaleDateString(), + reactions: (msg.reactions || []).map((r: { name: string; count: number }) => ({ + emoji: `:${r.name}:`, + count: r.count, + })), + }); + } + } + + allMessages.sort((a, b) => parseFloat(b.id) - parseFloat(a.id)); + return NextResponse.json({ connected: true, messages: allMessages }); +} diff --git a/app/app/api/slack/token/route.ts b/app/app/api/slack/token/route.ts new file mode 100644 index 0000000..33293a8 --- /dev/null +++ b/app/app/api/slack/token/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { token } = await req.json(); + + if (!token || !token.startsWith("xoxp-")) { + return NextResponse.json({ error: "Invalid token — must be a User OAuth Token (xoxp-...)" }, { status: 400 }); + } + + const response = NextResponse.json({ ok: true }); + response.cookies.set("slack_token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, + path: "/", + }); + return response; +} diff --git a/app/app/marketplace/page.tsx b/app/app/marketplace/page.tsx new file mode 100644 index 0000000..ce2a9a5 --- /dev/null +++ b/app/app/marketplace/page.tsx @@ -0,0 +1,10 @@ +import { MarketplaceView } from "@/components/marketplace/marketplace-view"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Talent Directory — AgentOS", +}; + +export default function MarketplacePage() { + return ; +} diff --git a/app/app/test/page.tsx b/app/app/test/page.tsx new file mode 100644 index 0000000..6f2cd62 --- /dev/null +++ b/app/app/test/page.tsx @@ -0,0 +1,5 @@ +import { TestDashboard } from "@/components/test/test-dashboard"; + +export default function TestPage() { + return ; +} diff --git a/app/components/agents/global-chat.tsx b/app/components/agents/global-chat.tsx new file mode 100644 index 0000000..c04e889 --- /dev/null +++ b/app/components/agents/global-chat.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState } from "react"; +import { Card } from "@/components/ui/card"; +import { ChatPanel } from "@/components/test/chat-panel"; +import { cn } from "@/lib/utils"; + +export function GlobalChat() { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+
+

Ask Your Team

+

+ Query across all your AI employees at once +

+
+ +
+ + +
+ +
+
+
+ ); +} diff --git a/app/components/agents/slack-feed.tsx b/app/components/agents/slack-feed.tsx new file mode 100644 index 0000000..9e65ec7 --- /dev/null +++ b/app/components/agents/slack-feed.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { mockSlackMessages, SlackMessage } from "@/lib/mock-data"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +const channelColors: Record = { + "#engineering": "text-blue-400 bg-blue-400/10", + "#product": "text-purple-400 bg-purple-400/10", + "#general": "text-green-400 bg-green-400/10", + "#design": "text-pink-400 bg-pink-400/10", +}; + +function getChannelColor(channel: string) { + return channelColors[channel] || "text-muted-foreground bg-muted"; +} + +interface SlackFeedProps { + onConnectionChange?: (connected: boolean) => void; +} + +export function SlackFeed({ onConnectionChange }: SlackFeedProps) { + const [messages, setMessages] = useState([]); + const [connected, setConnected] = useState(false); + const [loading, setLoading] = useState(true); + const [disconnecting, setDisconnecting] = useState(false); + const [showTokenInput, setShowTokenInput] = useState(false); + const [tokenInput, setTokenInput] = useState(""); + const [savingToken, setSavingToken] = useState(false); + + useEffect(() => { + fetchMessages(); + }, []); + + async function fetchMessages() { + setLoading(true); + try { + const res = await fetch("/api/slack/messages"); + const data = await res.json(); + if (data.connected) { + setConnected(true); + setMessages(data.messages); + onConnectionChange?.(true); + } else { + setConnected(false); + setMessages(mockSlackMessages); + onConnectionChange?.(false); + } + } catch { + setConnected(false); + setMessages(mockSlackMessages); + } finally { + setLoading(false); + } + } + + async function handleDisconnect() { + setDisconnecting(true); + await fetch("/api/slack/messages", { method: "DELETE" }); + setConnected(false); + setMessages(mockSlackMessages); + onConnectionChange?.(false); + setDisconnecting(false); + } + + async function handleSaveToken() { + if (!tokenInput.trim()) return; + setSavingToken(true); + try { + const res = await fetch("/api/slack/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: tokenInput.trim() }), + }); + if (res.ok) { + setShowTokenInput(false); + setTokenInput(""); + await fetchMessages(); + } + } finally { + setSavingToken(false); + } + } + + return ( +
+
+
+ {connected ? ( + <> +
+ Live from Slack + + ) : ( + <> +
+ Using mock data + + )} +
+ {connected ? ( + + ) : ( +
+ + +
+ )} +
+ + {showTokenInput && !connected && ( +
+

+ Go to your Slack app → OAuth & Permissions → Install to Workspace → copy the User OAuth Token (xoxp-...) +

+
+ setTokenInput(e.target.value)} + placeholder="xoxp-..." + className="text-xs h-7 font-mono" + /> + +
+
+ )} + + {loading ? ( +
+
+ + + +
+
+ ) : ( + +
+ {messages.map((msg) => ( +
+
+ + + {msg.avatar} + + +
+
+ {msg.user} + + {msg.channel} + + {msg.time} +
+

{msg.text}

+ {msg.reactions && msg.reactions.length > 0 && ( +
+ {msg.reactions.map((r) => ( + + {r.emoji} {r.count} + + ))} +
+ )} +
+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/app/components/marketplace/marketplace-view.tsx b/app/components/marketplace/marketplace-view.tsx new file mode 100644 index 0000000..67a0077 --- /dev/null +++ b/app/components/marketplace/marketplace-view.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useState } from "react"; +import { installedAgents, marketplaceAgents, Agent } from "@/lib/mock-data"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +export function MarketplaceView() { + const [search, setSearch] = useState(""); + const [hired, setHired] = useState>( + new Set(installedAgents.map((a) => a.id)) + ); + const [hiring, setHiring] = useState(null); + + const allAgents = [...installedAgents, ...marketplaceAgents]; + + const filtered = allAgents.filter( + (a) => + a.name.toLowerCase().includes(search.toLowerCase()) || + a.description.toLowerCase().includes(search.toLowerCase()) || + a.integration.toLowerCase().includes(search.toLowerCase()) + ); + + async function handleHire(agent: Agent) { + setHiring(agent.id); + try { + await fetch("/api/employees", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: agent.id, config: {} }), + }); + } catch { + // Backend unavailable — still show as hired in UI for demo + } + setHired((prev) => new Set([...prev, agent.id])); + setHiring(null); + } + + async function handleLetGo(agent: Agent) { + if (["inbox", "slack", "github"].includes(agent.id)) return; + try { + await fetch("/api/employees", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: agent.id }), + }); + } catch { + // Best effort + } + setHired((prev) => { + const next = new Set(prev); + next.delete(agent.id); + return next; + }); + } + + const hiredList = filtered.filter((a) => hired.has(a.id)); + const availableList = filtered.filter((a) => !hired.has(a.id)); + + return ( +
+
+

Talent Directory

+

+ Browse and hire AI employees for your team. +

+
+ +
+ setSearch(e.target.value)} + className="max-w-sm" + /> + + {hired.size} hired · {marketplaceAgents.filter(a => !hired.has(a.id)).length} available + +
+ + {hiredList.length > 0 && ( +
+

+ Your Team ({hiredList.length}) +

+
+ {hiredList.map((agent) => ( + handleLetGo(agent)} + /> + ))} +
+
+ )} + + {availableList.length > 0 && ( +
+

+ Available to Hire ({availableList.length}) +

+
+ {availableList.map((agent) => ( + handleHire(agent)} + /> + ))} +
+
+ )} + + +
+ ); +} + +function EmployeeCard({ + agent, + isHired, + isCore, + hiring, + onHire, + onLetGo, +}: { + agent: Agent; + isHired: boolean; + isCore: boolean; + hiring?: boolean; + onHire?: () => void; + onLetGo?: () => void; +}) { + return ( + + +
+
+ {agent.icon} +
+

{agent.name}

+ + {agent.integration} + +
+
+ {isHired && ( + + Hired + + )} +
+
+ +

+ {agent.description} +

+ {isHired ? ( +
+ {!isCore ? ( + + ) : ( + Core employee + )} +
+ ) : ( + + )} +
+
+ ); +} + +function CreateEmployeeCard() { + return ( +
+

+ Build Your Own +

+ + +
+

+ Create a Custom AI Employee +

+

+ Connect any API, define your employee's role and work style, and add them to your team. +

+ +
+
+
+ ); +} diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx new file mode 100644 index 0000000..fecf214 --- /dev/null +++ b/app/components/navbar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +export function Navbar() { + const pathname = usePathname(); + + const links = [ + { href: "/", label: "Dashboard" }, + { href: "/marketplace", label: "Talent Directory" }, + ]; + + return ( +
+
+
+
+ + AgentOS + beta + + +
+
+
+ 3 AI employees active +
+
+
+
+ ); +} diff --git a/app/components/test/chat-panel.tsx b/app/components/test/chat-panel.tsx new file mode 100644 index 0000000..14d1dce --- /dev/null +++ b/app/components/test/chat-panel.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +type Message = { role: "user" | "assistant"; content: string }; + +const SUGGESTIONS: Record = { + slack: [ + "What are the key blockers from today's messages?", + "Summarize the most important discussions", + "What action items need follow-up?", + ], + gmail: [ + "What emails need my attention today?", + "Any urgent items I should respond to first?", + "Summarize the highest priority threads", + ], + github: [ + "Which PRs need review most urgently?", + "What issues are blocking a release?", + "Give me a status summary of open PRs", + ], + global: [ + "What's my top priority right now?", + "Any blockers I should know about?", + "Give me a full briefing across all tools", + ], +}; + +export function ChatPanel({ agentId, context }: { agentId: string; context?: string }) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + async function send() { + const text = input.trim(); + if (!text || loading) return; + const next: Message[] = [...messages, { role: "user", content: text }]; + setMessages(next); + setInput(""); + setLoading(true); + + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId, context, messages: next }), + }); + + if (!res.body) { setLoading(false); return; } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let text2 = ""; + setMessages(prev => [...prev, { role: "assistant", content: "" }]); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + text2 += decoder.decode(value, { stream: true }); + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { role: "assistant", content: text2 }; + return updated; + }); + } + setLoading(false); + } + + return ( +
+
+

Ask the agent

+

Query your data, get actionable answers

+
+ + + {messages.length === 0 ? ( +
+

Try asking:

+ {(SUGGESTIONS[agentId] || SUGGESTIONS.global).map(s => ( + + ))} +
+ ) : ( +
+ {messages.map((m, i) => ( +
+
+ {m.content || ( + + {[0,150,300].map(d => ( + + ))} + + )} +
+
+ ))} +
+ )} +
+ + +
+
+