Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)를 따릅니다.
Expand Down
80 changes: 80 additions & 0 deletions src/components/chat/ChatInputBar.ai-availability.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ChatInputBar isMinimized={minimized} onSendChat={onSendChat} onSendAi={onSendAi} />,
));
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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("대화 요약해줘");
});
50 changes: 27 additions & 23 deletions src/components/chat/ChatInputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -125,6 +126,7 @@ export function ChatInputBar({
}, [mentionOpen]);

function toggleAi() {
if (!CHAT_AI_ENABLED) return;
setAiEnabled((prev) => {
const next = !prev;
if (next) {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -217,7 +219,7 @@ export function ChatInputBar({
return;
}

if (aiEnabled) onSendAi(trimmed);
if (CHAT_AI_ENABLED && aiEnabled) onSendAi(trimmed);
else onSendChat(trimmed);

setMessage("");
Expand Down Expand Up @@ -365,25 +367,27 @@ export function ChatInputBar({
)}
>
<div className="flex min-w-0 items-center gap-1">
<motion.button
type="button"
onClick={toggleAi}
whileTap={reduceMotion ? undefined : chatTapSoft}
transition={chatTapTransition}
className={`flex shrink-0 cursor-pointer items-center gap-1 rounded-lg px-2 py-1 transition-colors ${
aiEnabled
? "bg-primary text-white"
: "bg-light-gray text-black/40"
}`}
>
<span className={typo.input}>{CHAT_AI_MENTION_LABEL}</span>
{aiEnabled && (
<Check
className={cn(isMinimized ? "h-3 w-3" : "h-3.5 w-3.5")}
strokeWidth={3}
/>
)}
</motion.button>
{CHAT_AI_ENABLED ? (
<motion.button
type="button"
onClick={toggleAi}
whileTap={reduceMotion ? undefined : chatTapSoft}
transition={chatTapTransition}
className={`flex shrink-0 cursor-pointer items-center gap-1 rounded-lg px-2 py-1 transition-colors ${
aiEnabled
? "bg-primary text-white"
: "bg-light-gray text-black/40"
}`}
>
<span className={typo.input}>{CHAT_AI_MENTION_LABEL}</span>
{aiEnabled && (
<Check
className={cn(isMinimized ? "h-3 w-3" : "h-3.5 w-3.5")}
strokeWidth={3}
/>
)}
</motion.button>
) : null}
{aiEnabled ? (
<>
<motion.button
Expand Down
50 changes: 50 additions & 0 deletions src/hooks/useChatActions.ai-disabled.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { act } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { hookHarness } from "@/test/hook-harness";
import { useSessionStore } from "@/stores/session-store";
import { useChatActions } from "./useChatActions";

const boundary = vi.hoisted(() => {
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();
}
});
3 changes: 3 additions & 0 deletions src/hooks/useChatActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -49,6 +50,7 @@ export function useChatActions() {

const sendAiMessage = useCallback(
(content: string) => {
if (!CHAT_AI_ENABLED) return;
const trimmed = content.trim();
if (
!client ||
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/lib/chat/ai-availability.ts
Original file line number Diff line number Diff line change
@@ -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";
21 changes: 21 additions & 0 deletions src/lib/stomp/user-error-dispatch.ai-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
});
Loading