diff --git a/README.md b/README.md index defdf085..16df0d7c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ npm run lint npm test npm run build +## QA에서 AI 입력 끄기 + +QA용 Vercel 빌드 환경에 `NEXT_PUBLIC_AI_ENABLED=false`를 설정하고 재빌드합니다. 정확히 `false`인 경우에만 끄며, 미설정 또는 `true`이면 기존 AI UI·동작을 유지합니다. 이 공개 값은 빌드 시 고정되므로 서버 환경변수만 바꾸거나 브라우저를 새로고침해서는 적용되지 않습니다. + +- AI 버튼·추천 문구·`@` 멘션 선택과 `@ai`의 AI 모드 전환을 제공하지 않습니다. 입력한 `@ai ...`는 원문 그대로 일반 채팅으로 전송됩니다. +- AI 요청·취소 publish도 차단합니다. 일반 채팅·장소 공유·수신 흐름은 유지합니다. +- 이 flag는 UI 제어이며 권한/보안 경계가 아닙니다. QA 백엔드도 `AI_ENABLED=false`여야 자동 요약과 외부 AI 호출이 중단됩니다. 백엔드의 `AI_DISABLED`, `retryable=false` 응답은 기존 공통 오류 처리로 안내합니다. +- `API_BASE_URL`과 `NEXT_PUBLIC_API_BASE_URL`은 같은 QA API를 가리켜야 합니다. 고정 QA 커스텀 도메인·공유 가능한 쿠키 범위·Google callback 일치는 별도 설정 전제입니다. + +실제 Vercel 설정·배포·OAuth·도메인 변경은 이 코드 변경에 포함되지 않습니다. 운영 빌드에는 QA의 `false` 설정을 적용하지 않습니다. + ## 기여하기 커밋, 브랜치 생성, PR 및 병합 절차는 [CONTRIBUTING.md](./CONTRIBUTING.md)를 따릅니다. diff --git a/src/components/chat/ChatInputBar.ai-availability.test.tsx b/src/components/chat/ChatInputBar.ai-availability.test.tsx new file mode 100644 index 00000000..671732be --- /dev/null +++ b/src/components/chat/ChatInputBar.ai-availability.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +let root: Root | undefined; +let host: HTMLDivElement | undefined; + +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + host?.remove(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +async function renderInput(flag: string | undefined, minimized = false) { + vi.stubEnv("NEXT_PUBLIC_AI_ENABLED", flag); + vi.resetModules(); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + const { ChatInputBar } = await import("./ChatInputBar"); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const onSendChat = vi.fn(); + const onSendAi = vi.fn(); + await act(async () => root!.render( + , + )); + const input = host.querySelector("textarea")!; + async function type(value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(input, value); + input.setSelectionRange(value.length, value.length); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + async function send() { + await act(async () => input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); + } + return { onSendChat, onSendAi, input, type, send }; +} + +it.each([false, true])("QA off hides AI controls and preserves normal chat (minimized=%s)", async (minimized) => { + const view = await renderInput("false", minimized); + expect([...host!.querySelectorAll("button")].some(button => button.textContent === "@ai")).toBe(false); + await view.type("@"); + expect(host!.querySelector('[aria-label="@ai로 AI 질문 모드 적용"]')).toBeNull(); + await view.type("@ai 장소 추천해줘"); + expect(view.input.value).toBe("@ai 장소 추천해줘"); + await view.send(); + expect(view.onSendChat).toHaveBeenCalledExactlyOnceWith("@ai 장소 추천해줘"); + expect(view.onSendAi).not.toHaveBeenCalled(); + expect(view.input.value).toBe(""); + await view.type("일반 대화"); + await act(async () => host!.querySelector('[aria-label="메시지 보내기"]')!.click()); + expect(view.onSendChat).toHaveBeenLastCalledWith("일반 대화"); +}); + +it.each([undefined, "true"])("default/enabled retains AI toggle, suggestions and send (%s)", async (flag) => { + const view = await renderInput(flag); + const toggle = [...host!.querySelectorAll("button")].find(button => button.textContent === "@ai"); + expect(toggle).toBeDefined(); + await act(async () => toggle!.click()); + await act(async () => host!.querySelector('[aria-label="입력란에 장소 추천 요청 문구 넣기"]')!.click()); + await view.send(); + expect(view.onSendAi).toHaveBeenCalledExactlyOnceWith("장소 추천해줘."); + expect(view.onSendChat).not.toHaveBeenCalled(); +}); + +it("default retains typed AI mention activation", async () => { + const view = await renderInput(undefined); + await view.type("@ai 대화 요약해줘"); + expect(view.input.value).toBe("대화 요약해줘"); + await view.send(); + expect(view.onSendAi).toHaveBeenCalledExactlyOnceWith("대화 요약해줘"); +}); diff --git a/src/components/chat/ChatInputBar.tsx b/src/components/chat/ChatInputBar.tsx index 22553e57..e3412746 100644 --- a/src/components/chat/ChatInputBar.tsx +++ b/src/components/chat/ChatInputBar.tsx @@ -20,6 +20,7 @@ import { CHAT_MESSAGE_TOO_LONG_TOAST, } from "@/components/chat/chat-constants"; import { cn } from "@/lib/utils"; +import { CHAT_AI_ENABLED } from "@/lib/chat/ai-availability"; interface ChatInputBarProps { isMinimized: boolean; @@ -81,7 +82,7 @@ export function ChatInputBar({ const updateMentionFromDom = useCallback( (v: string, cursor: number, aiOn: boolean) => { - if (aiOn) { + if (!CHAT_AI_ENABLED || aiOn) { setMentionOpen(false); setMentionAnchor(null); return; @@ -125,6 +126,7 @@ export function ChatInputBar({ }, [mentionOpen]); function toggleAi() { + if (!CHAT_AI_ENABLED) return; setAiEnabled((prev) => { const next = !prev; if (next) { @@ -156,7 +158,7 @@ export function ChatInputBar({ } function pickMentionAi() { - if (mentionAnchor === null) return; + if (!CHAT_AI_ENABLED || mentionAnchor === null) return; const el = inputRef.current; if (!el) return; const v = el.value; @@ -180,7 +182,7 @@ export function ChatInputBar({ const v = el.value; const cursor = el.selectionStart ?? v.length; - if (!aiEnabled && /^@ai(\s+|$)/i.test(v)) { + if (CHAT_AI_ENABLED && !aiEnabled && /^@ai(\s+|$)/i.test(v)) { const rest = v.replace(/^@ai\s*/i, ""); setAiEnabled(true); setMentionOpen(false); @@ -217,7 +219,7 @@ export function ChatInputBar({ return; } - if (aiEnabled) onSendAi(trimmed); + if (CHAT_AI_ENABLED && aiEnabled) onSendAi(trimmed); else onSendChat(trimmed); setMessage(""); @@ -365,25 +367,27 @@ export function ChatInputBar({ )} >
- - {CHAT_AI_MENTION_LABEL} - {aiEnabled && ( - - )} - + {CHAT_AI_ENABLED ? ( + + {CHAT_AI_MENTION_LABEL} + {aiEnabled && ( + + )} + + ) : null} {aiEnabled ? ( <> { + vi.stubEnv("NEXT_PUBLIC_AI_ENABLED", "false"); + return { publish: vi.fn(), track: vi.fn() }; +}); +vi.mock("@/contexts/StompContext", () => ({ + useStompContext: () => ({ client: { publish: boundary.publish }, connected: true }), +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { chatMessageSent: "chat_message_sent" }, + trackAnalyticsEvent: boundary.track, +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +afterEach(() => vi.unstubAllEnvs()); + +it("QA off blocks direct AI sends/cancels while ordinary chat and place sharing still publish", async () => { + useSessionStore.setState({ currentRoomId: "room-qa" }); + const hook = hookHarness(() => useChatActions()); + try { + await hook.render(undefined); + expect(hook.current.canSend).toBe(true); + await act(async () => { + hook.current.sendAiMessage("추천해줘"); + hook.current.sendCancelAiRequest("request-1"); + }); + expect(boundary.publish).not.toHaveBeenCalled(); + expect(boundary.track).not.toHaveBeenCalled(); + + await act(async () => { + hook.current.sendChatMessage("일반 대화"); + hook.current.sendPlaceMessage({ + googlePlaceId: "place-1", name: "장소", formattedAddress: "주소", + latitude: 1, longitude: 2, rating: 4, + }); + }); + expect(boundary.publish.mock.calls.map(([frame]) => frame.destination)).toEqual([ + "/app/rooms/room-qa/messages/chat", "/app/rooms/room-qa/messages/place", + ]); + expect(JSON.parse(boundary.publish.mock.calls[0][0].body).content).toBe("일반 대화"); + expect(boundary.track.mock.calls.map(([, properties]) => properties.message_type)).toEqual(["text", "place"]); + } finally { + await hook.unmount(); + } +}); diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts index 373d3b8a..830b3f97 100644 --- a/src/hooks/useChatActions.ts +++ b/src/hooks/useChatActions.ts @@ -6,6 +6,7 @@ import { useStompContext } from "@/contexts/StompContext"; import { isChatSendBlocked } from "@/stores/chat-rate-limit-store"; import { useSessionStore } from "@/stores/session-store"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; +import { CHAT_AI_ENABLED } from "@/lib/chat/ai-availability"; import type { PlaceShareData } from "@/types/chat"; /** @@ -49,6 +50,7 @@ export function useChatActions() { const sendAiMessage = useCallback( (content: string) => { + if (!CHAT_AI_ENABLED) return; const trimmed = content.trim(); if ( !client || @@ -99,6 +101,7 @@ export function useChatActions() { const sendCancelAiRequest = useCallback( (requestMessageId: string) => { + if (!CHAT_AI_ENABLED) return; const id = requestMessageId.trim(); if (!client || !connected || !ridTrimmed || !id) return; client.publish({ diff --git a/src/lib/chat/ai-availability.ts b/src/lib/chat/ai-availability.ts new file mode 100644 index 00000000..35466040 --- /dev/null +++ b/src/lib/chat/ai-availability.ts @@ -0,0 +1,2 @@ +// NEXT_PUBLIC values are fixed at build time; an omitted flag preserves AI access. +export const CHAT_AI_ENABLED = process.env.NEXT_PUBLIC_AI_ENABLED !== "false"; diff --git a/src/lib/stomp/user-error-dispatch.ai-disabled.test.ts b/src/lib/stomp/user-error-dispatch.ai-disabled.test.ts new file mode 100644 index 00000000..506bcd62 --- /dev/null +++ b/src/lib/stomp/user-error-dispatch.ai-disabled.test.ts @@ -0,0 +1,21 @@ +import { expect, it, vi } from "vitest"; +import { toast } from "sonner"; +import { dispatchUserError } from "./user-error-dispatch"; +import { parseUserErrorMessage } from "./user-error-events"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +it("accepts AI_DISABLED without suggesting a retry", () => { + const payload = parseUserErrorMessage(JSON.stringify({ + domain: "MESSAGE", action: "SEND", code: "AI_DISABLED", + clientRequestId: "client-1", retryable: false, + message: "이 환경에서는 AI 기능을 제공하지 않습니다", + })); + expect(payload).not.toBeNull(); + if (!payload) throw new Error("Expected the existing error contract to parse"); + dispatchUserError(payload); + expect(toast.error).toHaveBeenCalledExactlyOnceWith( + "이 환경에서는 AI 기능을 제공하지 않습니다", + { duration: 4500 }, + ); +});