Spaces:
Sleeping
Sleeping
| import { OnboardingScreen } from "./components/OnboardingScreen.js"; | |
| import { CategorizationScreen } from "./components/CategorizationScreen.js"; | |
| import { CustomTaskInput } from "./components/CustomTaskInput.js"; | |
| import { WorkflowPicker } from "./components/WorkflowPicker.js"; | |
| import { DeployingScreen } from "./components/DeployingScreen.js"; | |
| import { DashboardScreen } from "./components/DashboardScreen.js"; | |
| import { EscalationPanel } from "./components/EscalationPanel.js"; | |
| import { apiRequest } from "./hooks/useApi.js"; | |
| const root = document.getElementById("root"); | |
| const defaultDescription = | |
| "I run an apple orchard. Customers email me orders, I check inventory in my Google Sheet, reply with pickup details, and every Friday I count weekly orders."; | |
| const defaultBackendUrl = "http://localhost:8000/api"; | |
| const setupSteps = [ | |
| { id: 1, label: "Describe" }, | |
| { id: 2, label: "Review" }, | |
| { id: 3, label: "Rule" }, | |
| { id: 4, label: "Modes" }, | |
| { id: 5, label: "Launch" } | |
| ]; | |
| const state = { | |
| view: "setup", | |
| currentStep: 1, | |
| settingsOpen: false, | |
| backendUrl: defaultBackendUrl, | |
| ownerEmail: "", | |
| ownerId: "", | |
| description: defaultDescription, | |
| analysis: null, | |
| selectedTaskIds: [], | |
| customRule: "When a restaurant client emails, flag it as priority and move it to the top of my orders sheet.", | |
| workflowSelections: {}, | |
| workflowCursor: 0, | |
| liveStats: { | |
| activeWorkflows: 0, | |
| executionsToday: 0, | |
| pendingEscalations: 0 | |
| }, | |
| escalations: [], | |
| status: "Ready.", | |
| statusError: false | |
| }; | |
| init(); | |
| async function init() { | |
| const profile = await getIdentityProfile(); | |
| if (profile) { | |
| await chrome.storage.local.set({ flowpilotOwnerProfile: profile }); | |
| } | |
| const stored = await chrome.storage.local.get(["flowpilotBackendUrl", "flowpilotOwnerProfile"]); | |
| state.backendUrl = stored.flowpilotBackendUrl || defaultBackendUrl; | |
| state.ownerEmail = stored.flowpilotOwnerProfile?.email || ""; | |
| state.ownerId = stored.flowpilotOwnerProfile?.ownerId || ""; | |
| if (!state.ownerEmail) { | |
| setStatus("Sign into Chrome with Google to let FlowPilot identify the mailbox.", false); | |
| } | |
| await refreshLiveData(); | |
| render(); | |
| } | |
| function render() { | |
| root.innerHTML = ` | |
| <div class="app-shell ${state.view === "quiet" ? "app-live" : ""} ${state.view === "escalation" ? "app-alert" : ""}"> | |
| <div class="sidebar-shell"> | |
| ${renderHeader()} | |
| ${state.settingsOpen ? renderSettings() : ""} | |
| <main class="screen-stage">${renderBody()}</main> | |
| </div> | |
| </div> | |
| `; | |
| bindEvents(); | |
| } | |
| function renderHeader() { | |
| const indicator = getViewIndicator(); | |
| return ` | |
| <header class="topbar surface-card"> | |
| <div class="brand-lockup"> | |
| <div class="brand-mark">F</div> | |
| <div> | |
| <p class="brand-name">FlowPilot</p> | |
| <p class="brand-subtitle">${escapeHtml(indicator.label)}</p> | |
| </div> | |
| </div> | |
| </header> | |
| `; | |
| } | |
| function renderSettings() { | |
| return ` | |
| <section class="settings-panel surface-card"> | |
| <label class="field-label" for="flowpilot-backend-url">Backend URL</label> | |
| <input id="flowpilot-backend-url" class="text-input" value="${escapeAttribute(state.backendUrl)}" /> | |
| <div class="button-row split-actions compact-actions"> | |
| <button id="flowpilot-close-settings" class="ghost-button">Close</button> | |
| <button id="flowpilot-save-url" class="secondary-button">Save</button> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| function renderBody() { | |
| if (state.view === "escalation") { | |
| return EscalationPanel({ escalation: state.escalations[0] || null }); | |
| } | |
| if (state.view === "quiet") { | |
| return DashboardScreen({ stats: state.liveStats }); | |
| } | |
| if (state.currentStep === 1) { | |
| return OnboardingScreen({ | |
| description: state.description, | |
| status: state.status, | |
| statusError: state.statusError, | |
| ownerEmail: state.ownerEmail | |
| }); | |
| } | |
| if (state.currentStep === 2) { | |
| return CategorizationScreen({ | |
| summary: state.analysis?.summary || "Review the suggested workstreams.", | |
| groups: buildTaskGroups(), | |
| selectedTaskIds: state.selectedTaskIds | |
| }); | |
| } | |
| if (state.currentStep === 3) { | |
| return CustomTaskInput({ value: state.customRule }); | |
| } | |
| if (state.currentStep === 4) { | |
| const tasks = getWorkflowTasks(); | |
| const task = tasks[state.workflowCursor]; | |
| return WorkflowPicker({ | |
| task, | |
| selectedOption: task ? state.workflowSelections[task.id] || null : null, | |
| current: state.workflowCursor + 1, | |
| total: tasks.length | |
| }); | |
| } | |
| return DeployingScreen(); | |
| } | |
| function bindEvents() { | |
| document.getElementById("flowpilot-save-url")?.addEventListener("click", saveBackendUrl); | |
| document.getElementById("flowpilot-close-settings")?.addEventListener("click", toggleSettings); | |
| document.getElementById("flowpilot-open-settings")?.addEventListener("click", toggleSettings); | |
| document.getElementById("flowpilot-back-button")?.addEventListener("click", goBack); | |
| document.getElementById("flowpilot-back-dashboard")?.addEventListener("click", () => { | |
| state.view = "quiet"; | |
| render(); | |
| }); | |
| if (state.view === "setup" && state.currentStep === 1) { | |
| document.getElementById("flowpilot-business-description")?.addEventListener("input", (event) => { | |
| state.description = event.target.value; | |
| }); | |
| document.getElementById("flowpilot-analyze-button")?.addEventListener("click", analyzeBusiness); | |
| } | |
| if (state.view === "setup" && state.currentStep === 2) { | |
| document.querySelectorAll("[data-task-id]").forEach((input) => { | |
| input.addEventListener("change", toggleTaskSelection); | |
| }); | |
| document.getElementById("flowpilot-continue-tasks")?.addEventListener("click", continueFromTasks); | |
| } | |
| if (state.view === "setup" && state.currentStep === 3) { | |
| document.getElementById("flowpilot-custom-rule")?.addEventListener("input", (event) => { | |
| state.customRule = event.target.value; | |
| }); | |
| document.getElementById("flowpilot-skip-rule")?.addEventListener("click", goToWorkflowStep); | |
| document.getElementById("flowpilot-save-rule")?.addEventListener("click", goToWorkflowStep); | |
| } | |
| if (state.view === "setup" && state.currentStep === 4) { | |
| document.querySelectorAll("[data-workflow-option]").forEach((button) => { | |
| button.addEventListener("click", selectWorkflowOption); | |
| }); | |
| } | |
| if (state.view === "setup" && state.currentStep === 5) { | |
| document.getElementById("flowpilot-deploy-button")?.addEventListener("click", completeSetup); | |
| } | |
| if (state.view === "quiet") { | |
| document.getElementById("flowpilot-open-escalation")?.addEventListener("click", () => { | |
| if (!state.escalations.length) { | |
| return; | |
| } | |
| state.view = "escalation"; | |
| render(); | |
| }); | |
| document.getElementById("flowpilot-stop-automation")?.addEventListener("click", stopAutomation); | |
| } | |
| if (state.view === "escalation") { | |
| document.getElementById("flowpilot-resolve-escalation")?.addEventListener("click", () => resolveEscalation("approve")); | |
| document.getElementById("flowpilot-ask-customer")?.addEventListener("click", () => resolveEscalation("ask_customer")); | |
| } | |
| } | |
| function toggleSettings() { | |
| state.settingsOpen = !state.settingsOpen; | |
| render(); | |
| } | |
| function goBack() { | |
| if (state.view !== "setup") { | |
| return; | |
| } | |
| if (state.currentStep === 2) { | |
| state.currentStep = 1; | |
| } else if (state.currentStep === 3) { | |
| state.currentStep = 2; | |
| } else if (state.currentStep === 4) { | |
| if (state.workflowCursor > 0) { | |
| state.workflowCursor -= 1; | |
| } else { | |
| state.currentStep = 3; | |
| } | |
| } else if (state.currentStep === 5) { | |
| if (getWorkflowTasks().length) { | |
| state.currentStep = 4; | |
| state.workflowCursor = Math.max(getWorkflowTasks().length - 1, 0); | |
| } else { | |
| state.currentStep = 3; | |
| } | |
| } | |
| render(); | |
| } | |
| async function saveBackendUrl() { | |
| const input = document.getElementById("flowpilot-backend-url"); | |
| const nextUrl = (input?.value || "").trim().replace(/\/$/, ""); | |
| if (!nextUrl) { | |
| setStatus("Enter a backend URL.", true); | |
| render(); | |
| return; | |
| } | |
| if (!isSupportedBackendUrl(nextUrl)) { | |
| setStatus("Use either http://localhost:8000/api or an https://...hf.space/api backend.", true); | |
| render(); | |
| return; | |
| } | |
| await chrome.storage.local.set({ flowpilotBackendUrl: nextUrl }); | |
| state.backendUrl = nextUrl; | |
| state.settingsOpen = false; | |
| setStatus("Backend saved."); | |
| render(); | |
| } | |
| async function analyzeBusiness() { | |
| if (!state.description.trim()) { | |
| setStatus("Add a short business description before running analysis.", true); | |
| render(); | |
| return; | |
| } | |
| if (!state.ownerEmail || !state.ownerId) { | |
| setStatus("Could not detect the signed-in Gmail account yet.", true); | |
| render(); | |
| return; | |
| } | |
| setStatus("Analyzing..."); | |
| render(); | |
| try { | |
| const payload = await apiRequest("/analyze", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| owner_id: state.ownerId, | |
| owner_email: state.ownerEmail, | |
| description: state.description.trim() | |
| }) | |
| }); | |
| state.analysis = payload; | |
| state.selectedTaskIds = getDefaultSelectedTaskIds(payload); | |
| state.currentStep = 2; | |
| setStatus("Ready."); | |
| await refreshLiveData(); | |
| } catch (error) { | |
| setStatus(error.message || "Could not reach the backend.", true); | |
| } | |
| render(); | |
| } | |
| function toggleTaskSelection(event) { | |
| const taskId = event.target.dataset.taskId; | |
| if (!taskId) { | |
| return; | |
| } | |
| if (event.target.checked) { | |
| if (!state.selectedTaskIds.includes(taskId)) { | |
| state.selectedTaskIds.push(taskId); | |
| } | |
| } else { | |
| state.selectedTaskIds = state.selectedTaskIds.filter((id) => id !== taskId); | |
| } | |
| } | |
| function continueFromTasks() { | |
| if (!state.selectedTaskIds.length) { | |
| setStatus("Select at least one task to continue.", true); | |
| render(); | |
| return; | |
| } | |
| state.currentStep = 3; | |
| setStatus("Ready."); | |
| render(); | |
| } | |
| function goToWorkflowStep() { | |
| state.currentStep = 4; | |
| state.workflowCursor = 0; | |
| render(); | |
| } | |
| function selectWorkflowOption(event) { | |
| const optionId = event.currentTarget.dataset.workflowOption; | |
| const taskId = event.currentTarget.dataset.taskId; | |
| if (!optionId || !taskId) { | |
| return; | |
| } | |
| state.workflowSelections[taskId] = optionId; | |
| if (state.workflowCursor < getWorkflowTasks().length - 1) { | |
| state.workflowCursor += 1; | |
| render(); | |
| return; | |
| } | |
| state.currentStep = 5; | |
| render(); | |
| } | |
| async function completeSetup() { | |
| await refreshLiveData(); | |
| state.view = "quiet"; | |
| render(); | |
| } | |
| async function stopAutomation() { | |
| if (!state.ownerId) { | |
| return; | |
| } | |
| try { | |
| await apiRequest("/stop", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| owner_id: state.ownerId | |
| }) | |
| }); | |
| await refreshLiveData(); | |
| setStatus("Automation stopped."); | |
| } catch (error) { | |
| setStatus(error.message || "Could not stop automation.", true); | |
| } | |
| render(); | |
| } | |
| async function resolveEscalation(response) { | |
| const escalation = state.escalations[0]; | |
| if (!escalation) { | |
| state.view = "quiet"; | |
| render(); | |
| return; | |
| } | |
| try { | |
| await apiRequest("/escalation-reply", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| escalation_id: escalation.id, | |
| response | |
| }) | |
| }); | |
| } catch (error) { | |
| setStatus(error.message || "Could not resolve escalation.", true); | |
| } | |
| await refreshLiveData(); | |
| state.view = "quiet"; | |
| render(); | |
| } | |
| function getDefaultSelectedTaskIds(analysis) { | |
| return [ | |
| ...(analysis?.tasks?.fully_automatable || []), | |
| ...(analysis?.tasks?.ai_assisted || []) | |
| ].map((task) => task.id); | |
| } | |
| function buildTaskGroups() { | |
| return [ | |
| { | |
| title: "Automate", | |
| items: state.analysis?.tasks?.fully_automatable || [] | |
| }, | |
| { | |
| title: "Assist", | |
| items: state.analysis?.tasks?.ai_assisted || [] | |
| }, | |
| { | |
| title: "Manual", | |
| items: state.analysis?.tasks?.manual || [] | |
| } | |
| ]; | |
| } | |
| function getWorkflowTasks() { | |
| const lookup = new Map(); | |
| for (const group of buildTaskGroups()) { | |
| for (const task of group.items) { | |
| lookup.set(task.id, task); | |
| } | |
| } | |
| return state.selectedTaskIds.map((id) => lookup.get(id)).filter(Boolean); | |
| } | |
| function getViewIndicator() { | |
| if (state.view === "escalation") { | |
| return { label: "Needs review" }; | |
| } | |
| if (state.view === "quiet") { | |
| return { label: "Running live" }; | |
| } | |
| return { label: `${setupSteps[state.currentStep - 1].label}` }; | |
| } | |
| function setStatus(message, isError = false) { | |
| state.status = message; | |
| state.statusError = isError; | |
| } | |
| async function refreshLiveData() { | |
| if (!state.ownerId) { | |
| state.liveStats = { | |
| activeWorkflows: 0, | |
| executionsToday: 0, | |
| pendingEscalations: 0 | |
| }; | |
| state.escalations = []; | |
| return; | |
| } | |
| try { | |
| const [statusPayload, escalationPayload] = await Promise.all([ | |
| apiRequest(`/status?owner_id=${encodeURIComponent(state.ownerId)}`), | |
| apiRequest(`/escalations?owner_id=${encodeURIComponent(state.ownerId)}`) | |
| ]); | |
| const workflows = statusPayload.workflows || []; | |
| const executions = statusPayload.recent_executions || []; | |
| const pendingEscalations = (escalationPayload.items || []).filter((item) => item.status === "pending"); | |
| state.liveStats = { | |
| activeWorkflows: workflows.filter((item) => item.status === "active").length, | |
| executionsToday: executions.filter((item) => isToday(item.executed_at)).length, | |
| pendingEscalations: pendingEscalations.length | |
| }; | |
| state.escalations = pendingEscalations; | |
| } catch { | |
| state.liveStats = { | |
| activeWorkflows: 0, | |
| executionsToday: 0, | |
| pendingEscalations: 0 | |
| }; | |
| state.escalations = []; | |
| } | |
| } | |
| function isSupportedBackendUrl(value) { | |
| try { | |
| const url = new URL(value); | |
| const isLocal = url.protocol === "http:" && url.hostname === "localhost" && url.pathname === "/api"; | |
| const isSpace = url.protocol === "https:" && url.hostname.endsWith(".hf.space") && url.pathname === "/api"; | |
| return isLocal || isSpace; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function escapeAttribute(value) { | |
| return String(value).replaceAll("&", "&").replaceAll('"', """); | |
| } | |
| function escapeHtml(value) { | |
| return String(value) | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function isToday(value) { | |
| if (!value) { | |
| return false; | |
| } | |
| const candidate = new Date(value); | |
| const now = new Date(); | |
| return ( | |
| candidate.getFullYear() === now.getFullYear() && | |
| candidate.getMonth() === now.getMonth() && | |
| candidate.getDate() === now.getDate() | |
| ); | |
| } | |
| async function getIdentityProfile() { | |
| if (!chrome.identity?.getProfileUserInfo) { | |
| return null; | |
| } | |
| try { | |
| const profile = await new Promise((resolve) => { | |
| chrome.identity.getProfileUserInfo({ accountStatus: "ANY" }, resolve); | |
| }); | |
| if (!profile.email) { | |
| return null; | |
| } | |
| return { | |
| email: profile.email, | |
| ownerId: profile.id || profile.email.toLowerCase() | |
| }; | |
| } catch { | |
| return null; | |
| } | |
| } | |