From 67d6bd85b9674f2cacdd6290b4d3452086f31b42 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Sat, 20 Jun 2026 15:50:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=BC=20=EC=A7=91=EC=A4=91?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=C2=B7LLM=20=ED=94=84=EB=A1=9C=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EB=8D=94=20=ED=99=95=EC=9E=A5(Gemini=C2=B7OpenAI?= =?UTF-8?q?=ED=98=B8=ED=99=98)=C2=B7=ED=8A=B8=EB=A6=AC=EC=95=84=EC=A7=80?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLM: gemini/openai_compat 프로바이더 추가 및 provider/ollama 연동 - 메일: 집중/기타·스팸 정리·트리아지 로그 모달 등 수정 - 설정/스키마/모델/워커·암호화·DB 정비 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/config.py | 8 +- backend/app/connectors/base.py | 5 + backend/app/connectors/mail/real_gmail.py | 5 +- backend/app/connectors/mail/real_outlook.py | 8 +- backend/app/crypto.py | 23 ++ backend/app/db.py | 1 + backend/app/llm/gemini.py | 96 ++++++ backend/app/llm/ollama.py | 4 +- backend/app/llm/openai_compat.py | 108 ++++++ backend/app/llm/provider.py | 32 +- backend/app/main.py | 22 +- backend/app/models.py | 16 + backend/app/routers/mail.py | 184 +++++++--- backend/app/routers/settings.py | 18 +- backend/app/runtime_config.py | 19 +- backend/app/schemas.py | 7 +- backend/app/services/settings_service.py | 10 +- backend/app/worker/sync_jobs.py | 98 +++++- backend/tests/test_settings.py | 55 +++ backend/tests/test_spam_purge.py | 28 ++ backend/tests/test_triage_baseline.py | 134 +++++++- frontend/components/mail/MailClient.tsx | 77 ++--- frontend/components/mail/Triage.tsx | 172 +++++----- frontend/components/mail/TriageLogModal.tsx | 188 +++++++++++ frontend/components/settings/LlmTab.tsx | 257 +++++++++++--- frontend/lib/hooks/useMail.ts | 5 +- frontend/lib/mail/api.ts | 61 +++- frontend/lib/settings/api.ts | 2 +- frontend/lib/types.ts | 10 +- frontend/styles/mail.css | 351 +++++++++++++++----- frontend/styles/settings.css | 18 + 31 files changed, 1657 insertions(+), 365 deletions(-) create mode 100644 backend/app/llm/gemini.py create mode 100644 backend/app/llm/openai_compat.py create mode 100644 backend/tests/test_spam_purge.py create mode 100644 frontend/components/mail/TriageLogModal.tsx diff --git a/backend/app/config.py b/backend/app/config.py index d65d968..3238c29 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,8 +12,10 @@ class Settings(BaseSettings): # LLM — 절대 특정 모델을 하드코딩하지 않는다. 설치된 모델을 env로 주입. ollama_host: str = "http://localhost:11434" ollama_model: str = "llama3.1" # 기본 placeholder. 실제 운영은 .env 로 덮어쓴다. - llm_provider: str = "ollama" # 실 LLM 전용(휴리스틱 폴백 제거, phase-16+) + llm_provider: str = "ollama" # ollama | openai(호환) | gemini llm_timeout: float = 20.0 # 초 + # OpenAI 호환/Gemini 용 API 키(.env 기본값; 설정 페이지 오버레이가 우선·암호화 저장). + llm_api_key: str = "" # LLM_API_KEY # 리스크 계산 기준일 — CONTRACT: TODAY=8 (6/8). 시드가 '6/7~6/12 한 주' 가정. risk_today: int = 8 @@ -54,6 +56,10 @@ class Settings(BaseSettings): # 연결된 real 계정을 증분 sync 한다(별도 워커 프로세스 불필요). auto_sync_enabled: bool = True # AUTO_SYNC_ENABLED auto_sync_interval_seconds: int = 10 # AUTO_SYNC_INTERVAL_SECONDS (기본 10초) + # 새 메일 자동 정리: auto-sync 직후 미분석 집중 메일을 백그라운드 LLM 분석 — + # 사용자가 '정리 맡기기'를 누르지 않아도 아리 정리 카드가 미리 채워진다. + auto_triage_enabled: bool = True # AUTO_TRIAGE_ENABLED + auto_triage_batch: int = 3 # 한 자동 사이클당 최대 분석 수(Ollama 부하 제한) # ---- phase-15: 인증/멀티유저 + 관측 + 운영 ---- auth_enabled: bool = False # AUTH_ENABLED 기본 off(데모 무손상) diff --git a/backend/app/connectors/base.py b/backend/app/connectors/base.py index 486a53b..b6b47c4 100644 --- a/backend/app/connectors/base.py +++ b/backend/app/connectors/base.py @@ -107,6 +107,11 @@ class BaseConnector(ABC): res.skipped += 1 continue norm = self.normalize(raw) + # provider 가 스팸으로 분류한 메일은 저장하지 않는다(유저: 아예 안 가져옴). + # folder 없는 도메인(캘린더 등)은 .get→None 이라 영향 없음. + if norm.fields.get("folder") == "spam": + res.skipped += 1 + continue entity_id, created = self.write(session, norm) self._upsert_link(session, raw, norm, entity_id) if created: diff --git a/backend/app/connectors/mail/real_gmail.py b/backend/app/connectors/mail/real_gmail.py index 8d3d4ff..623470f 100644 --- a/backend/app/connectors/mail/real_gmail.py +++ b/backend/app/connectors/mail/real_gmail.py @@ -92,8 +92,9 @@ class GmailConnector(BaseConnector): session.commit() return # new_hist None = historyId 만료 → 아래 윈도우 폴백으로 재동기화. - # full / 최초 / 만료: 받은편지함+스팸(+휴지통) 윈도우 수집 후 현재 historyId 캡처. - scopes = [("in:inbox", cfg.sync_max_messages), ("in:spam", cfg.sync_page_size)] + # full / 최초 / 만료: 받은편지함(+휴지통) 윈도우 수집 후 현재 historyId 캡처. + # 스팸은 가져오지 않는다(유저 요청 — 이미 스팸 분류된 건 수집 제외). + scopes = [("in:inbox", cfg.sync_max_messages)] if full: scopes.append(("in:trash", cfg.sync_page_size)) for base_q, cap in scopes: diff --git a/backend/app/connectors/mail/real_outlook.py b/backend/app/connectors/mail/real_outlook.py index df7bc23..bdc8d81 100644 --- a/backend/app/connectors/mail/real_outlook.py +++ b/backend/app/connectors/mail/real_outlook.py @@ -154,7 +154,7 @@ class OutlookConnector(BaseConnector): ext_to_link = {lk.external_id: lk for lk in links} eid_to_ext = {lk.entity_id: lk.external_id for lk in links} for fid, slug, _ in fetch_ids: - if slug not in ("inbox", "spam"): + if slug != "inbox": # 스팸은 동기화 대상에서 제외(유저 요청) — inbox 만 재조회. continue self._rl.acquire() r = c.get( @@ -225,8 +225,10 @@ class OutlookConnector(BaseConnector): # 읽음/별표 상태 동기화(증분 fetch 가 안 건드리는 기존 메일) — 가벼운 재조회. self._reconcile_status(session, c, auth, fetch_ids) for fid, slug, fname in fetch_ids: - # 받은편지함/스팸만 증분(매 sync) — 나머지(휴지통/보관함/커스텀)는 full 때만. - light = slug in ("inbox", "spam") + if slug == "spam": # 스팸은 아예 가져오지 않는다(유저 요청). + continue + # 받은편지함만 증분(매 sync) — 나머지(휴지통/보관함/커스텀)는 full 때만. + light = slug == "inbox" if not light and not full: continue params = { diff --git a/backend/app/crypto.py b/backend/app/crypto.py index b287930..f7e88d5 100644 --- a/backend/app/crypto.py +++ b/backend/app/crypto.py @@ -28,3 +28,26 @@ def decrypt_token(enc: str) -> dict: return json.loads(_fernet().decrypt(enc.encode("utf-8")).decode("utf-8")) except (InvalidToken, ValueError): return {} # 키 회전/손상 → 빈 dict (호출측은 token_expired 처리) + + +# LLM API 키 등 단일 문자열 비밀의 대칭 암복호화. 평문은 DB 에 저장하지 않는다. +_ENC_PREFIX = "enc:" # 암호문 식별 접두사(평문/암호문 혼재 시 구분) + + +def encrypt_str(plain: str) -> str: + """평문 → 'enc:<암호문>'. 빈 값은 그대로 빈 문자열.""" + if not plain: + return "" + return _ENC_PREFIX + _fernet().encrypt(plain.encode("utf-8")).decode("utf-8") + + +def decrypt_str(enc: str) -> str: + """'enc:<암호문>' → 평문. 접두사 없으면(구 평문값) 그대로 반환(하위호환).""" + if not enc: + return "" + if not enc.startswith(_ENC_PREFIX): + return enc # 접두사 없음 = 암호화 전 평문(하위호환) + try: + return _fernet().decrypt(enc[len(_ENC_PREFIX) :].encode("utf-8")).decode("utf-8") + except (InvalidToken, ValueError): + return "" # 키 회전/손상 diff --git a/backend/app/db.py b/backend/app/db.py index 80bf72c..9a2c45c 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -31,6 +31,7 @@ _DEV_COLUMNS: list[tuple[str, str, str]] = [ ("email", "folder", "TEXT NOT NULL DEFAULT 'inbox'"), # 시스템 폴더/버킷 슬러그 ("email", "folder_name", "TEXT NOT NULL DEFAULT ''"), # Outlook 커스텀 폴더 표시명 ("email", "inference", "TEXT NOT NULL DEFAULT ''"), # Outlook 집중/기타(focused/other) + ("email", "dismissed", "BOOLEAN NOT NULL DEFAULT 0"), # 아리 정리에서 기각(다이제스트 제외) ("outbound_mail", "cc", "TEXT NOT NULL DEFAULT ''"), ("outbound_mail", "bcc", "TEXT NOT NULL DEFAULT ''"), ("outbound_mail", "attachments", "JSON NOT NULL DEFAULT '[]'"), diff --git a/backend/app/llm/gemini.py b/backend/app/llm/gemini.py new file mode 100644 index 0000000..1bdfe41 --- /dev/null +++ b/backend/app/llm/gemini.py @@ -0,0 +1,96 @@ +# backend/app/llm/gemini.py +# Google Gemini (Generative Language API) 프로바이더. +# 엔드포인트: {base}/models/{model}:generateContent?key=API_KEY (base 기본 v1beta). +# host 오버레이가 ollama 용 값으로 남아 있을 수 있어, gemini 답지 않은 host 는 기본 base 로 무시. +import httpx + +from ..runtime_config import effective_llm +from .ollama import _coerce, _lenient_json +from .prompts import CLASSIFY_SYSTEM, build_classify_prompt +from .provider import Classification, LLMProvider + +DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta" + + +def _resolve_base(host: str | None) -> str: + h = (host or "").rstrip("/") + # 비어 있거나 ollama 류 host(11434/ollama/localhost)면 gemini 기본 base 사용. + if not h or ":11434" in h or "ollama" in h or "localhost" in h or "127.0.0.1" in h: + return DEFAULT_BASE + return h + + +class GeminiProvider(LLMProvider): + name = "gemini" + + def __init__( + self, + host: str | None = None, + model: str | None = None, + api_key: str | None = None, + timeout: float | None = None, + ): + eff = effective_llm() + self.base = _resolve_base(host if host is not None else eff.host) + self.model = model or eff.model + self.api_key = api_key if api_key is not None else eff.api_key + self.timeout = timeout if timeout is not None else eff.timeout + + def _generate(self, text: str) -> str: + body = { + "contents": [{"role": "user", "parts": [{"text": text}]}], + "generationConfig": {"temperature": 0.2, "responseMimeType": "application/json"}, + } + r = httpx.post( + f"{self.base}/models/{self.model}:generateContent", + params={"key": self.api_key}, + json=body, + timeout=self.timeout, + ) + r.raise_for_status() + cands = r.json().get("candidates", []) + if not cands: + return "{}" + parts = cands[0].get("content", {}).get("parts", []) + return "".join(p.get("text", "") for p in parts) + + def list_models(self) -> list[str]: + try: + r = httpx.get(f"{self.base}/models", params={"key": self.api_key}, timeout=5.0) + if r.status_code != 200: + return [] + out = [] + for m in r.json().get("models", []): + if "generateContent" in (m.get("supportedGenerationMethods") or []): + out.append((m.get("name") or "").removeprefix("models/")) + return [m for m in out if m] + except Exception: + return [] + + def health(self) -> dict: + try: + r = httpx.get(f"{self.base}/models", params={"key": self.api_key}, timeout=5.0) + ok = r.status_code == 200 + return { + "reachable": ok, + "provider": self.name, + "model": self.model, + "host": self.base, + "detail": "ok" if ok else f"HTTP {r.status_code}", + } + except Exception as e: + return { + "reachable": False, + "provider": self.name, + "model": self.model, + "host": self.base, + "detail": str(e), + } + + def generate_json(self, prompt: str, schema: dict | None = None) -> dict: + return _lenient_json(self._generate(prompt)) + + def classify_capture(self, raw: str, context: dict) -> Classification: + prompt = f"{CLASSIFY_SYSTEM}\n\n{build_classify_prompt(raw, context)}" + data = _lenient_json(self._generate(prompt)) + return _coerce(data, raw, context, model=f"gemini:{self.model}") diff --git a/backend/app/llm/ollama.py b/backend/app/llm/ollama.py index 60482d6..4b3c5f2 100644 --- a/backend/app/llm/ollama.py +++ b/backend/app/llm/ollama.py @@ -30,7 +30,9 @@ class OllamaProvider(LLMProvider): ): # 유효 설정(런타임 오버레이 > .env). 인자가 주어지면 그 값으로 덮어쓴다(연결 테스트용). eff = effective_llm() - self.host = host or eff.host + # 끝 슬래시 제거 — 원격 엔드포인트(예: https://host/)를 그대로 받아도 `{host}/api/..` 가 + # `//api/..` 로 깨지지 않게 정규화. + self.host = (host or eff.host).rstrip("/") self.model = model or eff.model # 비종속: 주입값 그대로 사용 self.timeout = timeout if timeout is not None else eff.timeout diff --git a/backend/app/llm/openai_compat.py b/backend/app/llm/openai_compat.py new file mode 100644 index 0000000..a0052cf --- /dev/null +++ b/backend/app/llm/openai_compat.py @@ -0,0 +1,108 @@ +# backend/app/llm/openai_compat.py +# OpenAI 호환 Chat Completions 엔드포인트용 프로바이더. +# OpenAI 본가뿐 아니라 OpenRouter·Groq·Together·vLLM·LM Studio·Gemini(OpenAI 호환 베이스) 등 +# `/v1/chat/completions` + `/v1/models` 규약을 따르는 모든 엔드포인트를 host(base_url)로 받는다. +import httpx + +from ..runtime_config import effective_llm +from .ollama import _coerce, _lenient_json +from .prompts import CLASSIFY_SYSTEM, build_classify_prompt +from .provider import Classification, LLMProvider + + +class OpenAICompatProvider(LLMProvider): + name = "openai" + + def __init__( + self, + host: str | None = None, + model: str | None = None, + api_key: str | None = None, + timeout: float | None = None, + ): + eff = effective_llm() + # host = base_url(예: https://api.openai.com/v1). 끝 슬래시 정규화. + self.base = (host or eff.host).rstrip("/") + self.model = model or eff.model + self.api_key = api_key if api_key is not None else eff.api_key + self.timeout = timeout if timeout is not None else eff.timeout + + def _headers(self) -> dict: + h = {"Content-Type": "application/json"} + if self.api_key: + h["Authorization"] = f"Bearer {self.api_key}" + return h + + def _chat(self, messages: list[dict]) -> str: + body = { + "model": self.model, + "messages": messages, + "temperature": 0.2, + "response_format": {"type": "json_object"}, + } + try: + r = httpx.post( + f"{self.base}/chat/completions", + json=body, + headers=self._headers(), + timeout=self.timeout, + ) + r.raise_for_status() + except httpx.HTTPStatusError as e: + # 일부 호환 서버는 response_format 을 모름(400) → 빼고 1회 재시도. + if e.response is not None and e.response.status_code == 400: + body.pop("response_format", None) + r = httpx.post( + f"{self.base}/chat/completions", + json=body, + headers=self._headers(), + timeout=self.timeout, + ) + r.raise_for_status() + else: + raise + return r.json()["choices"][0]["message"]["content"] + + def list_models(self) -> list[str]: + try: + r = httpx.get(f"{self.base}/models", headers=self._headers(), timeout=5.0) + if r.status_code != 200: + return [] + data = r.json().get("data", []) + return [m.get("id", "") for m in data if m.get("id")] + except Exception: + return [] + + def health(self) -> dict: + try: + r = httpx.get(f"{self.base}/models", headers=self._headers(), timeout=5.0) + ok = r.status_code == 200 + return { + "reachable": ok, + "provider": self.name, + "model": self.model, + "host": self.base, + "detail": "ok" if ok else f"HTTP {r.status_code}", + } + except Exception as e: + return { + "reachable": False, + "provider": self.name, + "model": self.model, + "host": self.base, + "detail": str(e), + } + + def generate_json(self, prompt: str, schema: dict | None = None) -> dict: + content = self._chat([{"role": "user", "content": prompt}]) + return _lenient_json(content) + + def classify_capture(self, raw: str, context: dict) -> Classification: + prompt = build_classify_prompt(raw, context) + content = self._chat( + [ + {"role": "system", "content": CLASSIFY_SYSTEM}, + {"role": "user", "content": prompt}, + ] + ) + return _coerce(_lenient_json(content), raw, context, model=f"openai:{self.model}") diff --git a/backend/app/llm/provider.py b/backend/app/llm/provider.py index 7d1eb6b..c0a0f21 100644 --- a/backend/app/llm/provider.py +++ b/backend/app/llm/provider.py @@ -44,15 +44,39 @@ class LLMProvider(ABC): _cached: LLMProvider | None = None +def build_provider( + provider: str, + *, + host: str | None = None, + model: str | None = None, + api_key: str | None = None, + timeout: float | None = None, +) -> LLMProvider: + """provider 종류로 구현 인스턴스를 만든다. 인자 None 이면 각 구현이 effective_llm 기본값 사용. + 연결 테스트(저장 전)에서는 명시 인자(host/model/api_key)로 임시 구성한다.""" + if provider == "openai": + from .openai_compat import OpenAICompatProvider + + return OpenAICompatProvider(host=host, model=model, api_key=api_key, timeout=timeout) + if provider == "gemini": + from .gemini import GeminiProvider + + return GeminiProvider(host=host, model=model, api_key=api_key, timeout=timeout) + from .ollama import OllamaProvider + + return OllamaProvider(host=host, model=model, timeout=timeout) + + def get_provider(force: str | None = None) -> LLMProvider: - """실 LLM(Ollama) 전용. 휴리스틱 폴백 제거(phase-16+): 미가동 시 호출부에서 예외. + """유효 설정(오버레이>env)의 provider 로 실 LLM 구현을 만든다(ollama|openai|gemini). + 미가동/미설정 시 호출부에서 예외(휴리스틱 폴백 없음, phase-16+). 테스트는 conftest 가 get_provider 의존성을 결정적 FakeLLM 으로 오버라이드한다. - force 인자는 하위호환용(현재 Ollama 단일 — 무시). + force 로 provider 를 직접 지정할 수 있다(미지정 시 effective_llm().provider). """ - from .ollama import OllamaProvider + from ..runtime_config import effective_llm - return OllamaProvider() + return build_provider(force or effective_llm().provider) def reset_provider_cache(): diff --git a/backend/app/main.py b/backend/app/main.py index a7119fb..770f677 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -61,7 +61,7 @@ def _register_event_bus() -> None: async def _auto_sync_loop() -> None: """앱 내장 자동 풀링: 주기적으로 연결된 real 계정(메일·일정)을 증분 sync. blocking httpx/DB 작업이라 to_thread 로 실행해 이벤트 루프를 막지 않는다.""" - from .worker.sync_jobs import run_periodic_sync + from .worker.sync_jobs import run_auto_triage, run_periodic_sync log = logging.getLogger("ari.autosync") interval = max(5, settings.auto_sync_interval_seconds) @@ -76,6 +76,16 @@ async def _auto_sync_loop() -> None: raise except Exception: log.exception("auto-sync failed; retrying next interval") + # 새 메일 자동 정리(백그라운드 LLM 분석) — 사용자가 직접 누르지 않아도 카드가 채워진다. + if settings.auto_triage_enabled: + try: + done = await asyncio.to_thread(run_auto_triage, settings.auto_triage_batch) + if done: + log.info("auto-triage: %d mails analyzed", done) + except asyncio.CancelledError: + raise + except Exception: + log.exception("auto-triage failed; retrying next interval") await asyncio.sleep(interval) @@ -93,6 +103,16 @@ async def lifespan(app: FastAPI): with Session(engine) as _s: runtime_config.load_from_db(_s) + # 재시작/리로드로 'syncing' 에 멈춘 계정을 복구(안 하면 주기 sync 가 영원히 건너뜀). + from .worker.sync_jobs import purge_spam, recover_stuck_syncing + + if (_recovered := recover_stuck_syncing()): + logging.getLogger("ari.autosync").info( + "recovered %d stuck-syncing account(s) on startup", _recovered + ) + # 이미 저장된 스팸 일괄 제거(이후 sync 는 스팸을 아예 저장하지 않음). + if (_purged := purge_spam()): + logging.getLogger("ari.autosync").info("purged %d spam mail(s) on startup", _purged) sync_task = asyncio.create_task(_auto_sync_loop()) if settings.auto_sync_enabled else None try: yield diff --git a/backend/app/models.py b/backend/app/models.py index e2aaa95..8368c70 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -530,6 +530,8 @@ class Email(SQLModel, table=True): folder_name: str = "" # Outlook 커스텀 폴더 표시명(시스템 폴더는 비움 → 표준 한글명 사용) # Outlook 집중 받은편지함 분류(focused/other). 빈 문자열=미분류(Gmail 등). 받은편지함=focused. inference: str = "" + # 아리 정리(비서 다이제스트)에서 사용자가 기각한 메일. 다이제스트에서만 제외(받은편지함엔 잔류). + dismissed: bool = Field(default=False, index=True) created_at: datetime = Field(default_factory=now) @@ -1304,3 +1306,17 @@ class AppSetting(SQLModel, table=True): key: str = Field(primary_key=True) # 예: "llm.provider" value: str = "" # 항상 문자열 직렬화(숫자/불리언도 str) updated_at: datetime = Field(default_factory=now) + + +# ---------- 아리 자동 정리 실행 로그(관측성) ---------- +class TriageEvent(SQLModel, table=True): + """아리 자동 정리(auto-triage) 1회 실행 기록 — '오늘 한 일' 로그. + 실제로 새 메일을 읽고 분류했는지/에러가 났는지 사용자가 눈으로 확인할 수 있게 남긴다. + 무활동 사이클(분석 0·에러 없음)은 기록하지 않는다(로그가 무의미하게 쌓이지 않게).""" + + __tablename__ = "triage_event" + id: Optional[int] = Field(default=None, primary_key=True) + at: datetime = Field(default_factory=now, index=True) + analyzed: int = 0 # 이번 사이클에 LLM 으로 읽은 새 집중 메일 수 + cards: int = 0 # 그중 챙길 거리(할 일/일정/답장·중요)로 카드가 된 수 + error: Optional[str] = None # 실패 사유(짧게). None=정상 diff --git a/backend/app/routers/mail.py b/backend/app/routers/mail.py index 457faf4..ba82b73 100644 --- a/backend/app/routers/mail.py +++ b/backend/app/routers/mail.py @@ -30,6 +30,7 @@ from ..models import ( Project, Task, TaskStatus, + TriageEvent, now, ) from ..schemas import ( @@ -195,12 +196,17 @@ def _email_in_folder(e: Email, slug: str) -> bool: folder = e.folder or "inbox" if slug in ("inbox", "smart"): # 집중(Focused): Gmail 기본(primary) 카테고리 + Outlook focused. (프로모션·기타는 제외) - return ( + focused = ( folder == "inbox" and not e.archived and _category_for(e.labels) == "primary" and (e.inference or "") != "other" ) + # 아리 정리(smart)는 사용자가 기각(dismiss)한 메일을 다이제스트에서만 뺀다 — + # 실제 받은편지함(inbox)에는 그대로 남는다(읽음/보관/삭제 아님). + if slug == "smart": + return focused and not e.dismissed + return focused if slug == "others": # 기타(Others): Gmail 비기본(프로모션/소셜/업데이트) 또는 Outlook other. return ( @@ -810,62 +816,162 @@ def extract_all(eid: str, s: Session = Depends(get_session)): ) -@router.post("/mail/triage/analyze") -def triage_analyze( - limit: int = Query(4, ge=1, le=12), +def _ai_actionable(ai: dict | None) -> bool: + """분석 결과가 아리 정리 '카드'로 노출되는가 — 할 일/일정/답장이 있거나 중요(높음). + 프론트 Triage 카드 필터(actionCount>0 || 높음)와 동일 기준. 정보성 메일은 카드가 없다.""" + if not ai: + return False + reply = 1 if (ai.get("replies") or []) else 0 + actions = len(ai.get("tasks") or []) + len(ai.get("events") or []) + reply + return actions > 0 or ai.get("priority") == "높음" + + +def _is_triage_card(e: Email) -> bool: + """아리 정리에 실제로 노출될 새 메일인가 — 미분석(곧 정리됨)이거나 분석 결과에 액션이 있음. + 분석 끝난 정보성 메일(액션 없음)은 카드가 없으니 배지에서도 빼서 패널 숫자와 일치시킨다.""" + if not e.ai_json: + return True # 미분석 = 정리 대기(pending) — 곧 자동 분석되어 카드가 되거나 빠진다 + return _ai_actionable(e.ai_json) + + +def _triage_summary( + s: Session, user: Person, since: datetime, day_start: datetime | None = None +) -> dict: + """아리 정리 요약 — 카드 수(count) + 자동 처리 현황(새 메일 총수·자동 걸러낸 수). + 빈 패널이 '아무것도 안 함'처럼 보이지 않게, 아리가 새 메일을 읽고 무엇을 넘겼는지 보여준다. + - count : 패널에 카드로 노출되는 수(미분석 대기 + 액션 있는 집중 메일) = 사이드바 배지. + 기준 시각 이후 전체 — 어제 도착한 챙길 거리도 빠뜨리지 않는다. + - new_total: '오늘'(day_start) 도착한 새 메일 총수(스팸·홍보 포함). 없으면 기준 이후 전체. + - screened : 그중 집중이 아니라 아리가 자동으로 넘긴 수(스팸/홍보/소셜/기타/보관). + 오늘로 한정하는 이유: 기준 시각이 며칠 전이면 누적치가 커져('새 메일 60통') 오늘 받은 양과 + 동떨어진다 — 표시는 '오늘 온 새 메일' 기준으로 맞춘다(경계=클라 로컬 자정).""" + rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] + new = [e for e in rows if _is_new_mail(e, since)] + count = sum(1 for e in new if _email_in_folder(e, "smart") and _is_triage_card(e)) + # 표시용 총수/걸러낸 수는 오늘 도착분만(누적 방지). 집중 후보(기각 포함)=inbox 판정과 동일. + floor = max(since, _as_utc(day_start)) if day_start else since + today = [e for e in new if _is_new_mail(e, floor)] + focused = sum(1 for e in today if _email_in_folder(e, "inbox")) + return {"count": count, "new_total": len(today), "screened": len(today) - focused} + + +def _triage_new_count(s: Session, user: Person, since: datetime) -> int: + """기준 시각 이후 도착한 집중(focused) 새 메일 중 '아리 정리에 노출되는' 수 — 배지=패널 일치. + 미분석(대기)은 포함하되, 분석 끝난 정보성 메일(액션 없음)은 카드가 없으므로 제외(읽음 무관).""" + return _triage_summary(s, user, since)["count"] + + +def _parse_day_start(day_start: str | None) -> datetime | None: + """클라가 보낸 로컬 자정(ISO)을 UTC-aware datetime 으로 — 파싱 실패 시 None.""" + if not day_start: + return None + try: + return _as_utc(datetime.fromisoformat(day_start)) + except ValueError: + return None + + +@router.get("/mail/triage/baseline") +def triage_baseline_get( + day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계(서버 UTC TZ 보정) s: Session = Depends(get_session), user: Person = Depends(current_user), - provider: LLMProvider = Depends(get_provider), ): - """아리 비서: 안읽음 집중 받은편지함 중 미분석 메일을 LLM 으로 배치 분석(best-effort). - 한 번에 limit 통만 처리하고 remaining 으로 남은 수를 알려 점진 분석을 가능케 한다.""" - from ..services.mail_ai import analyze_email - + """아리 정리 기준 시각(ISO) + '오늘'(day_start) 도착한 새 메일 요약 — 없으면 지금으로 초기화.""" since = _ensure_triage_since(s) - rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] - todo = [ - e - for e in rows - if not e.ai_json - and _email_in_folder(e, "smart") - and _is_new_mail(e, since) # 기준 시각 이후 새 메일(읽음 무관 — 폰서 먼저 읽어도 정리) + summary = _triage_summary(s, user, since, _parse_day_start(day_start)) + return {"since": since.isoformat(), **summary} + + +@router.get("/mail/triage/log") +def triage_log( + day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계 + s: Session = Depends(get_session), + user: Person = Depends(current_user), +): + """오늘(day_start 이후) 아리 자동 정리 실행 로그 — 최근 것부터 최대 20개. + 실제로 메일을 읽고 분류했는지/에러가 났는지 보여주는 관측성 피드(무활동 사이클은 기록 안 됨).""" + floor = _parse_day_start(day_start) + rows = s.exec(select(TriageEvent).order_by(TriageEvent.at.desc())).all() + if floor is not None: + rows = [r for r in rows if _as_utc(r.at) >= floor] + return [ + {"at": r.at.isoformat(), "analyzed": r.analyzed, "cards": r.cards, "error": r.error} + for r in rows[:20] ] - todo.sort(key=_email_sort_key, reverse=True) - analyzed, errors = 0, 0 - for e in todo[:limit]: - try: - analyze_email(s, e, provider) - analyzed += 1 - except Exception: # LLM 일시 오류·파싱 실패는 건너뛴다(다음 호출서 재시도). - errors += 1 - return {"analyzed": analyzed, "remaining": max(0, len(todo) - analyzed), "errors": errors} -def _triage_new_count(s: Session, user: Person, since: datetime) -> int: - """기준 시각 이후 도착한 집중(focused) 새 메일 수 — 아리 정리 배지(읽음 여부 무관). - 사용자가 폰/다른 앱에서 먼저 읽어도 '정리할 새 메일'로 센다(서버 read 가 곧 True 가 되므로).""" - rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] - return sum(1 for e in rows if _email_in_folder(e, "smart") and _is_new_mail(e, since)) +def _triage_bucket(e: Email) -> str: + """이 메일을 아리가 어디로 분류했는지 — 상세 로그용 한 단어 라벨(집중/홍보/스팸 등).""" + folder = e.folder or "inbox" + if folder == "spam": + return "스팸" + if folder == "trash": + return "휴지통" + if e.archived: + return "보관" + if _email_in_folder(e, "inbox"): + return "집중" + if (e.inference or "") == "other": + return "기타" + return {"promotions": "홍보", "social": "소셜", "updates": "업데이트"}.get( + _category_for(e.labels), "기타" + ) -@router.get("/mail/triage/baseline") -def triage_baseline_get( +@router.get("/mail/triage/classified") +def triage_classified( + day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계 s: Session = Depends(get_session), user: Person = Depends(current_user), ): - """아리 정리 기준 시각(ISO) + 그 이후 도착한 새 메일 수 — 없으면 지금으로 초기화.""" + """오늘 도착한 새 메일을 '아리가 어떻게 분류·분석했는지' 하나씩 — 상세 로그 뷰용. + 집중/홍보/스팸 버킷 + (집중이면) 분석 결과(우선순위·할 일/일정/답장)까지. 최신순 최대 200통.""" since = _ensure_triage_since(s) - return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)} + floor = max(since, _parse_day_start(day_start) or since) + mine = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] + rows = [e for e in mine if _is_new_mail(e, floor)] + rows.sort(key=_email_sort_key, reverse=True) + out = [] + for e in rows[:200]: + ai = e.ai_json or None + from_name, _ = sender_parts(e.from_key) + out.append( + { + "id": e.id, + "account": e.account, + "from_name": from_name, + "subject": e.subject, + "time": _list_time(e.received_at, e.time), + "bucket": _triage_bucket(e), + "focused": _email_in_folder(e, "inbox"), + "analyzed": ai is not None, + "priority": ai.get("priority") if ai else None, + "tasks": len(ai.get("tasks") or []) if ai else 0, + "events": len(ai.get("events") or []) if ai else 0, + "replies": 1 if (ai and (ai.get("replies") or [])) else 0, + "is_card": _email_in_folder(e, "smart") and _is_triage_card(e), + } + ) + return out -@router.post("/mail/triage/baseline") -def triage_baseline_reset( +@router.post("/mail/{mail_id}/triage/dismiss") +def triage_dismiss( + mail_id: str, s: Session = Depends(get_session), user: Person = Depends(current_user), ): - """기준 시각을 지금으로 재설정 — 이후 도착하는 새 메일부터 다시 정리.""" - since = _set_triage_since(s, now()) - return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)} + """아리 정리에서 이 카드를 기각 — 다이제스트에서 빼고 다시 분석/표시하지 않는다. + 실제 받은편지함에는 그대로 남는다(읽음/보관/삭제와 무관).""" + e = s.get(Email, mail_id) + if not e or e.user_id != user.id: + raise HTTPException(404, "메일을 찾을 수 없어요") + e.dismissed = True + s.add(e) + s.commit() + since = _ensure_triage_since(s) + return {"ok": True, **_triage_summary(s, user, since)} @router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index c7f3391..d576b2b 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -5,8 +5,7 @@ from sqlmodel import Session from .. import runtime_config from ..config import get_settings from ..db import get_session -from ..llm.ollama import OllamaProvider -from ..llm.provider import LLMProvider +from ..llm.provider import LLMProvider, build_provider from ..schemas import ( LLMConfigOut, LLMTestIn, @@ -49,7 +48,9 @@ def _llm_config(s: Session) -> LLMConfigOut: model=eff.model, host=eff.host, timeout=eff.timeout, + provider_options=list(runtime_config._PROVIDERS), overridden=settings_service.overlay_active(s), + api_key_set=bool(eff.api_key), # 값은 노출 금지 — 설정 여부만. stt_provider=st.stt_provider, vision_provider=st.vision_provider, ) @@ -69,6 +70,7 @@ def update_llm(body: LLMUpdateIn, s: Session = Depends(get_session)): model=body.model, host=body.host, timeout=body.timeout, + api_key=body.api_key, ) except ValueError as e: raise HTTPException(400, str(e)) from e @@ -77,12 +79,16 @@ def update_llm(body: LLMUpdateIn, s: Session = Depends(get_session)): @router.post("/settings/llm/test", response_model=LLMTestOut) def test_llm(body: LLMTestIn): - """저장 없이 연결만 테스트. provider/host/model 미지정 시 유효 설정값 사용.""" + """저장 없이 연결만 테스트. provider/host/model/api_key 미지정 시 유효 설정값 사용. + api_key 미지정이면 저장된 키(eff.api_key)로 테스트(키 재입력 없이 모델 목록 조회 가능).""" eff = runtime_config.effective_llm() provider = body.provider or eff.provider - - # 실 LLM 전용(휴리스틱 제거): 항상 Ollama 도달성으로 테스트. - impl: LLMProvider = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model) + impl: LLMProvider = build_provider( + provider, + host=body.host or eff.host, + model=body.model or eff.model, + api_key=body.api_key if body.api_key is not None else eff.api_key, + ) h = impl.health() return LLMTestOut( reachable=bool(h.get("reachable")), diff --git a/backend/app/runtime_config.py b/backend/app/runtime_config.py index 9b8a506..52ce111 100644 --- a/backend/app/runtime_config.py +++ b/backend/app/runtime_config.py @@ -12,19 +12,22 @@ from dataclasses import dataclass from .config import get_settings # 허용된 LLM 오버레이 키(이 외의 키는 무시). -LLM_KEYS = ("llm.provider", "llm.model", "llm.host", "llm.timeout") -_PROVIDERS = ("ollama",) # 실 LLM 전용(auto/heuristic 제거, phase-16+) +LLM_KEYS = ("llm.provider", "llm.model", "llm.host", "llm.timeout", "llm.api_key") +# api_key 는 암호화 저장(crypto.encrypt_str) — DB 평문 금지. +_SECRET_KEYS = ("llm.api_key",) +_PROVIDERS = ("ollama", "openai", "gemini") # ollama | openai(호환) | gemini -# 인메모리 오버레이. key → str value. +# 인메모리 오버레이. key → str value(api_key 는 복호화된 평문으로 보관). _overrides: dict[str, str] = {} @dataclass(frozen=True) class EffectiveLLM: - provider: str # auto | ollama | heuristic + provider: str # ollama | openai | gemini model: str host: str timeout: float + api_key: str = "" def clear() -> None: @@ -49,15 +52,16 @@ def set_llm(values: dict[str, object]) -> None: def load_from_db(session) -> None: - """앱 시작 시(lifespan) app_setting → 오버레이 적재.""" + """앱 시작 시(lifespan) app_setting → 오버레이 적재. 비밀키(api_key)는 복호화.""" from sqlmodel import select + from .crypto import decrypt_str from .models import AppSetting _overrides.clear() for row in session.exec(select(AppSetting)).all(): if row.key in LLM_KEYS: - _overrides[row.key] = row.value + _overrides[row.key] = decrypt_str(row.value) if row.key in _SECRET_KEYS else row.value def effective_llm() -> EffectiveLLM: @@ -68,9 +72,10 @@ def effective_llm() -> EffectiveLLM: provider = "ollama" # 미지원/구 값(auto·heuristic) → 실 LLM 으로 강제 model = _overrides.get("llm.model") or st.ollama_model host = _overrides.get("llm.host") or st.ollama_host + api_key = _overrides.get("llm.api_key") or st.llm_api_key raw_timeout = _overrides.get("llm.timeout") try: timeout = float(raw_timeout) if raw_timeout else st.llm_timeout except ValueError: timeout = st.llm_timeout - return EffectiveLLM(provider=provider, model=model, host=host, timeout=timeout) + return EffectiveLLM(provider=provider, model=model, host=host, timeout=timeout, api_key=api_key) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 88b1375..d8e455e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1138,24 +1138,26 @@ class TokenInfoOut(BaseModel): class LLMConfigOut(BaseModel): """유효 LLM 연결 설정 + 선택지 + 보조 프로바이더(읽기전용).""" - provider: str # 유효(오버레이>env): auto | ollama | heuristic + provider: str # 유효(오버레이>env): ollama | openai | gemini model: str host: str timeout: float provider_options: list[str] = ["ollama"] overridden: bool = False # 런타임 오버레이가 활성인가(env 기본과 다른가) + api_key_set: bool = False # API 키가 설정돼 있는가(값은 절대 노출하지 않음) # 보조 프로바이더(읽기전용, env 관리) stt_provider: str = "" vision_provider: str = "" class LLMUpdateIn(BaseModel): - """LLM 연결 저장. None/빈 문자열은 '기본으로 되돌리기'.""" + """LLM 연결 저장. None=미변경, 빈 문자열은 '기본으로 되돌리기'.""" provider: Optional[str] = None model: Optional[str] = None host: Optional[str] = None timeout: Optional[float] = None + api_key: Optional[str] = None # 암호화 저장. 빈 문자열=키 삭제, None=미변경 class LLMTestIn(BaseModel): @@ -1164,6 +1166,7 @@ class LLMTestIn(BaseModel): provider: Optional[str] = None host: Optional[str] = None model: Optional[str] = None + api_key: Optional[str] = None # 미지정 시 저장된 키 사용 class LLMTestOut(BaseModel): diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index f5d76e2..a5ff238 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -28,11 +28,14 @@ def update_llm( model: str | None = None, host: str | None = None, timeout: float | None = None, + api_key: str | None = None, ) -> None: """LLM 설정을 영속(app_setting)하고 런타임 오버레이에 즉시 반영. - provider 가 'auto'(=env 기본과 동등하게 둘 수도 있으나) 라도 명시 저장은 허용한다. - None 인 필드는 건드리지 않는다(부분 갱신).""" + provider 는 ollama|openai|gemini. None 인 필드는 건드리지 않는다(부분 갱신). + api_key 는 암호화 저장(평문 DB 금지) — 빈 문자열이면 삭제(키 제거).""" + from ..crypto import encrypt_str + fields: dict[str, object | None] = {} if provider is not None: if provider.strip() == "": @@ -48,6 +51,9 @@ def update_llm( if timeout is not None: # timeout<=0 은 '기본으로 되돌리기' 신호(프론트 리셋용). fields["llm.timeout"] = None if timeout <= 0 else timeout + if api_key is not None: + # 빈 문자열 → 삭제(None), 그 외 → 암호화 후 저장. + fields["llm.api_key"] = None if api_key.strip() == "" else encrypt_str(api_key) for key, val in fields.items(): _upsert(session, key, None if val is None else str(val)) diff --git a/backend/app/worker/sync_jobs.py b/backend/app/worker/sync_jobs.py index bc7cdb1..af050ed 100644 --- a/backend/app/worker/sync_jobs.py +++ b/backend/app/worker/sync_jobs.py @@ -1,11 +1,61 @@ # backend/app/worker/sync_jobs.py — phase-13 주기 sync 잡 + 수동 트리거 진입점 # 프로토타입은 수동 트리거(/api/connectors/sync-all)도 제공(데모 결정성). -from sqlmodel import Session +from sqlmodel import Session, select from ..config import get_settings from ..connectors.registry import ConnectorRegistry from ..db import engine -from ..models import ConnState +from ..models import ConnState, Email, ExternalLink + + +def recover_stuck_syncing(session: Session | None = None) -> int: + """기동 시 'syncing' 으로 멈춰 있는 계정을 'connected' 로 되돌린다. + sync() 는 시작할 때 state=syncing 을 즉시 커밋하고 끝에서 connected/error 로 바꾸는데, + 그 사이에 프로세스가 죽거나(uvicorn --reload, 크래시) 재시작되면 syncing 이 DB 에 영구히 남는다. + run_periodic_sync 는 connected/error 만 sync 하므로 멈춘 syncing 계정은 영영 동기화에서 빠진다. + 기동 직후엔 실제 진행 중인 sync 가 없으므로 syncing 은 모두 '깨진 상태' → connected 로 복구.""" + + def _do(s: Session) -> int: + stuck = [a for a in ConnectorRegistry.accounts(s) if a.state == ConnState.syncing] + for a in stuck: + a.state = ConnState.connected + s.add(a) + if stuck: + s.commit() + return len(stuck) + + if session is not None: + return _do(session) + with Session(engine) as s: + return _do(s) + + +def purge_spam(session: Session | None = None) -> int: + """이미 저장된 스팸(provider 가 스팸 분류)을 일괄 삭제 — 더는 스팸을 들고 있지 않는다. + 이후 sync 는 스팸을 저장하지 않으므로(fetch 제외 + base.sync 안전망) 재유입 없음.""" + + def _do(s: Session) -> int: + spam = s.exec(select(Email).where(Email.folder == "spam")).all() + ids = [e.id for e in spam] + if not ids: + return 0 + links = s.exec( + select(ExternalLink).where( + ExternalLink.entity_type == "email", + ExternalLink.entity_id.in_(ids), + ) + ).all() + for lk in links: + s.delete(lk) + for e in spam: + s.delete(e) + s.commit() + return len(ids) + + if session is not None: + return _do(session) + with Session(engine) as s: + return _do(s) def run_periodic_sync(session: Session | None = None) -> list[dict]: @@ -27,6 +77,50 @@ def run_periodic_sync(session: Session | None = None) -> list[dict]: return _do(s) +def run_auto_triage(limit: int = 3) -> int: + """새 메일 자동 정리: 기준 시각 이후 도착한 미분석 '집중' 메일을 LLM 으로 백그라운드 분석. + auto-sync 루프가 sync 직후 호출 — 사용자가 '정리 맡기기'를 누르지 않아도 카드가 미리 채워진다. + 한 사이클에 limit 통만 처리(부하 제한). LLM 미가용/오류 시 즉시 중단(다음 주기 재시도).""" + # 라우터 헬퍼(폴더 판정·기준 시각·정렬)를 재사용. 순환 임포트 방지로 함수 안에서 지연 임포트. + from ..llm.provider import get_provider + from ..models import TriageEvent + from ..routers.mail import ( + _ai_actionable, + _email_in_folder, + _email_sort_key, + _ensure_triage_since, + _is_new_mail, + ) + from ..services.mail_ai import analyze_email + + with Session(engine) as s: + since = _ensure_triage_since(s) + todo = [ + e + for e in s.exec(select(Email)).all() + if not e.ai_json and _email_in_folder(e, "smart") and _is_new_mail(e, since) + ] + if not todo: + return 0 + todo.sort(key=_email_sort_key, reverse=True) + provider = get_provider() + analyzed, cards, err = 0, 0, None + for e in todo[:limit]: + try: + ai = analyze_email(s, e, provider) + analyzed += 1 + if _ai_actionable(ai): + cards += 1 + except Exception as ex: # LLM 미가용/파싱 실패 — 이번 사이클 중단, 다음 주기에 재시도 + err = (str(ex) or ex.__class__.__name__)[:200] + break + # '오늘 한 일' 로그: 실제로 읽었거나 에러가 났을 때만 기록(무활동은 남기지 않음). + if analyzed or err: + s.add(TriageEvent(analyzed=analyzed, cards=cards, error=err)) + s.commit() + return analyzed + + def register(scheduler) -> None: """phase-7 worker.main(APScheduler)에서 호출(있을 때). 없으면 수동 트리거로 동작.""" mins = get_settings().sync_interval_minutes diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 5dbfc6b..e4be806 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -64,6 +64,61 @@ def test_llm_test_uses_ollama(client): assert isinstance(d["reachable"], bool) # 실 Ollama 가동 여부에 따라 달라짐(환경 의존) +def test_llm_provider_options_include_openai_gemini(client): + d = client.get("/api/settings/llm").json() + assert "openai" in d["provider_options"] + assert "gemini" in d["provider_options"] + assert d["api_key_set"] is False # 기본은 키 없음 + + +def test_llm_openai_with_api_key_encrypted(client, session): + s, _ = session + r = client.put( + "/api/settings/llm", + json={ + "provider": "openai", + "host": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "api_key": "sk-secret-xyz", + }, + ) + assert r.status_code == 200 + body = r.json() + assert body["provider"] == "openai" and body["model"] == "gpt-4o-mini" + assert body["api_key_set"] is True + assert "api_key" not in body # 키 값은 절대 응답에 없음 + + # 유효 설정/팩토리에 반영(복호화 라운드트립) + eff = runtime_config.effective_llm() + assert eff.provider == "openai" and eff.api_key == "sk-secret-xyz" + assert get_provider().name == "openai" + + # 저장은 암호화(평문 금지) + from app.models import AppSetting + + row = s.get(AppSetting, "llm.api_key") + assert row is not None and row.value.startswith("enc:") and "sk-secret-xyz" not in row.value + + +def test_llm_gemini_provider_select(client): + r = client.put("/api/settings/llm", json={"provider": "gemini", "model": "gemini-2.5-flash"}) + assert r.status_code == 200 and r.json()["provider"] == "gemini" + assert runtime_config.effective_llm().provider == "gemini" + assert get_provider().name == "gemini" + + +def test_llm_api_key_cleared_on_reset(client): + client.put("/api/settings/llm", json={"provider": "openai", "api_key": "sk-zzz"}) + assert client.get("/api/settings/llm").json()["api_key_set"] is True + # 빈 문자열들 → 전부 기본으로 되돌리기(키 삭제 포함) + client.put( + "/api/settings/llm", + json={"provider": "", "model": "", "host": "", "timeout": 0, "api_key": ""}, + ) + d = client.get("/api/settings/llm").json() + assert d["api_key_set"] is False and d["provider"] == "ollama" + + def test_profile_update(client): r = client.patch("/api/me", json={"name": "지우님", "role": "프로덕트 리드"}) assert r.status_code == 200 diff --git a/backend/tests/test_spam_purge.py b/backend/tests/test_spam_purge.py new file mode 100644 index 0000000..208d853 --- /dev/null +++ b/backend/tests/test_spam_purge.py @@ -0,0 +1,28 @@ +# backend/tests/test_spam_purge.py +# 스팸은 저장하지 않는다(유저 요청): 이미 들어온 스팸은 일괄 삭제, 일반 메일은 그대로. +from sqlmodel import select + +from app.models import Email, ExternalLink +from app.worker.sync_jobs import purge_spam +from tests._factories import make_email, make_mail_account + + +def test_purge_spam_removes_spam_and_links_keeps_normal(session): + s, _ = session + make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) + make_email(s, id="keep", account="work", subject="보통 메일") + junk = make_email(s, id="junk", account="work", subject="스팸 메일") + junk.folder = "spam" + s.add(junk) + s.add(ExternalLink(id="xl-junk", account_id="work", external_id="x-junk", + entity_type="email", entity_id="junk")) + s.add(ExternalLink(id="xl-keep", account_id="work", external_id="x-keep", + entity_type="email", entity_id="keep")) + s.commit() + + assert purge_spam(s) == 1 # 스팸 1통만 삭제 + assert s.get(Email, "junk") is None # 스팸 메일 제거 + assert s.get(Email, "keep") is not None # 일반 메일 유지 + # 스팸의 ExternalLink 도 제거(일반 것은 유지) — 재유입/유령 링크 방지. + remaining = sorted(lk.external_id for lk in s.exec(select(ExternalLink)).all()) + assert remaining == ["x-keep"] diff --git a/backend/tests/test_triage_baseline.py b/backend/tests/test_triage_baseline.py index 29433b0..d104738 100644 --- a/backend/tests/test_triage_baseline.py +++ b/backend/tests/test_triage_baseline.py @@ -1,10 +1,22 @@ # backend/tests/test_triage_baseline.py # 아리 정리(비서) 기준 시각: smart 보기/분석은 '기준 이후 도착한 새 메일'만 다룬다. -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta +from app.models import AppSetting, TriageEvent from tests._factories import make_email, make_mail_account +def _set_baseline(s, dt: datetime) -> None: + """기준 시각(mail.triage_since)을 직접 설정 — 수동 리셋 엔드포인트가 없어졌으므로 테스트에서 주입.""" + row = s.get(AppSetting, "mail.triage_since") + if row: + row.value = dt.isoformat() + s.add(row) + else: + s.add(AppSetting(key="mail.triage_since", value=dt.isoformat())) + s.commit() + + def _seed_old_new(s, since: datetime): """기준 시각 전/후로 도착한 집중(focused) 안읽음 메일을 하나씩 생성.""" make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) @@ -16,7 +28,7 @@ def _seed_old_new(s, since: datetime): def test_smart_folder_shows_only_new_mail(client, session): s, _ = session - since = datetime.fromisoformat(client.post("/api/mail/triage/baseline").json()["since"]) + since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"]) _seed_old_new(s, since) # 아리 정리(smart): 기준 이후 도착한 'new'만. smart = [r["id"] for r in client.get("/api/mail?folder=smart").json()] @@ -28,21 +40,115 @@ def test_smart_folder_shows_only_new_mail(client, session): assert inbox == ["new", "old"] -def test_baseline_get_initializes_and_post_advances(client, session): +def test_baseline_get_initializes(client, session): + """기준 시각은 첫 GET 조회 때 now 로 lazy-init 되고 이후 안정적으로 유지된다. + (수동 재설정 POST 는 제거 — 새 메일 정리는 전부 자동이라 사용자가 기준을 만질 일이 없다.)""" s, _ = session first = client.get("/api/mail/triage/baseline").json()["since"] assert first # 첫 조회 시 now 로 lazy-init - advanced = client.post("/api/mail/triage/baseline").json()["since"] - assert datetime.fromisoformat(advanced) >= datetime.fromisoformat(first) + assert client.get("/api/mail/triage/baseline").json()["since"] == first # 재조회 시 유지 -def test_triage_analyze_skips_old_mail(client, session): +def test_smart_count_excludes_analyzed_informational(client, session): + """분석이 끝난 '정보성'(액션 없음·낮음) 새 메일은 아리 정리 카드가 안 생기므로 배지에서 제외 — + 배지(count)와 아리 정리 패널(카드) 숫자를 일치시킨다(배지 1인데 패널은 비어 보이는 혼선 방지).""" s, _ = session - since = datetime.fromisoformat(client.post("/api/mail/triage/baseline").json()["since"]) - _seed_old_new(s, since) - res = client.post("/api/mail/triage/analyze").json() - assert res["analyzed"] == 1 # 새 메일 1통만 분석 - assert res["remaining"] == 0 - # 기준 이후 새 메일만 분석되고, 이전 메일의 ai 는 비어 있다. - assert client.get("/api/mail/new").json()["ai"] is not None - assert client.get("/api/mail/old").json()["ai"] is None + since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"]) + make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) + make_email(s, id="info", account="work", subject="운임 변경 알림", read=True, + received_at=since + timedelta(hours=1), + ai_json={"summary": "안내", "priority": "낮음", + "tasks": [], "events": [], "replies": []}) + # 카드가 없으므로 배지 0 — 단, 요약은 '아리가 1통을 읽었다'를 보고한다(빈 화면≠고장). + b = client.get("/api/mail/triage/baseline").json() + assert b["count"] == 0 and b["new_total"] == 1 and b["screened"] == 0 + # 단, 받은편지함/smart 목록에는 그대로 남는다(분석만 끝났을 뿐 — 패널이 카드로 필터). + assert [r["id"] for r in client.get("/api/mail?folder=smart").json()] == ["info"] + + +def test_triage_summary_reports_screened(client, session): + """홍보·스팸 등 집중이 아닌 새 메일은 screened 로 집계 — 패널이 '아리가 N통 걸렀어요'를 보여준다.""" + s, _ = session + since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"]) + make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) + # 집중(primary) 새 메일 1통 + 홍보 새 메일 1통 + make_email(s, id="focus", account="work", subject="검토 요청", + received_at=since + timedelta(hours=1), + ai_json={"summary": "검토", "priority": "보통", + "tasks": [{"text": "검토"}], "events": [], "replies": []}) + make_email(s, id="promo", account="work", subject="여름 세일", labels=["프로모션"], + received_at=since + timedelta(hours=1)) + b = client.get("/api/mail/triage/baseline").json() + assert b["new_total"] == 2 # 새 메일 총 2통 + assert b["screened"] == 1 # 그중 홍보 1통은 집중이 아니라 자동으로 걸러짐 + assert b["count"] == 1 # 카드는 집중 액션 메일 1통만 + + +def test_smart_count_includes_actionable(client, session): + """할 일/일정/답장이 있거나 중요(높음)인 분석 메일은 카드가 생기므로 배지에 포함(읽음 무관).""" + s, _ = session + since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"]) + make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) + make_email(s, id="todo", account="work", subject="검토 요청", read=True, + received_at=since + timedelta(hours=1), + ai_json={"summary": "검토", "priority": "보통", + "tasks": [{"text": "문서 검토"}], "events": [], "replies": []}) + assert client.get("/api/mail/triage/baseline").json()["count"] == 1 + + +def test_summary_new_total_counts_today_only(client, session): + """new_total/screened 는 day_start(클라 로컬 자정) 이후 도착분만 — 기준 시각이 며칠 전이어도 + 누적('새 메일 60통')되지 않고 '오늘 온 새 메일'만 표시. 단, 배지(count)는 기준 이후 전체라 + 어제 도착한 챙길 거리(카드)도 유지한다.""" + s, _ = session + day_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) # 오늘 자정 + _set_baseline(s, day_start - timedelta(days=3)) # 기준 시각은 3일 전 + make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) + actionable = {"summary": "검토", "priority": "보통", "tasks": [{"text": "검토"}], + "events": [], "replies": []} + # 어제 도착(집중·액션) + 오늘 도착(집중·액션) + 오늘 도착(홍보) + make_email(s, id="yday", account="work", subject="어제 검토", + received_at=day_start - timedelta(hours=5), ai_json=actionable) + make_email(s, id="tfocus", account="work", subject="오늘 검토", + received_at=day_start + timedelta(hours=2), ai_json=actionable) + make_email(s, id="tpromo", account="work", subject="오늘 세일", labels=["프로모션"], + received_at=day_start + timedelta(hours=3)) + b = client.get("/api/mail/triage/baseline", params={"day_start": day_start.isoformat()}).json() + assert b["new_total"] == 2 # 오늘 도착분(집중+홍보)만 — 어제(yday)는 제외 + assert b["screened"] == 1 # 오늘 홍보 1통 + assert b["count"] == 2 # 배지=기준 이후 전체 카드(어제+오늘 집중) — 어제 챙길 거리도 유지 + + +def test_triage_log_returns_today_events_newest_first(client, session): + """자동 정리 실행 로그: 오늘(day_start 이후) 이벤트만 최신순 — 분석 통수·에러를 그대로 노출.""" + s, _ = session + day_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) + s.add(TriageEvent(at=day_start - timedelta(hours=2), analyzed=2, cards=1)) # 어제(범위 밖) + s.add(TriageEvent(at=day_start + timedelta(hours=1), analyzed=3, cards=2)) # 오늘 + s.add(TriageEvent(at=day_start + timedelta(hours=3), analyzed=0, cards=0, error="모델 응답 없음")) + s.commit() + rows = client.get("/api/mail/triage/log", params={"day_start": day_start.isoformat()}).json() + assert len(rows) == 2 # 오늘 것만(어제 제외) + assert rows[0]["error"] == "모델 응답 없음" # 최신순 — 에러가 먼저 + assert rows[1]["analyzed"] == 3 and rows[1]["cards"] == 2 + + +def test_triage_classified_lists_today_mail_with_bucket(client, session): + """상세 로그: 오늘 새 메일을 버킷(집중/홍보)·분석 결과와 함께 하나씩 — 어제 것은 제외.""" + s, _ = session + day_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) + _set_baseline(s, day_start - timedelta(days=3)) + make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) + make_email(s, id="yday", account="work", subject="어제", received_at=day_start - timedelta(hours=5)) + make_email(s, id="focus", account="work", subject="검토 요청", + received_at=day_start + timedelta(hours=2), + ai_json={"summary": "검토", "priority": "보통", + "tasks": [{"text": "검토"}], "events": [], "replies": []}) + make_email(s, id="promo", account="work", subject="세일", labels=["프로모션"], + received_at=day_start + timedelta(hours=1)) + rows = client.get("/api/mail/triage/classified", params={"day_start": day_start.isoformat()}).json() + byid = {r["id"]: r for r in rows} + assert set(byid) == {"focus", "promo"} # 오늘 둘만(어제 yday 제외) + assert byid["focus"]["bucket"] == "집중" and byid["focus"]["focused"] is True + assert byid["focus"]["analyzed"] is True and byid["focus"]["tasks"] == 1 + assert byid["promo"]["bucket"] == "홍보" and byid["promo"]["focused"] is False diff --git a/frontend/components/mail/MailClient.tsx b/frontend/components/mail/MailClient.tsx index e84261c..856be3c 100644 --- a/frontend/components/mail/MailClient.tsx +++ b/frontend/components/mail/MailClient.tsx @@ -12,6 +12,7 @@ import { MailSidebar } from "./MailSidebar"; import { MailList } from "./MailList"; import { Reader } from "./Reader"; import { Triage } from "./Triage"; +import { TriageLogModal } from "./TriageLogModal"; import { Compose, type ComposeState } from "./Compose"; // 계정 구분 색 — 계정 tone 이 서로 겹쳐(둘 다 blue) dot 으로 구분이 안 되므로 팔레트에서 배정. @@ -53,7 +54,7 @@ export function MailClient() { const [compose, setCompose] = useState(null); const [toasts, setToasts] = useState([]); const [syncing, setSyncing] = useState(false); - const [analyzing, setAnalyzing] = useState(false); + const [logOpen, setLogOpen] = useState(false); const [selected, setSelected] = useState>(new Set()); const shellRef = useRef(null); const tid = useRef(0); @@ -78,10 +79,25 @@ export function MailClient() { revalidateOnFocus: false, }); // 아리 정리 기준 시각(이 시각 이후 새 메일만 정리) + 새 안읽음 수(사이드바 배지). - // 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다. + // 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다. day_start=로컬 자정을 넘겨 + // '오늘 온 새 메일' 수(new_total/screened)를 클라 TZ 기준으로 산출(서버는 UTC). const { data: baseline, mutate: mutateBaseline } = useSWR( "mail/triage/baseline", - () => mailApi.triageBaseline(), + () => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return mailApi.triageBaseline(d.toISOString()); + }, + { revalidateOnFocus: false, refreshInterval: 30000 }, + ); + // 오늘 아리 자동 정리 실행 로그('한 일' 피드) — 실제로 돌았는지/에러났는지 눈으로 확인용. + const { data: triageLog } = useSWR( + "mail/triage/log", + () => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return mailApi.triageLog(d.toISOString()); + }, { revalidateOnFocus: false, refreshInterval: 30000 }, ); @@ -319,39 +335,16 @@ export function MailClient() { const doAdd = (key: string, kind: ExtractKind, index: number) => doAddFor(selId!, key, kind, index); - // 아리 비서: 새 메일을 가져와(sync) 미분석분을 LLM 으로 정리. 먼저 동기화해 - // 기준 시각 이후 새로 도착한 메일을 받고, 작은 배치(2통)로 나눠 반복 분석 — - // 요청을 짧게 유지하고 라운드마다 목록을 갱신해 카드를 점진적으로 노출한다. - const runAnalyze = async () => { - if (analyzing) return; - setAnalyzing(true); - let total = 0; - try { - await mailApi.sync().catch(() => {}); // 새 메일 먼저 가져오기(실패해도 분석 진행) - await revalidateAll(); - for (let round = 0; round < 8; round++) { - const res = await mailApi.triageAnalyze(2); - total += res.analyzed; - await mutate(); // 라운드마다 카드 갱신 - if (res.remaining === 0 || res.analyzed === 0) break; - } - toast(total > 0 ? `새 메일 ${total}통을 정리했어요` : "정리할 새 메일이 없어요", "blue"); - } catch { - if (total > 0) await mutate(); - toast("정리하지 못했어요 (아리 모델 확인)", "coral"); - } finally { - setAnalyzing(false); - } - }; - - // 기준 시각 재설정 — 지금 이후 도착하는 새 메일부터 다시 정리. - const onResetBaseline = async () => { + // 아리 정리 카드 기각 — 다이제스트에서만 빼고(받은편지함엔 잔류) 배지/목록 갱신. + const onDismissCard = async (id: string) => { + mutate((list) => (list || []).filter((m) => m.id !== id), false); // 낙관적 제거 try { - await mailApi.triageBaselineReset(); - await Promise.all([mutateBaseline(), mutate()]); - toast("지금부터 도착하는 새 메일만 정리할게요", "blue"); + await mailApi.triageDismiss(id); + await Promise.all([mutateBaseline(), globalMutate("mail/triage/baseline")]); + toast("이 메일은 정리에서 뺐어요", "blue"); } catch { - toast("기준 시각을 바꾸지 못했어요", "coral"); + await mutate(); // 롤백(서버 상태로 복원) + toast("기각하지 못했어요", "coral"); } }; @@ -532,16 +525,24 @@ export function MailClient() { setLogOpen(true)} onOpen={openMail} onAdd={doAddFor} onAddAll={addAll} onReply={onReply} + onDismiss={onDismissCard} + /> + setLogOpen(false)} + log={triageLog ?? []} + accounts={accMap} + accColor={accColor} + onOpenMail={openMail} /> {selId && readerEl} diff --git a/frontend/components/mail/Triage.tsx b/frontend/components/mail/Triage.tsx index e5ca00a..b6602ca 100644 --- a/frontend/components/mail/Triage.tsx +++ b/frontend/components/mail/Triage.tsx @@ -2,31 +2,17 @@ // 새 메일 중 '챙길 거리'를 발신자 카드로 정리: 요약 + 할 일/일정/답장 액션을 바로 적용. "use client"; import { Icon } from "@/components/Icon"; -import type { AiAnalysis, AiReply, EmailRow, ExtractKind, Person } from "@/lib/types"; +import type { + AiAnalysis, + AiReply, + EmailRow, + ExtractKind, + MailAccount, + Person, +} from "@/lib/types"; -const PALETTE = ["var(--blue)", "var(--violet)", "var(--coral)", "var(--green)", "var(--amber)"]; const PRIO_RANK: Record = { 높음: 0, 보통: 1, 낮음: 2 }; -// 기준 시각(ISO) → 사용자 로컬 표기('6월 18일 오후 2:30'). -function fmtSince(iso?: string): string { - if (!iso) return ""; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ""; - return d.toLocaleString("ko-KR", { - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - -// 발신자 이름 → 안정적 색(메일마다 같은 발신자는 같은 색). -function senderColor(name: string): string { - let h = 0; - for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; - return PALETTE[h % PALETTE.length]; -} - // 메일 한 통에 달린 액션 수(할 일 + 일정 + 답장 1). function actionCount(ai: AiAnalysis): number { return ai.tasks.length + ai.events.length + (ai.replies.length ? 1 : 0); @@ -80,33 +66,44 @@ function ActionRow({ function SecretaryCard({ m, name, - color, + account, + accColor, added, onOpen, onAdd, onAddAll, onReply, + onDismiss, }: { m: EmailRow; name: string; - color: string; + account?: MailAccount; + accColor: string; added: Record; onOpen: (id: string) => void; onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void; onAddAll: (m: EmailRow) => void; onReply: (m: EmailRow, idx: number, r: AiReply) => void; + onDismiss: (id: string) => void; }) { const ai = m.ai; if (!ai) return null; const status = statusOf(ai); const n = actionCount(ai); const reply = ai.replies[0]; + // 아바타는 받은편지함 행과 동일하게 '어느 계정'(발신자 아님) — 계정 색 + 계정 이니셜. + const accName = account?.name ?? m.account; + const avaColor = accColor ?? (account?.tone ? `var(--${account.tone})` : "var(--muted)"); return (
-
- {name.slice(0, 1).toUpperCase()} +
+ {accName.slice(0, 1).toUpperCase()}
{name} @@ -115,6 +112,14 @@ function SecretaryCard({ +

onOpen(m.id)}> @@ -176,33 +181,31 @@ function SecretaryCard({ export function Triage({ emails, people, + accounts, accColor, added, - analyzing, - since, - onAnalyze, - onResetBaseline, + newTotal, + onOpenLog, onOpen, onAdd, onAddAll, onReply, + onDismiss, }: { emails: EmailRow[]; people: Record; + accounts: Record; accColor: (id: string) => string; added: Record; - analyzing: boolean; - since?: string; - onAnalyze: () => void; - onResetBaseline: () => void; + newTotal: number; + onOpenLog: () => void; onOpen: (id: string) => void; onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void; onAddAll: (m: EmailRow) => void; onReply: (m: EmailRow, idx: number, r: AiReply) => void; + onDismiss: (id: string) => void; }) { const nameOf = (m: EmailRow) => people[m.from]?.name ?? m.from_name ?? m.from; - const colorOf = (m: EmailRow) => - people[m.from]?.color ?? senderColor(nameOf(m)) ?? accColor(m.account); // 새 메일 = 기준 시각 이후 도착(서버가 smart 보기로 이미 필터). 읽음 여부와 무관 — // 사용자가 폰/다른 앱에서 먼저 읽어도 아리가 챙길 거리를 정리한다. @@ -228,96 +231,83 @@ export function Triage({ ); // 아직 아리가 정리하지 않은(분석 전) 새 메일 수. const pending = emails.filter((m) => !m.ai).length; + const hasStats = tot.tasks + tot.events + tot.replies > 0; + + // 아리가 백그라운드로 자동 정리 중인 상태(수동 버튼 없음 — auto-sync 루프가 알아서 분석). + const working = pending > 0; + + // 화면 상태 → 제목 한 줄 + 설명 한 줄. 같은 말을 두 번 하지 않도록 히어로 한 곳에서만 보여준다. + let title: string; + let sub: string; + if (cards.length > 0) { + title = `챙길 메일 ${cards.length}건을 추렸어요`; + sub = `새 메일 ${fresh.length}통에서 챙길 거리만 골랐어요.`; + } else if (working) { + title = `새 메일 ${pending}통, 아리가 정리하는 중…`; + sub = "잠시 후 챙길 거리만 카드로 정리해 둘게요."; + } else if (newTotal > 0) { + title = `오늘 온 새 메일 ${newTotal}통, 챙길 건 없었어요`; + sub = "챙길 게 생기면 할 일·일정·답장 카드로 여기 정리해 둘게요."; + } else { + title = "지금은 챙길 새 메일이 없어요"; + sub = "새 메일이 오면 아리가 할 일·일정·답장으로 정리해 둘게요."; + } return (
-
- +
+
-
아리 비서 · 새 메일
-

- {cards.length > 0 - ? `새 메일 ${fresh.length}통 중 ${cards.length}건을 챙겨뒀어요` - : pending > 0 - ? `새 메일 ${pending}통, 정리해 드릴까요?` - : "지금은 챙길 새 메일이 없어요"} -

-

- 아리가 기준 시각 이후 도착한 새 메일만 읽고 할 일·일정·답장으로 정리해요. - 카드마다 바로 적용하거나 한 번에 처리하면, 결재가 필요한 발송은 결재함으로 올려둘게요. -

-
- {cards.length > 0 && ( -
+

{title}

+

{sub}

+ {hasStats && ( +
+ {tot.tasks > 0 && ( {tot.tasks} 할 일 + )} + {tot.events > 0 && ( {tot.events} 일정 + )} + {tot.replies > 0 && ( {tot.replies} 답장 -
- )} - -
- {since && ( -
- - {fmtSince(since)} 이후 도착한 메일만 정리해요 - - + )}
)} + {/* 오늘 새 메일이 어떻게 분류/분석됐는지 상세는 다이얼로그로. */} +
- {cards.length === 0 ? ( -
-
- 0 ? "spark" : "check"} /> -
- {pending > 0 ? ( - <> -

아직 정리하지 않은 새 메일이 {pending}통 있어요

-

위 ‘정리 맡기기’를 누르면 아리가 읽고 할 일·일정·답장으로 정리해 드려요.

- - ) : ( - <> -

새로 도착한 메일이 없어요

-

기준 시각 이후 새 메일이 오면 아리가 여기에 할 일·일정·답장으로 정리해 둘게요.

- - )} -
- ) : ( + {cards.length > 0 && (
{cards.map((m) => ( ))}
diff --git a/frontend/components/mail/TriageLogModal.tsx b/frontend/components/mail/TriageLogModal.tsx new file mode 100644 index 0000000..f0e70f9 --- /dev/null +++ b/frontend/components/mail/TriageLogModal.tsx @@ -0,0 +1,188 @@ +// frontend/components/mail/TriageLogModal.tsx — '오늘 메일 상세 로그' 다이얼로그(히어로 버튼으로 열림) +// 정직성: 집중/홍보/스팸 '분류'는 Gmail·Outlook 이 한 것. 아리가 한 일은 '집중 메일 분석'뿐. +"use client"; +import { useEffect } from "react"; +import useSWR from "swr"; +import { Icon } from "@/components/Icon"; +import { mailApi, type TriageLogEntry } from "@/lib/mail/api"; +import type { MailAccount } from "@/lib/types"; + +// ISO → 'HH:MM'(로컬). +function fmtTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleTimeString("ko-KR", { hour: "numeric", minute: "2-digit" }); +} + +// 분류 버킷 → 칩 색 클래스 / 표시 순서. +const BUCKET_TONE: Record = { + 집중: "focus", + 홍보: "promo", + 소셜: "social", + 업데이트: "update", + 스팸: "spam", + 휴지통: "muted", + 보관: "muted", + 기타: "muted", +}; +const BUCKET_ORDER = ["집중", "홍보", "소셜", "업데이트", "스팸", "휴지통", "보관", "기타"]; + +export function TriageLogModal({ + open, + onClose, + log, + accounts, + accColor, + onOpenMail, +}: { + open: boolean; + onClose: () => void; + log: TriageLogEntry[]; + accounts: Record; + accColor: (id: string) => string; + onOpenMail: (id: string) => void; +}) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + // 열렸을 때만 상세 분류를 가져온다. + const { data: rows } = useSWR( + open ? "mail/triage/classified" : null, + () => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return mailApi.triageClassified(d.toISOString()); + }, + { revalidateOnFocus: false }, + ); + + if (!open) return null; + const list = rows ?? []; + const counts: Record = {}; + list.forEach((r) => (counts[r.bucket] = (counts[r.bucket] ?? 0) + 1)); + const buckets = BUCKET_ORDER.filter((b) => counts[b]); + + return ( +
+
e.stopPropagation()}> +
+
+ 오늘 메일 상세 로그 +
+ +
+ +
+ {/* 아리가 실제로 한 일 = 집중 메일 LLM 분석(분류 아님). */} +
+

아리가 분석한 집중 메일

+ {log.length === 0 ? ( +

오늘 아리가 분석한 집중 메일이 아직 없어요.

+ ) : ( +
    + {log.map((e, i) => ( +
  • + + + {e.error + ? `분석 실패 · ${e.error}` + : `집중 메일 ${e.analyzed}통 분석${e.cards ? ` · ${e.cards}건 챙길 거리` : ""}`} + +
  • + ))} +
+ )} +
+ + {/* 버킷 분류는 Gmail·Outlook 이 한 것 — 정직하게 출처 표기. */} +
+

오늘 새 메일 {list.length}통

+

+ 집중·홍보·스팸 분류는 Gmail·Outlook이 한 거예요. 아리는 집중 메일만 읽어 할 + 일·일정·답장을 정리해요. +

+ {buckets.length > 0 && ( +
+ {buckets.map((b) => ( + + {b} {counts[b]} + + ))} +
+ )} + {!rows ? ( +

불러오는 중…

+ ) : list.length === 0 ? ( +

오늘 도착한 새 메일이 없어요.

+ ) : ( +
    + {list.map((r) => { + const accName = accounts[r.account]?.name ?? r.account; + const noAction = r.tasks === 0 && r.events === 0 && r.replies === 0; + return ( +
  • { + onOpenMail(r.id); + onClose(); + }} + > +
    + {accName.slice(0, 1).toUpperCase()} +
    +
    +
    + {r.from_name} + {r.time} +
    +
    {r.subject}
    +
    +
    + + {r.bucket} + + {r.focused && + (r.analyzed ? ( + + {[ + r.priority, + r.tasks > 0 && `할 일 ${r.tasks}`, + r.events > 0 && `일정 ${r.events}`, + r.replies > 0 && "답장", + noAction && "정보성", + ] + .filter(Boolean) + .join(" · ")} + + ) : ( + 분석 대기 + ))} +
    +
  • + ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/settings/LlmTab.tsx b/frontend/components/settings/LlmTab.tsx index 3fc0420..83fb0a5 100644 --- a/frontend/components/settings/LlmTab.tsx +++ b/frontend/components/settings/LlmTab.tsx @@ -1,4 +1,5 @@ -// frontend/components/settings/LlmTab.tsx — LLM 연결(provider/model/host/timeout) 편집·테스트·적용 +// frontend/components/settings/LlmTab.tsx — LLM 연결(provider/model/host/api키/timeout) 편집·테스트·적용 +// 지원 provider: ollama | openai(호환 엔드포인트) | gemini. provider 별로 입력 필드가 달라진다. "use client"; import { useEffect, useState } from "react"; import { Button } from "@/components/Button"; @@ -9,21 +10,84 @@ import { useLlmConfig } from "@/lib/hooks/useSettings"; import type { LlmProvider, LlmTestResult } from "@/lib/types"; import type { ToastFn } from "./SettingsClient"; -const PROVIDER_LABEL: Record = { +const PROVIDER_LABEL: Record = { auto: "자동", ollama: "Ollama", heuristic: "규칙(오프라인)", + openai: "OpenAI 호환", + gemini: "Gemini", }; +type ProviderMeta = { + hint: string; + hostLabel: string; + hostPlaceholder: string; + modelPlaceholder: string; + needsHost: boolean; + needsKey: boolean; + keyPlaceholder: string; + presets: { label: string; url: string }[]; +}; + +// provider 별 입력 메타. needsHost=호스트/베이스URL 필드 표시, needsKey=API 키 필드 표시. +const PROVIDER_META: Record = { + ollama: { + hint: "Ollama 서버(로컬/원격)를 사용해요.", + hostLabel: "Ollama 호스트 (API 주소)", + hostPlaceholder: "https://ollama.yirugi.synology.me", + modelPlaceholder: "예: gemma4:26b-a4b-it-q8_0", + needsHost: true, + needsKey: false, + keyPlaceholder: "", + presets: [ + { label: "Synology 원격", url: "https://ollama.yirugi.synology.me" }, + { label: "로컬", url: "http://localhost:11434" }, + ], + }, + openai: { + hint: "OpenAI 호환 엔드포인트(OpenAI·OpenRouter·Groq·로컬 vLLM/LM Studio 등).", + hostLabel: "API 베이스 URL", + hostPlaceholder: "https://api.openai.com/v1", + modelPlaceholder: "예: gpt-4o-mini", + needsHost: true, + needsKey: true, + keyPlaceholder: "sk-…", + presets: [ + { label: "OpenAI", url: "https://api.openai.com/v1" }, + { label: "OpenRouter", url: "https://openrouter.ai/api/v1" }, + { label: "Gemini(호환)", url: "https://generativelanguage.googleapis.com/v1beta/openai" }, + ], + }, + gemini: { + hint: "Google Gemini API. 키만 입력하면 기본 엔드포인트를 사용해요.", + hostLabel: "API 베이스 URL (선택)", + hostPlaceholder: "https://generativelanguage.googleapis.com/v1beta", + modelPlaceholder: "예: gemini-2.5-flash", + needsHost: false, + needsKey: true, + keyPlaceholder: "AIza…", + presets: [], + }, +}; + +function metaFor(p: string): ProviderMeta { + return PROVIDER_META[p] ?? PROVIDER_META.ollama; +} + export function LlmTab({ toast }: { toast: ToastFn }) { const { data: cfg, isLoading, refresh } = useLlmConfig(); const [provider, setProvider] = useState("ollama"); const [model, setModel] = useState(""); const [host, setHost] = useState(""); + const [apiKey, setApiKey] = useState(""); const [timeout, setTimeoutVal] = useState(""); const [test, setTest] = useState(null); const [testing, setTesting] = useState(false); const [saving, setSaving] = useState(false); + // 호스트/키에서 불러온 사용 가능한 모델 목록(드롭다운 선택). reloadKey 증가 시 강제 재조회. + const [models, setModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); + const [reloadKey, setReloadKey] = useState(0); useEffect(() => { if (cfg) { @@ -31,17 +95,66 @@ export function LlmTab({ toast }: { toast: ToastFn }) { setModel(cfg.model); setHost(cfg.host); setTimeoutVal(String(cfg.timeout)); + setApiKey(""); // 키는 절대 내려받지 않음 — 비워두고 'set' 여부만 표시. } }, [cfg]); - const isHeuristic = provider === "heuristic"; + const meta = metaFor(provider); + const keySet = !!cfg?.api_key_set; + + // provider 전환: 잔여 host/key 혼입을 막고 새 provider 기본 호스트로 초기화. + const changeProvider = (p: LlmProvider) => { + setProvider(p); + setTest(null); + setModels([]); + setModel(""); // provider 마다 모델명 체계가 달라 초기화(목록서 다시 선택) + setApiKey(""); + const presets = metaFor(p).presets; + setHost(presets[0]?.url ?? ""); + }; + + // host/key 가 준비되면 모델 목록을 자동 조회 → 드롭다운. 디바운스 450ms. 새로고침=reloadKey. + useEffect(() => { + if (provider === "heuristic") { + setModels([]); + return; + } + const needsKey = provider === "openai" || provider === "gemini"; + const needsHost = provider === "ollama" || provider === "openai"; + if (needsHost && !host.trim()) { + setModels([]); + return; + } + if (needsKey && !apiKey.trim() && !cfg?.api_key_set) { + setModels([]); + return; + } + let cancelled = false; + const run = async () => { + setLoadingModels(true); + try { + const r = await settingsApi.testLlm({ provider, host, api_key: apiKey || undefined }); + if (!cancelled) setModels(r.reachable ? r.models : []); + } catch { + if (!cancelled) setModels([]); + } finally { + if (!cancelled) setLoadingModels(false); + } + }; + const t = setTimeout(run, 450); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [host, provider, apiKey, reloadKey, cfg?.api_key_set]); const onTest = async () => { setTesting(true); setTest(null); try { - const r = await settingsApi.testLlm({ provider, host, model }); + const r = await settingsApi.testLlm({ provider, host, model, api_key: apiKey || undefined }); setTest(r); + if (r.reachable) setModels(r.models); toast( r.reachable ? "연결에 성공했어요" : "연결할 수 없어요", r.reachable ? "green" : "coral", @@ -62,8 +175,11 @@ export function LlmTab({ toast }: { toast: ToastFn }) { model, host, timeout: Number.isFinite(t) && t > 0 ? t : undefined, + // 입력했을 때만 키 전송(빈칸=기존 키 유지). 지우려면 '기본값으로'. + api_key: apiKey.trim() ? apiKey : undefined, }); await refresh(); + setApiKey(""); toast("LLM 설정을 저장했어요", "green"); } catch { toast("저장에 실패했어요", "coral"); @@ -75,7 +191,7 @@ export function LlmTab({ toast }: { toast: ToastFn }) { const onReset = async () => { setSaving(true); try { - await settingsApi.updateLlm({ provider: "", model: "", host: "", timeout: 0 }); + await settingsApi.updateLlm({ provider: "", model: "", host: "", timeout: 0, api_key: "" }); const next = await refresh(); if (next) { setProvider(next.provider); @@ -83,6 +199,7 @@ export function LlmTab({ toast }: { toast: ToastFn }) { setHost(next.host); setTimeoutVal(String(next.timeout)); } + setApiKey(""); setTest(null); toast("기본값(.env)으로 되돌렸어요", "blue"); } catch { @@ -104,53 +221,112 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
({ + options={(cfg?.provider_options ?? ["ollama", "openai", "gemini"]).map((p) => ({ id: p, - label: PROVIDER_LABEL[p as LlmProvider] ?? p, + label: PROVIDER_LABEL[p] ?? p, }))} value={provider} - onChange={(id) => setProvider(id as LlmProvider)} + onChange={(id) => changeProvider(id as LlmProvider)} /> - 로컬 Ollama 서버를 사용해요. + {meta.hint}
-
- - setHost(e.target.value)} - placeholder="http://localhost:11434" - /> -
+ {meta.needsHost && ( +
+ + setHost(e.target.value)} + placeholder={meta.hostPlaceholder} + /> + {meta.presets.length > 0 && ( +
+ {meta.presets.map((h) => ( + + ))} +
+ )} +
+ )} + + {meta.needsKey && ( +
+ + setApiKey(e.target.value)} + placeholder={keySet ? "설정됨 · 새 키를 입력하면 교체돼요" : meta.keyPlaceholder} + /> + + {keySet + ? "키가 저장돼 있어요(암호화). 비워 두면 그대로 유지, 입력하면 교체돼요." + : "키는 암호화해서 저장하고, 화면에는 다시 표시하지 않아요."} + +
+ )}
- setModel(e.target.value)} - placeholder="예: llama3.1" - /> - {test && test.models.length > 0 && ( -
- {test.models.map((m) => ( - +
@@ -161,7 +337,6 @@ export function LlmTab({ toast }: { toast: ToastFn }) { min={1} className="set-input set-mono" value={timeout} - disabled={isHeuristic} onChange={(e) => setTimeoutVal(e.target.value)} />
diff --git a/frontend/lib/hooks/useMail.ts b/frontend/lib/hooks/useMail.ts index 1fd1162..a4df9e8 100644 --- a/frontend/lib/hooks/useMail.ts +++ b/frontend/lib/hooks/useMail.ts @@ -25,8 +25,11 @@ export function useMail(account: string | null, folder: string, query = "") { // 않게 해 전체 화면 스켈레톤(사이드바 언마운트=트리 닫힘)을 막는다. keepPreviousData: true, }); + // 계정별 안읽음 배지(집중/기타)의 소스. 폰 등 외부에서 읽으면 서버 카운트가 줄지만, + // 자동 재검증이 없으면 배지가 예전 값(예: 1)에 멈춘다 → 포커스·주기(30초) 갱신을 켠다. const { data: accounts } = useSWR("mail/accounts", () => mailApi.accounts(), { - revalidateOnFocus: false, + revalidateOnFocus: true, + refreshInterval: 30_000, }); const { data: folders } = useSWR("mail/folders", () => mailApi.folders(), { revalidateOnFocus: false, diff --git a/frontend/lib/mail/api.ts b/frontend/lib/mail/api.ts index 138fdeb..ebbb719 100644 --- a/frontend/lib/mail/api.ts +++ b/frontend/lib/mail/api.ts @@ -14,6 +14,26 @@ import type { } from "@/lib/types"; const J = { "Content-Type": "application/json" }; + +// 아리 자동 정리 실행 로그 1줄(사이클 단위). +export type TriageLogEntry = { at: string; analyzed: number; cards: number; error: string | null }; +// 오늘 새 메일 1통의 '아리 분류·분석' 상세 — 상세 로그 뷰용. +export type TriageClassifiedRow = { + id: string; + account: string; + from_name: string; + subject: string; + time: string; + bucket: string; // 집중/홍보/소셜/업데이트/스팸/휴지통/보관/기타 + focused: boolean; + analyzed: boolean; + priority: string | null; + tasks: number; + events: number; + replies: number; + is_card: boolean; +}; + async function ok(r: Response): Promise { if (!r.ok) { let msg = `${r.status} ${r.statusText}`; @@ -72,22 +92,31 @@ export const mailApi = { ok(r), ), - // 아리 비서: 안읽음 집중 메일 중 미분석분을 LLM 으로 배치 분석(점진). - triageAnalyze: (limit = 6) => - fetch(`/api/mail/triage/analyze?limit=${limit}`, { method: "POST", headers: J, body: "{}" }).then( - (r) => ok<{ analyzed: number; remaining: number; errors: number }>(r), - ), - - // 아리 정리 기준 시각(이 시각 이후 도착한 새 메일만 정리) + 새 메일 수(읽음 무관). - triageBaseline: () => - fetch("/api/mail/triage/baseline", { cache: "no-store" }).then((r) => - ok<{ since: string; count: number }>(r), - ), - - // 기준 시각을 지금으로 재설정 — 이후 새 메일부터 다시. - triageBaselineReset: () => - fetch("/api/mail/triage/baseline", { method: "POST", headers: J, body: "{}" }).then((r) => - ok<{ since: string; count: number }>(r), + // 아리 정리 기준 시각 + 요약: count(카드/배지) · new_total(오늘 온 새 메일) · screened(자동 걸러낸 수). + // 새 메일 분석은 백그라운드 auto-triage 가 전담 — 수동 분석/기준 재설정 엔드포인트는 없앴다. + // dayStart=클라 로컬 자정(ISO) → new_total/screened 를 '오늘 도착분'으로 한정(서버 UTC TZ 보정). + triageBaseline: (dayStart?: string) => + fetch(`/api/mail/triage/baseline${dayStart ? `?day_start=${encodeURIComponent(dayStart)}` : ""}`, { + cache: "no-store", + }).then((r) => ok<{ since: string; count: number; new_total: number; screened: number }>(r)), + + // 오늘 아리 자동 정리 실행 로그(관측성) — 실제 분석/에러를 시각과 함께. dayStart=클라 로컬 자정. + triageLog: (dayStart?: string) => + fetch(`/api/mail/triage/log${dayStart ? `?day_start=${encodeURIComponent(dayStart)}` : ""}`, { + cache: "no-store", + }).then((r) => ok(r)), + + // 오늘 새 메일별 분류·분석 상세(상세 로그 뷰) — 어떤 메일을 어디로 분류했는지 하나씩. + triageClassified: (dayStart?: string) => + fetch( + `/api/mail/triage/classified${dayStart ? `?day_start=${encodeURIComponent(dayStart)}` : ""}`, + { cache: "no-store" }, + ).then((r) => ok(r)), + + // 아리 정리에서 이 메일 카드를 기각 — 다이제스트에서 빼고 다시 표시하지 않음(받은편지함엔 잔류). + triageDismiss: (id: string) => + fetch(`/api/mail/${id}/triage/dismiss`, { method: "POST", headers: J, body: "{}" }).then((r) => + ok<{ ok: boolean; count: number }>(r), ), replyDraft: (id: string, reply_index: number | null) => diff --git a/frontend/lib/settings/api.ts b/frontend/lib/settings/api.ts index d748dd8..33ecc80 100644 --- a/frontend/lib/settings/api.ts +++ b/frontend/lib/settings/api.ts @@ -29,7 +29,7 @@ export const settingsApi = { body: JSON.stringify(body), }).then((r) => ok(r)), - testLlm: (body: { provider?: string; host?: string; model?: string }) => + testLlm: (body: { provider?: string; host?: string; model?: string; api_key?: string }) => fetch("/api/settings/llm/test", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 4c5805d..5e61df5 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -640,7 +640,7 @@ export type Me = { }; // ==================== 설정(settings) ==================== -export type LlmProvider = "auto" | "ollama" | "heuristic"; +export type LlmProvider = "auto" | "ollama" | "heuristic" | "openai" | "gemini"; export interface LlmConfig { provider: LlmProvider; @@ -649,9 +649,10 @@ export interface LlmConfig { timeout: number; provider_options: LlmProvider[]; overridden: boolean; - embed_provider: string; - embed_model: string; - agent_provider: string; + api_key_set?: boolean; + embed_provider?: string; + embed_model?: string; + agent_provider?: string; stt_provider: string; vision_provider: string; } @@ -661,6 +662,7 @@ export interface LlmUpdate { model?: string; host?: string; timeout?: number; + api_key?: string; } export interface LlmTestResult { diff --git a/frontend/styles/mail.css b/frontend/styles/mail.css index 350aef7..2ec25f0 100644 --- a/frontend/styles/mail.css +++ b/frontend/styles/mail.css @@ -2226,13 +2226,6 @@ .mailpage .sec-hero-main { min-width: 0; } -.mailpage .sec-eyebrow { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.02em; - color: var(--coral); - margin-bottom: 4px; -} .mailpage .sec-hero-main h2 { font-size: 18px; font-weight: 800; @@ -2242,7 +2235,7 @@ } .mailpage .sec-hero-main p { font-size: 13px; - line-height: 1.6; + line-height: 1.55; color: var(--ink-2); margin: 0; } @@ -2368,6 +2361,26 @@ width: 17px; height: 17px; } +.mailpage .sc-dismiss { + width: 30px; + height: 30px; + flex-shrink: 0; + border-radius: 9px; + display: grid; + place-items: center; + color: var(--muted); + transition: + background 0.13s, + color 0.13s; +} +.mailpage .sc-dismiss:hover { + background: var(--coral-tint, rgba(244, 63, 94, 0.12)); + color: var(--coral); +} +.mailpage .sc-dismiss .ic { + width: 16px; + height: 16px; +} .mailpage .sc-title { font-size: 14.5px; font-weight: 700; @@ -2517,117 +2530,277 @@ height: 15px; } -/* ---- 비어있음 ---- */ -.mailpage .sec-empty { - text-align: center; - padding: 50px 30px; +/* 자동 정리 중 — 스파크 아이콘이 돌며 '아리가 일하는 중'을 알린다(수동 버튼 없음). */ +.mailpage .sec-spark.working .ic { + animation: sec-spin 0.9s linear infinite; +} +@keyframes sec-spin { + to { + transform: rotate(360deg); + } +} + +/* 히어로 '오늘 메일 상세 로그' 버튼 — 다이얼로그를 연다. */ +.mailpage .sec-loglink { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 12px; + padding: 6px 12px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: 12px; + font-weight: 600; color: var(--ink-2); + background: var(--card); + cursor: pointer; + transition: + color 0.12s, + border-color 0.12s; +} +.mailpage .sec-loglink:hover { + color: var(--ink); + border-color: var(--line-2); +} +.mailpage .sec-loglink .ic { + width: 14px; + height: 14px; } -.mailpage .sec-empty .se-ico { - width: 52px; - height: 52px; - margin: 0 auto 14px; - border-radius: 15px; + +/* ---- 상세 로그 다이얼로그 ---- */ +.tlog-overlay { + position: fixed; + inset: 0; + z-index: 80; display: grid; place-items: center; - color: var(--green); - background: color-mix(in oklab, var(--green) 14%, transparent); + padding: 24px; + background: rgba(15, 18, 28, 0.46); + backdrop-filter: blur(2px); } -.mailpage .sec-empty .se-ico .ic { - width: 24px; - height: 24px; +.tlog-panel { + display: flex; + flex-direction: column; + width: min(640px, 100%); + max-height: min(80vh, 760px); + background: var(--card); + border: 1px solid var(--line); + border-radius: 18px; + box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45); + overflow: hidden; } -.mailpage .sec-empty h3 { +.tlog-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--line); +} +.tlog-title { + display: inline-flex; + align-items: center; + gap: 8px; font-size: 15px; - font-weight: 700; + font-weight: 800; color: var(--ink); - margin: 0 0 6px; } -.mailpage .sec-empty p { - font-size: 13px; +.tlog-title .ic { + width: 17px; + height: 17px; + color: var(--coral); +} +.tlog-close { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 9px; + color: var(--muted); + cursor: pointer; + transition: + background 0.13s, + color 0.13s; +} +.tlog-close:hover { + background: var(--card-2); + color: var(--ink); +} +.tlog-close .ic { + width: 17px; + height: 17px; +} +.tlog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 18px 18px; + scrollbar-width: thin; +} +.tlog-sec { + margin-bottom: 18px; +} +.tlog-sec:last-child { + margin-bottom: 0; +} +.tlog-sec h4 { + font-size: 12px; + font-weight: 700; + color: var(--ink-2); + margin: 0 0 8px; +} +.tlog-note { + font-size: 11.5px; + line-height: 1.5; + color: var(--muted); + margin: 0 0 10px; +} +.tlog-note b { + color: var(--ink-2); +} +.tlog-events { + display: flex; + flex-direction: column; + gap: 6px; margin: 0; + padding: 0; + list-style: none; + font-size: 12.5px; + color: var(--ink-2); } - -/* 비서 히어로: 통계 + 정리 맡기기 버튼 한 줄. */ -.mailpage .sec-hero-row { +.tlog-events li { display: flex; - align-items: center; gap: 10px; - flex-wrap: wrap; - margin-top: 12px; } -.mailpage .sec-hero-row .sec-stats { - margin-top: 0; +.tlog-events time { + flex-shrink: 0; + width: 58px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.tlog-events li.err span { + color: var(--coral); + font-weight: 600; +} +.tlog-muted { + font-size: 12.5px; + color: var(--muted); + margin: 0; } -.mailpage .sec-analyze { +.tlog-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} +.tlog-chip { display: inline-flex; align-items: center; - gap: 6px; - margin-left: auto; - padding: 8px 15px; - border-radius: 11px; - font-size: 12.5px; - font-weight: 700; - color: #fff; - background: linear-gradient(135deg, var(--coral), var(--violet)); - box-shadow: 0 8px 18px -9px var(--coral); - cursor: pointer; - transition: - transform 0.12s, - filter 0.14s; + padding: 3px 9px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 600; + color: var(--ink-2); + background: var(--card-2); + border: 1px solid var(--line); + white-space: nowrap; } -.mailpage .sec-analyze:hover { - transform: translateY(-1px); - filter: brightness(1.05); +.tlog-chip.focus { + color: var(--violet); + border-color: color-mix(in oklab, var(--violet) 34%, transparent); + background: color-mix(in oklab, var(--violet) 12%, transparent); } -.mailpage .sec-analyze:disabled { - cursor: default; - opacity: 0.85; +.tlog-chip.promo { + color: #b45309; + border-color: color-mix(in oklab, #f59e0b 36%, transparent); + background: color-mix(in oklab, #f59e0b 13%, transparent); } -.mailpage .sec-analyze .ic { - width: 15px; - height: 15px; +.tlog-chip.social { + color: var(--green); + border-color: color-mix(in oklab, var(--green) 34%, transparent); + background: color-mix(in oklab, var(--green) 12%, transparent); } -.mailpage .sec-analyze:disabled .ic { - animation: sec-spin 0.9s linear infinite; +.tlog-chip.update { + color: var(--blue); + border-color: color-mix(in oklab, var(--blue) 34%, transparent); + background: color-mix(in oklab, var(--blue) 12%, transparent); } -@keyframes sec-spin { - to { - transform: rotate(360deg); - } +.tlog-chip.spam { + color: var(--coral); + border-color: color-mix(in oklab, var(--coral) 34%, transparent); + background: color-mix(in oklab, var(--coral) 12%, transparent); } -/* 기준 시각 안내 + '지금부터 다시' 재설정 */ -.mailpage .sec-baseline { +.tlog-mails { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} +.tlog-mail { display: flex; align-items: center; + gap: 10px; + padding: 8px 8px; + border-radius: 10px; + cursor: pointer; + transition: background 0.12s; +} +.tlog-mail:hover { + background: var(--card-2); +} +.tlog-mail .tm-ava { + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: 8px; + display: grid; + place-items: center; + color: #fff; + font-weight: 800; + font-size: 12px; +} +.tlog-mail .tm-main { + min-width: 0; + flex: 1; +} +.tlog-mail .tm-top { + display: flex; + align-items: baseline; gap: 8px; - flex-wrap: wrap; - margin-top: 12px; - font-size: 11.5px; - color: var(--muted); } -.mailpage .sec-baseline b { - color: var(--text); - font-weight: 650; +.tlog-mail .tm-from { + font-size: 12.5px; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.mailpage .sec-baseline button { - padding: 3px 9px; - border: 1px solid var(--line); - border-radius: 999px; +.tlog-mail .tm-time { + flex-shrink: 0; font-size: 11px; - font-weight: 600; color: var(--muted); - background: var(--surface); - cursor: pointer; - transition: - color 0.12s, - border-color 0.12s; } -.mailpage .sec-baseline button:hover { - color: var(--text); - border-color: var(--muted); +.tlog-mail .tm-subj { + font-size: 12px; + color: var(--ink-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.mailpage .sec-baseline button:disabled { - cursor: default; - opacity: 0.6; +.tlog-mail .tm-tags { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + flex-shrink: 0; +} +.tlog-mail .tm-ana { + font-size: 10.5px; + color: var(--muted); + white-space: nowrap; +} +.tlog-mail .tm-ana.wait { + color: var(--blue); } diff --git a/frontend/styles/settings.css b/frontend/styles/settings.css index d5ea26e..7465af1 100644 --- a/frontend/styles/settings.css +++ b/frontend/styles/settings.css @@ -264,6 +264,24 @@ font-size: 12px; color: var(--muted); } +/* 모델 드롭다운 아래 메타 줄(안내 문구 + 새로고침) */ +.settingspage .set-modelmeta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} +/* 네이티브 select 를 set-input 룩에 맞춤(화살표 여백 확보) */ +.settingspage select.set-input { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2399a' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 34px; + cursor: pointer; +} /* ===== 태그/배지 ===== */ .settingspage .set-tag {