Skip to content

Commit 8bf3b17

Browse files
authored
Merge pull request #15 from Nexters/feature/chat-messaging
[FEAT] 채팅 기능 추가
2 parents 3b70609 + 6506e1e commit 8bf3b17

52 files changed

Lines changed: 2416 additions & 479 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

GAMSS.xcodeproj/project.pbxproj

Lines changed: 72 additions & 26 deletions
Large diffs are not rendered by default.

GAMSS/Sources/App/GAMSSApp.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct GAMSSApp: App {
2323

2424
var body: some Scene {
2525
WindowGroup {
26-
LoginView(viewModel: LoginViewModel(loginUseCase: DefaultLoginUseCase(authRepository: DefaultAuthRepository(networkManager: NetworkManager.shared, tokenStorage: TokenStorage.shared))))
26+
MainTabView()
2727
}
2828
}
2929
}

GAMSS/Sources/Core/Network/NetworkManager.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,18 @@ final class NetworkManager: NetworkRequesting {
3434
) async throws -> T {
3535

3636
let request = try endpoint.asURLRequest()
37-
37+
38+
let bodyString = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "-"
39+
Log.debug("📤 \(endpoint.method.rawValue) \(endpoint.path) body: \(bodyString)")
40+
3841
let (data, response) = try await session.data(
3942
for: request
4043
)
41-
44+
45+
if let jsonString = String(data: data, encoding: .utf8) {
46+
Log.debug("📦 \(endpoint.path) response: \(jsonString)")
47+
}
48+
4249
guard let response = response as? HTTPURLResponse else {
4350
throw NetworkError.invalidResponse
4451
}

GAMSS/Sources/Data/DTO/Chat/Request/CreateMessageRequestDTO.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
import Foundation
99

1010
struct CreateMessageRequestDTO: Encodable {
11-
let conversationId: Int
11+
let conversationId: Int?
1212
let content: String
13-
let repliesMessageId: Int
14-
let currentConversationSummary: String
13+
let repliesToMessageId: Int?
14+
let currentConversationSummary: String?
1515
let excludeCharacters: [String]
1616
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//
2+
// ConversationMessageDTO.swift
3+
// GAMSS
4+
//
5+
// Created by cchanmi on 8/7/26.
6+
//
7+
8+
import Foundation
9+
10+
/// 서버 필드명과 1:1 (Android ConversationMessage.kt 기준). senderType이 "CHARACTER"일 때만
11+
/// emotionType이 채워진다. senderType이 알 수 없거나 emotionType을 못 알아보면 toDomain()이
12+
/// nil을 반환 — 잘못된 주체로 그리는 것보다 목록에서 제외하는 쪽을 택한다.
13+
struct ConversationMessageDTO: Decodable {
14+
let id: Int
15+
let conversationId: Int
16+
let senderType: String
17+
let emotionType: String?
18+
let content: String
19+
let repliesToMessageId: Int?
20+
let rootMessageId: Int?
21+
let createdAt: String
22+
23+
private static let serverTypeToCharacter: [String: EmotionCharacter] = [
24+
"JOY": .joy, "ANGER": .anger, "ANXIETY": .anxiety,
25+
"GRUMPY": .prickly, "WARM": .sadness, "QUIRKY": .quirky,
26+
]
27+
28+
func toDomain() -> Message? {
29+
let sender: MessageSender
30+
switch senderType {
31+
case "USER":
32+
sender = .user
33+
case "CHARACTER":
34+
guard let character = Self.serverTypeToCharacter[emotionType ?? ""] else { return nil }
35+
sender = .character(character)
36+
default:
37+
return nil
38+
}
39+
return Message(id: id, conversationId: conversationId, sender: sender, content: content, repliesToMessageId: repliesToMessageId)
40+
}
41+
42+
/// 방금 보낸 사용자 메시지 응답 전용 — 항상 .user로 간주한다.
43+
func toSentUserMessage() -> Message {
44+
Message(id: id, conversationId: conversationId, sender: .user, content: content, repliesToMessageId: repliesToMessageId)
45+
}
46+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// ConversationSummaryDTO.swift
3+
// GAMSS
4+
//
5+
// Created by cchanmi on 8/8/26.
6+
//
7+
8+
import Foundation
9+
10+
struct ConversationSummaryDTO: Decodable {
11+
let id: Int
12+
let title: String?
13+
let status: String
14+
let createdAt: String
15+
let updatedAt: String
16+
17+
func toDomain() -> ConversationSummary {
18+
ConversationSummary(id: id, title: title, status: status, createdAt: createdAt)
19+
}
20+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// SaveMessageResponseDTO.swift
3+
// GAMSS
4+
//
5+
// Created by cchanmi on 8/7/26.
6+
//
7+
8+
import Foundation
9+
10+
struct SaveMessageResponseDTO: Decodable {
11+
let message: ConversationMessageDTO
12+
let commentStatus: String
13+
let comments: [ConversationMessageDTO]
14+
15+
func toDomain() -> SentMessage {
16+
SentMessage(
17+
message: message.toSentUserMessage(),
18+
commentStatus: commentStatus.toCommentGenerationStatus(),
19+
comments: comments.compactMap { $0.toDomain() }
20+
)
21+
}
22+
}
23+
24+
private extension String {
25+
func toCommentGenerationStatus() -> CommentGenerationStatus {
26+
switch self {
27+
case "DONE": return .done
28+
case "LIMIT_EXCEEDED": return .limitExceeded
29+
default: return .failed
30+
}
31+
}
32+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// DefaultConversationRepository.swift
3+
// GAMSS
4+
//
5+
// Created by cchanmi on 8/7/26.
6+
//
7+
8+
final class DefaultConversationRepository: ConversationRepository {
9+
private let networkManager: NetworkRequesting
10+
11+
init(networkManager: NetworkRequesting) {
12+
self.networkManager = networkManager
13+
}
14+
15+
func sendMessage(conversationId: Int?, content: String, repliesToMessageId: Int?, contextSummary: String?) async throws -> SentMessage {
16+
let request = CreateMessageRequestDTO(
17+
conversationId: conversationId,
18+
content: content,
19+
repliesToMessageId: repliesToMessageId,
20+
currentConversationSummary: contextSummary,
21+
excludeCharacters: []
22+
)
23+
let response = try await networkManager.request(
24+
ChatEndpoint.createMessage(request),
25+
responseType: APIResponse<SaveMessageResponseDTO>.self
26+
)
27+
return response.data.toDomain()
28+
}
29+
30+
func getMessages(conversationId: Int) async throws -> [Message] {
31+
let response = try await networkManager.request(
32+
ChatEndpoint.fetchMessages(chatId: String(conversationId)),
33+
responseType: APIResponse<[ConversationMessageDTO]>.self
34+
)
35+
return response.data.compactMap { $0.toDomain() }
36+
}
37+
38+
func getConversations(date: String) async throws -> [ConversationSummary] {
39+
let response = try await networkManager.request(
40+
ChatEndpoint.fetchChats(date: date),
41+
responseType: APIResponse<[ConversationSummaryDTO]>.self
42+
)
43+
return response.data.map { $0.toDomain() }
44+
}
45+
}

GAMSS/Sources/Data/Repository/DefaultDiarySummaryRepository.swift renamed to GAMSS/Sources/Data/Repository/DefaultSummaryRepository.swift

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
//
2-
// DefaultDiarySummaryRepository.swift
2+
// DefaultSummaryRepository.swift
33
// GAMSS
44
//
5-
// Created by cchanmi on 7/31/26.
5+
// Created by cchanmi on 8/7/26.
66
//
77

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

43-
static func make() async throws -> DefaultDiarySummaryRepository {
43+
static func make() async throws -> DefaultSummaryRepository {
4444
guard
4545
let encoderPath = Bundle.main.path(forResource: "kobart_encoder_int8", ofType: "onnx"),
4646
let decoderPath = Bundle.main.path(forResource: "kobart_decoder_int8", ofType: "onnx")
4747
else {
48-
throw DiarySummaryError.modelLoadFailed()
48+
throw SummaryError.modelLoadFailed()
4949
}
5050

5151
let encoder: ORTSession
@@ -55,11 +55,11 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
5555
encoder = try ORTSession(env: env, modelPath: encoderPath, sessionOptions: nil)
5656
decoder = try ORTSession(env: env, modelPath: decoderPath, sessionOptions: nil)
5757
} catch {
58-
throw DiarySummaryError.modelLoadFailed(underlying: error)
58+
throw SummaryError.modelLoadFailed(underlying: error)
5959
}
6060

6161
let tokenizer = try await Self.loadTokenizer()
62-
return DefaultDiarySummaryRepository(encoder: encoder, decoder: decoder, tokenizer: tokenizer)
62+
return DefaultSummaryRepository(encoder: encoder, decoder: decoder, tokenizer: tokenizer)
6363
}
6464

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

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

8888
return try await AutoTokenizer.from(modelFolder: isolatedFolder)
8989
} catch {
90-
throw DiarySummaryError.modelLoadFailed(underlying: error)
90+
throw SummaryError.modelLoadFailed(underlying: error)
9191
}
9292
}
9393

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

114114
let generatedTokens = try greedyDecode(encoderHidden: encoderHidden, encoderAttentionMask: maskTensor)
115115
let summary = tokenizer.decode(tokens: generatedTokens, skipSpecialTokens: true)
116116
return summary.trimmingCharacters(in: .whitespacesAndNewlines)
117-
} catch let error as DiarySummaryError {
117+
} catch let error as SummaryError {
118118
throw error
119119
} catch {
120120
Log.error("요약 추론 실패: \(error)")
121-
throw DiarySummaryError.inferenceFailed(underlying: error)
121+
throw SummaryError.inferenceFailed(underlying: error)
122122
}
123123
}
124124

125+
/// 절단 없는 실제 토큰 수. swift-transformers의 encode()는 자체적으로 truncation을 하지 않으므로
126+
/// (그 truncation은 summarize()의 buildEncoderInputs에서만 수동으로 함) 그대로 개수를 세면 된다.
127+
func countTokens(text: String) async throws -> Int {
128+
tokenizer.encode(text: text).count
129+
}
130+
125131
// 인코더 1회 실행 결과(encoderHidden)를 매 스텝 재사용하며, 지금까지 생성된 전체 시퀀스를
126132
// 다시 디코더에 통째로 넣는 cache-free 그리디 디코딩(디코더가 KV 캐시를 안 쓰므로).
127133
private func greedyDecode(encoderHidden: ORTValue, encoderAttentionMask: ORTValue) throws -> [Int] {
@@ -140,7 +146,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
140146
runOptions: nil
141147
)
142148
guard let logitsValue = decoderOutputs[Self.decoderOutputKey] else {
143-
throw DiarySummaryError.inferenceFailed()
149+
throw SummaryError.inferenceFailed()
144150
}
145151

146152
let nextToken = try Self.argmaxLastPosition(
@@ -189,7 +195,7 @@ actor DefaultDiarySummaryRepository: DiarySummaryRepository {
189195
let shapeInfo = try logits.tensorTypeAndShapeInfo()
190196
let shape = shapeInfo.shape.map(\.intValue)
191197
guard shape.count == 3, shape[1] == sequenceLength, shape[2] == vocabSize else {
192-
throw DiarySummaryError.inferenceFailed()
198+
throw SummaryError.inferenceFailed()
193199
}
194200

195201
let data = try logits.tensorData() as Data
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// LazyConversationSummaryStore.swift
3+
// GAMSS
4+
//
5+
// Created by cchanmi on 8/8/26.
6+
//
7+
8+
import Foundation
9+
10+
/// DefaultSummaryRepository.make()가 async throws라 화면 생성 시점에 바로 만들 수 없다.
11+
/// 실제로 처음 쓰일 때(add/current/restore 최초 호출) 한 번만 만들어 캐싱한다.
12+
/// 생성에 실패해도(예: 모델 로드 실패) 압축은 부가 기능이므로 조용히 무시한다.
13+
actor LazyConversationSummaryStore: ConversationSummaryStore {
14+
private let makeRepository: () async throws -> SummaryRepository
15+
private var underlying: DefaultConversationSummaryStore?
16+
private var resolutionFailed = false
17+
18+
init(makeRepository: @escaping () async throws -> SummaryRepository = { try await DefaultSummaryRepository.make() }) {
19+
self.makeRepository = makeRepository
20+
}
21+
22+
func add(_ utterance: String) async {
23+
await resolve()?.add(utterance)
24+
}
25+
26+
func current() async -> String? {
27+
await resolve()?.current()
28+
}
29+
30+
func reset() async {
31+
await resolve()?.reset()
32+
}
33+
34+
func restore(historicalUtterances: [String]) async {
35+
await resolve()?.restore(historicalUtterances: historicalUtterances)
36+
}
37+
38+
private func resolve() async -> DefaultConversationSummaryStore? {
39+
if let underlying { return underlying }
40+
guard !resolutionFailed, let repository = try? await makeRepository() else {
41+
resolutionFailed = true
42+
return nil
43+
}
44+
let store = DefaultConversationSummaryStore(summaryRepository: repository)
45+
underlying = store
46+
return store
47+
}
48+
}

0 commit comments

Comments
 (0)