diff --git a/client/package.json b/client/package.json index c4690f7..e007a2b 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "jsbarcode": "^3.11.6", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", + "marked": "^17.0.1", "mui": "^1.0.0", "next": "15.3.3", "quagga": "0.12.1", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 40ee95a..daa1510 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + marked: + specifier: ^17.0.1 + version: 17.0.1 mui: specifier: ^1.0.0 version: 1.0.0 @@ -2756,6 +2759,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5945,7 +5953,7 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.28.0)(typescript@5.8.3) eslint: 9.28.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.28.0) eslint-plugin-react: 7.37.5(eslint@9.28.0) @@ -5965,7 +5973,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -5980,14 +5988,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0))(eslint@9.28.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 5.62.0(eslint@9.28.0)(typescript@5.8.3) eslint: 9.28.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0) transitivePeerDependencies: - supports-color @@ -6002,7 +6010,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.28.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0))(eslint@9.28.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6712,6 +6720,8 @@ snapshots: dependencies: yallist: 3.1.1 + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdn-data@2.0.28: {} diff --git a/client/src/app/admin/ai/LeftList.tsx b/client/src/app/admin/ai/LeftList.tsx new file mode 100644 index 0000000..fb5c87e --- /dev/null +++ b/client/src/app/admin/ai/LeftList.tsx @@ -0,0 +1,111 @@ +"use client" + +import useAuth from "@/hooks/useAuth" +import { Stack, Typography, Box, Button } from "@mui/material" +import useAiChat from "./chat/useAiChat" +import { useEffect, useState } from "react" +import { AIChatRoom } from "@server/entity/ai/aiChatRoom" +import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline" +import dayjs from "dayjs" + +export default function AdminAIChatLeftComponent() { + const { authUserData } = useAuth() + const { + getChatRooms, + selectedChatRoomId, + setSelectedChatRoomId, + setSelectedChatRoom, + chatRooms, + } = useAiChat() + + useEffect(() => { + getChatRooms() + }, []) + + return ( + + + + 채팅 목록 + + + {authUserData?.name}님 + + + + + {/* 새 채팅 만들기 버튼 */} + + + {chatRooms.map((room) => ( + setSelectedChatRoomId(room.id)} + sx={{ + p: 2, + cursor: "pointer", + borderRadius: 2, + bgcolor: selectedChatRoomId === room.id ? "white" : "transparent", + boxShadow: + selectedChatRoomId === room.id + ? "0 2px 4px rgba(0,0,0,0.05)" + : "none", + transition: "all 0.2s", + "&:hover": { + bgcolor: + selectedChatRoomId === room.id ? "white" : "rgba(0,0,0,0.04)", + }, + border: + selectedChatRoomId === room.id + ? "1px solid #e0e0e0" + : "1px solid transparent", + }} + > + + {room.title || "새로운 채팅"} + + + {dayjs(room.createdAt).format("YY-MM-DD HH:mm")} + + + ))} + + + ) +} diff --git a/client/src/app/admin/ai/chat/Chat.tsx b/client/src/app/admin/ai/chat/Chat.tsx new file mode 100644 index 0000000..f399c6c --- /dev/null +++ b/client/src/app/admin/ai/chat/Chat.tsx @@ -0,0 +1,138 @@ +"use client" + +import useAiChat from "./useAiChat" +import { marked } from "marked" +import { AIChat } from "@server/entity/ai/aiChat" +import { Box, Stack, Typography, Avatar, CircularProgress } from "@mui/material" +import SmartToyIcon from "@mui/icons-material/SmartToy" +import PersonIcon from "@mui/icons-material/Person" +import MoreHorizIcon from "@mui/icons-material/MoreHoriz" + +import { useRef, useEffect } from "react" + +export enum ChatType { + USER = "user", + AI = "ai", + SYSTEM = "system", +} + +export default function AdminAIChatComponent() { + const { selectedChatRoom, isAiReplying } = useAiChat() + const scrollRef = useRef(null) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth" }) + } + }, [selectedChatRoom?.chats, isAiReplying]) + + return ( + + {selectedChatRoom && + selectedChatRoom.chats && + selectedChatRoom.chats.map((chat) => ( + + ))} + {isAiReplying && } +
+ + ) +} + +function AiLoadingComponent() { + return ( + + + + + + + + + ) +} + +function ChatComponent({ chat }: { chat: AIChat }) { + const isUser = chat.type === ChatType.USER + + return ( + + + {isUser ? ( + + ) : ( + + )} + + + +
+ + + ) +} diff --git a/client/src/app/admin/ai/chat/page.tsx b/client/src/app/admin/ai/chat/page.tsx new file mode 100644 index 0000000..6f4aa10 --- /dev/null +++ b/client/src/app/admin/ai/chat/page.tsx @@ -0,0 +1,105 @@ +"use client" + +import { + Button, + Stack, + TextField, + IconButton, + Paper, + Typography, + Alert, +} from "@mui/material" +import AdminAIChatComponent from "./Chat" +import AdminAIChatLeftComponent from "../LeftList" +import useAuth from "@/hooks/useAuth" +import { useState } from "react" +import useAiChat from "./useAiChat" +import SendIcon from "@mui/icons-material/Send" +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined" + +export default function AdminAIChatPage() { + const [message, setMessage] = useState("") + const { sendMessageToAi, isAiReplying } = useAiChat() + + const handleSendMessage = async () => { + if (!message.trim()) return + const msg = message + setMessage("") // 즉시 초기화 + await sendMessageToAi(msg) + } + + return ( + + + + + + + + + + + • 현재 시범 운영 중인 기능입니다. + + + • 제공되는 토큰이 한정되어 있어 필요한 만큼만 질문해 주세요. + + + • AI의 답변은 참고용으로만 활용 부탁드립니다. + + + + setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + }} + disabled={isAiReplying} + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 3, + bgcolor: "#f8f9fa", + }, + }} + /> + + + + + + + ) +} diff --git a/client/src/app/admin/ai/chat/useAiChat.ts b/client/src/app/admin/ai/chat/useAiChat.ts new file mode 100644 index 0000000..bd15f4e --- /dev/null +++ b/client/src/app/admin/ai/chat/useAiChat.ts @@ -0,0 +1,96 @@ +"use client" + +import axios from "@/config/axios" +import { AIChatRoom } from "@server/entity/ai/aiChatRoom" +import { atom, useAtom } from "jotai" +import { useEffect } from "react" + +const SelectedAiCharRoomIdAtom = atom("") +const SelectedAiCharRoomAtom = atom(null) +const AiChatRoomsAtom = atom([]) +const IsAiReplyingAtom = atom(false) + +export default function useAiChat() { + const [selectedChatRoom, setSelectedChatRoom] = useAtom( + SelectedAiCharRoomAtom, + ) + const [selectedChatRoomId, setSelectedChatRoomId] = useAtom( + SelectedAiCharRoomIdAtom, + ) + const [chatRooms, setChatRooms] = useAtom(AiChatRoomsAtom) + const [isAiReplying, setIsAiReplying] = useAtom(IsAiReplyingAtom) + + useEffect(() => { + if (selectedChatRoomId) { + getRoomData(selectedChatRoomId) + } + }, [selectedChatRoomId]) + + async function getChatRooms() { + const response = await axios.get("/admin/ai/my-rooms") + setChatRooms(response.data) + return response + } + + async function sendMessageToAi(message: string) { + // Optimistic Update: 사용자 메시지를 미리 보여줌 + const tempUserChat: any = { + id: `temp-${Date.now()}`, + message, + type: "user", + createdAt: new Date(), + } + + setSelectedChatRoom((prev) => { + if (!prev) { + // 새 채팅방인 경우 임시 방 생성 + return { + id: "temp-room", + title: "New Chat", + chats: [tempUserChat], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as AIChatRoom + } + return { + ...prev, + chats: [...(prev.chats || []), tempUserChat], + } + }) + + setIsAiReplying(true) + try { + const response = await axios.post("/admin/ai/ask", { + message: message, + roomId: selectedChatRoomId, + }) + setSelectedChatRoom(response.data) + + if (!selectedChatRoomId || selectedChatRoomId !== response.data.id) { + setSelectedChatRoomId(response.data.id) + getChatRooms() + } else { + // 이미 룸이 있어도 채팅 업데이트를 위해 리스트 갱신 (마지막 메세지, 시간 등) + getChatRooms() + } + } finally { + setIsAiReplying(false) + } + } + + async function getRoomData(roomId: string) { + const response = await axios.get(`/admin/ai/room/${roomId}`) + setSelectedChatRoom(response.data) + } + + return { + getChatRooms, + sendMessageToAi, + selectedChatRoom, + setSelectedChatRoom, + selectedChatRoomId, + setSelectedChatRoomId, + chatRooms, + isAiReplying, + } +} diff --git a/client/src/app/admin/components/Header/index.tsx b/client/src/app/admin/components/Header/index.tsx index 42e3a98..b00a8b9 100644 --- a/client/src/app/admin/components/Header/index.tsx +++ b/client/src/app/admin/components/Header/index.tsx @@ -9,6 +9,7 @@ import CommunityIcon from "@mui/icons-material/Groups" import HeaderDrawer, { DrawerItemsType } from "@/components/Header/Drawer" import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline" import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined" +import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome" export default function AdminHeader() { const { push } = useRouter() @@ -62,6 +63,12 @@ export default function AdminHeader() { path: "/admin/soon/attendance", type: "menu", }, + { + title: "AI로 데이터 분석", + icon: , + path: "/admin/ai/chat", + type: "menu", + }, { type: "divider", }, diff --git a/client/src/app/admin/layout.tsx b/client/src/app/admin/layout.tsx index bca6615..849ff94 100644 --- a/client/src/app/admin/layout.tsx +++ b/client/src/app/admin/layout.tsx @@ -1,6 +1,7 @@ "use client" import Header from "@/app/admin/components/Header" +import { Stack } from "@mui/material" export default function AdminLayout({ children, @@ -8,9 +9,11 @@ export default function AdminLayout({ children: React.ReactNode }) { return ( - <> +
- {children} - + + {children} + + ) } diff --git a/client/src/app/admin/soon/page.tsx b/client/src/app/admin/soon/page.tsx index d247696..6a126eb 100644 --- a/client/src/app/admin/soon/page.tsx +++ b/client/src/app/admin/soon/page.tsx @@ -150,7 +150,7 @@ export default function Soon() { const filteredUsers = orderingUserList() return ( - + AIChatRoom, (room) => room.id) + room: AIChatRoom + + @Column({ type: "enum", enum: ChatType }) + type: ChatType + + @Column("text") + message: string + + @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) + createdAt: Date +} diff --git a/server/src/entity/ai/aiChatRoom.ts b/server/src/entity/ai/aiChatRoom.ts new file mode 100644 index 0000000..e1416ff --- /dev/null +++ b/server/src/entity/ai/aiChatRoom.ts @@ -0,0 +1,29 @@ +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm" +import { AIChat } from "./aiChat" +import { User } from "../user" + +@Entity() +export class AIChatRoom { + @PrimaryGeneratedColumn("uuid") + id: string + + @OneToMany(() => AIChat, (chat) => chat.room, { + cascade: ["insert", "update"], + }) + chats: AIChat[] + + @ManyToOne(() => User, (user) => user.id) + user: User + + @Column() + title: string + + @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) + createdAt: Date +} diff --git a/server/src/migration/1765814621862-WorshipContest.ts b/server/src/migration/1765814621862-WorshipContest.ts index 14271fd..5a48a38 100644 --- a/server/src/migration/1765814621862-WorshipContest.ts +++ b/server/src/migration/1765814621862-WorshipContest.ts @@ -5,7 +5,7 @@ export class WorshipContest1765814621862 implements MigrationInterface { await queryRunner.query(` CREATE TABLE \`worship_contest\` ( \`id\` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - \`voteUserId\` CHAR(36), + \`voteUserId\` VARCHAR(36) NOT NULL, \`firstCommunity\` VARCHAR(255) NOT NULL, \`secondCommunity\` VARCHAR(255) NOT NULL, \`thirdCommunity\` VARCHAR(255) NOT NULL, diff --git a/server/src/migration/1768640534507-CreateAiChat.ts b/server/src/migration/1768640534507-CreateAiChat.ts new file mode 100644 index 0000000..24c61d1 --- /dev/null +++ b/server/src/migration/1768640534507-CreateAiChat.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CreateAiChat1768640534507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE \`ai_chat_room\` ( + \`id\` varchar(36) NOT NULL, + \`userId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`title\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_ai_chat_room_user\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + await queryRunner.query(` + CREATE TABLE \`ai_chat\` ( + \`id\` varchar(36) NOT NULL, + \`roomId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`type\` enum ('user', 'ai', 'system') NOT NULL, + \`message\` text COLLATE utf8mb4_general_ci NOT NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_ai_chat_roomId\` FOREIGN KEY (\`roomId\`) REFERENCES \`ai_chat_room\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`ai_chat\``) + await queryRunner.query(`DROP TABLE \`ai_chat_room\``) + } +} diff --git a/server/src/migration/1768657178669-AddCommentInAllColumn.ts b/server/src/migration/1768657178669-AddCommentInAllColumn.ts new file mode 100644 index 0000000..39d80d0 --- /dev/null +++ b/server/src/migration/1768657178669-AddCommentInAllColumn.ts @@ -0,0 +1,303 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddCommentInAllColumn1768657178669 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // FK Check 비활성화 + await queryRunner.query("SET FOREIGN_KEY_CHECKS = 0") + + // ========================================== + // 1. User Table (사용자) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '사용자 고유 ID (UUID)'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`kakaoId\` varchar(255) NULL COMMENT '카카오 계정 고유 ID (로그인 식별용)'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`name\` varchar(255) NULL COMMENT '사용자 실명'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`yearOfBirth\` int NULL COMMENT '출생년도 (YYYY 형식)'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`gender\` varchar(255) NULL COMMENT '성별 (man: 남성, woman: 여성)'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`phone\` varchar(255) NULL COMMENT '전화번호 (하이픈 없이 숫자만 or 하이픈 포함)'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '사용자 관련 비고/특이사항'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`token\` varchar(255) NULL COMMENT '자체 인증 토큰'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`expire\` datetime NULL COMMENT '인증 토큰 만료 시간'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`isSuperUser\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 최고 관리자 권한 여부 (1: 관리자, 0: 일반)'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '계정 생성 일시'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`deletedAt\` timestamp(6) NULL COMMENT '계정 삭제 일시 (Soft Delete, NULL이면 활성 계정)'`, + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`profile\` int NOT NULL DEFAULT 0 COMMENT '프로필 이미지 식별자 (ID)'`, + ) + // await queryRunner.query(`ALTER TABLE \`user\` MODIFY COLUMN \`communityId\` int NULL COMMENT '현재 소속된 공동체/순 ID (FK)'`); + + // ========================================== + // 2. Community Table (공동체/조직) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '공동체 고유 ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`parentId\` int NULL COMMENT '상위 조직 ID (FK, NULL이면 최상위 조직)'`); + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`name\` varchar(255) NOT NULL COMMENT '공동체 이름 (예: 무슨무슨 마을, 무슨무슨 다락방, 다락방이 최하위 조직으로 상위는 마을임)'`, + ) + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성 일시'`, + ) + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 정보 수정 일시'`, + ) + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`leaderId\` varchar(36) NULL COMMENT '리더(순장/마을장) 사용자 ID (FK)'`); + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`deputyLeaderId\` varchar(36) NULL COMMENT '부리더(부순장) 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`x\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 X 좌표'`, + ) + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`y\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 Y 좌표'`, + ) + + // ========================================== + // 3. Permission Table (권한 관리) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`permission\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '권한 레코드 ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`permission\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '권한이 부여된 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`permission\` MODIFY COLUMN \`permissionType\` int NOT NULL COMMENT '부여된 권한 종류 (0: superUser, 1: admin, 2: userList 등 Enum 참조)'`, + ) + await queryRunner.query( + `ALTER TABLE \`permission\` MODIFY COLUMN \`have\` tinyint NOT NULL COMMENT '권한 보유 여부 (1: 있음, 0: 없음)'`, + ) + + // ========================================== + // 4. WorshipSchedule Table (예배 일정) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '예배 일정 ID'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`kind\` int NOT NULL COMMENT '예배 종류 (1: 주일예배, 2: 금요예배, 3: 기타)'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`date\` varchar(255) NOT NULL COMMENT '예배 날짜 (YYYY-MM-DD 문자열 형식)'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`canEdit\` tinyint NOT NULL DEFAULT 1 COMMENT '리더의 출석 입력 가능 여부 (1: 가능, 0: 마감)'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`isVisible\` tinyint NOT NULL DEFAULT 1 COMMENT '앱 내 일정 노출 여부 (1: 보임, 0: 숨김)'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '일정 생성 일시'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 수정 일시'`, + ) + + // ========================================== + // 5. AttendData Table (출석 기록) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '출석 기록 고유 ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`worshipScheduleId\` int NULL COMMENT '대상 예배 일정 ID (FK)'`); + // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '출석 대상 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`isAttend\` varchar(255) NOT NULL COMMENT '출석 상태 (ATTEND: 출석, ABSENT: 결석, ETC: 늦참/기타)'`, + ) + await queryRunner.query( + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`memo\` varchar(255) NOT NULL DEFAULT '' COMMENT '사유 또는 비고 사항'`, + ) + + // ========================================== + // 6. RetreatAttend Table (수련회 참가 정보) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '수련회 참가 신청서 ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '신청자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`groupNumber\` int NOT NULL DEFAULT 0 COMMENT '배정된 조 번호 (0이면 미배정)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`roomNumber\` int NULL COMMENT '배정된 숙소/방 번호'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`memo\` text NULL COMMENT '관리자용 메모'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isDeposited\` varchar(255) NOT NULL DEFAULT 'none' COMMENT '회비 입금 상태 (none: 미입금, student: 학생회비, business: 직장인회비, half: 부분참석회비)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToGo\` int NULL COMMENT '가는 편 이동 수단 (1: 같이가기, 2: 자차(단독), 3: 자차(동승), 4: 얻어타기, 5: 따로가기 등)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToBack\` int NULL COMMENT '오는 편 이동 수단 (옵션 위와 동일)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isCanceled\` tinyint NOT NULL DEFAULT 0 COMMENT '참가 취소 여부 (1: 취소됨)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '신청서 기타란 내용'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`currentStatus\` int NOT NULL DEFAULT 0 COMMENT '참가 현황 상태 (0: null, 1: 교회도착, 2: 수련회장도착 등)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`attendanceNumber\` int NOT NULL DEFAULT 0 COMMENT '접수 번호 (순서대로 발급)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`postcardContent\` text NULL COMMENT '수련회 엽서 내용'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isWorker\` tinyint NOT NULL DEFAULT 1 COMMENT '직장인 여부 (1: 직장인, 0: 학생) - 회비 구분에 사용'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isHalf\` tinyint NOT NULL DEFAULT 0 COMMENT '부분 참석 여부 (1: 토요일 저녁 이후 참석/부분참석, 0: 전체참석)'`, + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '신청서 작성 일시'`, + ) + + // ========================================== + // 7. InOutInfo Table (입퇴실 상세 이동 정보) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '이동 정보 ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`retreatAttendId\` varchar(36) NULL COMMENT '수련회 참가 신청 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`day\` int NOT NULL COMMENT '수련회 1, 2, 3일차 구분 (1=금, 2=토, 3=일)'`, + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`time\` varchar(255) NOT NULL COMMENT '예상 이동 시각 (HH:MM 문자열)'`, + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`inOutType\` varchar(255) NOT NULL COMMENT '이동 방향 (IN: 수련회장으로 감, OUT: 수련회장에서 나옴, none: 없음)'`, + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`position\` varchar(255) NOT NULL COMMENT '출발지 또는 목적지 상세 (예: 교회, 집 등)'`, + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`howToMove\` int NOT NULL COMMENT '상세 이동 수단 (Enum 참조)'`, + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`autoCreated\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 자동 생성 여부'`, + ) + // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`rideCarInfoId\` int NULL COMMENT '카풀 시 탑승한 차량(운전자)의 이동 정보 ID (FK)'`); + + // ========================================== + // 8. SharingText Table (나눔 - 텍스트) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(글) ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`sharing_text\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '작성자 User ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`content\` text NOT NULL COMMENT '나눔 본문 내용'`, + ) + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부 (1: 공개, 0: 비공개)'`, + ) + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '작성 일시'`, + ) + + // ========================================== + // 9. SharingImage Table (나눔 - 이미지) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(사진) ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`sharing_image\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '업로더 User ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`url\` text NOT NULL COMMENT 'S3 등 스토리지 이미지 경로'`, + ) + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부'`, + ) + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`tags\` text NULL COMMENT '이미지 태그 (콤마로 구분되거나 JSON)'`, + ) + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '업로드 일시'`, + ) + + // ========================================== + // 10. WorshipContest Table (워십 콘테스트 투표) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '투표 기록 ID'`, + ) + // await queryRunner.query(`ALTER TABLE \`worship_contest\` MODIFY COLUMN \`voteUserId\` varchar(36) NULL COMMENT '투표한 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`firstCommunity\` varchar(255) NOT NULL COMMENT '1순위 투표 팀 이름'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`secondCommunity\` varchar(255) NOT NULL COMMENT '2순위 투표 팀 이름'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`thirdCommunity\` varchar(255) NOT NULL COMMENT '3순위 투표 팀 이름'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`term\` int NOT NULL COMMENT '투표 부문/회차 (예: 1부, 2부)'`, + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '투표 일시'`, + ) + + // ========================================== + // 11. AIChatRoom Table (AI 챗봇 채팅방) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '채팅방 고유 ID (UUID)'`, + ) + // await queryRunner.query(`ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '채팅방 소유 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '채팅방 생성 일시'`, + ) + + // ========================================== + // 12. AIChat Table (AI 챗봇 메시지) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '메시지 고유 ID (UUID)'`, + ) + // await queryRunner.query(`ALTER TABLE \`ai_chat\` MODIFY COLUMN \`roomId\` varchar(36) NULL COMMENT '소속 채팅방 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`type\` enum('user', 'ai', 'system') NOT NULL COMMENT '발화자 구분 (user: 사용자, ai: 챗봇, system: 시스템 프롬프트)'`, + ) + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`message\` text NOT NULL COMMENT '대화 내용'`, + ) + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '메시지 생성 일시'`, + ) + + // FK Check 활성화 + await queryRunner.query("SET FOREIGN_KEY_CHECKS = 1") + } + + public async down(queryRunner: QueryRunner): Promise { + // 생략 (롤백 시 코멘트 제거 로직이 필요하지만, 여기서는 생략함) + } +} diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts new file mode 100644 index 0000000..da87696 --- /dev/null +++ b/server/src/model/ai.ts @@ -0,0 +1,212 @@ +import { AIChat, ChatType } from "../entity/ai/aiChat" +import { AIChatRoom } from "../entity/ai/aiChatRoom" +import { User } from "../entity/user" +import dataSource, { aiChatRoomDatabase } from "./dataSource" + +/* + * AI와 주고받는 메시지 타입 정의 + */ +interface MessageType { + role: "user" | "assistant" | "system" + content: string +} + +interface AiChatResponse { + id: string + content: Array<{ + citations: any + text: string + type: string + }> + model: string + role: string + stop_reason: string | null + stop_sequence: string | null + type: string + usage: any +} + +const AiModel = { + async createNewRoom(user: User, title: string): Promise { + if (title.length > 100) { + title = title.slice(0, 100) + } + const room = await aiChatRoomDatabase.create({ + user: user, + title: title, + }) + await aiChatRoomDatabase.save(room) + return room + }, + + async getUserRooms(user: User) { + const rooms = await aiChatRoomDatabase.find({ + where: { + user: { + id: user.id, + }, + }, + }) + return rooms + }, + + async callSql(query: string) { + const sqlMatch = query.match(/```(?:sql)?\s*([\s\S]*?)\s*```/i) + const sql = sqlMatch ? sqlMatch[1] : query + + const result = await dataSource.query(sql) + return result + }, + + async requestChatAI(messages: AIChat[]) { + // Separate system prompt (assumed to be the first message if it's SYSTEM type) + let systemPrompt = await getSystemPrompt() + const SIZE_LIMIT = 8 + if (messages.length > SIZE_LIMIT) { + messages = messages.slice(messages.length - SIZE_LIMIT, messages.length) + } + let chatMessages = [...messages] + + const body = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 1024, + system: systemPrompt, + messages: chatMessages.map((chat) => { + let role: "user" | "assistant" = "user" + switch (chat.type) { + case ChatType.USER: + role = "user" + break + case ChatType.AI: + role = "assistant" + break + case ChatType.SYSTEM: + // Query Result is treated as User input (context for the AI) + // SQL Query (generated by AI previously) is treated as Assistant output + if (chat.message.startsWith("Query Result:")) { + role = "user" + } else { + role = "assistant" + } + break + } + return { + role: role, + content: chat.message, + } + }), + } + + const response = await fetch( + "https://factchat-cloud.mindlogic.ai/v1/api/anthropic/messages", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.AI_API_KEY}`, + }, + body: JSON.stringify(body), + }, + ) + const data = (await response.json()) as AiChatResponse + + if (!data.content || data.content.length === 0) { + console.error("AI returned empty content", data) + throw new Error("AI returned empty content") + } + + return { + type: ChatType.AI, + message: data.content[0].text, + } as AIChat + }, + + async getChatRoom(roomId: string, isSystemChatIncluded = false) { + const chatRoom = await aiChatRoomDatabase.findOne({ + select: { + id: true, + title: true, + createdAt: true, + user: { + id: true, + }, + chats: { + id: true, + type: true, + message: true, + createdAt: true, + }, + }, + where: { + id: roomId, + }, + relations: { + chats: true, + user: true, + }, + order: { + chats: { createdAt: "ASC" }, + }, + }) + if (!isSystemChatIncluded) { + chatRoom.chats = chatRoom.chats.filter( + (chat) => chat.type !== ChatType.SYSTEM, + ) + } + return chatRoom + }, +} + +async function getSystemPrompt(): Promise { + const schema = await dataSource.query(` + SELECT + TABLE_NAME, + COLUMN_NAME, + DATA_TYPE, + COLUMN_COMMENT +FROM + INFORMATION_SCHEMA.COLUMNS +WHERE + TABLE_SCHEMA = '${process.env.DB_NAME}' +ORDER BY + TABLE_NAME, + ORDINAL_POSITION; + `) + + return ` + 당신은 수원제일교회 청년부 관리 시스템의 데이터베이스 전문가 AI 비서입니다. + 사용자의 질문을 분석하여 통계를 내거나 정보를 찾기 위해 SQL 쿼리를 작성하고, 이후 제공되는 쿼리 결과를 분석하여 사용자에게 답변을 제공합니다. + + [수원제일교회 청년부 정보] + 수원제일교회 청년부의 이름은 새벽이슬이며, 사역자가 각 마을들을 관리하며 마을들 안에는 다락방이 존재합니다. + 다락방은 하위 조직이 없는 단위이며 다락방의 리더를 순장, 부순장으로 지칭 합니다. + 마을의 리더는 마을장으로 지칭 합니다. + + [데이터베이스 스키마 정보] + ${JSON.stringify(schema, null, 2)} + + [작업 절차] + 1. 사용자의 질문이 데이터 조회가 필요한 경우, 표준 SQL(Mysql 호환) 쿼리를 작성해 주세요. + - 쿼리는 반드시 \`\`\`sql ... \`\`\` 코드 블록 안에 작성해야 합니다. + - 다른 설명 없이 오직 SQL 쿼리만 반환하는 것을 강제합니다. + 2. 만약 입력으로 "Query Result:" 와 함께 데이터가 주어진다면, 그 데이터를 분석하여 사용자의 원래 질문에 대해 친절하게 답변해 주세요. + 3. 조회 이외의 모든 쿼리 예(데이터 삽입, 수정, 삭제 등)은 절대 절대 금지 함으로 작성하지 마세요. + 4. 쿼리 결과가 너무 많이 나오지 않도록 항상 적절한 제한을 걸어 주세요. + 5. 시스템에 악영향을 미칠 수 있거나 부적절한 요청에 대해서는 정중하게 거절하는 답변을 해 주세요. + 6. 오늘 날짜는 ${new Date().getFullYear()}년 ${ + new Date().getMonth() + 1 + }월 ${new Date().getDate()}일 입니다. + + [예시] + User: "저번주 주일예배 출석률 알려줘" + Assistant: + \`\`\`sql + SELECT count(*) as count, isAttend FROM AttendData + LEFT JOIN WorshipSchedule ON AttendData.worshipScheduleId = WorshipSchedule.id + WHERE WorshipSchedule.kind = 1 AND WorshipSchedule.date = '...' + GROUP BY isAttend + \`\`\` + ` +} + +export default AiModel diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index 1999d5c..59c67b3 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -12,6 +12,8 @@ import { import { WorshipSchedule } from "../entity/worshipSchedule" import { AttendData } from "../entity/attendData" import { WorshipContest } from "../entity/event/worshipContest" +import { AIChat } from "../entity/ai/aiChat" +import { AIChatRoom } from "../entity/ai/aiChatRoom" const dataSource = new DataSource(require("../../ormconfig.js")) @@ -28,6 +30,9 @@ export const sharingTextDatabase = dataSource.getRepository(SharingText) export const sharingImageDatabase = dataSource.getRepository(SharingImage) export const sharingVideoDatabase = dataSource.getRepository(SharingVideo) +export const aiChatDatabase = dataSource.getRepository(AIChat) +export const aiChatRoomDatabase = dataSource.getRepository(AIChatRoom) + export const worshipContestDatabase = dataSource.getRepository(WorshipContest) export default dataSource diff --git a/server/src/routes/admin/ai.ts b/server/src/routes/admin/ai.ts index 2b60d13..23c5e73 100644 --- a/server/src/routes/admin/ai.ts +++ b/server/src/routes/admin/ai.ts @@ -1,36 +1,99 @@ import { Router } from "express" +import { getUserFromToken, hasPermissionFromReq } from "../../util/util" +import AiModel from "../../model/ai" +import { AIChat, ChatType } from "../../entity/ai/aiChat" +import { aiChatRoomDatabase } from "../../model/dataSource" +import { PermissionType } from "../../entity/types" const router = Router() router.post("/ask", async (req, res) => { - // AI 질문 처리 로직 구현 - - const body = { - model: "claude-sonnet-4-5-20250929", - max_tokens: 1024, - messages: [ - { - role: "user", - content: "Say this is a test!", - }, - ], + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ message: "Unauthorized" }) + return } - const response = await fetch( - "https://factchat-cloud.mindlogic.ai/v1/api/anthropic/messages", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.AI_API_KEY}`, - }, - body: JSON.stringify(body), + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + if (!isAdmin) { + res.status(403).json({ message: "Forbidden" }) + return + } + + const userRequestMessage = req.body.message + let roomId: string = req.body.roomId + if (!roomId) { + const newRoom = await AiModel.createNewRoom(user, userRequestMessage) + roomId = newRoom.id + } + + const chatRoom = await AiModel.getChatRoom(roomId, true) + + const requestChat = new AIChat() + requestChat.room = chatRoom + requestChat.type = ChatType.USER + requestChat.message = userRequestMessage + requestChat.createdAt = new Date() + chatRoom.chats.push(requestChat) + + let responseChat: AIChat + do { + responseChat = await AiModel.requestChatAI(chatRoom.chats) + responseChat.room = chatRoom + responseChat.createdAt = new Date() + if (responseChat.message.includes("```sql")) { + console.log("query:", responseChat.message) + responseChat.type = ChatType.SYSTEM // 쿼리는 시스템으로 저장 + chatRoom.chats.push(responseChat) + const sqlResult = await AiModel.callSql(responseChat.message) + const queryResult = JSON.stringify(sqlResult, null, 2).slice(0, 3000) + const queryChat = new AIChat() + queryChat.room = chatRoom + queryChat.type = ChatType.SYSTEM + queryChat.message = `Query Result:\n${queryResult}` + queryChat.createdAt = new Date() + chatRoom.chats.push(queryChat) + continue } - ) - const data = (await response.json()) as any + responseChat.type = ChatType.AI + chatRoom.chats.push(responseChat) + } while (responseChat.message.includes("```sql")) + + await aiChatRoomDatabase.save(chatRoom) + + const savedChatRoom = await AiModel.getChatRoom(roomId, false) + savedChatRoom.chats.forEach((chat) => { + delete chat.room + }) + res.json(savedChatRoom) +}) + +router.get("/my-rooms", async (req, res) => { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ message: "Unauthorized" }) + return + } + + const rooms = await AiModel.getUserRooms(user) + res.json(rooms) +}) + +router.get("/room/:roomId", async (req, res) => { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ message: "Unauthorized" }) + return + } + + const roomId: string = req.params.roomId as string + const chatRoom = await AiModel.getChatRoom(roomId) + if (chatRoom.user.id !== user.id) { + res.status(403).json({ message: "Forbidden" }) + return + } - console.log("AI response data:", data) - res.json(data.content) + res.json(chatRoom) }) export default router