diff --git a/build.gradle b/build.gradle
index 302de1e..061391c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -21,6 +21,7 @@ configurations {
repositories {
mavenCentral()
+ maven { url 'https://repo.spring.io/milestone' }
}
dependencies {
@@ -35,6 +36,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'org.jsoup:jsoup:1.17.2'
+ implementation platform('org.springframework.ai:spring-ai-bom:1.1.2')
+ implementation 'org.springframework.ai:spring-ai-starter-model-openai'
+ implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
diff --git a/docs/Backend/01. backend-convention.md b/docs/Backend/01. backend-convention.md
index 53e4523..d6e9282 100644
--- a/docs/Backend/01. backend-convention.md
+++ b/docs/Backend/01. backend-convention.md
@@ -25,11 +25,16 @@
## 5) 도메인/에러 규칙
- 도메인 객체 생성은 `@Builder`를 사용하고, Builder로 생성한다.
-- 에러 코드는 도메인별로 커스텀 `*ErrorCode`를 작성한다.
+- 에러 코드는 도메인별로 커스텀 `*ErrorCode` enum을 작성한다.
- 에러 코드는 짧고 명확하게 유지한다.
+- 각 도메인은 전용 Exception 클래스(`*Exception`)를 작성하여 사용한다.
+ - 모든 커스텀 예외는 `com.web.SearchWeb.config.exception.BusinessException`을 상속받는다.
+ - 에러 코드 인터페이스: `com.web.SearchWeb.config.exception.ErrorCode`
+ - 예시: `linkanalysis` 도메인 → `LinkAnalysisException`
## 6) Config 배치 규칙
- Config는 `config` 폴더 하위에 도메인 폴더를 만든 뒤 배치한다.
+- 공통 프레임워크 관련 Config(예외, 응답 등)는 `config.exception`, `config.common` 등에 배치한다.
- 예시: 아이템 도메인 Config -> `config/item/ItemConfig.java`
## 7) 머지 기준
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index da841ee..abd236f 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -188,7 +188,7 @@ export default function MyLinksPage() {
className="bg-white dark:bg-card-dark rounded-lg p-2.5 border border-gray-100 dark:border-gray-800 hover:shadow-sm hover:border-purple-200 dark:hover:border-purple-900/50 transition-all duration-300 group cursor-pointer h-[90px] flex flex-col justify-between focus:ring-1 focus:ring-purple-300 outline-none hover:bg-purple-50/50 dark:hover:bg-purple-900/10"
>
-
+
folder_open
- {/* AI Recommended Badge */}
-
- smart_toy
- AI Recommended
-
+ {analyzeLinkMutation.isSuccess && analyzeLinkMutation.data?.suggestedFolder && (
+
+ smart_toy
+ AI Recommended
+
+ )}
- {folders?.slice(0, 6).map((folder) => {
- const isActive = selectedFolderId === folder.memberFolderId;
+ {displayFolders.map((folder) => {
+ const isPending = folder.memberFolderId === PENDING_FOLDER_SENTINEL_ID;
+ const isActive = isPending
+ ? !!pendingNewFolderName // pending 폴더는 존재 자체가 선택 상태
+ : selectedFolderId === folder.memberFolderId;
return (
-
setSelectedFolderId(isActive ? null : folder.memberFolderId)}
- className={`${styles.folderTile} ${isActive ? styles.folderTileActive : ''} group`}
- >
- folder
+ {
+ if (isPending) {
+ // pending 폴더 클릭 → 해제하면 AI 추천 취소
+ setPendingNewFolderName(null);
+ } else {
+ // 기존 폴더 클릭 → pending 해제, 기존 폴더 선택
+ setPendingNewFolderName(null);
+ setSelectedFolderId(isActive ? null : folder.memberFolderId);
+ }
+ }}
+ className={`${styles.folderTile} ${isActive ? styles.folderTileActive : ''} group`}
+ >
+ {/* AI 추천 새 폴더 뱃지 */}
+ {isPending && (
+ NEW
+ )}
+ {isPending ? 'create_new_folder' : 'folder'}
{folder.folderName}
-
+
);
})}
-
-
- Browse all folders...
- expand_more
-
+
+
setOpenFolderBrowser(!openFolderBrowser)}
+ >
+
+ {pendingNewFolderName
+ ? pendingNewFolderName
+ : selectedFolderId
+ ? folders?.find(f => f.memberFolderId === selectedFolderId)?.folderName ?? 'Browse all folders...'
+ : 'Browse all folders...'}
+
+ expand_more
+
+
+ {/* 폴더 브라우저 드롭다운 */}
+ {openFolderBrowser && (
+
+
+ folder
+ All Folders ({folders?.length ?? 0})
+
+
+ {folders?.map((folder) => {
+ const isActive = selectedFolderId === folder.memberFolderId;
+ return (
+
{
+ const newId = isActive ? null : folder.memberFolderId;
+ setSelectedFolderId(newId);
+ setPendingNewFolderName(null); // 기존 폴더 선택 시 AI 추천 새 폴더 해제
+ // 원래 top3에 없는 폴더만 고정 (맨 앞으로 이동)
+ if (newId !== null) {
+ const top3 = (folders ?? []).slice(0, 3).map(f => f.memberFolderId);
+ if (!top3.includes(newId)) {
+ setPinnedFolderId(newId);
+ }
+ } else {
+ setPinnedFolderId(null);
+ }
+ setOpenFolderBrowser(false);
+ }}
+ >
+ folder
+ {folder.folderName}
+ {isActive && check}
+
+ );
+ })}
+
+ )}
-
+ useUIStore.getState().toggleCreateFolderDialog(true)}
+ >
add
@@ -318,22 +550,38 @@ export function SaveLinkDialog() {
2. '선택하지 않은' 기존 태그들은 앞의 8개까지만 "추천"으로 보여줍니다.
3. 나머지는 'Add tag' 버튼을 통해 검색해서 찾도록 유도합니다.
*/}
+ {/* AI 추천 태그 (새 태그, isExisting: false) 먼저 표시 */}
+ {selectedTags
+ .filter(tag => aiSuggestedTags.has(tag) && !existingTagsList.includes(tag))
+ .map((tag) => (
+
toggleTagSelection(tag)}
+ className={`${styles.tagChip} bg-violet-50 text-violet-700 border-violet-300 shadow-sm font-semibold cursor-pointer ring-1 ring-violet-200`}
+ >
+ auto_awesome
+ {tag}
+
+ ))}
+ {/* 기존 태그 목록 */}
{existingTagsList
- .filter(tag =>
+ .filter(tag =>
selectedTags.includes(tag) || // 선택된 태그거나
existingTagsList.indexOf(tag) < 8 // 상위 8개인 경우만 노출
)
.map((tag) => {
const isSelected = selectedTags.includes(tag);
-
+
return (
-
toggleTagSelection(tag)}
className={`${styles.tagChip} ${isSelected ? styles.tagChipSelected : styles.tagChipExisting} cursor-pointer`}
>
{tag}
-
+
);
})}
@@ -442,7 +690,8 @@ export function SaveLinkDialog() {
{existingTagsList
.filter(tag => tag.toLowerCase().includes(tagInput.toLowerCase()))
.map(tag => (
-
{
@@ -452,7 +701,7 @@ export function SaveLinkDialog() {
}}
>
# {tag}
-
+
))}
@@ -468,10 +717,16 @@ export function SaveLinkDialog() {
edit
-
- auto_awesome
- Generate AI Summary
-
+ {(analyzeLinkMutation.isPending || analyzeLinkMutation.isSuccess) && (
+
+ auto_awesome
+ {analyzeLinkMutation.isPending ? 'Generating...' : 'Regenerate AI Summary'}
+
+ )}