From a5e6207825b19b1877dc27a1933e677d164e276d Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Sun, 12 Apr 2026 13:38:49 -0400 Subject: [PATCH 1/3] feat: frontend build + backend integration - Next.js app with dashboard, agent detail views, talent directory, and chat - Slack OAuth flow with cookie fallback when backend is unavailable - Backend client proxies credential storage and Slack reads through FastAPI - Streaming Claude chat per agent and cross-agent global query - Terminology updated throughout (AI employees, talent directory, hire/let go) - CORS, allowedDevOrigins, and ngrok support configured - Backend: auth router, Slack/Gmail OAuth routes, gateway read endpoints - Migration: expand credentials allowlist to include github and hubspot Co-Authored-By: Claude Sonnet 4.6 --- app/app/api/auth/slack/callback/route.ts | 51 +++-- app/app/api/auth/slack/route.ts | 19 +- app/app/api/employees/route.ts | 36 ++++ app/app/api/slack/messages/route.ts | 138 ++++++-------- app/app/api/slack/token/route.ts | 19 ++ app/app/marketplace/page.tsx | 5 + app/app/page.tsx | 2 +- app/components/agents/global-chat.tsx | 4 +- app/components/agents/slack-feed.tsx | 73 ++++++- .../marketplace/marketplace-view.tsx | 129 +++++++------ app/components/navbar.tsx | 4 +- app/lib/backend-client.ts | 61 ++++++ app/next.config.ts | 6 +- backend/.env.example | 11 ++ backend/app/config.py | 9 + backend/app/main.py | 16 +- backend/app/routers/auth.py | 180 ++++++++++++++++++ backend/app/routers/gateway.py | 106 ++++++++++- .../migrations/002_add_github_credential.sql | 9 + 19 files changed, 696 insertions(+), 182 deletions(-) create mode 100644 app/app/api/employees/route.ts create mode 100644 app/app/api/slack/token/route.ts create mode 100644 app/lib/backend-client.ts create mode 100644 backend/app/routers/auth.py create mode 100644 backend/migrations/002_add_github_credential.sql diff --git a/app/app/api/auth/slack/callback/route.ts b/app/app/api/auth/slack/callback/route.ts index 5834e07..77b970e 100644 --- a/app/app/api/auth/slack/callback/route.ts +++ b/app/app/api/auth/slack/callback/route.ts @@ -1,10 +1,10 @@ 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 REDIRECT_URI = process.env.NEXT_PUBLIC_BASE_URL - ? `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/slack/callback` - : "http://localhost:3000/api/auth/slack/callback"; +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"); @@ -12,11 +12,11 @@ export async function GET(req: NextRequest) { if (error || !code) { return NextResponse.redirect( - new URL(`/agent/slack?error=${error || "missing_code"}`, req.url) + `${BASE_URL}/agent/slack?error=${error || "missing_code"}` ); } - // Exchange code for access token + // Exchange code for token const params = new URLSearchParams({ client_id: SLACK_CLIENT_ID, client_secret: SLACK_CLIENT_SECRET, @@ -34,31 +34,40 @@ export async function GET(req: NextRequest) { if (!data.ok) { return NextResponse.redirect( - new URL(`/agent/slack?error=${data.error}`, req.url) + `${BASE_URL}/agent/slack?error=${data.error}` ); } - // User token is nested under authed_user for user scopes const userToken = data.authed_user?.access_token; - if (!userToken) { return NextResponse.redirect( - new URL("/agent/slack?error=no_user_token", req.url) + `${BASE_URL}/agent/slack?error=no_user_token` ); } - // Store token in httpOnly cookie (no DB needed for now) - const response = NextResponse.redirect( - new URL("/agent/slack?connected=true", req.url) - ); + const scopes = (data.authed_user?.scope || "").split(",").filter(Boolean); - response.cookies.set("slack_token", userToken, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 60 * 60 * 24 * 30, // 30 days - path: "/", - }); + // 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}/agent/slack?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 response; + return NextResponse.redirect(`${BASE_URL}/agent/slack?connected=true`); } diff --git a/app/app/api/auth/slack/route.ts b/app/app/api/auth/slack/route.ts index 8fde949..e157c13 100644 --- a/app/app/api/auth/slack/route.ts +++ b/app/app/api/auth/slack/route.ts @@ -1,22 +1,29 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; +import { getSlackOAuthUrl } from "@/lib/backend-client"; const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID!; -const REDIRECT_URI = process.env.NEXT_PUBLIC_BASE_URL - ? `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/slack/callback` - : "http://localhost:3000/api/auth/slack/callback"; +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(_req: NextRequest) { +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/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/slack/messages/route.ts b/app/app/api/slack/messages/route.ts index a583931..b2837bc 100644 --- a/app/app/api/slack/messages/route.ts +++ b/app/app/api/slack/messages/route.ts @@ -1,79 +1,61 @@ import { NextRequest, NextResponse } from "next/server"; +import { getSlackMessages, disconnectSlack } from "@/lib/backend-client"; -interface SlackChannel { - id: string; - name: string; - is_member: boolean; -} +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 + } -interface SlackMessage { - ts: string; - user?: string; - text: string; - reactions?: { name: string; count: number }[]; -} + // 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: [] }); + } -interface SlackUser { - real_name: string; - profile: { display_name: string; real_name: string }; + return fetchSlackDirect(cookieToken); } -// Simple in-memory cache for user names so we don't hammer the API -const userCache = new Map(); - -async function getUsername(token: string, userId: string): Promise { - if (userCache.has(userId)) return userCache.get(userId)!; - const res = await fetch( - `https://slack.com/api/users.info?user=${userId}`, - { headers: { Authorization: `Bearer ${token}` } } - ); - const data = await res.json(); - if (data.ok) { - const user: SlackUser = data.user; - const name = - user.profile.display_name || user.profile.real_name || user.real_name; - userCache.set(userId, name); - return name; +export async function DELETE() { + // Try to delete from backend + try { + await disconnectSlack(); + } catch { + // Backend unreachable, best effort } - return userId; -} -function initials(name: string): string { - return name - .split(" ") - .map((w) => w[0]) - .join("") - .toUpperCase() - .slice(0, 2); + // Also clear cookie fallback + const response = NextResponse.json({ ok: true }); + response.cookies.delete("slack_token"); + return response; } -export async function GET(req: NextRequest) { - const token = req.cookies.get("slack_token")?.value; +async function fetchSlackDirect(token: string) { + const userCache = new Map(); - if (!token) { - return NextResponse.json({ connected: false, messages: [] }); - } - - // 1. Get channels the user is a member of + // Use only public_channel to avoid needing groups:read scope const channelsRes = await fetch( - "https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=20&exclude_archived=true", + "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) { - return NextResponse.json( - { connected: false, error: channelsData.error }, - { status: 401 } - ); + console.error("[Slack] conversations.list error:", channelsData.error, channelsData); + return NextResponse.json({ connected: false, error: channelsData.error }); } - const channels: SlackChannel[] = (channelsData.channels || []).filter( - (c: SlackChannel) => c.is_member + const channels = (channelsData.channels || []).filter( + (c: { is_member: boolean }) => c.is_member ); - - // 2. Fetch recent messages from each channel (up to 5 channels, 3 messages each) 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`, @@ -82,29 +64,35 @@ export async function GET(req: NextRequest) { const histData = await histRes.json(); if (!histData.ok || !histData.messages) continue; - for (const msg of histData.messages as SlackMessage[]) { - if (!msg.user || !msg.text || msg.text.startsWith("joined #")) continue; - - const userName = await getUsername(token, msg.user); - const date = new Date(parseFloat(msg.ts) * 1000); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); + 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); - let timeLabel: string; - if (diffMins < 60) timeLabel = `${diffMins}m ago`; - else if (diffHours < 24) timeLabel = `${diffHours}h ago`; - else timeLabel = date.toLocaleDateString(); - allMessages.push({ id: msg.ts, channel: `#${channel.name}`, - user: userName, - avatar: initials(userName), + user: name, + avatar: name.split(" ").map((w: string) => w[0]).join("").toUpperCase().slice(0, 2), text: msg.text, - time: timeLabel, - reactions: (msg.reactions || []).map((r) => ({ + 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, })), @@ -112,14 +100,6 @@ export async function GET(req: NextRequest) { } } - // Sort by timestamp descending allMessages.sort((a, b) => parseFloat(b.id) - parseFloat(a.id)); - return NextResponse.json({ connected: true, messages: allMessages }); } - -export async function DELETE(req: NextRequest) { - const response = NextResponse.json({ ok: true }); - response.cookies.delete("slack_token"); - return response; -} 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 index a70f765..ce2a9a5 100644 --- a/app/app/marketplace/page.tsx +++ b/app/app/marketplace/page.tsx @@ -1,4 +1,9 @@ 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/page.tsx b/app/app/page.tsx index 2285255..5252e2e 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -8,7 +8,7 @@ export default function DashboardPage() {

