diff --git a/app/package.json b/app/package.json index 2f26151..9125df9 100644 --- a/app/package.json +++ b/app/package.json @@ -18,7 +18,8 @@ "react-slick": "^0.30.2", "react-syntax-highlighter": "^15.5.0", "remark-gfm": "^4.0.0", - "slick-carousel": "^1.8.1" + "slick-carousel": "^1.8.1", + "zustand": "^5.0.8" }, "devDependencies": { "@types/react": "^18.2.0", diff --git a/app/public/nodata.png b/app/public/nodata.png new file mode 100644 index 0000000..e8679d2 Binary files /dev/null and b/app/public/nodata.png differ diff --git a/app/src/App.tsx b/app/src/App.tsx index 9c62205..70aae17 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,29 +1,27 @@ import React, { useEffect, useState } from 'react'; -import { initDB, useIndexedDB } from "react-indexed-db-hook"; +import { initDB } from "react-indexed-db-hook"; import { onAuthStateChanged, User } from 'firebase/auth'; import { DBConfig } from './DBConfig'; import { auth } from './firebase'; -import { chatCompletions } from './api/ncloud-api'; -import { getCurrentDate } from './modules/utils'; import FlashCardViewer from './pages/FlashCardViewer'; import Login from './pages/Login'; import Settings from './pages/Settings'; -import { getGithubData } from './services/github-service'; +import NoDataView from './pages/NoDataView'; import UserDropdown from './widgets/UserDropdown'; +import { useTodayFlashcards } from './hooks/useTodayFlashcards'; +import { useNavigationStore } from './stores/navigationStore'; initDB(DBConfig); -const dates = [1, 7, 30]; // days ago list - -type Page = 'flashcard' | 'settings'; const App: React.FC = () => { - const { add, getByID } = useIndexedDB("data"); - const [loading, setLoading] = useState(true); const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); - const [currentPage, setCurrentPage] = useState('flashcard'); const [isScrollAtTop, setIsScrollAtTop] = useState(true); + const { currentPage, navigateToSettings, navigateToFlashcard } = useNavigationStore(); + + // 오늘의 플래시카드 데이터 로드 + const { loading, hasData } = useTodayFlashcards(user); // 인증 상태 감지 useEffect(() => { @@ -50,43 +48,6 @@ const App: React.FC = () => { return () => window.removeEventListener('scroll', handleScroll); }, []); - // 사용자가 로그인한 경우에만 데이터 로드 - useEffect(() => { - if (!user) { - setLoading(false); - return; - } - - (async () => { - const todayData = await getByID(getCurrentDate()); - if (todayData) { - setLoading(false); - return; - } - - let list: Array<{question: string, answer: string}> = []; - for (const ago of dates) { - try { - const githubData = await getGithubData(ago); - if (githubData) { - const result = await chatCompletions(githubData); - const questions = JSON.parse(result.body.result.message.content); - for (let ncloudData of questions) { - list.push({question: ncloudData, answer: githubData}); - } - } - } catch (error) { - console.error(`Error fetching data for ${ago}:`, error); - } - } - - if (list.length > 0) { - add({date: getCurrentDate(), data: list }); - } - setLoading(false); - })(); - }, [add, getByID, user]); - // 인증 로딩 중 if (authLoading) { return ( @@ -115,13 +76,45 @@ const App: React.FC = () => { justifyContent: 'center', alignItems: 'center', height: '100vh', - fontSize: '1.2rem' + fontSize: '1.2rem', + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + color: 'white' }}> - 데이터를 불러오는 중... +
+
+

📚 플래시카드 준비 중

+

GitHub에서 최근 커밋을 분석하고 있습니다...

+
+ ); } + // 데이터가 없는 경우 - 하지만 Settings 페이지는 허용 + if (!hasData && currentPage === 'flashcard') { + return ; + } + // 메인 앱 렌더링 return (
@@ -145,7 +138,7 @@ const App: React.FC = () => {
{currentPage === 'settings' && (
)} > - {cards.map((card, i) => -
- {flipped ? :

{card.question}

} -
- )} + {cards.map((card, i) => { + const contentType = card.contentType || 'markdown'; + + return ( +
+ {flipped ? ( + contentType === 'code-diff' ? ( + + ) : ( + + ) + ) : ( +

{card.question}

+ )} +
+ ); + })}
diff --git a/app/src/pages/NoDataView.tsx b/app/src/pages/NoDataView.tsx new file mode 100644 index 0000000..3419a1d --- /dev/null +++ b/app/src/pages/NoDataView.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { useNavigationStore } from '../stores/navigationStore'; + +const NoDataView: React.FC = () => { + const { navigateToSettings } = useNavigationStore(); + return ( +
+
+ 데이터 없음 +

+ 📭 플래시카드가 없습니다 +

+

+ 최근 커밋에서 학습할 내용을 찾지 못했습니다 +

+

+ ⚙️ 설정 에서 다른 리포지토리나 브랜치를 시도해보세요 +

+ + +
+
+ ); +}; + +export default NoDataView; diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 5950098..78da5a0 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -2,37 +2,42 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { doc, getDoc, setDoc, deleteDoc } from 'firebase/firestore'; import { useIndexedDB } from 'react-indexed-db-hook'; import { auth, db } from '../firebase'; -import { getRepositories } from '../api/github-api'; +import { getRepositories, getBranches, Branch } from '../api/github-api'; import { Repository } from '@til-alarm/shared'; import './Settings.css'; interface RepositorySettings { repositoryFullName: string; repositoryUrl: string; + branch: string; } // 캐시 설정 (컴포넌트 외부로 이동) const CACHE_KEY = 'github_repositories'; -const CACHE_DURATION = 30 * 60 * 1000; // 30분 const Settings: React.FC = () => { const [settings, setSettings] = useState({ repositoryFullName: '', repositoryUrl: '', + branch: 'main', }); const [repositories, setRepositories] = useState([]); + const [branches, setBranches] = useState([]); const [loading, setLoading] = useState(true); const [loadingRepos, setLoadingRepos] = useState(false); + const [loadingBranches, setLoadingBranches] = useState(false); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); const [deleting, setDeleting] = useState(false); + const [hasChanges, setHasChanges] = useState(false); const dropdownRef = useRef(null); // IndexedDB 훅 const repositoriesDB = useIndexedDB('repositories'); + const flashcardsDB = useIndexedDB('data'); // 플래시카드 데이터 스토어 // 드롭다운 외부 클릭 감지 useEffect(() => { @@ -56,23 +61,17 @@ const Settings: React.FC = () => { try { setLoadingRepos(true); - // 캐시 확인 (강제 새로고침이 아닌 경우) + // 캐시 확인 (수동 새로고침이 아닌 경우) if (!forceRefresh) { try { const cached = await repositoriesDB.getByID(CACHE_KEY); if (cached) { const now = Date.now(); const cacheAge = now - cached.timestamp; - - // 캐시가 유효한 경우 (30분 이내) - if (cacheAge < CACHE_DURATION) { - console.log(`✅ 캐시된 리포지토리 목록 사용 (IndexedDB) - ${Math.floor(cacheAge / 1000 / 60)}분 전 캐시`); - setRepositories(cached.data); - setLoadingRepos(false); - return; - } else { - console.log(`⏰ 캐시 만료됨 (${Math.floor(cacheAge / 1000 / 60)}분 경과) - API 호출`); - } + console.log(`✅ 캐시된 리포지토리 목록 사용 (IndexedDB) - ${Math.floor(cacheAge / 1000 / 60)}분 전 캐시`); + setRepositories(cached.data); + setLoadingRepos(false); + return; } else { console.log('📭 캐시 없음 - API 호출'); } @@ -80,7 +79,7 @@ const Settings: React.FC = () => { console.error('❌ 캐시 읽기 실패:', cacheError); } } else { - console.log('🔄 강제 새로고침 - API 호출'); + console.log('🔄 수동 새로고침 - API 호출'); } // API 호출 @@ -115,6 +114,23 @@ const Settings: React.FC = () => { } }, []); + // 브랜치 목록 불러오기 + const fetchBranches = useCallback(async (owner: string, repo: string) => { + try { + setLoadingBranches(true); + console.log(`🌿 브랜치 목록 불러오기: ${owner}/${repo}`); + const branchList = await getBranches(owner, repo); + setBranches(branchList); + console.log(`✅ ${branchList.length}개의 브랜치 발견`); + } catch (error) { + console.error('❌ 브랜치 불러오기 실패:', error); + setMessage({ type: 'error', text: '브랜치 목록을 불러오는데 실패했습니다.' }); + setBranches([]); + } finally { + setLoadingBranches(false); + } + }, []); + // 설정 및 리포지토리 목록 불러오기 useEffect(() => { let mounted = true; @@ -136,7 +152,14 @@ const Settings: React.FC = () => { setSettings({ repositoryFullName: data.repositoryFullName || '', repositoryUrl: data.repositoryUrl || '', + branch: data.branch || 'main', }); + + // 리포지토리가 선택되어 있으면 브랜치 목록 불러오기 + if (data.repositoryFullName) { + const [owner, repo] = data.repositoryFullName.split('/'); + await fetchBranches(owner, repo); + } } } @@ -163,8 +186,27 @@ const Settings: React.FC = () => { }; }, []); // 마운트 시 한 번만 실행 - // 리포지토리 선택 및 즉시 저장 + // 리포지토리 선택 (상태만 변경) const handleRepositorySelect = async (repo: Repository) => { + setIsDropdownOpen(false); + setMessage(null); + + // 설정 업데이트 + setSettings({ + repositoryFullName: repo.full_name, + repositoryUrl: repo.html_url, + branch: 'main', // 리포지토리 변경 시 기본 브랜치로 초기화 + }); + + // 브랜치 목록 불러오기 + const [owner, repoName] = repo.full_name.split('/'); + await fetchBranches(owner, repoName); + + setHasChanges(true); + }; + + // 설정 저장 + const handleSaveSettings = async () => { const user = auth.currentUser; if (!user) { @@ -172,51 +214,77 @@ const Settings: React.FC = () => { return; } - setIsDropdownOpen(false); + if (!settings.repositoryFullName) { + setMessage({ type: 'error', text: '리포지토리를 선택해주세요.' }); + return; + } + + if (!settings.branch) { + setMessage({ type: 'error', text: '브랜치를 선택해주세요.' }); + return; + } + setSaving(true); setMessage(null); try { - // 설정 업데이트 - setSettings({ - repositoryFullName: repo.full_name, - repositoryUrl: repo.html_url, - }); - - // 기존 데이터 유지하면서 업데이트 + // 기존 리포지토리 확인 const userDoc = await getDoc(doc(db, 'users', user.uid)); const existingData = userDoc.exists() ? userDoc.data() : {}; + const previousRepo = existingData.repositoryFullName; + const previousBranch = existingData.branch; + + // 리포지토리 또는 브랜치가 변경되었는지 확인 + const isRepoOrBranchChanged = + (previousRepo && previousRepo !== settings.repositoryFullName) || + (previousBranch && previousBranch !== settings.branch); // full_name에서 username과 repository 분리 - const [githubUsername, repositoryName] = repo.full_name.split('/'); + const [githubUsername, repositoryName] = settings.repositoryFullName.split('/'); await setDoc(doc(db, 'users', user.uid), { ...existingData, - repositoryFullName: repo.full_name, - repositoryUrl: repo.html_url, + repositoryFullName: settings.repositoryFullName, + repositoryUrl: settings.repositoryUrl, githubUsername, repositoryName, + branch: settings.branch, updatedAt: new Date().toISOString(), }); - setMessage({ type: 'success', text: '✅ 리포지토리가 성공적으로 변경되었습니다!' }); - - // 3초 후 메시지 자동 제거 - setTimeout(() => { - setMessage(null); - }, 3000); + // 리포지토리 또는 브랜치가 변경된 경우 플래시카드 데이터 삭제 및 페이지 새로고침 + if (isRepoOrBranchChanged) { + try { + await flashcardsDB.clear(); + console.log('🗑️ 설정 변경으로 인해 플래시카드 데이터를 삭제했습니다.'); + setMessage({ type: 'success', text: '✅ 설정이 저장되었습니다. 페이지를 새로고침합니다...' }); + + // 1초 후 페이지 새로고침 + setTimeout(() => { + window.location.reload(); + }, 1000); + } catch (clearError) { + console.error('❌ 플래시카드 데이터 삭제 실패:', clearError); + setMessage({ type: 'success', text: '✅ 설정이 저장되었습니다.' }); + setHasChanges(false); + + // 3초 후 메시지 자동 제거 + setTimeout(() => { + setMessage(null); + }, 3000); + } + } else { + setMessage({ type: 'success', text: '✅ 설정이 성공적으로 저장되었습니다!' }); + setHasChanges(false); + + // 3초 후 메시지 자동 제거 + setTimeout(() => { + setMessage(null); + }, 3000); + } } catch (error) { console.error('설정 저장 실패:', error); setMessage({ type: 'error', text: '설정 저장에 실패했습니다.' }); - // 실패 시 이전 상태로 복원 - const userDoc = await getDoc(doc(db, 'users', user.uid)); - if (userDoc.exists()) { - const data = userDoc.data(); - setSettings({ - repositoryFullName: data.repositoryFullName || '', - repositoryUrl: data.repositoryUrl || '', - }); - } } finally { setSaving(false); } @@ -362,19 +430,67 @@ const Settings: React.FC = () => {
{settings.repositoryFullName && ( -
-

📂 선택된 리포지토리:

- - - {settings.repositoryUrl} - - -
+ <> +
+

📂 선택된 리포지토리:

+ + + {settings.repositoryUrl} + + +
+ +
+ +

+ 커밋을 가져올 브랜치를 선택하세요 +

+ + {loadingBranches ? ( +
+
+ 브랜치 목록을 불러오는 중... +
+ ) : branches.length > 0 ? ( + + ) : ( +

+ 리포지토리를 선택하면 브랜치 목록이 표시됩니다. +

+ )} +
+ )} {message && ( @@ -382,6 +498,30 @@ const Settings: React.FC = () => { {message.text} )} + + {settings.repositoryFullName && ( + + )}
diff --git a/app/src/services/github-service.ts b/app/src/services/github-service.ts deleted file mode 100644 index 38451c3..0000000 --- a/app/src/services/github-service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getCommits, getFilename, getMarkdown } from '../api/github-api'; - -export async function getGithubData(daysAgo: number): Promise { - const interval = 24 * daysAgo * 60 * 60 * 1000; - const currentDate = new Date(); - const pastDate = new Date(currentDate.getTime() - interval); - - const since = new Date(pastDate); - since.setHours(0, 0, 0, 0); - - const until = new Date(pastDate); - until.setHours(23, 59, 59, 999); - - const commits = await getCommits(since, until); - - if (commits.length === 0) { - return null; - } - - for (let commit of commits) { - const commit_detail = await getFilename(commit.sha); - let dump: Record = {}; - for (let file of commit_detail.files) { - if(file.filename.endsWith(".md")) { - if (dump && Object.keys(dump).includes(file.filename)) { - return dump[file.filename]; - } else { - const data = await getMarkdown(file.filename); - dump[file.filename] = data; - return data; - } - } - } - } - return null; -} diff --git a/app/src/stores/navigationStore.ts b/app/src/stores/navigationStore.ts new file mode 100644 index 0000000..fb5e053 --- /dev/null +++ b/app/src/stores/navigationStore.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +export type Page = 'flashcard' | 'settings'; + +interface NavigationState { + currentPage: Page; + setCurrentPage: (page: Page) => void; + navigateToSettings: () => void; + navigateToFlashcard: () => void; +} + +export const useNavigationStore = create((set) => ({ + currentPage: 'flashcard', + setCurrentPage: (page) => set({ currentPage: page }), + navigateToSettings: () => set({ currentPage: 'settings' }), + navigateToFlashcard: () => set({ currentPage: 'flashcard' }), +})); diff --git a/app/src/templates/CodeDiffBlock.tsx b/app/src/templates/CodeDiffBlock.tsx new file mode 100644 index 0000000..b27c8fc --- /dev/null +++ b/app/src/templates/CodeDiffBlock.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +interface CodeDiffBlockProps { + diffContent: string; +} + +const CodeDiffBlock: React.FC = ({ diffContent }) => { + return ( +
+ + { + const lineContent = String(children).split('\n')[lineNumber - 1] || ''; + const style: React.CSSProperties = { display: 'block' }; + + if (lineContent.startsWith('+') && !lineContent.startsWith('+++')) { + style.backgroundColor = 'rgba(46, 160, 67, 0.15)'; + style.borderLeft = '3px solid #2ea043'; + } else if (lineContent.startsWith('-') && !lineContent.startsWith('---')) { + style.backgroundColor = 'rgba(248, 81, 73, 0.15)'; + style.borderLeft = '3px solid #f85149'; + } else if (lineContent.startsWith('@@')) { + style.backgroundColor = 'rgba(84, 174, 255, 0.15)'; + style.fontWeight = '600'; + } + + return { style }; + }} + {...props} + > + {String(children).replace(/\n$/, '')} + +
+ ); + } + + // 일반 코드 블록 + return ( + + {String(children).replace(/\n$/, '')} + + ); + } + + return ( + + {children} + + ); + }, + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ 📄 {children} +

