diff --git a/.env.guide.md b/.env.guide.md index 1503db50..95f47a4c 100644 --- a/.env.guide.md +++ b/.env.guide.md @@ -99,6 +99,45 @@ NEXT_PUBLIC_KAKAO_JS_KEY=b285223d3e57a6820552018b93805658 --- +## AI Inspector Env Registration + +### 1) Web 런타임 (Next.js API Route) + +등록 위치: +- 로컬: `apps/web/.env.local` +- 배포: Vercel Project > Settings > Environment Variables (Preview/Production) + +필수: +``` +NEXT_PUBLIC_API_SERVER_URL=https://api..solid-connection.com +AI_INSPECTOR_FIREBASE_PROJECT_ID= +AI_INSPECTOR_FIREBASE_CLIENT_EMAIL= +AI_INSPECTOR_FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +``` + +선택: +``` +AI_INSPECTOR_FIRESTORE_COLLECTION=aiInspectorTasks +``` + +### 2) Worker (GitHub Actions) + +등록 위치: +- GitHub Repository > Settings > Secrets and variables > Actions > Repository secrets + +필수: +- `AI_INSPECTOR_FIREBASE_PROJECT_ID` +- `AI_INSPECTOR_FIREBASE_CLIENT_EMAIL` +- `AI_INSPECTOR_FIREBASE_PRIVATE_KEY` + +선택: +- `AI_INSPECTOR_PATCH_ENDPOINT` +- `AI_INSPECTOR_PATCH_API_KEY` +- `AI_INSPECTOR_PREVIEW_URL_TEMPLATE` +- `AI_INSPECTOR_DISCORD_WEBHOOK_URL` + +--- + ## Best Practices 1. **환경별 값은 Vercel 대시보드에서 관리** diff --git a/.env.preview b/.env.preview index dde31a77..b63a2183 100644 --- a/.env.preview +++ b/.env.preview @@ -8,7 +8,3 @@ NEXT_PUBLIC_API_SERVER_URL=https://api.stage.solid-connection.com # kakao NEXT_PUBLIC_KAKAO_JS_KEY=c080f1d215a69b47401cda1d7528418a - -# Login mode configuration for preview -# Preview 환경에서는 stage API와 쿠키 로그인 사용 -NEXT_PUBLIC_COOKIE_LOGIN_ENABLED=true diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs new file mode 100644 index 00000000..e69a6735 --- /dev/null +++ b/.github/scripts/ai-inspector-worker.mjs @@ -0,0 +1,382 @@ +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 toErrorMessage = (error) => String(error instanceof Error ? error.message : error); + +const markTaskFailed = async (taskRef, error) => { + await taskRef.update({ + status: "failed", + errorMessage: toErrorMessage(error), + updatedAt: FieldValue.serverTimestamp(), + failedAt: FieldValue.serverTimestamp(), + }); +}; + +const claimQueuedTask = async (db, collectionName) => { + const queued = await db + .collection(collectionName) + .where("status", "==", "queued") + .orderBy("createdAt", "asc") + .limit(10) + .get(); + + for (const doc of queued.docs) { + const taskRef = doc.ref; + const claimedTask = await db.runTransaction(async (transaction) => { + const snapshot = await transaction.get(taskRef); + if (!snapshot.exists) { + return null; + } + + const task = snapshot.data(); + if (!task || task.status !== "queued") { + return null; + } + + transaction.update(taskRef, { + status: "processing", + startedAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + return { + taskId: snapshot.id, + task, + }; + }); + + if (claimedTask) { + return { ...claimedTask, taskRef }; + } + } + + return null; +}; + +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 claimed = await claimQueuedTask(db, collectionName); + if (!claimed) { + console.log("No queued inspector tasks."); + return; + } + + const { taskRef, task, taskId } = claimed; + + 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: "completed", + branchName, + prUrl, + previewUrl, + completedAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + try { + await sendDiscordNotification({ + taskId, + prUrl, + previewUrl, + instruction: task.instruction, + }); + + await taskRef.update({ + notificationSent: true, + notificationError: FieldValue.delete(), + updatedAt: FieldValue.serverTimestamp(), + }); + } catch (notificationError) { + console.error(`Task ${taskId} notification failed`, notificationError); + try { + await taskRef.update({ + notificationSent: false, + notificationError: toErrorMessage(notificationError), + updatedAt: FieldValue.serverTimestamp(), + }); + } catch (updateError) { + console.error(`Task ${taskId} notification error update failed`, updateError); + } + } + + 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..aa22fb32 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,54 @@ If you have an existing clone: rm -rf node_modules package-lock.json pnpm install ``` + +## AI Inspector Workflow + +Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출됩니다. + +1. 인스펙터 버튼 클릭 후 요소 선택 +2. 자연어 수정 요청 입력 +3. `POST /api/ai-inspector-requests` 호출 (클라이언트는 Firebase 직접 접근하지 않음) +4. API 서버가 관리자 권한을 검증하고 Firestore에 `queued` 상태로 저장 +5. GitHub Actions 크론(`.github/workflows/ai-inspector-worker.yml`)이 `queued -> processing`으로 작업 클레임 후 처리 +6. 작업 결과를 `completed` 또는 `failed`로 업데이트하고 PR/프리뷰 링크를 Discord webhook으로 전송 + +### Shared package + +인스펙터 핵심 로직은 공용 패키지 [`packages/ai-inspector`](./packages/ai-inspector)로 분리되어 있습니다. + +- DOM 선택/selector 계산 및 hover rect 생성 +- 인스펙트 상태 관리 hook (`useAiInspectorSelection`) +- 요청 전송 API helper (`createAiInspectorRequest`) +- 공용 타입(`ElementSelection`, request payload/response) + +웹/어드민 모두 동일 패키지를 import해서 확장할 수 있습니다. + +### Web env (server) + +`apps/web` 서버 런타임에 아래 환경변수가 필요합니다. + +- `NEXT_PUBLIC_API_SERVER_URL` (토큰 검증용 `/my` 호출) +- `AI_INSPECTOR_FIREBASE_PROJECT_ID` +- `AI_INSPECTOR_FIREBASE_CLIENT_EMAIL` +- `AI_INSPECTOR_FIREBASE_PRIVATE_KEY` +- `AI_INSPECTOR_FIRESTORE_COLLECTION` (optional, default: `aiInspectorTasks`) + +등록 위치: +- 로컬 개발: `apps/web/.env.local` +- Vercel 배포: Vercel Project > Settings > Environment Variables (Preview/Production 둘 다) + +### 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) + +등록 위치: +- GitHub Repository > Settings > Secrets and variables > Actions > Repository secrets diff --git a/apps/web/.env b/apps/web/.env index 6286acfa..3f9f3abe 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -1,11 +1,6 @@ # Shared configuration across all environments # Environment-specific values are in .env.development, .env.preview, .env.production -# Login mode configuration -# true: HTTP-only 쿠키 + Zustand (보안 강화) -# false: 로컬스토리지 + Zustand (개발/테스트 편의) -NEXT_PUBLIC_COOKIE_LOGIN_ENABLED=true - NEXT_PUBLIC_IMAGE_URL=https://cdn.default.solid-connection.com NEXT_PUBLIC_UPLOADED_IMAGE_URL=https://cdn.upload.solid-connection.com @@ -21,6 +16,13 @@ NEXT_PUBLIC_APPLE_SCOPE="email" NEXT_PUBLIC_CONTACT_LINK=https://open.kakao.com/o/grTrKWdg +# AI Inspector (server-only) +# Set these values in .env.local or deployment environment variables. +# AI_INSPECTOR_FIREBASE_PROJECT_ID= +# AI_INSPECTOR_FIREBASE_CLIENT_EMAIL= +# AI_INSPECTOR_FIREBASE_PRIVATE_KEY= +# AI_INSPECTOR_FIRESTORE_COLLECTION=aiInspectorTasks + # BELOW IS EXAMPLE FOR .env.local # firebase private diff --git a/apps/web/.env.development b/apps/web/.env.development index b8bff5c1..91c3e448 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -8,7 +8,3 @@ NEXT_PUBLIC_API_SERVER_URL=https://api.stage.solid-connection.com # kakao NEXT_PUBLIC_KAKAO_JS_KEY=c080f1d215a69b47401cda1d7528418a - -# Login mode configuration for development -# 개발 환경에서는 로컬스토리지 모드 사용 (디버깅 편의) -NEXT_PUBLIC_COOKIE_LOGIN_ENABLED=false diff --git a/apps/web/.env.production b/apps/web/.env.production index 45738b2f..b11575ff 100644 --- a/apps/web/.env.production +++ b/apps/web/.env.production @@ -8,7 +8,3 @@ NEXT_PUBLIC_API_SERVER_URL=https://api.solid-connection.com # kakao NEXT_PUBLIC_KAKAO_JS_KEY=b285223d3e57a6820552018b93805658 - -# Login mode configuration for production -# 프로덕션 환경에서는 HTTP-only 쿠키 모드 사용 (보안 강화) -NEXT_PUBLIC_COOKIE_LOGIN_ENABLED=true \ No newline at end of file diff --git a/apps/web/AUTHENTICATION.md b/apps/web/AUTHENTICATION.md index a7e1e3e9..fe9ddd02 100644 --- a/apps/web/AUTHENTICATION.md +++ b/apps/web/AUTHENTICATION.md @@ -65,20 +65,10 @@ After successful authentication, users continue to be redirected to `/`. ### Configuration -Authentication mode is controlled by environment variable: - -```env -NEXT_PUBLIC_COOKIE_LOGIN_ENABLED=true -``` - -When `true`: -- Uses HTTP-only cookies for refresh tokens -- Middleware enforces authentication checks -- Enhanced security - -When `false`: -- Uses localStorage for tokens -- No middleware checks (development/testing mode) +Authentication is cookie-based: +- Refresh token: HTTP-only cookie +- Middleware: 보호 페이지 접근 시 refresh token 존재 여부 확인 +- 로그인 성공 후: 메인(`/`)으로 이동 ### Token Management @@ -110,7 +100,6 @@ const needLogin = loginNeedPages.some(...) || isNewRouteSubPath; ### Troubleshooting #### Redirect not working? -- Check if `NEXT_PUBLIC_COOKIE_LOGIN_ENABLED=true` in `.env` - Verify refresh token exists in cookies - Check middleware matcher pattern excludes static files diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 44217529..cf9c4041 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -10,6 +10,7 @@ const withBundleAnalyzer = bundleAnalyzer({ /** @type {import('next').NextConfig} */ const nextConfig = { swcMinify: true, + transpilePackages: ["@solid-connect/ai-inspector"], images: { unoptimized: true, domains: ["k.kakaocdn.net", "cdn.default.solid-connection.com", "cdn.upload.solid-connection.com"], diff --git a/apps/web/package.json b/apps/web/package.json index 38472596..93bfbf01 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.1.6", "@react-google-maps/api": "^2.19.2", "@sentry/nextjs": "^10.22.0", + "@solid-connect/ai-inspector": "workspace:^", "@stomp/stompjs": "^7.1.1", "@tanstack/react-query": "^5.84.1", "@tanstack/react-query-devtools": "^5.84.1", @@ -32,6 +33,7 @@ "axios": "^1.6.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "firebase-admin": "^13.7.0", "linkify-react": "^4.3.2", "linkifyjs": "^4.3.2", "lucide-react": "^0.479.0", diff --git a/apps/web/src/app/api/ai-inspector-requests/route.ts b/apps/web/src/app/api/ai-inspector-requests/route.ts new file mode 100644 index 00000000..dc5c76db --- /dev/null +++ b/apps/web/src/app/api/ai-inspector-requests/route.ts @@ -0,0 +1,127 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { queueAiInspectorTask } from "@/lib/server/aiInspector/taskService"; +import { UserRole } from "@/types/mentor"; + +const selectionSchema = z.object({ + selector: z.string().trim().min(1).max(500), + tagName: z.string().trim().min(1).max(80), + className: z.string().max(1000), + textSnippet: z.string().max(280), + rect: z.object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite(), + height: z.number().finite(), + }), +}); + +const requestBodySchema = z.object({ + instruction: z.string().trim().min(1).max(1000), + pageUrl: z.string().trim().url().max(2000), + selection: selectionSchema, +}); + +const decodeTokenUserId = (accessToken: string): string | null => { + try { + const payloadSegment = accessToken.split(".")[1]; + if (!payloadSegment) { + return null; + } + + const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as { sub?: string | number }; + + if (!payload.sub) { + return null; + } + + return String(payload.sub); + } catch { + return null; + } +}; + +const verifyAdminRole = async (accessToken: string): Promise => { + const apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL?.trim(); + if (!apiServerUrl) { + throw new Error("NEXT_PUBLIC_API_SERVER_URL is not configured."); + } + + try { + const response = await fetch(`${apiServerUrl}/my`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + return false; + } + + const data = (await response.json()) as { role?: string }; + return data.role === UserRole.ADMIN; + } catch { + return false; + } +}; + +const getAccessToken = (request: NextRequest): string | null => { + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return null; + } + + const token = authHeader.substring(7).trim(); + return token || null; +}; + +async function POST(request: NextRequest) { + const accessToken = getAccessToken(request); + if (!accessToken) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + let body: z.infer; + try { + const parsedBody = await request.json(); + body = requestBodySchema.parse(parsedBody); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ message: "요청 값이 올바르지 않습니다." }, { status: 400 }); + } + + return NextResponse.json({ message: "요청 본문을 읽을 수 없습니다." }, { status: 400 }); + } + + let isAdmin = false; + try { + isAdmin = await verifyAdminRole(accessToken); + } catch { + return NextResponse.json({ message: "서버 인증 설정 오류입니다." }, { status: 500 }); + } + + if (!isAdmin) { + return NextResponse.json({ message: "관리자 권한이 필요합니다." }, { status: 403 }); + } + + try { + const result = await queueAiInspectorTask({ + instruction: body.instruction, + pageUrl: body.pageUrl, + selection: body.selection, + requestedBy: { + role: UserRole.ADMIN, + userId: decodeTokenUserId(accessToken), + }, + }); + + return NextResponse.json({ taskId: result.taskId, status: "queued" }, { status: 201 }); + } catch { + return NextResponse.json({ message: "요청 저장에 실패했습니다." }, { status: 503 }); + } +} + +export { POST }; 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..ccfaa105 --- /dev/null +++ b/apps/web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { + AiInspectorRequestError, + createAiInspectorRequest, + useAiInspectorSelection, +} from "@solid-connect/ai-inspector"; +import { Bot, Target, X } from "lucide-react"; +import { useState } from "react"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { toast } from "@/lib/zustand/useToastStore"; +import { UserRole } from "@/types/mentor"; + +const AIInspectorFab = () => { + const { userRole, isInitialized, accessToken } = useAuthStore(); + const isAdmin = isInitialized && userRole === UserRole.ADMIN; + + const { isInspecting, setIsInspecting, hoverRect, selection, clearSelection, resetInspector } = + useAiInspectorSelection({ isEnabled: isAdmin }); + const [instruction, setInstruction] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + if (!isAdmin) { + return null; + } + + const resetForm = () => { + setInstruction(""); + resetInspector(); + }; + + const handleSave = async () => { + if (!selection) { + toast.error("먼저 수정할 요소를 선택해주세요."); + return; + } + + if (!instruction.trim()) { + toast.error("수정 요청 문구를 입력해주세요."); + return; + } + + if (!accessToken) { + toast.error("로그인 세션이 만료되었습니다. 다시 로그인해주세요."); + return; + } + + setIsSaving(true); + try { + const result = await createAiInspectorRequest({ + accessToken, + payload: { + instruction: instruction.trim(), + pageUrl: window.location.href, + selection, + }, + }); + + toast.success(`요청이 저장되었습니다. (${result.taskId.slice(0, 8)})`); + resetForm(); + } catch (error) { + if (error instanceof AiInspectorRequestError) { + toast.error(error.message); + } else { + toast.error("요청 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + } finally { + setIsSaving(false); + } + }; + + return ( + <> + {isInspecting && hoverRect && ( +
+ )} + +
+ + + {selection && ( +
+
+

AI 인스펙터 요청

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