feat: implement 단톡 따라잡기 (group catch-up summary) — roadmap.md §2.7-A

Closes the biggest content gap found against PRD.md §3.1 P0: group chat
catch-up was one of only two v1 MVP scenarios and had zero implementation.

Backend (core-backend):
- ConversationParticipant.LastReadMessageID read marker.
- POST /conversations/:id/read advances the caller's marker (never backward).
- GET /conversations/:id/summary builds context from messages since that
  marker and calls ai-service's new POST /summarize; returns unread_count
  and needs_reply.
- GET /conversations now reports unread_count per room.
- Safety: group conversations now unconditionally block twin-authored
  sends (POST /conversations/:id/messages), regardless of the sender's
  global autonomy level. PRD.md §2.3-③ requires this scenario stay L0-fixed
  with no auto-send; autonomy level is a per-user global setting today, so
  this closes the only path a global L2 whitelist match could otherwise
  auto-send into a group.

AI service (ai-service): new summarize.py module (same Gemini-call shape as
generation.py's draft_reply, no escalation/identity gating since nothing
generated here is ever sent) + POST /summarize.

Mobile: "새 대화" dialog now supports adding/removing multiple peer fields
(2+ peers -> is_group:true automatically); unread badge on conversation
rows; ChatScreen takes isGroup and renders its L1 panel as L0-locked for
groups; new "안 본 동안 요약" AppBar action opens a dialog with the summary
and, when a reply looks needed, a button that feeds straight into the
existing draft-request flow.

Verified with a real Flutter build against a live core-backend + ai-service
instance (Playwright driving 3 demo accounts through group creation, unread
badges, and the summary dialog) — this caught a real ordering bug: marking
the read marker on chat *open* meant the summary was always empty by the
time you could tap it, since opening the room already advanced the marker
past everything you'd come to catch up on. Fixed by marking read on screen
*exit* (dispose) instead, so the marker reflects what was unread that whole
visit and updates once you leave.

go test / pytest / flutter analyze+test all pass.
This commit is contained in:
Claude 2026-08-03 03:16:54 +00:00
parent be9d5be7fe
commit 64323b824e
No known key found for this signature in database
17 changed files with 777 additions and 66 deletions

View File

@ -7,6 +7,7 @@ from pydantic import BaseModel, model_validator
from .escalation_filter import check as check_escalation from .escalation_filter import check as check_escalation
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
from .retrieve_style import retrieve as retrieve_style_examples from .retrieve_style import retrieve as retrieve_style_examples
from .summarize import summarize_messages
@asynccontextmanager @asynccontextmanager
@ -72,3 +73,22 @@ def draft(req: DraftRequest):
status, text = draft_reply(style_examples, req.context_lines, model=req.model) status, text = draft_reply(style_examples, req.context_lines, model=req.model)
return DraftResponse(status=status, text=text) 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)

View File

@ -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()

View File

@ -90,3 +90,18 @@ def test_draft_rejects_both_style_sources():
def test_draft_rejects_neither_style_source(): def test_draft_rejects_neither_style_source():
resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]}) resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]})
assert resp.status_code == 422 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"]

View File

@ -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"]

View File

@ -193,12 +193,25 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
for _, pp := range pps { for _, pp := range pps {
participantIDs = append(participantIDs, pp.UserID) 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{ out = append(out, gin.H{
"id": conv.ID, "id": conv.ID,
"is_group": conv.IsGroup, "is_group": conv.IsGroup,
"twin_disabled_by_peer": conv.TwinDisabledByPeer, "twin_disabled_by_peer": conv.TwinDisabledByPeer,
"user_ids": participantIDs, "user_ids": participantIDs,
"created_at": conv.CreatedAt, "created_at": conv.CreatedAt,
"unread_count": unreadCount,
}) })
} }
c.JSON(http.StatusOK, gin.H{"conversations": out}) c.JSON(http.StatusOK, gin.H{"conversations": out})

