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:
@@ -28,6 +28,15 @@ function NewNotePage() {
|
||||
const [transcription, setTranscription] = useState("");
|
||||
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 () => {
|
||||
if (!title.trim() && !content.trim()) return;
|
||||
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 () => {
|
||||
if (!audioFile) return;
|
||||
setTranscribing(true);
|
||||
@@ -146,17 +204,94 @@ function NewNotePage() {
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="노트 내용을 입력하세요..."
|
||||
rows={15}
|
||||
placeholder="두서없이 적어도 됩니다. 'AI로 정리'를 누르면 카테고리·제목·태그·요약으로 정리해 드립니다..."
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveText}
|
||||
disabled={saving || (!title.trim() && !content.trim())}
|
||||
className="mt-4 px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</button>
|
||||
<div className="mt-4 flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleOrganize}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user