Dashboard

- Your active agents are monitoring and reasoning across your tools. + Your AI employees are monitoring and reasoning across your tools.

diff --git a/app/components/agents/global-chat.tsx b/app/components/agents/global-chat.tsx index e74688c..b0c364e 100644 --- a/app/components/agents/global-chat.tsx +++ b/app/components/agents/global-chat.tsx @@ -12,9 +12,9 @@ export function GlobalChat() {
-

Cross-Agent Query

+

Ask Your Team

- Ask across all your integrations at once + Query across all your AI employees at once

) : ( - +
+ + +
)}
+ {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 ? (
diff --git a/app/components/marketplace/marketplace-view.tsx b/app/components/marketplace/marketplace-view.tsx index 7d36c23..67a0077 100644 --- a/app/components/marketplace/marketplace-view.tsx +++ b/app/components/marketplace/marketplace-view.tsx @@ -10,10 +10,10 @@ import { cn } from "@/lib/utils"; export function MarketplaceView() { const [search, setSearch] = useState(""); - const [installed, setInstalled] = useState>( + const [hired, setHired] = useState>( new Set(installedAgents.map((a) => a.id)) ); - const [installing, setInstalling] = useState(null); + const [hiring, setHiring] = useState(null); const allAgents = [...installedAgents, ...marketplaceAgents]; @@ -24,60 +24,76 @@ export function MarketplaceView() { a.integration.toLowerCase().includes(search.toLowerCase()) ); - async function handleInstall(agent: Agent) { - setInstalling(agent.id); - await new Promise((r) => setTimeout(r, 1200)); - setInstalled((prev) => new Set([...prev, agent.id])); - setInstalling(null); + 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); } - function handleUninstall(agentId: string) { - // Don't allow uninstalling the core three for demo purposes - if (["inbox", "slack", "github"].includes(agentId)) return; - setInstalled((prev) => { + 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(agentId); + next.delete(agent.id); return next; }); } - const installedList = filtered.filter((a) => installed.has(a.id)); - const availableList = filtered.filter((a) => !installed.has(a.id)); + const hiredList = filtered.filter((a) => hired.has(a.id)); + const availableList = filtered.filter((a) => !hired.has(a.id)); return (
-

Marketplace

+

Talent Directory

- Browse and install agents to extend your dashboard. + Browse and hire AI employees for your team.

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

- Installed ({installedList.length}) + Your Team ({hiredList.length})

- {installedList.map((agent) => ( - ( + handleUninstall(agent.id)} + onLetGo={() => handleLetGo(agent)} /> ))}
@@ -87,45 +103,45 @@ export function MarketplaceView() { {availableList.length > 0 && (

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

{availableList.map((agent) => ( - handleInstall(agent)} + hiring={hiring === agent.id} + onHire={() => handleHire(agent)} /> ))}
)} - +
); } -function MarketplaceCard({ +function EmployeeCard({ agent, - isInstalled, + isHired, isCore, - installing, - onInstall, - onUninstall, + hiring, + onHire, + onLetGo, }: { agent: Agent; - isInstalled: boolean; + isHired: boolean; isCore: boolean; - installing?: boolean; - onInstall?: () => void; - onUninstall?: () => void; + hiring?: boolean; + onHire?: () => void; + onLetGo?: () => void; }) { return ( - +
@@ -137,9 +153,9 @@ function MarketplaceCard({
- {isInstalled && ( + {isHired && ( - Installed + Hired )}
@@ -148,36 +164,35 @@ function MarketplaceCard({

{agent.description}

- {isInstalled ? ( + {isHired ? (
- {!isCore && ( + {!isCore ? ( - )} - {isCore && ( - Core integration + ) : ( + Core employee )}
) : ( )} @@ -186,7 +201,7 @@ function MarketplaceCard({ ); } -function CreateAgentCard() { +function CreateEmployeeCard() { return (

@@ -196,10 +211,10 @@ function CreateAgentCard() {

- Create a Custom Agent + Create a Custom AI Employee

- Connect any API, define your agent's behavior, and add it to your dashboard. + Connect any API, define your employee's role and work style, and add them to your team.

- 3 agents active + 3 AI employees active
diff --git a/app/lib/backend-client.ts b/app/lib/backend-client.ts new file mode 100644 index 0000000..67455bd --- /dev/null +++ b/app/lib/backend-client.ts @@ -0,0 +1,61 @@ +/** + * Server-side only — never import this from a client component. + * All calls require BACKEND_API_KEY which must stay out of the browser bundle. + */ + +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000"; +const BACKEND_API_KEY = process.env.BACKEND_API_KEY || ""; + +async function backendFetch(path: string, options: RequestInit = {}) { + const res = await fetch(`${BACKEND_URL}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + "X-API-Key": BACKEND_API_KEY, + ...options.headers, + }, + }); + return res; +} + +export async function storeCredential( + service: string, + token: string, + scopes: string[] +) { + return backendFetch("/credentials", { + method: "POST", + body: JSON.stringify({ service, token, scopes }), + }); +} + +export async function deleteCredential(service: string) { + return backendFetch(`/credentials/${service}`, { method: "DELETE" }); +} + +export async function getSlackMessages() { + return backendFetch("/gateway/slack/messages"); +} + +export async function disconnectSlack() { + return backendFetch("/gateway/slack/disconnect", { method: "DELETE" }); +} + +export async function hireEmployee(role: string, config?: Record) { + return backendFetch("/agents", { + method: "POST", + body: JSON.stringify({ role, config: config || {} }), + }); +} + +export async function listEmployees() { + return backendFetch("/agents"); +} + +export async function fireEmployee(agentId: string) { + return backendFetch(`/agents/${agentId}`, { method: "DELETE" }); +} + +export function getSlackOAuthUrl() { + return `${BACKEND_URL}/auth/slack?api_key=${encodeURIComponent(BACKEND_API_KEY)}`; +} diff --git a/app/next.config.ts b/app/next.config.ts index e9ffa30..438703f 100644 --- a/app/next.config.ts +++ b/app/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from "next"; +const ngrokHost = (process.env.NEXT_PUBLIC_BASE_URL || "") + .replace("https://", "") + .replace("http://", ""); + const nextConfig: NextConfig = { - /* config options here */ + allowedDevOrigins: ngrokHost ? [ngrokHost] : [], }; export default nextConfig; diff --git a/backend/.env.example b/backend/.env.example index 533781e..6b391e0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,7 +12,18 @@ OPENCLAW_AGENT_IMAGE=openclaw/agent:latest # LLM LLM_API_KEY=your-llm-api-key +# OAuth — Slack +SLACK_CLIENT_ID=your-slack-client-id +SLACK_CLIENT_SECRET=your-slack-client-secret + +# OAuth — Gmail / Google +# Create at https://console.cloud.google.com → APIs & Services → Credentials → OAuth 2.0 Client ID +# Enable: Gmail API | Authorized redirect URI: http://localhost:3000/api/auth/gmail/callback +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret + # Platform PLATFORM_HOST=0.0.0.0 PLATFORM_PORT=8000 PLATFORM_GATEWAY_URL=http://host.docker.internal:8000/gateway +FRONTEND_URL=http://localhost:3000 diff --git a/backend/app/config.py b/backend/app/config.py index a18b5c6..1d12e40 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -17,10 +17,19 @@ class Settings(BaseSettings): # LLM llm_api_key: str = "" + # OAuth — Slack + slack_client_id: str = "" + slack_client_secret: str = "" + + # OAuth — Gmail / Google + google_client_id: str = "" + google_client_secret: str = "" + # Platform platform_host: str = "0.0.0.0" platform_port: int = 8000 platform_gateway_url: str = "http://host.docker.internal:8000/gateway" + frontend_url: str = "http://localhost:3000" model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} diff --git a/backend/app/main.py b/backend/app/main.py index 6e17506..9e498bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,16 +1,26 @@ from fastapi import FastAPI -from app.routers import users, agents, gateway, credentials +from fastapi.middleware.cors import CORSMiddleware +from app.routers import users, agents, gateway, credentials, auth app = FastAPI( - title="OpenClaw Platform", - description="Multi-tenant platform for hiring OpenClaw agents as specialized roles", + title="AgentOS Platform", + description="Multi-tenant platform for hiring AI employees", version="0.1.0", ) +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router(users.router) app.include_router(agents.router) app.include_router(credentials.router) app.include_router(gateway.router) +app.include_router(auth.router) @app.get("/health") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..b5036b5 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,180 @@ +"""OAuth routes — handles provider consent flows and stores tokens in the credential vault.""" + +import httpx +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import RedirectResponse +from app.config import get_settings +from app.models.user import UserModel +from app.services.credential_store import CredentialStore + +router = APIRouter(prefix="/auth", tags=["auth"]) + +SLACK_SCOPES = "channels:history,channels:read,users:read,reactions:read" + +GMAIL_SCOPES = " ".join([ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/userinfo.email", +]) + + +@router.get("/slack") +def slack_oauth_start(api_key: str = Query(...)): + """ + Initiate Slack OAuth. The frontend passes the user's api_key as a query param + so the callback knows which user to store the token for. + The api_key is used as the OAuth state parameter (transmitted only server-to-server). + """ + settings = get_settings() + if not settings.slack_client_id: + raise HTTPException(500, "Slack OAuth not configured") + + user = UserModel.get_by_api_key(api_key) + if not user: + raise HTTPException(401, "Invalid API key") + + redirect_uri = f"{settings.frontend_url}/api/auth/slack/callback" + url = ( + "https://slack.com/oauth/v2/authorize" + f"?client_id={settings.slack_client_id}" + f"&user_scope={SLACK_SCOPES}" + f"&redirect_uri={redirect_uri}" + f"&state={api_key}" + ) + return RedirectResponse(url) + + +@router.get("/slack/callback") +async def slack_oauth_callback( + code: str = Query(None), + state: str = Query(None), + error: str = Query(None), +): + """ + Slack redirects here after the user consents. + Exchange the code for a token and store it in the credential vault. + Redirect the browser back to the frontend. + """ + settings = get_settings() + + if error or not code or not state: + return RedirectResponse( + f"{settings.frontend_url}/agent/slack?error={error or 'missing_code'}" + ) + + user = UserModel.get_by_api_key(state) + if not user: + return RedirectResponse( + f"{settings.frontend_url}/agent/slack?error=invalid_state" + ) + + redirect_uri = f"{settings.frontend_url}/api/auth/slack/callback" + async with httpx.AsyncClient() as client: + resp = await client.post( + "https://slack.com/api/oauth.v2.access", + data={ + "client_id": settings.slack_client_id, + "client_secret": settings.slack_client_secret, + "code": code, + "redirect_uri": redirect_uri, + }, + ) + data = resp.json() + + if not data.get("ok"): + return RedirectResponse( + f"{settings.frontend_url}/agent/slack?error={data.get('error', 'oauth_failed')}" + ) + + user_token = data.get("authed_user", {}).get("access_token") + if not user_token: + return RedirectResponse( + f"{settings.frontend_url}/agent/slack?error=no_user_token" + ) + + scopes = data.get("authed_user", {}).get("scope", "").split(",") + CredentialStore.store(user["id"], "slack", user_token, scopes) + + return RedirectResponse( + f"{settings.frontend_url}/agent/slack?connected=true" + ) + + +@router.get("/gmail") +def gmail_oauth_start(api_key: str = Query(...)): + """Initiate Gmail OAuth. State carries the api_key so the callback knows which user to store the token for.""" + settings = get_settings() + if not settings.google_client_id: + raise HTTPException(500, "Gmail OAuth not configured") + + user = UserModel.get_by_api_key(api_key) + if not user: + raise HTTPException(401, "Invalid API key") + + redirect_uri = f"{settings.frontend_url}/api/auth/gmail/callback" + url = ( + "https://accounts.google.com/o/oauth2/v2/auth" + f"?client_id={settings.google_client_id}" + f"&redirect_uri={redirect_uri}" + f"&response_type=code" + f"&scope={GMAIL_SCOPES}" + f"&access_type=offline" + f"&prompt=consent" + f"&state={api_key}" + ) + return RedirectResponse(url) + + +@router.get("/gmail/callback") +async def gmail_oauth_callback( + code: str = Query(None), + state: str = Query(None), + error: str = Query(None), +): + """Google redirects here after consent. Exchange code for tokens and store in credential vault.""" + settings = get_settings() + + if error or not code or not state: + return RedirectResponse( + f"{settings.frontend_url}/agent/gmail?error={error or 'missing_code'}" + ) + + user = UserModel.get_by_api_key(state) + if not user: + return RedirectResponse( + f"{settings.frontend_url}/agent/gmail?error=invalid_state" + ) + + redirect_uri = f"{settings.frontend_url}/api/auth/gmail/callback" + async with httpx.AsyncClient() as client: + resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + ) + data = resp.json() + + if "error" in data: + return RedirectResponse( + f"{settings.frontend_url}/agent/gmail?error={data.get('error', 'oauth_failed')}" + ) + + # Store both access_token and refresh_token so the AI employee can renew without re-auth + token_payload = { + "access_token": data["access_token"], + "refresh_token": data.get("refresh_token", ""), + "token_type": data.get("token_type", "Bearer"), + "expires_in": data.get("expires_in"), + } + scopes = data.get("scope", "").split(" ") + CredentialStore.store(user["id"], "gmail", token_payload, scopes) + + return RedirectResponse( + f"{settings.frontend_url}/agent/gmail?connected=true" + ) diff --git a/backend/app/routers/gateway.py b/backend/app/routers/gateway.py index 4d582a9..0c02d00 100644 --- a/backend/app/routers/gateway.py +++ b/backend/app/routers/gateway.py @@ -1,12 +1,16 @@ +import time +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from app.auth import get_current_user from app.services.gateway import GatewayService +from app.services.credential_store import CredentialStore +import httpx router = APIRouter(prefix="/gateway", tags=["gateway"]) -# --- Request schemas --- +# ── Request schemas ──────────────────────────────────────────────────────────── class EmailRequest(BaseModel): to: str @@ -24,7 +28,7 @@ class DiscordRequest(BaseModel): content: str -# --- Endpoints --- +# ── Write endpoints ──────────────────────────────────────────────────────────── @router.post("/email/send") async def send_email( @@ -63,3 +67,101 @@ async def send_discord_message( ) except ValueError as e: raise HTTPException(400, str(e)) + + +# ── Read endpoints ───────────────────────────────────────────────────────────── + +@router.get("/slack/messages") +async def get_slack_messages(user: dict = Depends(get_current_user)): + """Fetch recent messages from the user's connected Slack workspace.""" + cred = CredentialStore.get(user["id"], "slack") + if not cred: + return {"connected": False, "messages": []} + + token = cred["token"] + user_cache: dict[str, str] = {} + + async with httpx.AsyncClient() as client: + channels_resp = await client.get( + "https://slack.com/api/conversations.list", + params={ + "types": "public_channel,private_channel", + "limit": 20, + "exclude_archived": "true", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + channels_data = channels_resp.json() + + if not channels_data.get("ok"): + return {"connected": False, "error": channels_data.get("error")} + + channels = [c for c in channels_data.get("channels", []) if c.get("is_member")] + messages = [] + + async with httpx.AsyncClient() as client: + for channel in channels[:5]: + hist_resp = await client.get( + "https://slack.com/api/conversations.history", + params={"channel": channel["id"], "limit": 3}, + headers={"Authorization": f"Bearer {token}"}, + ) + hist = hist_resp.json() + if not hist.get("ok") or not hist.get("messages"): + continue + + for msg in hist["messages"]: + if not msg.get("user") or not msg.get("text"): + continue + + uid = msg["user"] + if uid not in user_cache: + u_resp = await client.get( + "https://slack.com/api/users.info", + params={"user": uid}, + headers={"Authorization": f"Bearer {token}"}, + ) + u_data = u_resp.json() + if u_data.get("ok"): + profile = u_data["user"]["profile"] + name = profile.get("display_name") or profile.get("real_name") or uid + else: + name = uid + user_cache[uid] = name + + name = user_cache[uid] + ts = float(msg["ts"]) + now = time.time() + diff_mins = int((now - ts) / 60) + diff_hours = int((now - ts) / 3600) + + if diff_mins < 60: + time_label = f"{diff_mins}m ago" + elif diff_hours < 24: + time_label = f"{diff_hours}h ago" + else: + time_label = datetime.fromtimestamp(ts).strftime("%b %d") + + initials = "".join(w[0] for w in name.split() if w).upper()[:2] + + messages.append({ + "id": msg["ts"], + "channel": f"#{channel['name']}", + "user": name, + "avatar": initials, + "text": msg["text"], + "time": time_label, + "reactions": [ + {"emoji": f":{r['name']}:", "count": r["count"]} + for r in msg.get("reactions", []) + ], + }) + + messages.sort(key=lambda m: float(m["id"]), reverse=True) + return {"connected": True, "messages": messages} + + +@router.delete("/slack/disconnect") +def disconnect_slack(user: dict = Depends(get_current_user)): + CredentialStore.delete(user["id"], "slack") + return {"ok": True} diff --git a/backend/migrations/002_add_github_credential.sql b/backend/migrations/002_add_github_credential.sql new file mode 100644 index 0000000..f2c194e --- /dev/null +++ b/backend/migrations/002_add_github_credential.sql @@ -0,0 +1,9 @@ +-- Expand the service allowlist to include github and hubspot +-- Run in Supabase SQL Editor + +alter table credentials + drop constraint if exists credentials_service_check; + +alter table credentials + add constraint credentials_service_check + check (service in ('gmail', 'slack', 'discord', 'github', 'hubspot')); From 1d4641c6c76af7203867325b2298d7319735d657 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Sun, 12 Apr 2026 13:44:45 -0400 Subject: [PATCH 2/3] chore: resolve claude.md case collision before merge --- claude.md | 113 ------------------------------------------------------ 1 file changed, 113 deletions(-) delete mode 100644 claude.md diff --git a/claude.md b/claude.md deleted file mode 100644 index 5a08349..0000000 --- a/claude.md +++ /dev/null @@ -1,113 +0,0 @@ -# Claude Agent System Prompt — AI Dashboard + Marketplace - -## Overview - -You are the core intelligence behind an AI Agent Dashboard. - -Your job is to: -- Read data from multiple integrations (Slack, Gmail, GitHub) -- Reason across them -- Generate useful, actionable outputs -- Simulate intelligent agent behavior (not just summarization) - ---- - -## Core Principles - -1. You are NOT a chatbot. -2. You are an AI agent that: - - analyzes - - connects information - - suggests actions -3. You prioritize usefulness over verbosity. - ---- - -## Available Integrations (Mocked) - -You may receive structured data from: - -### Gmail -- emails -- threads -- sender, subject, body - -### Slack -- messages -- channels -- timestamps - -### GitHub -- pull requests -- comments -- issues - ---- - -## Your Responsibilities - -### 1. Cross-Integration Reasoning - -You must: -- connect information across tools - -Example: -- email mentions a task -- Slack discusses it -- GitHub has related PR - -→ You unify this into a coherent understanding - ---- - -### 2. Agent Behavior - -Each agent has a purpose. You must behave accordingly. - -Examples: - -#### Inbox Agent -- summarize emails -- detect priority -- draft replies - -#### Slack Agent -- summarize discussions -- extract action items - -#### GitHub Agent -- summarize PRs -- suggest improvements - ---- - -### 3. Action-Oriented Outputs - -DO NOT just summarize. - -Always include: -- recommended actions -- suggested next steps - ---- - -### 4. Structured Thinking - -Internally, follow this process: - -1. Identify key information -2. Detect relationships -3. Identify user intent -4. Generate useful output - ---- - -## Linkup Integration (External Context) - -If external knowledge is needed: - -You may receive: -```json -{ - "linkup_results": [...] -} \ No newline at end of file From 8fdcd7ada266206d932e29f06020219302079f8e Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Sun, 12 Apr 2026 14:09:49 -0400 Subject: [PATCH 3/3] slack and gmail --- .gitignore | 2 +- app/app/api/auth/github/callback/route.ts | 48 ++++++ app/app/api/auth/github/route.ts | 14 ++ app/app/api/auth/gmail/callback/route.ts | 53 +++++++ app/app/api/auth/gmail/route.ts | 20 +++ app/app/api/auth/slack/callback/route.ts | 10 +- app/app/api/chat/route.ts | 44 ++++++ app/app/api/github/activity/route.ts | 149 +++++++++++++++++++ app/app/api/gmail/messages/route.ts | 145 ++++++++++++++++++ app/app/test/page.tsx | 5 + app/components/agents/global-chat.tsx | 9 +- app/components/test/chat-panel.tsx | 135 +++++++++++++++++ app/components/test/github-feed.tsx | 171 ++++++++++++++++++++++ app/components/test/gmail-feed.tsx | 152 +++++++++++++++++++ app/components/test/test-dashboard.tsx | 109 ++++++++++++++ app/lib/mock-data.ts | 43 ++++++ backend/app/config.py | 7 +- 17 files changed, 1101 insertions(+), 15 deletions(-) create mode 100644 app/app/api/auth/github/callback/route.ts create mode 100644 app/app/api/auth/github/route.ts create mode 100644 app/app/api/auth/gmail/callback/route.ts create mode 100644 app/app/api/auth/gmail/route.ts create mode 100644 app/app/api/chat/route.ts create mode 100644 app/app/api/github/activity/route.ts create mode 100644 app/app/api/gmail/messages/route.ts create mode 100644 app/app/test/page.tsx create mode 100644 app/components/test/chat-panel.tsx create mode 100644 app/components/test/github-feed.tsx create mode 100644 app/components/test/gmail-feed.tsx create mode 100644 app/components/test/test-dashboard.tsx create mode 100644 app/lib/mock-data.ts 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 index 77b970e..462696a 100644 --- a/app/app/api/auth/slack/callback/route.ts +++ b/app/app/api/auth/slack/callback/route.ts @@ -12,7 +12,7 @@ export async function GET(req: NextRequest) { if (error || !code) { return NextResponse.redirect( - `${BASE_URL}/agent/slack?error=${error || "missing_code"}` + `${BASE_URL}/test?error=${error || "missing_code"}` ); } @@ -34,14 +34,14 @@ export async function GET(req: NextRequest) { if (!data.ok) { return NextResponse.redirect( - `${BASE_URL}/agent/slack?error=${data.error}` + `${BASE_URL}/test?error=${data.error}` ); } const userToken = data.authed_user?.access_token; if (!userToken) { return NextResponse.redirect( - `${BASE_URL}/agent/slack?error=no_user_token` + `${BASE_URL}/test?error=no_user_token` ); } @@ -57,7 +57,7 @@ export async function GET(req: NextRequest) { 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}/agent/slack?connected=true&mode=local` + `${BASE_URL}/test?connected=true&mode=local` ); fallback.cookies.set("slack_token", userToken, { httpOnly: true, @@ -69,5 +69,5 @@ export async function GET(req: NextRequest) { return fallback; } - return NextResponse.redirect(`${BASE_URL}/agent/slack?connected=true`); + return NextResponse.redirect(`${BASE_URL}/test?connected=true`); } 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/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/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 index b0c364e..c04e889 100644 --- a/app/components/agents/global-chat.tsx +++ b/app/components/agents/global-chat.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { ChatPanel } from "./chat-panel"; +import { Card } from "@/components/ui/card"; +import { ChatPanel } from "@/components/test/chat-panel"; import { cn } from "@/lib/utils"; export function GlobalChat() { @@ -27,10 +27,7 @@ export function GlobalChat() {
- +
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 => ( + + ))} + + )} +
+
+ ))} +
+ )} +
+ + +
+
+