diff --git a/ai-service/app/main.py b/ai-service/app/main.py index 8002e77..46172f2 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, model_validator from .escalation_filter import check as check_escalation from .generation import draft_reply, last_incoming_text, load_dotenv_if_present from .retrieve_style import retrieve as retrieve_style_examples +from .summarize import summarize_messages @asynccontextmanager @@ -72,3 +73,22 @@ def draft(req: DraftRequest): status, text = draft_reply(style_examples, req.context_lines, model=req.model) return DraftResponse(status=status, text=text) + + +class SummarizeRequest(BaseModel): + my_display_name: str + context_lines: List[str] + model: str = "gemini-2.5-flash" + + +class SummarizeResponse(BaseModel): + status: str + summary: str + + +@app.post("/summarize", response_model=SummarizeResponse) +def summarize(req: SummarizeRequest): + """단톡 따라잡기 (roadmap.md §2.7-A) -- read-only catch-up summary, no + escalation/identity gating since nothing generated here is ever sent.""" + status, text = summarize_messages(req.my_display_name, req.context_lines, model=req.model) + return SummarizeResponse(status=status, summary=text) diff --git a/ai-service/app/summarize.py b/ai-service/app/summarize.py new file mode 100644 index 0000000..4e39173 --- /dev/null +++ b/ai-service/app/summarize.py @@ -0,0 +1,46 @@ +"""Group catch-up summary -- roadmap.md §2.7-A "단톡 따라잡기". Separate from +generation.py's draft_reply: this never produces something meant to be sent, +so it carries none of the escalation/identity gating a draft needs, and it +summarizes many messages at once instead of drafting one reply. +""" +from .generation import load_dotenv_if_present # re-exported for main.py convenience + +SUMMARY_SYSTEM_PROMPT = """너는 사용자가 오랫동안 못 본 단체 대화방을 대신 읽고 요약해 주는 비서다. \ +아래 대화 로그를 보고, 이 사용자('나')에게 필요한 내용만 3~5줄로 한국어로 요약해라. + +지켜야 할 것: +- 나에게 멘션/질문된 것, 결정된 사항(약속·시간·장소 등)을 우선으로 다룬다. +- 나와 무관한 잡담은 요약에서 뺀다. +- 답장이 필요해 보이는 항목이 있으면 마지막 줄에 "-> 답장 필요: ..." 형식으로 짧게 짚어준다. +- 요약문만 출력한다. 인사말, 설명, 따옴표를 덧붙이지 않는다. +- 이 요약은 그 자체로 전송되지 않는다 -- 답장은 항상 사용자가 직접 검토해서 보낸다.""" + + +def build_summary_prompt(my_display_name, context_lines): + context = "\n".join(context_lines) + return f"""[내 이름] {my_display_name}\n\n[안 본 동안의 대화]\n{context}\n\n위 대화 요약:""" + + +def summarize_messages(my_display_name, context_lines, model="gemini-2.5-flash", api_key=None): + """Returns (status, text). status is one of "no_key" | "ok". + + "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). + "ok": text is the generated summary. + """ + import os + + api_key = api_key or os.environ.get("GEMINI_API_KEY") + prompt = build_summary_prompt(my_display_name, context_lines) + if not api_key: + return "no_key", prompt + + from google import genai # pip install google-genai + from google.genai import types + + client = genai.Client(api_key=api_key) + resp = client.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig(system_instruction=SUMMARY_SYSTEM_PROMPT, max_output_tokens=300), + ) + return "ok", resp.text.strip() diff --git a/ai-service/tests/test_main.py b/ai-service/tests/test_main.py index 311a398..9b5a781 100644 --- a/ai-service/tests/test_main.py +++ b/ai-service/tests/test_main.py @@ -90,3 +90,18 @@ def test_draft_rejects_both_style_sources(): def test_draft_rejects_neither_style_source(): resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]}) assert resp.status_code == 422 + + +def test_summarize_no_key(monkeypatch): + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + resp = client.post( + "/summarize", + json={ + "my_display_name": "민수", + "context_lines": ["철수: 토요일 모임 3시로 하자", "영희: ㅇㅋ"], + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "no_key" + assert "토요일 모임 3시로 하자" in body["summary"] diff --git a/ai-service/tests/test_summarize.py b/ai-service/tests/test_summarize.py new file mode 100644 index 0000000..0f762e8 --- /dev/null +++ b/ai-service/tests/test_summarize.py @@ -0,0 +1,55 @@ +"""단톡 따라잡기 (roadmap.md §2.7-A) -- the Gemini call is mocked, same pattern +as test_generation.py. Asserts the no_key fallback and the exact request +shape sent to the model; not real summary quality.""" +import sys +import types +from unittest.mock import MagicMock + +from app.summarize import build_summary_prompt, summarize_messages + + +def test_build_summary_prompt_includes_name_and_context(): + prompt = build_summary_prompt("민수", ["철수: 이번 주 토요일 모임 3시로 확정", "영희: 나 못 갈 것 같아"]) + assert "민수" in prompt + assert "토요일 모임 3시로 확정" in prompt + assert "나 못 갈 것 같아" in prompt + + +def test_summarize_messages_no_key_returns_prompt_without_calling_model(monkeypatch): + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + status, text = summarize_messages("민수", ["철수: 밥 언제 먹지"], api_key=None) + assert status == "no_key" + assert "밥 언제 먹지" in text + + +def test_summarize_messages_ok_calls_gemini_with_expected_args(monkeypatch): + # summarize.py does `from google import genai` / `from google.genai import + # types` *inside* summarize_messages, same stubbing approach as + # test_generation.py to avoid the real google-genai dependency chain. + fake_response = MagicMock() + fake_response.text = " 토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기 " + fake_client = MagicMock() + fake_client.models.generate_content.return_value = fake_response + + fake_genai = types.ModuleType("google.genai") + fake_genai.Client = MagicMock(return_value=fake_client) + fake_types = types.ModuleType("google.genai.types") + fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw) + fake_google = types.ModuleType("google") + fake_google.genai = fake_genai + + monkeypatch.setitem(sys.modules, "google", fake_google) + monkeypatch.setitem(sys.modules, "google.genai", fake_genai) + monkeypatch.setitem(sys.modules, "google.genai.types", fake_types) + + status, text = summarize_messages( + "민수", ["철수: 토요일 3시에 모이자", "영희: ㅇㅋ"], model="gemini-2.5-flash", api_key="fake-key" + ) + + assert status == "ok" + assert text == "토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기" + fake_genai.Client.assert_called_once_with(api_key="fake-key") + fake_client.models.generate_content.assert_called_once() + _, kwargs = fake_client.models.generate_content.call_args + assert kwargs["model"] == "gemini-2.5-flash" + assert "토요일 3시에 모이자" in kwargs["contents"] diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index 6d80c0b..155d8b6 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -193,12 +193,25 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { for _, pp := range pps { participantIDs = append(participantIDs, pp.UserID) } + + // Unread count (roadmap.md §2.7-A "안 본 동안" badge) -- messages + // after this participant's read marker, excluding their own sends + // (sending a message means you've obviously seen it). + unreadQuery := db.Model(&Message{}). + Where("conversation_id = ? AND retracted = ? AND sender_id != ?", conv.ID, false, userID) + if p.LastReadMessageID != nil { + unreadQuery = unreadQuery.Where("id > ?", *p.LastReadMessageID) + } + var unreadCount int64 + unreadQuery.Count(&unreadCount) + out = append(out, gin.H{ "id": conv.ID, "is_group": conv.IsGroup, "twin_disabled_by_peer": conv.TwinDisabledByPeer, "user_ids": participantIDs, "created_at": conv.CreatedAt, + "unread_count": unreadCount, }) } c.JSON(http.StatusOK, gin.H{"conversations": out}) diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go index 7bd6df1..746a76c 100644 --- a/core-backend/aiservice.go +++ b/core-backend/aiservice.go @@ -53,6 +53,42 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error) return &out, nil } +type summaryRequest struct { + MyDisplayName string `json:"my_display_name"` + ContextLines []string `json:"context_lines"` +} + +type summaryResponse struct { + Status string `json:"status"` + Summary string `json:"summary"` +} + +// requestSummary calls ai-service's POST /summarize -- the "단톡 따라잡기" +// catch-up summary (roadmap.md §2.7-A), separate from /draft since it never +// produces something meant to be sent, only read. +func (c *AIServiceClient) requestSummary(req summaryRequest) (*summaryResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + resp, err := c.HTTP.Post(c.BaseURL+"/summarize", "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ai service unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ai service returned %d", resp.StatusCode) + } + + var out summaryResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} + type escalationCheckRequest struct { Text string `json:"text"` } diff --git a/core-backend/group_summary.go b/core-backend/group_summary.go new file mode 100644 index 0000000..83c1694 --- /dev/null +++ b/core-backend/group_summary.go @@ -0,0 +1,135 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// registerGroupSummaryRoutes implements roadmap.md §2.7-A "단톡 따라잡기": +// a read marker per participant + an AI-generated catch-up summary of +// whatever arrived since that marker. Kept separate from a1_a2_routes.go +// since it's new (2026-07-31) scope, not part of the original A1/A2 surface. +func registerGroupSummaryRoutes(r *gin.Engine, db *gorm.DB, ai *AIServiceClient) { + // POST /conversations/:id/read advances the caller's read marker. Body: + // {"message_id": }. Never moves + // the marker backward, so out-of-order client calls can't lose history. + r.POST("/conversations/:id/read", func(c *gin.Context) { + actor, ok := currentUser(c, db, true) + if !ok { + return + } + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !isParticipant(db, convID, actor.ID) { + c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"}) + return + } + var req struct { + MessageID uint `json:"message_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + var participant ConversationParticipant + if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID). + First(&participant).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"}) + return + } + if participant.LastReadMessageID == nil || *participant.LastReadMessageID < req.MessageID { + participant.LastReadMessageID = &req.MessageID + if err := db.Save(&participant).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + } + c.JSON(http.StatusOK, gin.H{"last_read_message_id": participant.LastReadMessageID}) + }) + + // GET /conversations/:id/summary returns an AI-generated 3~5 line + // catch-up of messages since the caller's read marker, focused on + // mentions of the caller and decisions made (PRD.md §2.3-②). Read-only + // -- it never sends anything, so it carries none of the escalation/ + // autonomy-level machinery that POST /messages does. + r.GET("/conversations/:id/summary", func(c *gin.Context) { + actor, ok := currentUser(c, db, true) + if !ok { + return + } + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !isParticipant(db, convID, actor.ID) { + c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"}) + return + } + + var participant ConversationParticipant + if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID). + First(&participant).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"}) + return + } + + q := db.Where("conversation_id = ? AND retracted = ?", convID, false) + if participant.LastReadMessageID != nil { + q = q.Where("id > ?", *participant.LastReadMessageID) + } + var unread []Message + q.Order("id asc").Find(&unread) + + if len(unread) == 0 { + c.JSON(http.StatusOK, gin.H{"status": "empty", "summary": "", "unread_count": 0}) + return + } + + names := map[uint]string{} + contextLines := make([]string, 0, len(unread)) + needsReply := false + for _, m := range unread { + var label string + if m.SenderID == actor.ID { + label = "나" + } else { + name, cached := names[m.SenderID] + if !cached { + var sender User + if err := db.First(&sender, m.SenderID).Error; err == nil { + name = sender.DisplayName + } + if name == "" { + name = "상대" + } + names[m.SenderID] = name + } + label = name + needsReply = true + } + contextLines = append(contextLines, label+": "+m.Text) + } + + result, err := ai.requestSummary(summaryRequest{ + MyDisplayName: actor.DisplayName, + ContextLines: contextLines, + }) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": result.Status, + "summary": strings.TrimSpace(result.Summary), + "unread_count": len(unread), + "needs_reply": needsReply, + }) + }) +} diff --git a/core-backend/group_summary_test.go b/core-backend/group_summary_test.go new file mode 100644 index 0000000..d6b7fda --- /dev/null +++ b/core-backend/group_summary_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "testing" +) + +func createGroup(t *testing.T, serverURL, token string, userIDs []uint) uint { + t.Helper() + resp := postJSONAuth(t, serverURL+"/conversations", token, createConversationRequest{ + UserIDs: userIDs, + IsGroup: true, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("create group: %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + return uint(out["id"].(float64)) +} + +func TestGroupConversationBlocksTwinAutoSendRegardlessOfAutonomyLevel(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "주인") + peerID, _ := mustSignup(t, server.URL, "친구") + convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID}) + + // L2 with a matching whitelist rule would normally auto-send in a 1:1 + // conversation (main_test.go's autonomy flow tests cover that) -- group + // conversations must block twin sends outright regardless (PRD.md + // §2.3-③, roadmap.md §2.7-A: "이 시나리오는 L0 고정, 자동 발송 없음"). + setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL2) + + resp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{ + SenderID: ownerID, + Text: "ㅇㅋ 알겠음", + SenderMode: SenderTwin, + Approved: true, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 blocking twin send in group, got %d", resp.StatusCode) + } + + // A human-authored send in the same group is unaffected. + humanResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{ + SenderID: ownerID, + Text: "내가 직접 씀", + }) + if humanResp.StatusCode != http.StatusOK { + t.Fatalf("expected human send to succeed, got %d", humanResp.StatusCode) + } +} + +func TestGroupSummaryReflectsUnreadSinceReadMarker(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID}) + convIDStr := strconv.FormatUint(uint64(convID), 10) + + // Nothing read yet -- summary should be empty (no messages at all). + emptyResp, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil) + emptyResp.Header.Set("Authorization", "Bearer "+ownerToken) + emptyOut, err := http.DefaultClient.Do(emptyResp) + if err != nil { + t.Fatalf("get summary: %v", err) + } + var emptyBody map[string]interface{} + json.NewDecoder(emptyOut.Body).Decode(&emptyBody) + if emptyBody["status"] != "empty" { + t.Fatalf("expected empty status with no messages, got %v", emptyBody) + } + + // Peer sends two messages the owner hasn't read. + postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{ + SenderID: peerID, Text: "토요일 모임 3시 어때?", + }) + postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{ + SenderID: peerID, Text: "민수야 답장 좀", + }) + + sumReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil) + sumReq.Header.Set("Authorization", "Bearer "+ownerToken) + sumResp, err := http.DefaultClient.Do(sumReq) + if err != nil { + t.Fatalf("get summary: %v", err) + } + var sumBody map[string]interface{} + json.NewDecoder(sumResp.Body).Decode(&sumBody) + if sumBody["status"] != "ok" { + t.Fatalf("expected ok status, got %v", sumBody) + } + if int(sumBody["unread_count"].(float64)) != 2 { + t.Fatalf("expected 2 unread, got %v", sumBody["unread_count"]) + } + if sumBody["needs_reply"] != true { + t.Fatalf("expected needs_reply true, got %v", sumBody["needs_reply"]) + } + summaryText := sumBody["summary"].(string) + if !containsAll(summaryText, "토요일 모임 3시 어때", "민수야 답장 좀") { + t.Fatalf("summary missing unread content: %v", summaryText) + } + + // List conversations should report the same unread count. + listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations", nil) + listReq.Header.Set("Authorization", "Bearer "+ownerToken) + listResp, err := http.DefaultClient.Do(listReq) + if err != nil { + t.Fatalf("list conversations: %v", err) + } + var listBody map[string]interface{} + json.NewDecoder(listResp.Body).Decode(&listBody) + convs := listBody["conversations"].([]interface{}) + found := false + for _, raw := range convs { + c := raw.(map[string]interface{}) + if uint(c["id"].(float64)) == convID { + found = true + if int(c["unread_count"].(float64)) != 2 { + t.Fatalf("expected list unread_count 2, got %v", c["unread_count"]) + } + } + } + if !found { + t.Fatalf("conversation %d not found in list", convID) + } + + // Mark read up to the latest message, then re-fetch: should be empty + // again and the list's unread_count should drop to 0. + var msgs map[string]interface{} + msgsReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/messages", nil) + msgsReq.Header.Set("Authorization", "Bearer "+ownerToken) + msgsResp, _ := http.DefaultClient.Do(msgsReq) + json.NewDecoder(msgsResp.Body).Decode(&msgs) + msgList := msgs["messages"].([]interface{}) + lastID := uint(msgList[len(msgList)-1].(map[string]interface{})["id"].(float64)) + + readResp := postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/read", ownerToken, map[string]interface{}{"message_id": lastID}) + if readResp.StatusCode != http.StatusOK { + t.Fatalf("mark read: %d", readResp.StatusCode) + } + + afterReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil) + afterReq.Header.Set("Authorization", "Bearer "+ownerToken) + afterResp, _ := http.DefaultClient.Do(afterReq) + var afterBody map[string]interface{} + json.NewDecoder(afterResp.Body).Decode(&afterBody) + if afterBody["status"] != "empty" { + t.Fatalf("expected empty summary after marking read, got %v", afterBody) + } +} + +func containsAll(haystack string, needles ...string) bool { + for _, n := range needles { + if !strings.Contains(haystack, n) { + return false + } + } + return true +} diff --git a/core-backend/main.go b/core-backend/main.go index 9d3de30..0b10dbe 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -84,6 +84,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi registerBRoutes(r, db) registerInviteOpsRoutes(r, db) registerDemoRoutes(r) + registerGroupSummaryRoutes(r, db, ai) r.GET("/admin/metrics", func(c *gin.Context) { if !requireAdmin(c) { @@ -262,6 +263,16 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return } + // 단톡 따라잡기(PRD.md §2.3-③, roadmap.md §2.7-A): 이 시나리오는 + // L0 고정, 자동 발송 없음 -- 사용자의 전역 자율성 레벨(L1/L2)과 + // 무관하게 그룹 대화에서는 와카뷰 발송 자체를 막는다. 초안은 + // 항상 사람이 검토해서 직접 보낸다. + if conversation.IsGroup { + runtimeMetrics.recordTwinBlocked() + c.JSON(http.StatusForbidden, gin.H{"detail": "그룹 대화에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) + return + } + result, err := ai.checkEscalation(req.Text) runtimeMetrics.recordEscalate(err) if err != nil { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 1f2bd1d..5251d88 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -50,6 +50,17 @@ func mockAIService(t *testing.T) *httptest.Server { } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(out) + case "/summarize": + var req summaryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(summaryResponse{ + Status: "ok", + Summary: "mock summary for " + req.MyDisplayName + ": " + strings.Join(req.ContextLines, " | "), + }) default: w.WriteHeader(http.StatusNotFound) } diff --git a/core-backend/models.go b/core-backend/models.go index c5938a7..7e456ed 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -74,6 +74,10 @@ type ConversationParticipant struct { ID uint `gorm:"primaryKey"` ConversationID uint `gorm:"not null;index"` UserID uint `gorm:"not null;index"` + // LastReadMessageID is this participant's read marker (roadmap.md + // §2.7-A "단톡 따라잡기") -- nil means nothing read yet. Drives the + // unread badge and GET /conversations/:id/summary's "안 본 동안" window. + LastReadMessageID *uint } type Message struct { diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index c8dc8fa..7e5b34f 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -41,8 +41,10 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. - UI: **Soft Neutral + surface hierarchy** (`553cb62`) — GitHub `main` 머지 완료. 프로덕션 web 재빌드는 이 세션에 SSH/`.env`가 없어 보류 → 서버에서 `cd ~/project/ykavu && git pull && docker compose build web && docker compose up -d web` -- **발견(2026-07-31): Track C 콘텐츠 갭** — `PRD.md` P0 대비 단톡 따라잡기·관계별 페르소나· - 스팸 감지 미구현. Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 지금 바로 코드 착수 가능 +- **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료** (2026-08-03) — 그룹 생성 UI, 안 본 동안 요약 + (`GET /conversations/:id/summary`), 읽음 마커, 안 본 배지, 그룹 트윈 발송 서버측 차단까지. + 다음은 C2(관계별 페르소나) → C3(스팸 감지). Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 + 별개로 계속 진행 가능 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -180,11 +182,11 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | todo | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 | -| **N4-C1b** | `GET /conversations/:id/summary` | todo | 마지막 읽음 이후 메시지 모아 AI 서비스로 전달하는 core-backend 라우트 | -| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | todo | `ai-service/app/` 신규 모듈, `generate_draft.py` 패턴 재사용 | -| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | todo | `chat_screen.dart`. 자동발송 없음 — 안전 불변식 유지 | -| **N4-C1e** | "안 본 동안" 배지 | todo | 대화 목록에 마지막 읽음 이후 새 메시지 수 표시, read-marker 필요 | +| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | **done** (2026-08-03) | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 | +| **N4-C1b** | `GET /conversations/:id/summary` | **done** (2026-08-03) | `core-backend/group_summary.go` + 읽음 마커(`POST /conversations/:id/read`) | +| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` | +| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 | +| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 | | **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 | | **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` | | **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_screen.dart`, 자율성 레벨 상대별 예외와 동일 패턴 | @@ -249,8 +251,9 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) -5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Track C 콘텐츠 갭(N4-C1→C2→C3, 지금 착수 가능)** - 병행하며 **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10) +5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03) — 다음: + **Track C2 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)** → + N4-4 스모크 → Android UI QA (N4-5~10) 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index ec9b863..994d103 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -133,18 +133,24 @@ 단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼; 페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소 버전"이라 상대적으로 작음). -**2.7-A 단톡 따라잡기** (`PRD.md` §2.3, 현재 미구현 — `isGroup`은 아이콘 표시용 플래그뿐, 요약 -로직·엔드포인트·UI 전부 없음) -- [ ] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가 - 상대 1명만 받음. 서버(`is_group`+복수 `user_ids`)는 이미 지원하므로 클라이언트만 필요 -- [ ] `GET /conversations/:id/summary` — 마지막 읽음 시점 이후 메시지를 모아 AI 서비스로 전달 — - `core-backend`(신규 라우트) -- [ ] 요약 생성 — "나에게 멘션된 것/결정된 사항" 위주 3~5줄(`PRD.md` §2.3-②) — `ai-service/app/` - 신규 모듈, `generate_draft.py`의 Gemini 호출 패턴 재사용 -- [ ] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`. **이 시나리오는 L0 고정, - 자동발송 없음**(PRD §2.3-③) — 안전 불변식 그대로 유지, 우회 경로 만들지 않기 -- [ ] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart` + - 서버 read-marker 필요 +**2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03) +- [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가 + 동적으로 상대 필드를 추가/삭제할 수 있게 됨. 2명 이상이면 자동으로 `is_group:true`로 생성 +- [x] `GET /conversations/:id/summary` — `core-backend/group_summary.go`. 참가자별 읽음 마커 + (`ConversationParticipant.LastReadMessageID`)를 두고, 그 이후 메시지만 모아 AI 서비스로 전달. + `POST /conversations/:id/read`로 마커 갱신. `GET /conversations`에도 `unread_count` 추가 +- [x] 요약 생성 — `ai-service/app/summarize.py`(신규), "나에게 멘션된 것/결정된 사항" 위주 3~5줄 + + 답장 필요 항목 표시(`PRD.md` §2.3-②). `POST /summarize` 엔드포인트. 읽기 전용이라 escalation/ + identity 게이트 없음(아무것도 발송되지 않으므로) +- [x] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`의 안 본 동안 요약 다이얼로그에서 + 바로 `_requestDraft()`로 이어감. **그룹 대화는 전역 자율성 레벨과 무관하게 서버(`main.go`)가 + 트윈 발송 자체를 항상 차단** — 클라이언트도 L0 취급으로 렌더링(PRD §2.3-③, 안전 불변식 유지) +- [x] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart`, 서버가 + 계산한 `unread_count`를 그대로 표시 +- **주의**: 읽음 마커는 채팅방에 "들어올 때"가 아니라 "나갈 때"(`dispose()`) 찍는다 — 들어오는 + 순간 찍으면 그 방을 여는 즉시 안 본 게 0이 되어 "안 본 동안 요약"이 항상 빈 결과만 보여주는 + 버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려 + 있는 동안은 계속 읽음으로 따라가도 됨 **2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재 `TwinSettings`엔 `AutonomyLevel`만 있고 페르소나 필드 없음) diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart index 926fe90..60454e2 100644 --- a/mobile/lib/models/models.dart +++ b/mobile/lib/models/models.dart @@ -40,6 +40,7 @@ class ConversationSummary { required this.isGroup, required this.userIds, required this.twinDisabledByPeer, + this.unreadCount = 0, this.createdAt, }); @@ -47,6 +48,7 @@ class ConversationSummary { final bool isGroup; final List userIds; final bool twinDisabledByPeer; + final int unreadCount; final DateTime? createdAt; factory ConversationSummary.fromJson(Map json) { @@ -56,6 +58,7 @@ class ConversationSummary { isGroup: json['is_group'] as bool? ?? false, userIds: rawIds.map((e) => e as int).toList(), twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false, + unreadCount: json['unread_count'] as int? ?? 0, createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), ); } @@ -166,6 +169,30 @@ class WhitelistRule { ); } +/// 단톡 따라잡기(roadmap.md §2.7-A) — GET /conversations/:id/summary result. +class GroupSummaryResult { + GroupSummaryResult({ + required this.status, + required this.summary, + required this.unreadCount, + required this.needsReply, + }); + + final String status; // empty | ok + final String summary; + final int unreadCount; + final bool needsReply; + + factory GroupSummaryResult.fromJson(Map json) => GroupSummaryResult( + status: json['status'] as String? ?? 'empty', + summary: json['summary'] as String? ?? '', + unreadCount: json['unread_count'] as int? ?? 0, + needsReply: json['needs_reply'] as bool? ?? false, + ); + + bool get isEmpty => status == 'empty' || unreadCount == 0; +} + class DraftResult { DraftResult({required this.status, required this.text}); diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 52b5299..849a87f 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -11,10 +11,11 @@ import '../theme/app_theme.dart'; import '../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { - const ChatScreen({super.key, required this.conversationId, this.title}); + const ChatScreen({super.key, required this.conversationId, this.title, this.isGroup = false}); final int conversationId; final String? title; + final bool isGroup; @override State createState() => _ChatScreenState(); @@ -31,10 +32,12 @@ class _ChatScreenState extends State { DraftResult? _pendingDraft; bool _busy = false; bool _loadingHistory = true; + late final ApiClient _api; @override void initState() { super.initState(); + _api = context.read().api; _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); _loadHistory(); @@ -52,6 +55,11 @@ class _ChatScreenState extends State { _loadingHistory = false; }); _scrollToEnd(); + // 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 -- + // 즉시 찍으면 그룹 채팅방을 여는 순간 안 본 메시지가 0이 되어 버려서 + // "안 본 동안 요약" 버튼이 항상 빈 결과만 보여주게 된다(roadmap.md + // §2.7-A). 화면이 열려 있는 동안 실시간으로 온 새 메시지는 지금 + // 보고 있는 것이니 그대로 마커를 따라가도 된다 (_onEvent 참고). } on ApiException catch (e) { if (!mounted) return; setState(() { @@ -61,6 +69,17 @@ class _ChatScreenState extends State { } } + /// 단톡 따라잡기(roadmap.md §2.7-A) 읽음 마커. + Future _markLatestRead() async { + if (_messages.isEmpty) return; + final latestId = _messages.map((m) => m.id).reduce((a, b) => a > b ? a : b); + try { + await _api.markRead(widget.conversationId, latestId); + } on ApiException { + // Best-effort — an unread badge staying stale isn't worth surfacing an error for. + } + } + void _scrollToEnd() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scroll.hasClients) return; @@ -97,10 +116,15 @@ class _ChatScreenState extends State { if (_messages.any((m) => m.id == msg.id)) return; setState(() => _messages.add(msg)); _scrollToEnd(); + _markLatestRead(); } @override void dispose() { + // 화면을 나가는 시점에 읽음 마커를 찍는다 — _loadHistory()의 주석 참고. + // Fire-and-forget: dispose는 동기라 기다릴 수 없고, 실패해도 안 읽음 + // 배지가 조금 늦게 갱신되는 정도라 굳이 에러를 보여줄 필요 없음. + _markLatestRead(); _sub?.cancel(); _socket?.dispose(); _input.dispose(); @@ -176,13 +200,17 @@ class _ChatScreenState extends State { final text = _draftEdit.text.trim(); if (text.isEmpty) return; - // L0: twin send is forbidden server-side — move text to human composer instead. - if (session.autonomyLevel == AutonomyLevel.L0) { + // L0: twin send is forbidden server-side — move text to human composer + // instead. 그룹 대화는 전역 자율성 레벨과 무관하게 항상 L0 취급 + // (PRD.md §2.3-③, roadmap.md §2.7-A "이 시나리오는 L0 고정, 자동 발송 없음"). + if (session.autonomyLevel == AutonomyLevel.L0 || widget.isGroup) { setState(() { _input.text = text; _pendingDraft = null; _draftEdit.clear(); - _banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.'; + _banner = widget.isGroup + ? '단톡에서는 와카뷰가 자동으로 보내지 않습니다. 초안을 검토하고 아래 입력창에서 직접 보내세요.' + : 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.'; }); return; } @@ -248,6 +276,57 @@ class _ChatScreenState extends State { } } + /// 단톡 따라잡기(roadmap.md §2.7-A): 안 본 동안 온 메시지를 3~5줄로 요약해 + /// 보여준다. 답장이 필요해 보이면 그 자리에서 초안 요청으로 이어갈 수 있음 + /// — 발송은 항상 사람이 직접(이 화면의 L0 고정 규칙 그대로). + Future _openSummary() async { + final session = context.read(); + GroupSummaryResult? result; + String? error; + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + if (result == null && error == null) { + session.api.getGroupSummary(widget.conversationId).then((r) { + setDialogState(() => result = r); + }).catchError((e) { + setDialogState(() => error = e is ApiException ? '요약 실패 (${e.statusCode})' : '요약 실패'); + }); + } + final r = result; + return AlertDialog( + title: const Text('안 본 동안 요약'), + content: SizedBox( + width: 320, + child: error != null + ? Text(error!, style: TextStyle(color: Theme.of(ctx).colorScheme.error)) + : r == null + ? const SizedBox( + height: 60, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ) + : r.isEmpty + ? const Text('안 본 메시지가 없습니다.') + : Text(r.summary.isEmpty ? '요약할 내용이 없습니다.' : r.summary), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('닫기')), + if (r != null && !r.isEmpty && r.needsReply) + FilledButton( + onPressed: () { + Navigator.pop(ctx); + _requestDraft(); + }, + child: const Text('초안 요청'), + ), + ], + ); + }, + ), + ); + } + Widget _buildL1Panel(BuildContext context) { final theme = Theme.of(context); final draft = _pendingDraft; @@ -291,7 +370,7 @@ class _ChatScreenState extends State { } final level = context.watch().autonomyLevel; - final isL0 = level == AutonomyLevel.L0; + final isL0 = level == AutonomyLevel.L0 || widget.isGroup; final title = isL0 ? '초안 (L0) — 직접 보내기' : level == AutonomyLevel.L1 @@ -319,7 +398,9 @@ class _ChatScreenState extends State { if (isL0) ...[ const SizedBox(height: 6), Text( - 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.', + widget.isGroup + ? '단톡에서는 와카뷰 자동 발송이 항상 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내세요.' + : 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ], @@ -362,6 +443,12 @@ class _ChatScreenState extends State { appBar: AppBar( title: Text(widget.title ?? '대화방 #${widget.conversationId}'), actions: [ + if (widget.isGroup) + IconButton( + tooltip: '안 본 동안 요약', + onPressed: _openSummary, + icon: const Icon(Icons.summarize_outlined), + ), IconButton( tooltip: '거부권 (와카뷰 자동응대 중단)', onPressed: _busy ? null : _veto, diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 611de4a..aabd03e 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -83,67 +83,116 @@ class _ConversationListScreenState extends State { } Future _createConversation() async { - final peerCtrl = TextEditingController(); final session = context.read(); final myId = session.user?.id; + // 그룹 대화 생성(roadmap.md §2.7-A "단톡 따라잡기"): 상대를 여러 명 추가하면 + // 자동으로 그룹이 된다. 필드 1개(상대 1명)면 기존 1:1 흐름과 동일. + final peerCtrls = [TextEditingController()]; final ok = await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('새 대화'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (myId != null) ...[ - MyUserIdChip(userId: myId), - const SizedBox(height: 12), - Text( - '상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.', - style: Theme.of(ctx).textTheme.bodySmall, - ), - const SizedBox(height: 12), - ], - TextField( - controller: peerCtrl, - keyboardType: TextInputType.number, - autofocus: true, - decoration: const InputDecoration( - labelText: '상대 사용자 ID (숫자)', - helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.', - ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: const Text('새 대화'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (myId != null) ...[ + MyUserIdChip(userId: myId), + const SizedBox(height: 12), + Text( + '상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요. ' + '2명 이상 넣으면 그룹 대화로 만들어집니다.', + style: Theme.of(ctx).textTheme.bodySmall, + ), + const SizedBox(height: 12), + ], + for (var i = 0; i < peerCtrls.length; i++) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: peerCtrls[i], + keyboardType: TextInputType.number, + autofocus: i == 0, + decoration: InputDecoration( + labelText: i == 0 ? '상대 사용자 ID (숫자)' : '상대 ${i + 1} 사용자 ID', + helperText: i == 0 ? '이름/닉네임이 아니라 숫자 ID입니다.' : null, + ), + ), + ), + if (peerCtrls.length > 1) + IconButton( + tooltip: '삭제', + onPressed: () => setDialogState(() => peerCtrls.removeAt(i)), + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => setDialogState(() => peerCtrls.add(TextEditingController())), + icon: const Icon(Icons.add, size: 18), + label: const Text('상대 추가 (그룹으로)'), + ), + ), + ], ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')), ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')), - ], ), ); if (ok != true || !mounted) return; final me = session.user; - final peer = int.tryParse(peerCtrl.text.trim()); if (me == null) return; - if (peer == null) { + + final peers = []; + for (final ctrl in peerCtrls) { + final text = ctrl.text.trim(); + if (text.isEmpty) continue; + final id = int.tryParse(text); + if (id == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')), + ); + return; + } + if (id == me.id) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')), + ); + return; + } + if (!peers.contains(id)) peers.add(id); + } + if (peers.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')), - ); - return; - } - if (peer == me.id) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')), + const SnackBar(content: Text('상대 사용자 ID를 1명 이상 입력하세요.')), ); return; } + final isGroup = peers.length > 1; try { - final conv = await session.api.createConversation(userIds: [me.id, peer]); + final conv = await session.api.createConversation( + userIds: [me.id, ...peers], + isGroup: isGroup, + ); if (!mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChatScreen( conversationId: conv.id, - title: _peerNames[peer] ?? '상대 #$peer', + title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'), + isGroup: isGroup, ), ), ); @@ -292,6 +341,7 @@ class _ConversationListScreenState extends State { builder: (_) => ChatScreen( conversationId: room.id, title: _titleFor(room, me), + isGroup: room.isGroup, ), ), ); @@ -343,6 +393,23 @@ class _ConversationListScreenState extends State { ], ), ), + if (room.unreadCount > 0) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + room.unreadCount > 99 ? '99+' : '${room.unreadCount}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + ], Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant), ], ), diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 6e3f264..f346ea6 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -185,6 +185,18 @@ class ApiClient { await _json('POST', '/conversations/$conversationId/veto'); } + /// 단톡 따라잡기(roadmap.md §2.7-A): advances the caller's read marker. + Future markRead(int conversationId, int messageId) async { + await _json('POST', '/conversations/$conversationId/read', body: { + 'message_id': messageId, + }); + } + + Future getGroupSummary(int conversationId) async { + final json = await _getObject('/conversations/$conversationId/summary'); + return GroupSummaryResult.fromJson(json); + } + Future retractMessage(int messageId) async { await _json('POST', '/messages/$messageId/retract'); }