View File

@ -53,6 +53,42 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error)
return &out, nil 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 { type escalationCheckRequest struct {
Text string `json:"text"` Text string `json:"text"`
} }

View File

@ -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": <highest message id the client has seen>}. 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,
})
})
}

View File

@ -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
}

View File

@ -84,6 +84,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
registerBRoutes(r, db) registerBRoutes(r, db)
registerInviteOpsRoutes(r, db) registerInviteOpsRoutes(r, db)
registerDemoRoutes(r) registerDemoRoutes(r)
registerGroupSummaryRoutes(r, db, ai)
r.GET("/admin/metrics", func(c *gin.Context) { r.GET("/admin/metrics", func(c *gin.Context) {
if !requireAdmin(c) { if !requireAdmin(c) {
@ -262,6 +263,16 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return 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) result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err) runtimeMetrics.recordEscalate(err)
if err != nil { if err != nil {

View File

@ -50,6 +50,17 @@ func mockAIService(t *testing.T) *httptest.Server {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out) 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: default:
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }

View File

@ -74,6 +74,10 @@ type ConversationParticipant struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
ConversationID uint `gorm:"not null;index"` ConversationID uint `gorm:"not null;index"`
UserID 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 { type Message struct {

View File

@ -41,8 +41,10 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- UI: **Soft Neutral + surface hierarchy** (`553cb62`) — GitHub `main` 머지 완료. - UI: **Soft Neutral + surface hierarchy** (`553cb62`) — GitHub `main` 머지 완료.
프로덕션 web 재빌드는 이 세션에 SSH/`.env`가 없어 보류 → 서버에서 프로덕션 web 재빌드는 이 세션에 SSH/`.env`가 없어 보류 → 서버에서
`cd ~/project/ykavu && git pull && docker compose build web && docker compose up -d web` `cd ~/project/ykavu && git pull && docker compose build web && docker compose up -d web`
- **발견(2026-07-31): Track C 콘텐츠 갭**`PRD.md` P0 대비 단톡 따라잡기·관계별 페르소나· - **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료** (2026-08-03) — 그룹 생성 UI, 안 본 동안 요약
스팸 감지 미구현. Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 지금 바로 코드 착수 가능 (`GET /conversations/:id/summary`), 읽음 마커, 안 본 배지, 그룹 트윈 발송 서버측 차단까지.
다음은 C2(관계별 페르소나) → C3(스팸 감지). Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과
별개로 계속 진행 가능
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서 ### NEXT 순서
@ -180,11 +182,11 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| ID | 작업 | Status | 완료 조건 | | ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------| |----|------|--------|-----------|
| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | todo | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 | | **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | **done** (2026-08-03) | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 |
| **N4-C1b** | `GET /conversations/:id/summary` | todo | 마지막 읽음 이후 메시지 모아 AI 서비스로 전달하는 core-backend 라우트 | | **N4-C1b** | `GET /conversations/:id/summary` | **done** (2026-08-03) | `core-backend/group_summary.go` + 읽음 마커(`POST /conversations/:id/read`) |
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | todo | `ai-service/app/` 신규 모듈, `generate_draft.py` 패턴 재사용 | | **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | todo | `chat_screen.dart`. 자동발송 없음 — 안전 불변식 유지 | | **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
| **N4-C1e** | "안 본 동안" 배지 | todo | 대화 목록에 마지막 읽음 이후 새 메시지 수 표시, read-marker 필요 | | **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 | | **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 |
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` | | **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` |
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_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 탭은 테스터) 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Track C 콘텐츠 갭(N4-C1→C2→C3, 지금 착수 가능)** 5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03) — 다음:
병행하며 **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10) **Track C2 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)**
N4-4 스모크 → Android UI QA (N4-5~10)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -133,18 +133,24 @@
단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼; 단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼;
페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소 버전"이라 상대적으로 작음). 페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소 버전"이라 상대적으로 작음).
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3, 현재 미구현 — `isGroup`은 아이콘 표시용 플래그뿐, 요약 **2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03)
로직·엔드포인트·UI 전부 없음) - [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
- [ ] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가 동적으로 상대 필드를 추가/삭제할 수 있게 됨. 2명 이상이면 자동으로 `is_group:true`로 생성
상대 1명만 받음. 서버(`is_group`+복수 `user_ids`)는 이미 지원하므로 클라이언트만 필요 - [x] `GET /conversations/:id/summary``core-backend/group_summary.go`. 참가자별 읽음 마커
- [ ] `GET /conversations/:id/summary` — 마지막 읽음 시점 이후 메시지를 모아 AI 서비스로 전달 — (`ConversationParticipant.LastReadMessageID`)를 두고, 그 이후 메시지만 모아 AI 서비스로 전달.
`core-backend`(신규 라우트) `POST /conversations/:id/read`로 마커 갱신. `GET /conversations`에도 `unread_count` 추가
- [ ] 요약 생성 — "나에게 멘션된 것/결정된 사항" 위주 3~5줄(`PRD.md` §2.3-②) — `ai-service/app/` - [x] 요약 생성 — `ai-service/app/summarize.py`(신규), "나에게 멘션된 것/결정된 사항" 위주 3~5줄
신규 모듈, `generate_draft.py`의 Gemini 호출 패턴 재사용 + 답장 필요 항목 표시(`PRD.md` §2.3-②). `POST /summarize` 엔드포인트. 읽기 전용이라 escalation/
- [ ] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`. **이 시나리오는 L0 고정, identity 게이트 없음(아무것도 발송되지 않으므로)
자동발송 없음**(PRD §2.3-③) — 안전 불변식 그대로 유지, 우회 경로 만들지 않기 - [x] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`의 안 본 동안 요약 다이얼로그에서
- [ ] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart` + 바로 `_requestDraft()`로 이어감. **그룹 대화는 전역 자율성 레벨과 무관하게 서버(`main.go`)가
서버 read-marker 필요 트윈 발송 자체를 항상 차단** — 클라이언트도 L0 취급으로 렌더링(PRD §2.3-③, 안전 불변식 유지)
- [x] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart`, 서버가
계산한 `unread_count`를 그대로 표시
- **주의**: 읽음 마커는 채팅방에 "들어올 때"가 아니라 "나갈 때"(`dispose()`) 찍는다 — 들어오는
순간 찍으면 그 방을 여는 즉시 안 본 게 0이 되어 "안 본 동안 요약"이 항상 빈 결과만 보여주는
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
있는 동안은 계속 읽음으로 따라가도 됨
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재 **2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재
`TwinSettings``AutonomyLevel`만 있고 페르소나 필드 없음) `TwinSettings``AutonomyLevel`만 있고 페르소나 필드 없음)

