memo: 두서없는 메모 → LLM(PARA+GTD) 자동정리·분류 저장
- MemoService(save_memo tool): organize(정리·분류→구조화 JSON) + saveMemo(카테고리 findOrCreate + 노트+태그 적재) 분리 설계. GUI/외부API/향후 MCP 공통 사용. - 분류 체계: PARA(카테고리 경로) + GTD(next-action/waiting/someday/reference 태그) 하이브리드. - NoteController: POST /api/notes/organize (미리보기, save=true면 원샷 저장), POST /api/notes/memo (구조화 필드 저장). - NoteRepository: insertMemo(project 포함), addTag/findTags. - DB: notes.project 컬럼 + note_tags 테이블 (V2 마이그레이션). - 프론트: notes/new 텍스트 모드에 'AI로 정리' → 미리보기(카테고리/제목/태그/ 프로젝트/요약/본문 수정) → 정리본 저장 흐름 추가. - LLM은 OCI GenAI(현재 gpt-5.6-terra) 구조화 JSON 출력 방식(tool-calling 의존 없음). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
db/migration/V2__memo_tags_project.sql
Normal file
15
db/migration/V2__memo_tags_project.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 메모 자동정리·분류(PARA+GTD) 기능용 스키마
|
||||||
|
-- 2026-07-01 적용 (SQLcl로 수동 반영됨)
|
||||||
|
|
||||||
|
-- notes에 프로젝트 컬럼 추가
|
||||||
|
ALTER TABLE notes ADD (project VARCHAR2(200));
|
||||||
|
|
||||||
|
-- 노트 검색 태그 테이블 (GTD 액션 태그: next-action/waiting/someday/reference 포함)
|
||||||
|
CREATE TABLE note_tags (
|
||||||
|
note_id RAW(16) NOT NULL,
|
||||||
|
tag VARCHAR2(100) NOT NULL,
|
||||||
|
CONSTRAINT fk_note_tags_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_note_tags UNIQUE (note_id, tag)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_note_tags_note ON note_tags(note_id);
|
||||||
|
CREATE INDEX idx_note_tags_tag ON note_tags(tag);
|
||||||
@@ -41,12 +41,15 @@ public class NoteController {
|
|||||||
private final NoteRepository noteRepository;
|
private final NoteRepository noteRepository;
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final com.sundol.service.OciGenAiService genAiService;
|
private final com.sundol.service.OciGenAiService genAiService;
|
||||||
|
private final com.sundol.service.MemoService memoService;
|
||||||
|
|
||||||
public NoteController(NoteRepository noteRepository, CategoryRepository categoryRepository,
|
public NoteController(NoteRepository noteRepository, CategoryRepository categoryRepository,
|
||||||
com.sundol.service.OciGenAiService genAiService) {
|
com.sundol.service.OciGenAiService genAiService,
|
||||||
|
com.sundol.service.MemoService memoService) {
|
||||||
this.noteRepository = noteRepository;
|
this.noteRepository = noteRepository;
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.genAiService = genAiService;
|
this.genAiService = genAiService;
|
||||||
|
this.memoService = memoService;
|
||||||
try { Files.createDirectories(AUDIO_DIR); } catch (Exception ignored) {}
|
try { Files.createDirectories(AUDIO_DIR); } catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +86,62 @@ public class NoteController {
|
|||||||
}).subscribeOn(Schedulers.boundedElastic());
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두서없는 메모를 LLM(PARA+GTD)으로 정리·분류한다. save=true면 저장까지 수행한다(API 원샷).
|
||||||
|
*/
|
||||||
|
@PostMapping("/organize")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> organize(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
String content = str(body.get("content"));
|
||||||
|
boolean save = Boolean.TRUE.equals(body.get("save"))
|
||||||
|
|| "true".equalsIgnoreCase(String.valueOf(body.get("save")));
|
||||||
|
com.sundol.service.MemoService.MemoDraft draft = memoService.organize(userId, content);
|
||||||
|
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||||
|
result.put("category", draft.category());
|
||||||
|
result.put("title", draft.title());
|
||||||
|
result.put("summary", draft.summary());
|
||||||
|
result.put("content", draft.content());
|
||||||
|
result.put("tags", draft.tags());
|
||||||
|
result.put("project", draft.project());
|
||||||
|
if (save) {
|
||||||
|
String id = memoService.saveMemo(userId, draft.category(), draft.title(),
|
||||||
|
draft.summary(), draft.content(), draft.tags(), draft.project());
|
||||||
|
result.put("id", id);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정리된 메모를 저장한다(save_memo). GUI가 미리보기를 수정 후 호출하거나 외부에서 직접 호출.
|
||||||
|
*/
|
||||||
|
@PostMapping("/memo")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> saveMemo(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
String category = str(body.get("category"));
|
||||||
|
String title = str(body.get("title"));
|
||||||
|
String summary = str(body.get("summary"));
|
||||||
|
String content = str(body.get("content"));
|
||||||
|
String project = body.get("project") != null ? str(body.get("project")) : null;
|
||||||
|
List<String> tags = new ArrayList<>();
|
||||||
|
if (body.get("tags") instanceof List<?> list) {
|
||||||
|
for (Object o : list) {
|
||||||
|
if (o != null && !o.toString().isBlank()) tags.add(o.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String id = memoService.saveMemo(userId, category, title, summary, content, tags, project);
|
||||||
|
return ResponseEntity.ok(Map.<String, Object>of("id", id));
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? "" : o.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
public Mono<ResponseEntity<Map<String, Object>>> update(
|
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
|
|||||||
@@ -39,6 +39,59 @@ public class NoteRepository {
|
|||||||
return (String) result.get(0).get("ID");
|
return (String) result.get(0).get("ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정리된 메모를 저장한다. project 컬럼을 포함하며 note_type은 TEXT로 저장한다.
|
||||||
|
*/
|
||||||
|
public String insertMemo(String userId, String title, String content, String categoryId, String project) {
|
||||||
|
if (categoryId != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO notes (id, user_id, title, content, note_type, category_id, project, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, 'TEXT', HEXTORAW(?), ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
new Object[]{userId, title, content, categoryId, project},
|
||||||
|
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO notes (id, user_id, title, content, note_type, project, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, 'TEXT', ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
new Object[]{userId, title, content, project},
|
||||||
|
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var result = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id FROM notes WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return (String) result.get(0).get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 노트에 태그를 추가한다. (note_id, tag) 중복이면 무시한다.
|
||||||
|
*/
|
||||||
|
public void addTag(String noteId, String tag) {
|
||||||
|
Integer cnt = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM note_tags WHERE note_id = HEXTORAW(?) AND tag = ?",
|
||||||
|
Integer.class, noteId, tag
|
||||||
|
);
|
||||||
|
if (cnt == null || cnt == 0) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO note_tags (note_id, tag) VALUES (HEXTORAW(?), ?)",
|
||||||
|
noteId, tag
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 노트의 태그 목록을 조회한다.
|
||||||
|
*/
|
||||||
|
public List<String> findTags(String noteId) {
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
"SELECT tag FROM note_tags WHERE note_id = HEXTORAW(?) ORDER BY tag",
|
||||||
|
(rs, rowNum) -> rs.getString("tag"),
|
||||||
|
noteId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Map<String, Object>> list(String userId, String categoryId) {
|
public List<Map<String, Object>> list(String userId, String categoryId) {
|
||||||
if (categoryId != null && !categoryId.isBlank()) {
|
if (categoryId != null && !categoryId.isBlank()) {
|
||||||
return jdbcTemplate.queryForList(
|
return jdbcTemplate.queryForList(
|
||||||
|
|||||||
172
sundol-backend/src/main/java/com/sundol/service/MemoService.java
Normal file
172
sundol-backend/src/main/java/com/sundol/service/MemoService.java
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sundol.repository.CategoryRepository;
|
||||||
|
import com.sundol.repository.NoteRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두서없는 텍스트 메모를 PARA+GTD 기준으로 LLM이 정리·분류하고, "save_memo" tool 형태로 저장한다.
|
||||||
|
* 추출(organize)과 저장(saveMemo)을 분리하여 GUI 미리보기/외부 API/향후 MCP 노출에 공통 사용한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MemoService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MemoService.class);
|
||||||
|
|
||||||
|
private final OciGenAiService genAiService;
|
||||||
|
private final NoteRepository noteRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public MemoService(OciGenAiService genAiService, NoteRepository noteRepository,
|
||||||
|
CategoryRepository categoryRepository) {
|
||||||
|
this.genAiService = genAiService;
|
||||||
|
this.noteRepository = noteRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정리·분류 결과. save_memo tool의 인자와 1:1 대응한다.
|
||||||
|
*/
|
||||||
|
public record MemoDraft(String category, String title, String summary,
|
||||||
|
String content, List<String> tags, String project) {}
|
||||||
|
|
||||||
|
private static final String ORGANIZE_SYSTEM = """
|
||||||
|
당신은 PARA + GTD 기반 메모 정리·분류 전문가입니다.
|
||||||
|
사용자가 두서없이 적은 메모를 읽고, 아래 JSON 객체 "하나만" 출력하세요. 다른 설명/코드펜스 없이 JSON만.
|
||||||
|
|
||||||
|
## 필드
|
||||||
|
- category: 반드시 "Projects", "Areas", "Resources", "Archives" 중 하나로 시작하는 경로(예: "Projects/사이트리뉴얼").
|
||||||
|
(Projects=마감·목표 있는 진행 과제, Areas=지속 책임영역, Resources=참고자료·관심사, Archives=완료·보관)
|
||||||
|
가능하면 아래 "기존 카테고리" 중 적합한 것을 그대로 재사용하세요.
|
||||||
|
- title: 12자 내외의 짧은 제목.
|
||||||
|
- summary: 핵심 요약. 불릿 2~3줄(Markdown).
|
||||||
|
- content: 구조화·교정된 본문(Markdown). 소제목·불릿으로 정리하되 원문의 정보를 빠짐없이 보존하세요.
|
||||||
|
원문에 없는 내용을 추가하거나 추측하지 마세요. 정보를 임의로 삭제하지 마세요.
|
||||||
|
- tags: 검색용 태그 배열. 실행이 필요한 메모면 GTD 액션 태그(next-action, waiting, someday, reference)를 함께 포함하세요.
|
||||||
|
- project: 특정 프로젝트에 속하면 프로젝트명, 아니면 null.
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
1. 원문과 같은 언어로 작성하세요.
|
||||||
|
2. category는 위 4개 루트 중 하나로 반드시 시작해야 합니다.
|
||||||
|
3. 반드시 유효한 JSON 하나만 출력하세요.
|
||||||
|
|
||||||
|
## 기존 카테고리
|
||||||
|
%s
|
||||||
|
""";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메모를 LLM으로 정리·분류하여 구조화된 초안을 반환한다. (DB 저장 안 함)
|
||||||
|
*/
|
||||||
|
public MemoDraft organize(String userId, String rawText) throws Exception {
|
||||||
|
if (rawText == null || rawText.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("정리할 메모 내용이 비어 있습니다.");
|
||||||
|
}
|
||||||
|
if (!genAiService.isConfigured()) {
|
||||||
|
throw new IllegalStateException("OCI GenAI is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> existingCats = new ArrayList<>();
|
||||||
|
for (Map<String, Object> c : categoryRepository.findAllByUser(userId)) {
|
||||||
|
Object fp = c.get("FULL_PATH");
|
||||||
|
if (fp != null && !fp.toString().isBlank()) existingCats.add(fp.toString());
|
||||||
|
}
|
||||||
|
String catList = existingCats.isEmpty() ? "(없음 - 새로 만드세요)" : String.join("\n", existingCats);
|
||||||
|
|
||||||
|
String systemMsg = String.format(ORGANIZE_SYSTEM, catList);
|
||||||
|
String llm = genAiService.chat(systemMsg, "아래 메모를 정리·분류하세요:\n\n" + rawText, null);
|
||||||
|
|
||||||
|
String json = extractJson(llm);
|
||||||
|
JsonNode n;
|
||||||
|
try {
|
||||||
|
n = objectMapper.readTree(json);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("메모 정리 JSON 파싱 실패. 원문 응답: {}", llm.substring(0, Math.min(500, llm.length())));
|
||||||
|
throw new IllegalStateException("LLM 응답을 JSON으로 파싱하지 못했습니다: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
String category = n.path("category").asText("").strip();
|
||||||
|
if (category.isBlank()) {
|
||||||
|
throw new IllegalStateException("LLM이 category를 반환하지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> tags = new ArrayList<>();
|
||||||
|
JsonNode tagsNode = n.path("tags");
|
||||||
|
if (tagsNode.isArray()) {
|
||||||
|
for (JsonNode t : tagsNode) {
|
||||||
|
String tag = t.asText("").strip();
|
||||||
|
if (!tag.isBlank()) tags.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode projNode = n.path("project");
|
||||||
|
String project = (projNode.isNull() || projNode.isMissingNode()) ? null : projNode.asText(null);
|
||||||
|
if (project != null && project.isBlank()) project = null;
|
||||||
|
|
||||||
|
return new MemoDraft(
|
||||||
|
category,
|
||||||
|
n.path("title").asText("").strip(),
|
||||||
|
n.path("summary").asText("").strip(),
|
||||||
|
n.path("content").asText("").strip(),
|
||||||
|
tags,
|
||||||
|
project
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정리된 메모를 저장한다(save_memo tool 본체). 카테고리 경로를 확보하고 노트+태그를 적재한다.
|
||||||
|
* @return 생성된 노트 id
|
||||||
|
*/
|
||||||
|
public String saveMemo(String userId, String category, String title, String summary,
|
||||||
|
String content, List<String> tags, String project) {
|
||||||
|
if (category == null || category.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("category는 필수입니다.");
|
||||||
|
}
|
||||||
|
String categoryId = categoryRepository.findOrCreate(userId, category);
|
||||||
|
|
||||||
|
String safeSummary = summary == null ? "" : summary;
|
||||||
|
String safeContent = content == null ? "" : content;
|
||||||
|
String body = "# 요약\n\n" + safeSummary + "\n\n---\n\n# 본문\n\n" + safeContent
|
||||||
|
+ "\n\n---\n\n*🤖 정리·분류: " + genAiService.getDefaultModel() + "*";
|
||||||
|
|
||||||
|
String noteTitle = (title == null || title.isBlank()) ? "제목 없는 메모" : title;
|
||||||
|
String noteId = noteRepository.insertMemo(userId, noteTitle, body, categoryId, project);
|
||||||
|
|
||||||
|
if (tags != null) {
|
||||||
|
for (String tag : tags) {
|
||||||
|
if (tag != null && !tag.isBlank()) {
|
||||||
|
noteRepository.addTag(noteId, tag.strip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Memo saved: note={} category={} tags={} project={}", noteId, category, tags, project);
|
||||||
|
return noteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 응답에서 JSON 객체 부분만 추출한다. 코드펜스(```)와 앞뒤 잡텍스트를 제거한다.
|
||||||
|
*/
|
||||||
|
private String extractJson(String s) {
|
||||||
|
String t = s.strip();
|
||||||
|
if (t.startsWith("```")) {
|
||||||
|
int firstNl = t.indexOf('\n');
|
||||||
|
if (firstNl >= 0) t = t.substring(firstNl + 1);
|
||||||
|
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
|
||||||
|
t = t.strip();
|
||||||
|
}
|
||||||
|
int a = t.indexOf('{');
|
||||||
|
int b = t.lastIndexOf('}');
|
||||||
|
if (a >= 0 && b > a) {
|
||||||
|
return t.substring(a, b + 1);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@ function NewNotePage() {
|
|||||||
const [transcription, setTranscription] = useState("");
|
const [transcription, setTranscription] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// AI 정리(PARA+GTD) 상태
|
||||||
|
const [organizing, setOrganizing] = useState(false);
|
||||||
|
const [hasDraft, setHasDraft] = useState(false);
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [project, setProject] = useState("");
|
||||||
|
const [tagsInput, setTagsInput] = useState("");
|
||||||
|
const [summary, setSummary] = useState("");
|
||||||
|
const [organizedContent, setOrganizedContent] = useState("");
|
||||||
|
|
||||||
const handleSaveText = async () => {
|
const handleSaveText = async () => {
|
||||||
if (!title.trim() && !content.trim()) return;
|
if (!title.trim() && !content.trim()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -42,6 +51,55 @@ function NewNotePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOrganize = async () => {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
setOrganizing(true);
|
||||||
|
try {
|
||||||
|
const draft = await request<{
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
project: string | null;
|
||||||
|
}>({ method: "POST", url: "/api/notes/organize", data: { content } });
|
||||||
|
setTitle(draft.title || "");
|
||||||
|
setCategory(draft.category || "");
|
||||||
|
setProject(draft.project || "");
|
||||||
|
setTagsInput((draft.tags || []).join(", "));
|
||||||
|
setSummary(draft.summary || "");
|
||||||
|
setOrganizedContent(draft.content || "");
|
||||||
|
setHasDraft(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to organize memo:", err);
|
||||||
|
alert("메모 정리에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setOrganizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveMemo = async () => {
|
||||||
|
if (!category.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const tags = tagsInput
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const res = await request<{ id: string }>({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/notes/memo",
|
||||||
|
data: { category, title, summary, content: organizedContent, tags, project: project || null },
|
||||||
|
});
|
||||||
|
router.push(`/notes/${res.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save memo:", err);
|
||||||
|
alert("정리본 저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUploadAudio = async () => {
|
const handleUploadAudio = async () => {
|
||||||
if (!audioFile) return;
|
if (!audioFile) return;
|
||||||
setTranscribing(true);
|
setTranscribing(true);
|
||||||
@@ -146,17 +204,94 @@ function NewNotePage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
placeholder="노트 내용을 입력하세요..."
|
placeholder="두서없이 적어도 됩니다. 'AI로 정리'를 누르면 카테고리·제목·태그·요약으로 정리해 드립니다..."
|
||||||
rows={15}
|
rows={12}
|
||||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="mt-4 flex gap-2 flex-wrap">
|
||||||
onClick={handleSaveText}
|
<button
|
||||||
disabled={saving || (!title.trim() && !content.trim())}
|
onClick={handleOrganize}
|
||||||
className="mt-4 px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
|
disabled={organizing || !content.trim()}
|
||||||
>
|
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
|
||||||
{saving ? "저장 중..." : "저장"}
|
>
|
||||||
</button>
|
{organizing ? "정리 중..." : "✨ AI로 정리"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveText}
|
||||||
|
disabled={saving || (!title.trim() && !content.trim())}
|
||||||
|
className="px-6 py-2 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? "저장 중..." : "정리 없이 저장"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasDraft && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--color-border)] space-y-4">
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-primary)]">
|
||||||
|
✨ 정리 결과 — 확인·수정 후 저장하세요
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
|
||||||
|
카테고리 (PARA 경로)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder="예: Projects/사이트리뉴얼"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--color-text-muted)] mb-1">프로젝트</label>
|
||||||
|
<input
|
||||||
|
value={project}
|
||||||
|
onChange={(e) => setProject(e.target.value)}
|
||||||
|
placeholder="없으면 비워두세요"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
|
||||||
|
태그 (쉼표로 구분)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
placeholder="예: 예산안, next-action, 회의"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--color-text-muted)] mb-1">요약</label>
|
||||||
|
<textarea
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
|
||||||
|
정리된 본문 (Markdown)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={organizedContent}
|
||||||
|
onChange={(e) => setOrganizedContent(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveMemo}
|
||||||
|
disabled={saving || !category.trim()}
|
||||||
|
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? "저장 중..." : "정리본 저장"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user