Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
583a46a
[REFACTOR] 일기 요약 코드를 대화 요약(Summary)으로 리네임하고 countTokens 추가
cchanmi Aug 7, 2026
e17db08
[FEAT] 대화 도메인 값 타입(Message, EmotionCharacter, CommentRevealPolicy 등) 추가
cchanmi Aug 7, 2026
a43b5ac
[CHORE] 파일 헤더에 Created by 주석 추가
cchanmi Aug 7, 2026
c162505
[FIX] pod install 훅이 덮어쓴 baseConfigurationReference를 Config/*.xcconfi…
cchanmi Aug 7, 2026
26da231
[CHORE] pbxproj 오브젝트 ID를 규칙에 맞게 24자리 hex로 교체
cchanmi Aug 7, 2026
f2583e8
[FEAT] 대화 압축 저장소(ConversationSummaryStore) 추가
cchanmi Aug 7, 2026
31b7463
[FEAT] ConversationRepository 프로토콜과 SendMessageUseCase/GetMessagesUse…
cchanmi Aug 7, 2026
b2ae0a3
[FEAT] 대화 네트워크 계층(ConversationEndpoint, DTO, DefaultConversationRepos…
cchanmi Aug 7, 2026
4959a4a
[FEAT] ChatViewModel과 ChatView 추가
cchanmi Aug 7, 2026
ce70fd6
[FIX] 댓글 순차 노출 중 취소 시 마지막 댓글이 유실되는 레이스 컨디션 수정
cchanmi Aug 7, 2026
2b8b398
[FIX] restore() 시 짧은 대화 복원 후 첫 발화가 청크에 중복 편입되는 버그 수정
cchanmi Aug 7, 2026
bc39bb9
[FEAT] 채팅방 목록 조회 API 연동 및 홈·대화 목록 화면 추가
cchanmi Aug 8, 2026
618ace4
[FEAT] 감정 페르소나 다정이를 슬픔이로 교체하고 채팅 답장에 이름 표시 추가
cchanmi Aug 9, 2026
530cd0e
[FEAT] 홈에서 보낸 메시지를 채팅방으로 전달하고 채팅 화면 키보드 UX 정리
cchanmi Aug 9, 2026
ef7286f
[FEAT] 채팅 메시지에 인용 답장(대댓글) UI 추가
cchanmi Aug 9, 2026
9ec4c1a
[FIX] 슬픔이 emotionType 매핑을 WARM으로 수정
cchanmi Aug 9, 2026
8c5581c
[CHORE] develop 병합 및 Endpoint 프로토콜 변경사항 반영
cchanmi Aug 9, 2026
6506e1e
[FIX] 코드 리뷰 반영
cchanmi Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 72 additions & 26 deletions GAMSS.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion GAMSS/Sources/App/GAMSSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct GAMSSApp: App {

var body: some Scene {
WindowGroup {
LoginView(viewModel: LoginViewModel(loginUseCase: DefaultLoginUseCase(authRepository: DefaultAuthRepository(networkManager: NetworkManager.shared, tokenStorage: TokenStorage.shared))))
MainTabView()
}
}
}
11 changes: 9 additions & 2 deletions GAMSS/Sources/Core/Network/NetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,18 @@ final class NetworkManager: NetworkRequesting {
) async throws -> T {

let request = try endpoint.asURLRequest()


let bodyString = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "-"
Log.debug("📤 \(endpoint.method.rawValue) \(endpoint.path) body: \(bodyString)")

let (data, response) = try await session.data(
for: request
)


if let jsonString = String(data: data, encoding: .utf8) {
Log.debug("📦 \(endpoint.path) response: \(jsonString)")
}

guard let response = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import Foundation

struct CreateMessageRequestDTO: Encodable {
let conversationId: Int
let conversationId: Int?
let content: String
let repliesMessageId: Int
let currentConversationSummary: String
let repliesToMessageId: Int?
let currentConversationSummary: String?
let excludeCharacters: [String]
}
46 changes: 46 additions & 0 deletions GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// ConversationMessageDTO.swift
// GAMSS
//
// Created by cchanmi on 8/7/26.
//

import Foundation

/// 서버 필드명과 1:1 (Android ConversationMessage.kt 기준). senderType이 "CHARACTER"일 때만
/// emotionType이 채워진다. senderType이 알 수 없거나 emotionType을 못 알아보면 toDomain()이
/// nil을 반환 — 잘못된 주체로 그리는 것보다 목록에서 제외하는 쪽을 택한다.
struct ConversationMessageDTO: Decodable {
let id: Int
let conversationId: Int
let senderType: String
let emotionType: String?
let content: String
let repliesToMessageId: Int?
let rootMessageId: Int?
let createdAt: String

private static let serverTypeToCharacter: [String: EmotionCharacter] = [
"JOY": .joy, "ANGER": .anger, "ANXIETY": .anxiety,
"GRUMPY": .prickly, "WARM": .sadness, "QUIRKY": .quirky,
]

func toDomain() -> Message? {
let sender: MessageSender
switch senderType {
case "USER":
sender = .user
case "CHARACTER":
guard let character = Self.serverTypeToCharacter[emotionType ?? ""] else { return nil }
sender = .character(character)
default:
return nil
}
return Message(id: id, conversationId: conversationId, sender: sender, content: content, repliesToMessageId: repliesToMessageId)
}

/// 방금 보낸 사용자 메시지 응답 전용 — 항상 .user로 간주한다.
func toSentUserMessage() -> Message {
Message(id: id, conversationId: conversationId, sender: .user, content: content, repliesToMessageId: repliesToMessageId)
}
}
20 changes: 20 additions & 0 deletions GAMSS/Sources/Data/DTO/ConversationSummaryDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// ConversationSummaryDTO.swift
// GAMSS
//
// Created by cchanmi on 8/8/26.
//

import Foundation

struct ConversationSummaryDTO: Decodable {
let id: Int
let title: String?
let status: String
let createdAt: String
let updatedAt: String

func toDomain() -> ConversationSummary {
ConversationSummary(id: id, title: title, status: status, createdAt: createdAt)
}
}
32 changes: 32 additions & 0 deletions GAMSS/Sources/Data/DTO/SaveMessageResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// SaveMessageResponseDTO.swift
// GAMSS
//
// Created by cchanmi on 8/7/26.
//

import Foundation

struct SaveMessageResponseDTO: Decodable {
let message: ConversationMessageDTO
let commentStatus: String
let comments: [ConversationMessageDTO]

func toDomain() -> SentMessage {
SentMessage(
message: message.toSentUserMessage(),
commentStatus: commentStatus.toCommentGenerationStatus(),
comments: comments.compactMap { $0.toDomain() }
)
}
}

private extension String {
func toCommentGenerationStatus() -> CommentGenerationStatus {
switch self {
case "DONE": return .done
case "LIMIT_EXCEEDED": return .limitExceeded
default: return .failed
}
}
}
45 changes: 45 additions & 0 deletions GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// DefaultConversationRepository.swift
// GAMSS
//
// Created by cchanmi on 8/7/26.
//

final class DefaultConversationRepository: ConversationRepository {
private let networkManager: NetworkRequesting

init(networkManager: NetworkRequesting) {
self.networkManager = networkManager
}

func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage {
let request = CreateMessageRequestDTO(
conversationId: conversationId,
content: content,
repliesToMessageId: repliesToMessageId,
currentConversationSummary: contextSummary,
excludeCharacters: []
)
let response = try await networkManager.request(
ChatEndpoint.createMessage(request),
responseType: APIResponse<SaveMessageResponseDTO>.self
)
return response.data.toDomain()
}

func getMessages(conversationId: Int) async throws -> [Message] {
let response = try await networkManager.request(
ChatEndpoint.fetchMessages(chatId: String(conversationId)),
responseType: APIResponse<[ConversationMessageDTO]>.self
)
return response.data.compactMap { $0.toDomain() }
}

func getConversations(date: String) async throws -> [ConversationSummary] {
let response = try await networkManager.request(
ChatEndpoint.fetchChats(date: date),
responseType: APIResponse<[ConversationSummaryDTO]>.self
)
return response.data.map { $0.toDomain() }
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//
// DefaultDiarySummaryRepository.swift
// DefaultSummaryRepository.swift
// GAMSS
//
// Created by cchanmi on 7/31/26.
// Created by cchanmi on 8/7/26.
//

import Foundation
Expand All @@ -12,7 +12,7 @@ import Tokenizers
// actor로 선언해 encoder/decoder ORTSession과 tokenizer에 대한 동시 접근을 직렬화한다.
// ONNX Runtime 세션은 스레드 세이프하지 않아서, summarize가 여러 곳에서 동시에
// 호출되면 run() 호출들이 서로 레이스할 수 있다.
actor DefaultDiarySummaryRepository: DiarySummaryRepository {
actor DefaultSummaryRepository: SummaryRepository {
// 모델이 이 값들에 맞춰 학습/export되어 있으므로 임의 변경 금지.
private static let decoderStartToken = 1
private static let eosToken = 1
Expand Down Expand Up @@ -40,12 +40,12 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
self.tokenizer = tokenizer
}

static func make() async throws -> DefaultDiarySummaryRepository {
static func make() async throws -> DefaultSummaryRepository {
guard
let encoderPath = Bundle.main.path(forResource: "kobart_encoder_int8", ofType: "onnx"),
let decoderPath = Bundle.main.path(forResource: "kobart_decoder_int8", ofType: "onnx")
else {
throw DiarySummaryError.modelLoadFailed()
throw SummaryError.modelLoadFailed()
}

let encoder: ORTSession
Expand All @@ -55,11 +55,11 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
encoder = try ORTSession(env: env, modelPath: encoderPath, sessionOptions: nil)
decoder = try ORTSession(env: env, modelPath: decoderPath, sessionOptions: nil)
} catch {
throw DiarySummaryError.modelLoadFailed(underlying: error)
throw SummaryError.modelLoadFailed(underlying: error)
}

let tokenizer = try await Self.loadTokenizer()
return DefaultDiarySummaryRepository(encoder: encoder, decoder: decoder, tokenizer: tokenizer)
return DefaultSummaryRepository(encoder: encoder, decoder: decoder, tokenizer: tokenizer)
}

// AutoTokenizer.from(modelFolder:)는 폴더 안에서 표준 파일명("tokenizer.json"/"tokenizer_config.json")을
Expand All @@ -68,7 +68,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
// 이를 피하기 위해 표준 파일명으로만 구성된 격리된 임시 폴더를 만들어 그 안에서 로드한다.
private static func loadTokenizer() async throws -> Tokenizer {
guard let tokenizerURL = Bundle.main.url(forResource: "kobart_tokenizer", withExtension: "json") else {
throw DiarySummaryError.modelLoadFailed()
throw SummaryError.modelLoadFailed()
}

let isolatedFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
Expand All @@ -87,7 +87,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {

return try await AutoTokenizer.from(modelFolder: isolatedFolder)
} catch {
throw DiarySummaryError.modelLoadFailed(underlying: error)
throw SummaryError.modelLoadFailed(underlying: error)
}
}

Expand All @@ -108,20 +108,26 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
runOptions: nil
)
guard let encoderHidden = encoderOutputs[Self.encoderOutputKey] else {
throw DiarySummaryError.inferenceFailed()
throw SummaryError.inferenceFailed()
}

let generatedTokens = try greedyDecode(encoderHidden: encoderHidden, encoderAttentionMask: maskTensor)
let summary = tokenizer.decode(tokens: generatedTokens, skipSpecialTokens: true)
return summary.trimmingCharacters(in: .whitespacesAndNewlines)
} catch let error as DiarySummaryError {
} catch let error as SummaryError {
throw error
} catch {
Log.error("요약 추론 실패: \(error)")
throw DiarySummaryError.inferenceFailed(underlying: error)
throw SummaryError.inferenceFailed(underlying: error)
}
}

/// 절단 없는 실제 토큰 수. swift-transformers의 encode()는 자체적으로 truncation을 하지 않으므로
/// (그 truncation은 summarize()의 buildEncoderInputs에서만 수동으로 함) 그대로 개수를 세면 된다.
func countTokens(text: String) async throws -> Int {
tokenizer.encode(text: text).count
}

// 인코더 1회 실행 결과(encoderHidden)를 매 스텝 재사용하며, 지금까지 생성된 전체 시퀀스를
// 다시 디코더에 통째로 넣는 cache-free 그리디 디코딩(디코더가 KV 캐시를 안 쓰므로).
private func greedyDecode(encoderHidden: ORTValue, encoderAttentionMask: ORTValue) throws -> [Int] {
Expand All @@ -140,7 +146,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
runOptions: nil
)
guard let logitsValue = decoderOutputs[Self.decoderOutputKey] else {
throw DiarySummaryError.inferenceFailed()
throw SummaryError.inferenceFailed()
}

let nextToken = try Self.argmaxLastPosition(
Expand Down Expand Up @@ -189,7 +195,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
let shapeInfo = try logits.tensorTypeAndShapeInfo()
let shape = shapeInfo.shape.map(\.intValue)
guard shape.count == 3, shape[1] == sequenceLength, shape[2] == vocabSize else {
throw DiarySummaryError.inferenceFailed()
throw SummaryError.inferenceFailed()
}

let data = try logits.tensorData() as Data
Expand Down
48 changes: 48 additions & 0 deletions GAMSS/Sources/Data/Repository/LazyConversationSummaryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// LazyConversationSummaryStore.swift
// GAMSS
//
// Created by cchanmi on 8/8/26.
//

import Foundation

/// DefaultSummaryRepository.make()가 async throws라 화면 생성 시점에 바로 만들 수 없다.
/// 실제로 처음 쓰일 때(add/current/restore 최초 호출) 한 번만 만들어 캐싱한다.
/// 생성에 실패해도(예: 모델 로드 실패) 압축은 부가 기능이므로 조용히 무시한다.
actor LazyConversationSummaryStore: ConversationSummaryStore {
private let makeRepository: () async throws -> SummaryRepository
private var underlying: DefaultConversationSummaryStore?
private var resolutionFailed = false

init(makeRepository: @escaping () async throws -> SummaryRepository = { try await DefaultSummaryRepository.make() }) {
self.makeRepository = makeRepository
}

func add(_ utterance: String) async {
await resolve()?.add(utterance)
}

func current() async -> String? {
await resolve()?.current()
}

func reset() async {
await resolve()?.reset()
}

func restore(historicalUtterances: [String]) async {
await resolve()?.restore(historicalUtterances: historicalUtterances)
}

private func resolve() async -> DefaultConversationSummaryStore? {
if let underlying { return underlying }
guard !resolutionFailed, let repository = try? await makeRepository() else {
resolutionFailed = true
return nil
}
let store = DefaultConversationSummaryStore(summaryRepository: repository)
underlying = store
return store
}
}
15 changes: 15 additions & 0 deletions GAMSS/Sources/Domain/Conversation/CommentRevealPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// CommentRevealPolicy.swift
// GAMSS
//
// Created by cchanmi on 8/7/26.
//

enum CommentRevealPolicy {
static let minSeconds = 1.0
static let maxSeconds = 3.0

static func nextGapSeconds() -> Double {
.random(in: minSeconds...maxSeconds)
}
}
18 changes: 18 additions & 0 deletions GAMSS/Sources/Domain/Conversation/ConversationRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// ConversationRepository.swift
// GAMSS
//
// Created by cchanmi on 8/7/26.
//

protocol ConversationRepository {
/// conversationId가 nil이면 서버가 새 채팅방을 만든다.
/// contextSummary는 저장되지 않고 캐릭터 응답 생성 컨텍스트로만 쓰인다.
func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage

/// 작성순으로 온다. 화면이 이 순서에 의존한다.
func getMessages(conversationId: Int) async throws -> [Message]

/// 지정한 날짜(yyyy-MM-dd, KST 00:00~24:00)에 생성된 채팅방 목록을 반환한다.
func getConversations(date: String) async throws -> [ConversationSummary]
}
Loading
Loading