+ ), + strong: ({ children }) => { + const text = String(children); + let color = '#24292f'; + + if (text.includes('Status')) { + color = '#0969da'; + } else if (text.includes('Changes')) { + color = '#8250df'; + } + + return ( + + {children} + + ); + }, + }} + > + {diffContent} + + + +
+ ); +}; + +export default CodeDiffBlock; + diff --git a/functions/src/github.ts b/functions/src/github.ts index 766d3d1..4f70377 100644 --- a/functions/src/github.ts +++ b/functions/src/github.ts @@ -10,6 +10,7 @@ async function getUserData(req: any): Promise<{ githubToken: string; githubUsername: string; repositoryName: string; + branch: string; }> { const authHeader = req.headers.authorization; @@ -35,6 +36,7 @@ async function getUserData(req: any): Promise<{ const githubToken = userData?.githubToken; const githubUsername = userData?.githubUsername; const repositoryName = userData?.repositoryName; + const branch = userData?.branch || 'main'; // 기본값 main if (!githubToken) { throw new Error('GitHub token not found. Please login with GitHub again.'); @@ -48,6 +50,7 @@ async function getUserData(req: any): Promise<{ githubToken, githubUsername, repositoryName, + branch, }; } catch (error) { console.error('Authentication error:', error); @@ -70,7 +73,8 @@ export const getCommits = onRequest( const userData = await getUserData(req); const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; - const response = await fetch(`https://api.github.com/repos/${repoPath}/commits?since=${since}&until=${until}`, { + // 브랜치를 지정하여 커밋 가져오기 + const response = await fetch(`https://api.github.com/repos/${repoPath}/commits?sha=${userData.branch}&since=${since}&until=${until}`, { headers: { "Authorization": `Bearer ${userData.githubToken}`, "Accept": "application/vnd.github.v3+json", @@ -158,7 +162,8 @@ export const getMarkdown = onRequest( const userData = await getUserData(req); const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; - const response = await fetch(`https://api.github.com/repos/${repoPath}/contents/${filename}`, { + // 브랜치를 지정하여 파일 가져오기 + const response = await fetch(`https://api.github.com/repos/${repoPath}/contents/${filename}?ref=${userData.branch}`, { headers: { "Accept": "application/vnd.github.raw", "Authorization": `Bearer ${userData.githubToken}`, @@ -254,4 +259,79 @@ export const getRepositories = onRequest( }); } } +); + +/** + * 특정 리포지토리의 브랜치 목록 가져오기 + * Settings 페이지에서 브랜치 선택을 위해 사용 + */ +export const getBranches = onRequest( + { cors: true }, + async (req, res) => { + try { + const { owner, repo } = req.query; + + if (!owner || !repo) { + res.status(400).json({ error: 'owner and repo parameters are required' }); + return; + } + + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Firebase ID token not provided. Please authenticate.' }); + return; + } + + const idToken = authHeader.split('Bearer ')[1]; + + // Firebase ID Token 검증 + const decodedToken = await getAuth().verifyIdToken(idToken); + const userId = decodedToken.uid; + + // Firestore에서 GitHub 토큰 조회 + const userDoc = await getFirestore().collection('users').doc(userId).get(); + + if (!userDoc.exists) { + res.status(404).json({ error: 'User not found. Please login again.' }); + return; + } + + const userData = userDoc.data(); + const githubToken = userData?.githubToken; + + if (!githubToken) { + res.status(400).json({ error: 'GitHub token not found. Please login with GitHub again.' }); + return; + } + + // GitHub API로 브랜치 목록 가져오기 + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/branches`, { + headers: { + 'Authorization': `Bearer ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error(`GitHub API error: ${response.status}`, errorBody); + res.status(response.status).json({ + error: 'Failed to fetch branches from GitHub', + details: errorBody + }); + return; + } + + const branches = await response.json(); + res.json(branches); + } catch (error) { + console.error('Error fetching branches:', error); + res.status(500).json({ + error: 'Failed to fetch branches', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + } ); \ No newline at end of file diff --git a/functions/src/hypercloax.ts b/functions/src/hypercloax.ts deleted file mode 100644 index d98d101..0000000 --- a/functions/src/hypercloax.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { onRequest } from 'firebase-functions/v2/https'; - -// Hypercloax 관련 API Functions -export const hypercloaxApi = onRequest( - { cors: true }, - async (req, res) => { - try { - const { method, path } = req.query; - - if (!method || !path) { - res.status(400).json({ error: 'method and path parameters are required' }); - return; - } - - // TODO: Hypercloax API 호출 로직 구현 - // 현재는 플레이스홀더 응답 - res.json({ - message: 'Hypercloax API endpoint', - method, - path, - status: 'not_implemented' - }); - } catch (error) { - console.error('Error calling Hypercloax API:', error); - res.status(500).json({ error: 'Failed to call Hypercloax API' }); - } - } -); - -// CLOVA Studio API (기존 NCloud API) -export const chatCompletions = onRequest( - { cors: true }, - async (req, res) => { - try { - const { prompt, text } = req.body; - - if (!prompt || !text) { - res.status(400).json({ error: 'prompt and text are required' }); - return; - } - - // CLOVA Studio API 호출 - const response = await fetch('https://clovastudio.apigw.ntruss.com/testapp/v1/chat-completions/HMX-001', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-NCP-CLOVASTUDIO-API-KEY': process.env.CLOVA_API_KEY || '', - 'X-NCP-APIGW-API-KEY': process.env.NCLOUD_API_KEY || '' - }, - body: JSON.stringify({ - messages: [ - { - role: 'user', - content: `${prompt}\n\n${text}` - } - ], - maxTokens: 1000, - temperature: 0.7, - topK: 0, - topP: 0.8, - repeatPenalty: 1.0 - }) - }); - - if (!response.ok) { - throw new Error(`CLOVA API error: ${response.status}`); - } - - const data = await response.json(); - res.json(data); - } catch (error) { - console.error('Error calling CLOVA API:', error); - res.status(500).json({ error: 'Failed to call CLOVA API' }); - } - } -); - -// Firebase Cloud Messaging 관련 API -export const registerDeviceToken = onRequest( - { cors: true }, - async (req, res) => { - try { - const { userId, token } = req.body; - - if (!userId || !token) { - res.status(400).json({ error: 'userId and token are required' }); - return; - } - - // TODO: Firestore에 토큰 저장 로직 구현 - console.log('Registering device token:', { userId, token }); - res.json({ success: true }); - } catch (error) { - console.error('Error registering device token:', error); - res.status(500).json({ error: 'Failed to register device token' }); - } - } -); - -export const removeDeviceToken = onRequest( - { cors: true }, - async (req, res) => { - try { - const { userId } = req.body; - - if (!userId) { - res.status(400).json({ error: 'userId is required' }); - return; - } - - // TODO: Firestore에서 토큰 삭제 로직 구현 - console.log('Removing device token for user:', userId); - res.json({ success: true }); - } catch (error) { - console.error('Error removing device token:', error); - res.status(500).json({ error: 'Failed to remove device token' }); - } - } -); - -export const registerSchedule = onRequest( - { cors: true }, - async (req, res) => { - try { - const { scheduleCode } = req.body; - - if (!scheduleCode) { - res.status(400).json({ error: 'scheduleCode is required' }); - return; - } - - // TODO: 스케줄 등록 로직 구현 - console.log('Registering schedule:', scheduleCode); - res.json({ success: true }); - } catch (error) { - console.error('Error registering schedule:', error); - res.status(500).json({ error: 'Failed to register schedule' }); - } - } -); diff --git a/functions/src/hyperclovax.ts b/functions/src/hyperclovax.ts new file mode 100644 index 0000000..c0c8573 --- /dev/null +++ b/functions/src/hyperclovax.ts @@ -0,0 +1,72 @@ +import { onRequest } from 'firebase-functions/v2/https'; + +// HyperCLOVA X API (HCX-007) +export const chatCompletions = onRequest( + { cors: true }, + async (req, res) => { + try { + const { prompt, text } = req.body; + + if (!prompt || !text) { + res.status(400).json({ error: 'prompt and text are required' }); + return; + } + + // 고유한 요청 ID 생성 + const requestId = crypto.randomUUID().replace(/-/g, ''); + + // HyperCLOVA X API 호출 (HCX-007 모델) + const response = await fetch('https://clovastudio.stream.ntruss.com/v3/chat-completions/HCX-007', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.CLOVA_API_KEY || ''}`, + 'X-NCP-CLOVASTUDIO-REQUEST-ID': requestId + }, + body: JSON.stringify({ + messages: [ + { + role: 'system', + content: [ + { + type: 'text', + text: prompt + } + ] + }, + { + role: 'user', + content: [ + { + type: 'text', + text: text + } + ] + } + ], + thinking: { + effort: 'low' + }, + topP: 0.8, + topK: 0, + maxCompletionTokens: 20480, + temperature: 0.5, + repetitionPenalty: 1.1, + seed: 0, + includeAiFilters: true + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HCX API error: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + res.json(data); + } catch (error) { + console.error('Error calling HCX API:', error); + res.status(500).json({ error: 'Failed to call HCX API' }); + } + } +); diff --git a/functions/src/index.ts b/functions/src/index.ts index d46680b..5b44ecc 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -5,4 +5,4 @@ initializeApp(); export * from './github'; export * from './schedule'; -export * from './hypercloax'; +export * from './hyperclovax'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf76b1e..d4c3899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: slick-carousel: specifier: ^1.8.1 version: 1.8.1(jquery@3.7.1) + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@18.3.25)(react@18.3.1) devDependencies: '@types/react': specifier: ^18.2.0 @@ -3727,6 +3730,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -8227,4 +8248,9 @@ snapshots: yocto-queue@0.1.0: optional: true + zustand@5.0.8(@types/react@18.3.25)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.25 + react: 18.3.1 + zwitch@2.0.4: {}