View File

@ -40,6 +40,7 @@ class ConversationSummary {
required this.isGroup, required this.isGroup,
required this.userIds, required this.userIds,
required this.twinDisabledByPeer, required this.twinDisabledByPeer,
this.unreadCount = 0,
this.createdAt, this.createdAt,
}); });
@ -47,6 +48,7 @@ class ConversationSummary {
final bool isGroup; final bool isGroup;
final List<int> userIds; final List<int> userIds;
final bool twinDisabledByPeer; final bool twinDisabledByPeer;
final int unreadCount;
final DateTime? createdAt; final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) { factory ConversationSummary.fromJson(Map<String, dynamic> json) {
@ -56,6 +58,7 @@ class ConversationSummary {
isGroup: json['is_group'] as bool? ?? false, isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(), userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false, twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
unreadCount: json['unread_count'] as int? ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), 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<String, dynamic> 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 { class DraftResult {
DraftResult({required this.status, required this.text}); DraftResult({required this.status, required this.text});

View File

@ -11,10 +11,11 @@ import '../theme/app_theme.dart';
import '../widgets/message_bubble.dart'; import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget { 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 int conversationId;
final String? title; final String? title;
final bool isGroup;
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
@ -31,10 +32,12 @@ class _ChatScreenState extends State<ChatScreen> {
DraftResult? _pendingDraft; DraftResult? _pendingDraft;
bool _busy = false; bool _busy = false;
bool _loadingHistory = true; bool _loadingHistory = true;
late final ApiClient _api;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_api = context.read<SessionState>().api;
_socket = ConversationSocket(widget.conversationId)..connect(); _socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent); _sub = _socket!.events.listen(_onEvent);
_loadHistory(); _loadHistory();
@ -52,6 +55,11 @@ class _ChatScreenState extends State<ChatScreen> {
_loadingHistory = false; _loadingHistory = false;
}); });
_scrollToEnd(); _scrollToEnd();
// "나갈 때"(dispose) --
// 0
// "안 본 동안 요약" (roadmap.md
// §2.7-A).
// (_onEvent ).
} on ApiException catch (e) { } on ApiException catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -61,6 +69,17 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
/// (roadmap.md §2.7-A) .
Future<void> _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() { void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) return; if (!_scroll.hasClients) return;
@ -97,10 +116,15 @@ class _ChatScreenState extends State<ChatScreen> {
if (_messages.any((m) => m.id == msg.id)) return; if (_messages.any((m) => m.id == msg.id)) return;
setState(() => _messages.add(msg)); setState(() => _messages.add(msg));
_scrollToEnd(); _scrollToEnd();
_markLatestRead();
} }
@override @override
void dispose() { void dispose() {
// _loadHistory() .
// Fire-and-forget: dispose는 ,
// .
_markLatestRead();
_sub?.cancel(); _sub?.cancel();
_socket?.dispose(); _socket?.dispose();
_input.dispose(); _input.dispose();
@ -176,13 +200,17 @@ class _ChatScreenState extends State<ChatScreen> {
final text = _draftEdit.text.trim(); final text = _draftEdit.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
// L0: twin send is forbidden server-side move text to human composer instead. // L0: twin send is forbidden server-side move text to human composer
if (session.autonomyLevel == AutonomyLevel.L0) { // instead. L0
// (PRD.md §2.3-, roadmap.md §2.7-A "이 시나리오는 L0 고정, 자동 발송 없음").
if (session.autonomyLevel == AutonomyLevel.L0 || widget.isGroup) {
setState(() { setState(() {
_input.text = text; _input.text = text;
_pendingDraft = null; _pendingDraft = null;
_draftEdit.clear(); _draftEdit.clear();
_banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.'; _banner = widget.isGroup
? '단톡에서는 와카뷰가 자동으로 보내지 않습니다. 초안을 검토하고 아래 입력창에서 직접 보내세요.'
: 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
}); });
return; return;
} }
@ -248,6 +276,57 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
/// (roadmap.md §2.7-A): 3~5
/// .
/// ( L0 ).
Future<void> _openSummary() async {
final session = context.read<SessionState>();
GroupSummaryResult? result;
String? error;
await showDialog<void>(
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) { Widget _buildL1Panel(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final draft = _pendingDraft; final draft = _pendingDraft;
@ -291,7 +370,7 @@ class _ChatScreenState extends State<ChatScreen> {
} }
final level = context.watch<SessionState>().autonomyLevel; final level = context.watch<SessionState>().autonomyLevel;
final isL0 = level == AutonomyLevel.L0; final isL0 = level == AutonomyLevel.L0 || widget.isGroup;
final title = isL0 final title = isL0
? '초안 (L0) — 직접 보내기' ? '초안 (L0) — 직접 보내기'
: level == AutonomyLevel.L1 : level == AutonomyLevel.L1
@ -319,7 +398,9 @@ class _ChatScreenState extends State<ChatScreen> {
if (isL0) ...[ if (isL0) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.', widget.isGroup
? '단톡에서는 와카뷰 자동 발송이 항상 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내세요.'
: 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
), ),
], ],
@ -362,6 +443,12 @@ class _ChatScreenState extends State<ChatScreen> {
appBar: AppBar( appBar: AppBar(
title: Text(widget.title ?? '대화방 #${widget.conversationId}'), title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
actions: [ actions: [
if (widget.isGroup)
IconButton(
tooltip: '안 본 동안 요약',
onPressed: _openSummary,
icon: const Icon(Icons.summarize_outlined),
),
IconButton( IconButton(
tooltip: '거부권 (와카뷰 자동응대 중단)', tooltip: '거부권 (와카뷰 자동응대 중단)',
onPressed: _busy ? null : _veto, onPressed: _busy ? null : _veto,

View File

@ -83,14 +83,18 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
} }
Future<void> _createConversation() async { Future<void> _createConversation() async {
final peerCtrl = TextEditingController();
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final myId = session.user?.id; final myId = session.user?.id;
// (roadmap.md §2.7-A "단톡 따라잡기"):
// . 1( 1) 1:1 .
final peerCtrls = <TextEditingController>[TextEditingController()];
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('새 대화'), title: const Text('새 대화'),
content: Column( content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -98,52 +102,97 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
MyUserIdChip(userId: myId), MyUserIdChip(userId: myId),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.', '상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요. '
'2명 이상 넣으면 그룹 대화로 만들어집니다.',
style: Theme.of(ctx).textTheme.bodySmall, style: Theme.of(ctx).textTheme.bodySmall,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
TextField( for (var i = 0; i < peerCtrls.length; i++)
controller: peerCtrl, Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: peerCtrls[i],
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
autofocus: true, autofocus: i == 0,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '상대 사용자 ID (숫자)', labelText: i == 0 ? '상대 사용자 ID (숫자)' : '상대 ${i + 1} 사용자 ID',
helperText: '이름/닉네임이 아니라 숫자 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: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
], ],
), ),
),
); );
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
final me = session.user; final me = session.user;
final peer = int.tryParse(peerCtrl.text.trim());
if (me == null) return; if (me == null) return;
if (peer == null) {
final peers = <int>[];
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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')), const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
); );
return; return;
} }
if (peer == me.id) { if (id == me.id) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')), const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
); );
return; return;
} }
if (!peers.contains(id)) peers.add(id);
}
if (peers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID를 1명 이상 입력하세요.')),
);
return;
}
final isGroup = peers.length > 1;
try { 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; if (!mounted) return;
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ChatScreen( builder: (_) => ChatScreen(
conversationId: conv.id, 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<ConversationListScreen> {
builder: (_) => ChatScreen( builder: (_) => ChatScreen(
conversationId: room.id, conversationId: room.id,
title: _titleFor(room, me), title: _titleFor(room, me),
isGroup: room.isGroup,
), ),
), ),
); );
@ -343,6 +393,23 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
], ],
), ),
), ),
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), Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant),
], ],
), ),

View File

@ -185,6 +185,18 @@ class ApiClient {
await _json('POST', '/conversations/$conversationId/veto'); await _json('POST', '/conversations/$conversationId/veto');
} }
/// (roadmap.md §2.7-A): advances the caller's read marker.
Future<void> markRead(int conversationId, int messageId) async {
await _json('POST', '/conversations/$conversationId/read', body: {
'message_id': messageId,
});
}
Future<GroupSummaryResult> getGroupSummary(int conversationId) async {
final json = await _getObject('/conversations/$conversationId/summary');
return GroupSummaryResult.fromJson(json);
}
Future<void> retractMessage(int messageId) async { Future<void> retractMessage(int messageId) async {
await _json('POST', '/messages/$messageId/retract'); await _json('POST', '/messages/$messageId/retract');
} }