feat(memos): 내장 메모 기능 — 파일 없는 문서(file_type='note')
Document Server에 Memos 앱 대체 기능 내장. 메모를 documents 테이블의 file_type='note' 레코드로 관리하여 기존 AI 파이프라인(classify/embed/ chunk/search/ask) 재활용. Backend: - migration 105: source_channel 'memo', file_path NULL 허용, user_tags/pinned/ask_includable 컬럼, 메모 인덱스 - api/memos.py: CRUD 7개 엔드포인트 + #태그 파싱 + stale AI 초기화 + 큐 pending 중복 방지 - queue_consumer: note extract/preview skip - documents API: file_path NULL 가드, 목록에서 메모 제외 - search /ask: ask_includable=false 문서 evidence 제외 Frontend: - /memos 타임라인 페이지 (빠른 입력 + 피드 + 인라인 편집 + 태그 필터) - QuickMemoButton FAB (Ctrl+M, 모든 페이지) - Sidebar 메모 링크 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+18
-3
@@ -27,7 +27,7 @@ router = APIRouter()
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: int
|
||||
file_path: str
|
||||
file_path: str | None
|
||||
file_format: str
|
||||
file_size: int | None
|
||||
file_type: str
|
||||
@@ -40,6 +40,9 @@ class DocumentResponse(BaseModel):
|
||||
importance: str | None
|
||||
ai_confidence: float | None
|
||||
user_note: str | None
|
||||
user_tags: list | None
|
||||
pinned: bool | None
|
||||
ask_includable: bool | None
|
||||
derived_path: str | None
|
||||
original_format: str | None
|
||||
conversion_status: str | None
|
||||
@@ -149,8 +152,12 @@ async def list_documents(
|
||||
format: str | None = None,
|
||||
review_status: str | None = Query(None, description="pending | approved | rejected"),
|
||||
):
|
||||
"""문서 목록 조회 (페이지네이션 + 필터, 뉴스 제외)"""
|
||||
query = select(Document).where(Document.deleted_at == None, Document.source_channel != "news")
|
||||
"""문서 목록 조회 (페이지네이션 + 필터, 뉴스/메모 제외)"""
|
||||
query = select(Document).where(
|
||||
Document.deleted_at == None, # noqa: E711
|
||||
Document.source_channel != "news",
|
||||
Document.file_type != "note",
|
||||
)
|
||||
|
||||
if domain:
|
||||
# prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함
|
||||
@@ -216,6 +223,10 @@ async def get_document_file(
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
# note(메모)는 물리 파일이 없음
|
||||
if not doc.file_path:
|
||||
raise HTTPException(status_code=404, detail="파일이 없는 문서입니다 (메모)")
|
||||
|
||||
file_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
@@ -340,6 +351,10 @@ async def save_document_content(
|
||||
if doc.file_format not in ("md", "txt"):
|
||||
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
|
||||
|
||||
# note(메모)는 /api/memos/{id} PATCH로 수정
|
||||
if not doc.file_path:
|
||||
raise HTTPException(status_code=400, detail="파일이 없는 문서입니다. 메모는 /api/memos 사용")
|
||||
|
||||
content = body.get("content", "") if body else ""
|
||||
file_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""메모 CRUD API — 파일 없는 문서(file_type='note')"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# #태그 파싱 패턴: 한글/영문/숫자/밑줄, 2자 이상
|
||||
TAG_PATTERN = re.compile(r"(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})")
|
||||
|
||||
|
||||
def _parse_hashtags(content: str) -> list[str]:
|
||||
"""본문에서 #태그 추출, 중복 제거, 순서 유지"""
|
||||
seen: set[str] = set()
|
||||
tags: list[str] = []
|
||||
for m in TAG_PATTERN.finditer(content):
|
||||
tag = m.group(1)
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
def _content_hash(content: str) -> str:
|
||||
"""메모 본문의 SHA-256 해시 (note의 file_hash = content hash)"""
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
|
||||
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
|
||||
stages = ["classify", "embed", "chunk"]
|
||||
# 기존 pending 건 삭제 (processing은 건드리지 않음 — worker 충돌 방지)
|
||||
await session.execute(
|
||||
delete(ProcessingQueue).where(
|
||||
ProcessingQueue.document_id == document_id,
|
||||
ProcessingQueue.stage.in_(stages),
|
||||
ProcessingQueue.status == "pending",
|
||||
)
|
||||
)
|
||||
for stage in stages:
|
||||
session.add(ProcessingQueue(
|
||||
document_id=document_id,
|
||||
stage=stage,
|
||||
status="pending",
|
||||
))
|
||||
|
||||
|
||||
# ─── 스키마 ───
|
||||
|
||||
|
||||
class MemoCreate(BaseModel):
|
||||
content: str
|
||||
title: str | None = None
|
||||
ask_includable: bool = True
|
||||
|
||||
|
||||
class MemoUpdate(BaseModel):
|
||||
content: str | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class MemoResponse(BaseModel):
|
||||
id: int
|
||||
title: str | None
|
||||
content: str | None # extracted_text
|
||||
file_format: str
|
||||
user_tags: list | None
|
||||
ai_tags: list | None
|
||||
ai_domain: str | None
|
||||
ai_sub_group: str | None
|
||||
ai_summary: str | None
|
||||
pinned: bool
|
||||
ask_includable: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MemoListResponse(BaseModel):
|
||||
items: list[MemoResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
def _to_memo_response(doc: Document) -> MemoResponse:
|
||||
return MemoResponse(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
content=doc.extracted_text,
|
||||
file_format=doc.file_format,
|
||||
user_tags=doc.user_tags,
|
||||
ai_tags=doc.ai_tags,
|
||||
ai_domain=doc.ai_domain,
|
||||
ai_sub_group=doc.ai_sub_group,
|
||||
ai_summary=doc.ai_summary,
|
||||
pinned=doc.pinned,
|
||||
ask_includable=doc.ask_includable,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ─── 엔드포인트 ───
|
||||
|
||||
|
||||
@router.post("/", response_model=MemoResponse, status_code=201)
|
||||
async def create_memo(
|
||||
body: MemoCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 생성 — file_type='note', 파일 없는 문서"""
|
||||
content = body.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
|
||||
|
||||
# 제목: 명시 지정 또는 첫 줄에서 추출
|
||||
title = body.title
|
||||
if not title:
|
||||
first_line = content.split("\n", 1)[0].strip()
|
||||
# 마크다운 헤딩 제거
|
||||
title = re.sub(r"^#+\s*", "", first_line)[:100] or "메모"
|
||||
|
||||
user_tags = _parse_hashtags(content)
|
||||
|
||||
doc = Document(
|
||||
file_path=None,
|
||||
file_hash=_content_hash(content),
|
||||
file_format="md",
|
||||
file_size=len(content.encode("utf-8")),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=content,
|
||||
review_status="approved",
|
||||
source_channel="memo",
|
||||
user_tags=user_tags,
|
||||
pinned=False,
|
||||
ask_includable=body.ask_includable,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush() # ID 확보
|
||||
|
||||
await _enqueue_ai_stages(session, doc.id)
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
@router.get("/", response_model=MemoListResponse)
|
||||
async def list_memos(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"),
|
||||
):
|
||||
"""메모 목록 — 핀 우선 + 최신순"""
|
||||
base = select(Document).where(
|
||||
Document.file_type == "note",
|
||||
Document.deleted_at == None, # noqa: E711
|
||||
)
|
||||
|
||||
if tag:
|
||||
# user_tags 또는 ai_tags에 포함된 태그 필터
|
||||
base = base.where(
|
||||
Document.user_tags.op("@>")(f'["{tag}"]')
|
||||
| Document.ai_tags.op("@>")(f'["{tag}"]')
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(base.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = base.order_by(
|
||||
Document.pinned.desc(),
|
||||
Document.created_at.desc(),
|
||||
).offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return MemoListResponse(
|
||||
items=[_to_memo_response(doc) for doc in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{memo_id}", response_model=MemoResponse)
|
||||
async def get_memo(
|
||||
memo_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 단건 조회"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
@router.patch("/{memo_id}", response_model=MemoResponse)
|
||||
async def update_memo(
|
||||
memo_id: int,
|
||||
body: MemoUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 수정 — content 변경 시 AI 데이터 초기화 + 재처리 큐 등록"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
if body.title is not None:
|
||||
doc.title = body.title
|
||||
|
||||
if body.content is not None:
|
||||
content = body.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
|
||||
|
||||
doc.extracted_text = content
|
||||
doc.file_hash = _content_hash(content)
|
||||
doc.file_size = len(content.encode("utf-8"))
|
||||
doc.user_tags = _parse_hashtags(content)
|
||||
|
||||
# stale AI 데이터 즉시 초기화
|
||||
doc.ai_summary = None
|
||||
doc.ai_domain = None
|
||||
doc.ai_sub_group = None
|
||||
doc.ai_tags = None
|
||||
doc.ai_confidence = None
|
||||
doc.ai_processed_at = None
|
||||
doc.embedding = None
|
||||
doc.embedded_at = None
|
||||
|
||||
# 기존 chunks 삭제
|
||||
from models.chunk import DocumentChunk
|
||||
await session.execute(
|
||||
delete(DocumentChunk).where(DocumentChunk.document_id == memo_id)
|
||||
)
|
||||
|
||||
# 재처리 큐 등록 (pending만 정리, processing은 건드리지 않음)
|
||||
await _enqueue_ai_stages(session, memo_id)
|
||||
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
@router.delete("/{memo_id}", status_code=204)
|
||||
async def delete_memo(
|
||||
memo_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 soft delete"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
doc.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.patch("/{memo_id}/pin", response_model=MemoResponse)
|
||||
async def toggle_pin(
|
||||
memo_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 핀 토글"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
doc.pinned = not doc.pinned
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
@router.patch("/{memo_id}/ask-includable", response_model=MemoResponse)
|
||||
async def toggle_ask_includable(
|
||||
memo_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""/ask 합성 포함 여부 토글"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
doc.ask_includable = not doc.ask_includable
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
|
||||
return _to_memo_response(doc)
|
||||
+20
-1
@@ -389,9 +389,28 @@ async def ask(
|
||||
fusion=DEFAULT_FUSION, rerank=True, analyze=True,
|
||||
)
|
||||
|
||||
# 1.5. ask_includable=false 문서를 evidence 입력에서 제외
|
||||
# 검색 결과 자체는 유지 (사용자에게 보여줌), evidence만 필터
|
||||
if pr.results:
|
||||
from sqlalchemy import select as sa_select
|
||||
from models.document import Document as DocModel
|
||||
ask_doc_ids = set()
|
||||
excluded_ids = {r.id for r in pr.results}
|
||||
rows = await session.execute(
|
||||
sa_select(DocModel.id, DocModel.ask_includable).where(
|
||||
DocModel.id.in_(excluded_ids)
|
||||
)
|
||||
)
|
||||
for doc_id, includable in rows:
|
||||
if includable is False:
|
||||
ask_doc_ids.add(doc_id)
|
||||
evidence_results = [r for r in pr.results if r.id not in ask_doc_ids]
|
||||
else:
|
||||
evidence_results = pr.results
|
||||
|
||||
# 2. Evidence + Classifier 병렬
|
||||
t_ev = time.perf_counter()
|
||||
evidence_task = asyncio.create_task(extract_evidence(q, pr.results))
|
||||
evidence_task = asyncio.create_task(extract_evidence(q, evidence_results))
|
||||
|
||||
# classifier input: top 3 chunks meta + rerank scores
|
||||
top_chunks = [
|
||||
|
||||
@@ -10,6 +10,7 @@ from api.auth import router as auth_router
|
||||
from api.dashboard import router as dashboard_router
|
||||
from api.digest import router as digest_router
|
||||
from api.documents import router as documents_router
|
||||
from api.memos import router as memos_router
|
||||
from api.news import router as news_router
|
||||
from api.search import router as search_router
|
||||
from api.setup import router as setup_router
|
||||
@@ -89,6 +90,7 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
|
||||
app.include_router(search_router, prefix="/api/search", tags=["search"])
|
||||
|
||||
app.include_router(memos_router, prefix="/api/memos", tags=["memos"])
|
||||
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
|
||||
app.include_router(news_router, prefix="/api/news", tags=["news"])
|
||||
app.include_router(digest_router, prefix="/api/digest", tags=["digest"])
|
||||
|
||||
+13
-2
@@ -7,6 +7,8 @@ from sqlalchemy import BigInteger, Boolean, DateTime, Enum, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
# Note: file_type='note' (메모) 문서는 file_path=NULL, file_hash=content SHA-256
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
@@ -16,7 +18,7 @@ class Document(Base):
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
|
||||
# 1계층: 원본 파일
|
||||
file_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
file_format: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
file_size: Mapped[int | None] = mapped_column(BigInteger)
|
||||
@@ -50,6 +52,15 @@ class Document(Base):
|
||||
# 사용자 메모
|
||||
user_note: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력)
|
||||
user_tags: Mapped[list | None] = mapped_column(JSONB, default=[])
|
||||
|
||||
# 핀 고정
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외)
|
||||
ask_includable: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# ODF 변환
|
||||
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
|
||||
original_format: Mapped[str | None] = mapped_column(String(20))
|
||||
@@ -73,7 +84,7 @@ class Document(Base):
|
||||
# 메타데이터
|
||||
source_channel: Mapped[str | None] = mapped_column(
|
||||
Enum("law_monitor", "devonagent", "email", "web_clip",
|
||||
"tksafety", "inbox_route", "manual", "drive_sync", "news",
|
||||
"tksafety", "inbox_route", "manual", "drive_sync", "news", "memo",
|
||||
name="source_channel")
|
||||
)
|
||||
data_origin: Mapped[str | None] = mapped_column(
|
||||
|
||||
@@ -109,6 +109,22 @@ async def consume_queue():
|
||||
|
||||
# 워커 실행 (독립 세션)
|
||||
try:
|
||||
# note(메모)는 이미 extracted_text가 있으므로 extract/preview skip
|
||||
if stage in ("extract", "preview"):
|
||||
from models.document import Document
|
||||
async with async_session() as check_session:
|
||||
doc = await check_session.get(Document, document_id)
|
||||
if doc and doc.file_type == "note":
|
||||
async with async_session() as skip_session:
|
||||
item = await skip_session.get(ProcessingQueue, queue_id)
|
||||
if item:
|
||||
item.status = "completed"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await skip_session.commit()
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
logger.info(f"[{stage}] document_id={document_id} skip (note)")
|
||||
continue
|
||||
|
||||
async with async_session() as worker_session:
|
||||
await worker_fn(document_id, worker_session)
|
||||
await worker_session.commit()
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script>
|
||||
import { StickyNote, X, Send } from 'lucide-svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let content = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
if (open) {
|
||||
// 다음 틱에 textarea에 포커스
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector('[data-quick-memo-input]')?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const text = content.trim();
|
||||
if (!text) return;
|
||||
submitting = true;
|
||||
try {
|
||||
await api('/memos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: text }),
|
||||
});
|
||||
content = '';
|
||||
open = false;
|
||||
addToast('success', '메모 생성됨');
|
||||
} catch (err) {
|
||||
addToast('error', '메모 생성 실패');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+M 글로벌 단축키
|
||||
function handleGlobalKeydown(e) {
|
||||
if (e.key === 'm' && (e.ctrlKey || e.metaKey) && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleGlobalKeydown} />
|
||||
|
||||
<!-- FAB 버튼 -->
|
||||
{#if !open}
|
||||
<button
|
||||
onclick={toggle}
|
||||
class="fixed bottom-6 right-6 z-40 w-12 h-12 rounded-full bg-accent text-white shadow-lg
|
||||
flex items-center justify-center hover:bg-accent-hover transition-colors
|
||||
focus-visible:ring-2 focus-visible:ring-accent-ring focus-visible:outline-none"
|
||||
title="빠른 메모 (Ctrl+M)"
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- 빠른 메모 패널 -->
|
||||
{#if open}
|
||||
<!-- 배경 오버레이 -->
|
||||
<button
|
||||
onclick={close}
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
aria-label="닫기"
|
||||
></button>
|
||||
|
||||
<!-- 패널 -->
|
||||
<div class="fixed bottom-6 right-6 z-50 w-80 bg-surface border border-default rounded-xl shadow-2xl">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-default">
|
||||
<span class="text-sm font-semibold text-text">빠른 메모</span>
|
||||
<button onclick={close} class="p-1 rounded text-dim hover:text-text hover:bg-surface-hover transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<textarea
|
||||
data-quick-memo-input
|
||||
bind:value={content}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="메모 입력... (Ctrl+Enter)"
|
||||
class="w-full h-28 bg-bg border border-default rounded-md px-3 py-2 text-sm text-text
|
||||
resize-none outline-none focus:border-accent placeholder:text-dim"
|
||||
></textarea>
|
||||
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<span class="text-[11px] text-dim">#태그 지원</span>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onclick={() => { close(); goto('/memos'); }}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={Send}
|
||||
loading={submitting}
|
||||
disabled={!content.trim()}
|
||||
onclick={submit}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale } from 'lucide-svelte';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
|
||||
|
||||
let tree = $state([]);
|
||||
let loading = $state(true);
|
||||
@@ -190,8 +190,18 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inbox -->
|
||||
<!-- 메모 & Inbox -->
|
||||
<div class="px-2 py-2 border-t border-default">
|
||||
<a
|
||||
href="/memos"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{$page.url.pathname === '/memos' ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<StickyNote size={16} />
|
||||
메모
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/inbox"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-text hover:bg-surface transition-colors"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
|
||||
import QuickMemoButton from '$lib/components/QuickMemoButton.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
@@ -100,6 +101,9 @@
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 메모 FAB (모든 페이지) -->
|
||||
<QuickMemoButton />
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
// #태그를 클릭 가능한 링크로 변환
|
||||
const withTags = text.replace(
|
||||
/(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})/g,
|
||||
'<a href="/memos?tag=$1" class="text-accent hover:underline">#$1</a>'
|
||||
);
|
||||
return DOMPurify.sanitize(marked(withTags), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
}
|
||||
|
||||
let memos = $state([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let loading = $state(true);
|
||||
let creating = $state(false);
|
||||
|
||||
// 빠른 입력
|
||||
let newContent = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
// 인라인 편집
|
||||
let editingId = $state(null);
|
||||
let editContent = $state('');
|
||||
let editTitle = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
// 태그 필터
|
||||
let activeTag = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
activeTag = params.get('tag');
|
||||
loadMemos();
|
||||
});
|
||||
|
||||
async function loadMemos() {
|
||||
loading = true;
|
||||
try {
|
||||
const tagParam = activeTag ? `&tag=${encodeURIComponent(activeTag)}` : '';
|
||||
const res = await api(`/memos?page=${page}&page_size=20${tagParam}`);
|
||||
memos = res.items;
|
||||
total = res.total;
|
||||
} catch (err) {
|
||||
addToast('error', '메모 로딩 실패');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createMemo() {
|
||||
const content = newContent.trim();
|
||||
if (!content) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const memo = await api('/memos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
memos = [memo, ...memos];
|
||||
total += 1;
|
||||
newContent = '';
|
||||
addToast('success', '메모 생성됨');
|
||||
} catch (err) {
|
||||
addToast('error', '메모 생성 실패');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(memo) {
|
||||
editingId = memo.id;
|
||||
editContent = memo.content || '';
|
||||
editTitle = memo.title || '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editContent = '';
|
||||
editTitle = '';
|
||||
}
|
||||
|
||||
async function saveEdit(memoId) {
|
||||
saving = true;
|
||||
try {
|
||||
const updated = await api(`/memos/${memoId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ content: editContent, title: editTitle }),
|
||||
});
|
||||
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
||||
editingId = null;
|
||||
addToast('success', '메모 수정됨');
|
||||
} catch (err) {
|
||||
addToast('error', '메모 수정 실패');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMemo(memoId) {
|
||||
if (!confirm('이 메모를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api(`/memos/${memoId}`, { method: 'DELETE' });
|
||||
memos = memos.filter((m) => m.id !== memoId);
|
||||
total -= 1;
|
||||
addToast('success', '메모 삭제됨');
|
||||
} catch (err) {
|
||||
addToast('error', '메모 삭제 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePin(memoId) {
|
||||
try {
|
||||
const updated = await api(`/memos/${memoId}/pin`, { method: 'PATCH' });
|
||||
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
||||
// 핀 변경 시 정렬 재적용
|
||||
memos = [...memos].sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return b.pinned ? 1 : -1;
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
} catch (err) {
|
||||
addToast('error', '핀 변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAskIncludable(memoId) {
|
||||
try {
|
||||
const updated = await api(`/memos/${memoId}/ask-includable`, { method: 'PATCH' });
|
||||
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
||||
} catch (err) {
|
||||
addToast('error', 'AI 포함 설정 변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function clearTag() {
|
||||
activeTag = null;
|
||||
history.replaceState(null, '', '/memos');
|
||||
loadMemos();
|
||||
}
|
||||
|
||||
function filterByTag(tag) {
|
||||
activeTag = tag;
|
||||
page = 1;
|
||||
history.replaceState(null, '', `/memos?tag=${encodeURIComponent(tag)}`);
|
||||
loadMemos();
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
createMemo();
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - d;
|
||||
if (diff < 60000) return '방금';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}시간 전`;
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}일 전`;
|
||||
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>메모 — Document Server</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-text">메모</h1>
|
||||
{#if total > 0}
|
||||
<span class="text-sm text-dim">{total}개</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 태그 필터 표시 -->
|
||||
{#if activeTag}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-sm text-dim">필터:</span>
|
||||
<button
|
||||
onclick={clearTag}
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
#{activeTag}
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 빠른 입력 -->
|
||||
<Card class="mb-6">
|
||||
<textarea
|
||||
bind:value={newContent}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="메모 입력... (Ctrl+Enter로 저장, #태그 지원)"
|
||||
class="w-full h-24 bg-transparent text-text text-sm resize-none outline-none placeholder:text-dim"
|
||||
></textarea>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span class="text-xs text-dim">마크다운 지원</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={submitting}
|
||||
disabled={!newContent.trim()}
|
||||
onclick={createMemo}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 메모 피드 -->
|
||||
{#if loading}
|
||||
{#each Array(3) as _}
|
||||
<div class="mb-4">
|
||||
<Skeleton class="h-32 w-full rounded-card" />
|
||||
</div>
|
||||
{/each}
|
||||
{:else if memos.length === 0}
|
||||
<EmptyState>
|
||||
{#if activeTag}
|
||||
<p>#{activeTag} 태그의 메모가 없습니다</p>
|
||||
{:else}
|
||||
<p>아직 메모가 없습니다</p>
|
||||
<p class="text-dim text-sm mt-1">위 입력창에서 첫 메모를 작성해보세요</p>
|
||||
{/if}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each memos as memo (memo.id)}
|
||||
<Card padded={false} class="group relative">
|
||||
<!-- 핀 뱃지 -->
|
||||
{#if memo.pinned}
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="text-xs text-accent"><Pin size={14} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4">
|
||||
{#if editingId === memo.id}
|
||||
<!-- 인라인 편집 모드 -->
|
||||
<textarea
|
||||
bind:value={editContent}
|
||||
class="w-full h-32 bg-bg border border-default rounded-md px-3 py-2 text-sm text-text resize-none outline-none focus:border-accent"
|
||||
></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<Button variant="primary" size="sm" icon={Check} loading={saving} onclick={() => saveEdit(memo.id)}>
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 읽기 모드 -->
|
||||
{#if memo.title}
|
||||
<h3 class="text-sm font-semibold text-text mb-1">{memo.title}</h3>
|
||||
{/if}
|
||||
<div class="prose prose-sm text-text max-w-none memo-content">
|
||||
{@html renderMd(memo.content || '')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 태그 -->
|
||||
{#if (memo.user_tags?.length || memo.ai_tags?.length) && editingId !== memo.id}
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
{#each memo.user_tags || [] as tag}
|
||||
<button
|
||||
onclick={() => filterByTag(tag)}
|
||||
class="px-1.5 py-0.5 rounded text-[11px] bg-accent/10 text-accent hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
{/each}
|
||||
{#each memo.ai_tags || [] as tag}
|
||||
<button
|
||||
onclick={() => filterByTag(tag)}
|
||||
class="px-1.5 py-0.5 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 하단: 시간 + 액션 -->
|
||||
{#if editingId !== memo.id}
|
||||
<div class="flex items-center justify-between mt-3 pt-2 border-t border-default">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-dim">{formatTime(memo.created_at)}</span>
|
||||
{#if memo.ai_domain}
|
||||
<span class="text-xs text-dim/70">| {memo.ai_domain}</span>
|
||||
{/if}
|
||||
{#if !memo.ask_includable}
|
||||
<span class="text-[10px] text-dim/50 flex items-center gap-0.5">
|
||||
<EyeOff size={10} /> AI 제외
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onclick={() => togglePin(memo.id)}
|
||||
class="p-1.5 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
|
||||
title={memo.pinned ? '핀 해제' : '핀 고정'}
|
||||
>
|
||||
{#if memo.pinned}
|
||||
<PinOff size={14} />
|
||||
{:else}
|
||||
<Pin size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => toggleAskIncludable(memo.id)}
|
||||
class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors"
|
||||
title={memo.ask_includable ? 'AI 답변에서 제외' : 'AI 답변에 포함'}
|
||||
>
|
||||
{#if memo.ask_includable}
|
||||
<Eye size={14} />
|
||||
{:else}
|
||||
<EyeOff size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => startEdit(memo)}
|
||||
class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors"
|
||||
title="편집"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteMemo(memo.id)}
|
||||
class="p-1.5 rounded text-dim hover:text-error hover:bg-error/10 transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
{#if total > 20}
|
||||
<div class="flex justify-center gap-2 mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onclick={() => { page -= 1; loadMemos(); }}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<span class="text-sm text-dim self-center">{page} / {Math.ceil(total / 20)}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page * 20 >= total}
|
||||
onclick={() => { page += 1; loadMemos(); }}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.memo-content :global(p) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.memo-content :global(ul), .memo-content :global(ol) {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.memo-content :global(code) {
|
||||
background: var(--bg);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.memo-content :global(pre) {
|
||||
background: var(--bg);
|
||||
padding: 0.75em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.memo-content :global(a) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.memo-content :global(blockquote) {
|
||||
border-left: 3px solid var(--border-default);
|
||||
padding-left: 0.75em;
|
||||
color: var(--text-dim);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 105: 메모(note) 기능 지원
|
||||
-- 메모 = file_type='note'인 document (파일 없는 문서)
|
||||
-- file_hash는 note에서 content SHA-256 (파일 해시가 아닌 본문 버전 해시)
|
||||
|
||||
-- source_channel enum에 'memo' 추가 (유입 경로: 내장 메모 UI)
|
||||
ALTER TYPE source_channel ADD VALUE IF NOT EXISTS 'memo';
|
||||
|
||||
-- file_path: NOT NULL 제거 (메모는 파일 없음)
|
||||
ALTER TABLE documents ALTER COLUMN file_path DROP NOT NULL;
|
||||
|
||||
-- file_path: 기존 UNIQUE → partial unique (NULL 허용, 값 있으면 유니크)
|
||||
DROP INDEX IF EXISTS documents_file_path_key;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_file_path
|
||||
ON documents(file_path) WHERE file_path IS NOT NULL;
|
||||
|
||||
-- user_tags: 사용자 수동 태그 (ai_tags와 분리, list[str])
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_tags JSONB DEFAULT '[]'::jsonb;
|
||||
|
||||
-- pinned: 메모 핀 고정
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS pinned BOOLEAN DEFAULT false;
|
||||
|
||||
-- ask_includable: /ask 합성 포함 여부 (false면 검색은 되지만 /ask evidence에서 제외)
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS ask_includable BOOLEAN DEFAULT true;
|
||||
|
||||
-- 메모 목록 최적화 인덱스 (핀 우선 + 최신순)
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_notes
|
||||
ON documents(pinned DESC, created_at DESC)
|
||||
WHERE file_type = 'note' AND deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user