From 98ee199f3115e06611464b4d7b39ca53ed406013 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Mar 2026 02:06:05 +0900 Subject: [PATCH 1/5] feat(web): add admin AI inspector intake and worker flow --- .github/scripts/ai-inspector-worker.mjs | 336 +++++++++ .github/workflows/ai-inspector-worker.yml | 41 ++ README.md | 34 + apps/web/package.json | 1 + .../components/layout/GlobalLayout/index.tsx | 2 + .../GlobalLayout/ui/AIInspectorFab/index.tsx | 292 ++++++++ apps/web/src/lib/firebase/client.ts | 34 + pnpm-lock.yaml | 675 ++++++++++++++++++ 8 files changed, 1415 insertions(+) create mode 100644 .github/scripts/ai-inspector-worker.mjs create mode 100644 .github/workflows/ai-inspector-worker.yml create mode 100644 apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx create mode 100644 apps/web/src/lib/firebase/client.ts diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs new file mode 100644 index 00000000..cab2b0f5 --- /dev/null +++ b/.github/scripts/ai-inspector-worker.mjs @@ -0,0 +1,336 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { cert, getApps, initializeApp } from "firebase-admin/app"; +import { FieldValue, getFirestore } from "firebase-admin/firestore"; + +const required = (name) => { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +}; + +const runGit = (args, options = {}) => { + execFileSync("git", args, { + stdio: "pipe", + encoding: "utf8", + ...options, + }); +}; + +const runGitOutput = (args, options = {}) => + execFileSync("git", args, { + stdio: "pipe", + encoding: "utf8", + ...options, + }); + +const escapeMarkdown = (value) => String(value ?? "").replace(/`/g, "\\`"); + +const toIso = () => new Date().toISOString(); + +const buildTaskMarkdown = (taskId, task) => { + const selector = task.selector ?? task.element?.selector ?? ""; + const pageUrl = task.pageUrl ?? ""; + const instruction = task.instruction ?? ""; + const requestedBy = task.requestedBy?.userId ?? "unknown"; + const role = task.requestedBy?.role ?? "unknown"; + const textSnippet = task.element?.textSnippet ?? ""; + + return `# AI Inspector Task ${taskId} + +- createdAt: ${task.createdAt?.toDate?.()?.toISOString?.() ?? "unknown"} +- requestedBy: ${escapeMarkdown(requestedBy)} (${escapeMarkdown(role)}) +- pageUrl: ${escapeMarkdown(pageUrl)} +- selector: \`${escapeMarkdown(selector)}\` +- textSnippet: ${escapeMarkdown(textSnippet)} + +## Instruction +${escapeMarkdown(instruction)} +`; +}; + +const getPreviewUrl = (branchName) => { + const template = process.env.AI_INSPECTOR_PREVIEW_URL_TEMPLATE; + if (!template) { + return ""; + } + + return template.replaceAll("{branch}", branchName.replaceAll("/", "-")); +}; + +const githubRequest = async (method, pathName, body) => { + const token = required("GITHUB_TOKEN"); + const response = await fetch(`https://api.github.com${pathName}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API error (${response.status}): ${text}`); + } + + return response.json(); +}; + +const findOpenPrByHead = async (owner, repo, branchName) => { + const token = required("GITHUB_TOKEN"); + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${owner}:${encodeURIComponent(branchName)}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + return Array.isArray(data) && data.length > 0 ? data[0] : null; +}; + +const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, instruction }) => { + const webhook = process.env.AI_INSPECTOR_DISCORD_WEBHOOK_URL; + if (!webhook) { + return; + } + + const timestamp = toIso(); + const response = await fetch(webhook, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "AI Inspector Bot", + content: "AI 인스펙터 작업이 완료되었습니다.", + embeds: [ + { + title: `Task ${taskId}`, + description: String(instruction ?? "").slice(0, 400), + color: 5763719, + fields: [ + { + name: "PR", + value: prUrl || "없음", + inline: false, + }, + { + name: "Preview", + value: previewUrl || "설정 없음", + inline: false, + }, + ], + timestamp, + }, + ], + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Discord webhook failed (${response.status}): ${body}`); + } +}; + +const requestPatchFromAiEndpoint = async ({ taskId, task, branchName }) => { + const endpoint = process.env.AI_INSPECTOR_PATCH_ENDPOINT; + if (!endpoint) { + return { + patch: "", + summary: "AI_INSPECTOR_PATCH_ENDPOINT 미설정: 작업 파일만 커밋했습니다.", + }; + } + + const apiKey = process.env.AI_INSPECTOR_PATCH_API_KEY; + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ + taskId, + task, + repository: process.env.GITHUB_REPOSITORY, + branchName, + baseBranch: process.env.AI_INSPECTOR_BASE_BRANCH || "main", + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`AI patch endpoint failed (${response.status}): ${body}`); + } + + const data = await response.json(); + return { + patch: typeof data.patch === "string" ? data.patch : "", + summary: typeof data.summary === "string" ? data.summary : "AI patch applied", + title: typeof data.title === "string" ? data.title : "", + }; +}; + +const applyPatch = (patch) => { + if (!patch.trim()) { + return false; + } + + const patchPath = path.resolve(".ai-inspector", "tmp.patch"); + fs.mkdirSync(path.dirname(patchPath), { recursive: true }); + fs.writeFileSync(patchPath, patch, "utf8"); + runGit(["apply", "--index", "--3way", patchPath]); + fs.rmSync(patchPath, { force: true }); + return true; +}; + +const markTaskFailed = async (taskRef, error) => { + await taskRef.update({ + status: "failed", + errorMessage: String(error instanceof Error ? error.message : error), + updatedAt: FieldValue.serverTimestamp(), + failedAt: FieldValue.serverTimestamp(), + }); +}; + +const main = async () => { + const repo = required("GITHUB_REPOSITORY"); + const [owner, repoName] = repo.split("/"); + const baseBranch = process.env.AI_INSPECTOR_BASE_BRANCH || "main"; + const collectionName = process.env.AI_INSPECTOR_FIRESTORE_COLLECTION || "aiInspectorTasks"; + + if (!owner || !repoName) { + throw new Error(`Invalid GITHUB_REPOSITORY: ${repo}`); + } + + if (getApps().length === 0) { + initializeApp({ + credential: cert({ + projectId: required("AI_INSPECTOR_FIREBASE_PROJECT_ID"), + clientEmail: required("AI_INSPECTOR_FIREBASE_CLIENT_EMAIL"), + privateKey: required("AI_INSPECTOR_FIREBASE_PRIVATE_KEY").replace(/\\n/g, "\n"), + }), + }); + } + + const db = getFirestore(); + const pending = await db + .collection(collectionName) + .where("status", "==", "pending") + .orderBy("createdAt", "asc") + .limit(1) + .get(); + + if (pending.empty) { + console.log("No pending inspector tasks."); + return; + } + + const taskDoc = pending.docs[0]; + const taskRef = taskDoc.ref; + const task = taskDoc.data(); + const taskId = taskDoc.id; + + await taskRef.update({ + status: "processing", + startedAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + try { + const branchName = `ai-inspector/${taskId}`; + const filePath = `.ai-inspector/tasks/${taskId}.md`; + + runGit(["fetch", "origin", baseBranch]); + runGit(["checkout", "-B", branchName, `origin/${baseBranch}`]); + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, buildTaskMarkdown(taskId, task), "utf8"); + runGit(["add", filePath]); + + const aiResult = await requestPatchFromAiEndpoint({ taskId, task, branchName }); + const patchApplied = applyPatch(aiResult.patch); + + const hasChanges = runGitOutput(["status", "--porcelain"]).trim().length > 0; + if (!hasChanges) { + throw new Error("No changes to commit after AI inspector processing."); + } + + const commitMessage = patchApplied + ? `[ai-inspector] apply task ${taskId}` + : `[ai-inspector] capture task ${taskId}`; + runGit(["commit", "-m", commitMessage]); + runGit(["push", "-u", "origin", branchName]); + + const existingPr = await findOpenPrByHead(owner, repoName, branchName); + const title = + aiResult.title || + `[AI Inspector] ${String(task.instruction ?? "UI update request").slice(0, 72)}`.trim(); + const body = [ + `## Inspector Task`, + `- taskId: ${taskId}`, + `- pageUrl: ${task.pageUrl ?? "unknown"}`, + `- selector: \`${task.selector ?? task.element?.selector ?? "unknown"}\``, + "", + `## Instruction`, + `${task.instruction ?? ""}`, + "", + `## Worker Summary`, + `${aiResult.summary ?? "N/A"}`, + ].join("\n"); + + const pr = + existingPr ?? + (await githubRequest("POST", `/repos/${owner}/${repoName}/pulls`, { + title, + head: branchName, + base: baseBranch, + body, + })); + + const prUrl = pr.html_url; + const previewUrl = getPreviewUrl(branchName); + + await taskRef.update({ + status: "done", + branchName, + prUrl, + previewUrl, + completedAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + await sendDiscordNotification({ + taskId, + prUrl, + previewUrl, + instruction: task.instruction, + }); + + console.log(`Task ${taskId} completed: ${prUrl}`); + } catch (error) { + await markTaskFailed(taskRef, error); + throw error; + } +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/.github/workflows/ai-inspector-worker.yml b/.github/workflows/ai-inspector-worker.yml new file mode 100644 index 00000000..0ae85b6b --- /dev/null +++ b/.github/workflows/ai-inspector-worker.yml @@ -0,0 +1,41 @@ +name: AI Inspector Worker + +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + +jobs: + process: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install worker dependencies + run: npm install firebase-admin + + - name: Run AI Inspector worker + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + AI_INSPECTOR_BASE_BRANCH: main + AI_INSPECTOR_FIRESTORE_COLLECTION: aiInspectorTasks + AI_INSPECTOR_FIREBASE_PROJECT_ID: ${{ secrets.AI_INSPECTOR_FIREBASE_PROJECT_ID }} + AI_INSPECTOR_FIREBASE_CLIENT_EMAIL: ${{ secrets.AI_INSPECTOR_FIREBASE_CLIENT_EMAIL }} + AI_INSPECTOR_FIREBASE_PRIVATE_KEY: ${{ secrets.AI_INSPECTOR_FIREBASE_PRIVATE_KEY }} + AI_INSPECTOR_PATCH_ENDPOINT: ${{ secrets.AI_INSPECTOR_PATCH_ENDPOINT }} + AI_INSPECTOR_PATCH_API_KEY: ${{ secrets.AI_INSPECTOR_PATCH_API_KEY }} + AI_INSPECTOR_PREVIEW_URL_TEMPLATE: ${{ secrets.AI_INSPECTOR_PREVIEW_URL_TEMPLATE }} + AI_INSPECTOR_DISCORD_WEBHOOK_URL: ${{ secrets.AI_INSPECTOR_DISCORD_WEBHOOK_URL }} + run: node .github/scripts/ai-inspector-worker.mjs diff --git a/README.md b/README.md index 48aace25..93856e0b 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,37 @@ If you have an existing clone: rm -rf node_modules package-lock.json pnpm install ``` + +## AI Inspector Workflow + +Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출됩니다. + +1. 인스펙터 버튼 클릭 후 요소 선택 +2. 자연어 수정 요청 입력 +3. Firebase `aiInspectorTasks` 컬렉션에 `pending`으로 저장 +4. GitHub Actions 크론(`.github/workflows/ai-inspector-worker.yml`)이 주기적으로 작업 처리 +5. 작업 결과 PR/프리뷰 링크를 Discord webhook으로 전송 + +### Web env (client) + +`apps/web` 런타임에 아래 환경변수가 필요합니다. + +- `NEXT_PUBLIC_FIREBASE_API_KEY` +- `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN` +- `NEXT_PUBLIC_FIREBASE_PROJECT_ID` +- `NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET` +- `NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID` +- `NEXT_PUBLIC_FIREBASE_APP_ID` +- `NEXT_PUBLIC_AI_INSPECTOR_COLLECTION` (optional, default: `aiInspectorTasks`) + +### GitHub secrets (worker) + +`.github/workflows/ai-inspector-worker.yml`에서 사용합니다. + +- `AI_INSPECTOR_FIREBASE_PROJECT_ID` +- `AI_INSPECTOR_FIREBASE_CLIENT_EMAIL` +- `AI_INSPECTOR_FIREBASE_PRIVATE_KEY` +- `AI_INSPECTOR_PATCH_ENDPOINT` (optional: AI patch 생성 endpoint) +- `AI_INSPECTOR_PATCH_API_KEY` (optional) +- `AI_INSPECTOR_PREVIEW_URL_TEMPLATE` (optional, example: `https://your-app-git-{branch}.vercel.app`) +- `AI_INSPECTOR_DISCORD_WEBHOOK_URL` (optional) diff --git a/apps/web/package.json b/apps/web/package.json index 38472596..4099b4f4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "axios": "^1.6.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "firebase": "^12.11.0", "linkify-react": "^4.3.2", "linkifyjs": "^4.3.2", "lucide-react": "^0.479.0", diff --git a/apps/web/src/components/layout/GlobalLayout/index.tsx b/apps/web/src/components/layout/GlobalLayout/index.tsx index 28989e42..4db968f5 100644 --- a/apps/web/src/components/layout/GlobalLayout/index.tsx +++ b/apps/web/src/components/layout/GlobalLayout/index.tsx @@ -6,6 +6,7 @@ import BottomNavigation from "./ui/BottomNavigation"; // import ServerModal from "./ui/ServerModal"; // const BottomNavigationDynamic = dynamic(() => import("./ui/BottomNavigation"), { ssr: false, loading: () => null }); +const AIInspectorFab = dynamic(() => import("./ui/AIInspectorFab/index"), { ssr: false, loading: () => null }); const ClientModal = dynamic(() => import("./ui/ClientModal"), { ssr: false, loading: () => null }); type LayoutProps = { @@ -17,6 +18,7 @@ const GlobalLayout = ({ children }: LayoutProps) => {
{children} + {/* */}
diff --git a/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx b/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx new file mode 100644 index 00000000..90c0a050 --- /dev/null +++ b/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { addDoc, collection, serverTimestamp } from "firebase/firestore"; +import { Bot, Target, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { getFirebaseDb } from "@/lib/firebase/client"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { toast } from "@/lib/zustand/useToastStore"; +import { UserRole } from "@/types/mentor"; +import { tokenParse } from "@/utils/jwtUtils"; + +interface HoverRect { + x: number; + y: number; + width: number; + height: number; +} + +interface ElementSelection { + selector: string; + tagName: string; + className: string; + textSnippet: string; + rect: HoverRect; +} + +const AI_INSPECTOR_COLLECTION = process.env.NEXT_PUBLIC_AI_INSPECTOR_COLLECTION ?? "aiInspectorTasks"; + +const toTextSnippet = (target: HTMLElement): string => { + const text = (target.innerText || target.textContent || "").replace(/\s+/g, " ").trim(); + return text.slice(0, 140); +}; + +const toClassName = (target: HTMLElement): string => { + if (typeof target.className === "string") { + return target.className; + } + + return target.getAttribute("class") ?? ""; +}; + +const toSelector = (target: HTMLElement): string => { + if (target.id) { + return `#${target.id}`; + } + + const segments: string[] = []; + let current: HTMLElement | null = target; + let depth = 0; + + while (current && depth < 6) { + const parent = current.parentElement; + const tag = current.tagName.toLowerCase(); + + if (!parent) { + segments.unshift(tag); + break; + } + + const sameTagSiblings = Array.from(parent.children).filter( + (child) => (child as Element).tagName.toLowerCase() === tag, + ); + const index = sameTagSiblings.indexOf(current) + 1; + segments.unshift(`${tag}:nth-of-type(${index})`); + + current = parent; + depth += 1; + } + + return segments.join(" > "); +}; + +const AIInspectorFab = () => { + const { userRole, isInitialized, accessToken } = useAuthStore(); + const isAdmin = isInitialized && userRole === UserRole.ADMIN; + + const [isInspecting, setIsInspecting] = useState(false); + const [hoverRect, setHoverRect] = useState(null); + const [selection, setSelection] = useState(null); + const [instruction, setInstruction] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + const requesterId = useMemo(() => { + const parsed = tokenParse(accessToken); + return parsed?.sub ? String(parsed.sub) : null; + }, [accessToken]); + + useEffect(() => { + if (!isInspecting) { + setHoverRect(null); + return; + } + + const handleMouseMove = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (target.closest("[data-ai-inspector-ui='true']")) { + setHoverRect(null); + return; + } + + const rect = target.getBoundingClientRect(); + setHoverRect({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }); + }; + + const handleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (target.closest("[data-ai-inspector-ui='true']")) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const rect = target.getBoundingClientRect(); + setSelection({ + selector: toSelector(target), + tagName: target.tagName.toLowerCase(), + className: toClassName(target), + textSnippet: toTextSnippet(target), + rect: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + }); + setIsInspecting(false); + setHoverRect(null); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsInspecting(false); + } + }; + + document.addEventListener("mousemove", handleMouseMove, true); + document.addEventListener("click", handleClick, true); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousemove", handleMouseMove, true); + document.removeEventListener("click", handleClick, true); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isInspecting]); + + if (!isAdmin) { + return null; + } + + const resetForm = () => { + setSelection(null); + setInstruction(""); + setHoverRect(null); + setIsInspecting(false); + }; + + const handleSave = async () => { + if (!selection) { + toast.error("먼저 수정할 요소를 선택해주세요."); + return; + } + + if (!instruction.trim()) { + toast.error("수정 요청 문구를 입력해주세요."); + return; + } + + const db = getFirebaseDb(); + if (!db) { + toast.error("Firebase 설정이 누락되어 저장할 수 없습니다."); + return; + } + + setIsSaving(true); + try { + const ref = await addDoc(collection(db, AI_INSPECTOR_COLLECTION), { + status: "pending", + instruction: instruction.trim(), + pageUrl: window.location.href, + selector: selection.selector, + element: selection, + source: "web-admin-inspector", + requestedBy: { + role: userRole, + userId: requesterId, + }, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }); + + toast.success(`요청이 저장되었습니다. (${ref.id.slice(0, 8)})`); + resetForm(); + } catch { + toast.error("요청 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + {isInspecting && hoverRect && ( +
+ )} + +
+ + + {selection && ( +
+
+

AI 인스펙터 요청

+ +
+ +
+
selector: {selection.selector}
+
tag: {selection.tagName}
+ {selection.textSnippet &&
text: {selection.textSnippet}
} +
+ +