diff --git a/Podfile b/Podfile index c9f1ce3..a84b770 100644 --- a/Podfile +++ b/Podfile @@ -38,6 +38,10 @@ target 'Visual_Novel_iOS' do pod 'AWSS3' # pod 'URLNavigator' pod 'BytePlusRTC', '~> 3.58.1' + + pod 'StreamChat', '~> 4.0.0' + pod 'StreamChatUI' + pod 'IKEventSource', '3.0.1' # OC pod 'SDWebImage' diff --git a/Podfile.lock b/Podfile.lock index ead453f..d1ef6bc 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -28,6 +28,7 @@ PODS: - Cache (6.0.0) - DateToolsSwift (5.0.0) - Delegate (1.3.0) + - IKEventSource (3.0.1) - IQKeyboardCore (1.0.8) - IQKeyboardManagerSwift (8.0.1): - IQKeyboardManagerSwift/Appearance (= 8.0.1) @@ -85,13 +86,22 @@ PODS: - YXArtemis_XCFramework - NIMSDK_LITE/NOS (10.9.52): - YXArtemis_XCFramework + - Nuke (10.7.1) - R.swift (7.8.0) - SDWebImage (5.21.3): - SDWebImage/Core (= 5.21.3) - SDWebImage/Core (5.21.3) - SnapKit (5.7.1) + - Starscream (4.0.8) + - StreamChat (4.0.4): + - Starscream (~> 4.0) + - StreamChatUI (4.0.4): + - Nuke (~> 10.0) + - StreamChat (= 4.0.4) + - SwiftyGif (~> 5.0) - SwiftDate (7.0.0) - SwiftyAttributes (5.4.0) + - SwiftyGif (5.4.5) - SwipeCellKit (2.7.1) - TZImagePickerController (3.8.9): - TZImagePickerController/Basic (= 3.8.9) @@ -109,6 +119,7 @@ DEPENDENCIES: - BytePlusRTC (~> 3.58.1) - Cache - DateToolsSwift + - IKEventSource (= 3.0.1) - IQKeyboardManagerSwift - JXPagingView/Paging - JXSegmentedView @@ -123,6 +134,8 @@ DEPENDENCIES: - R.swift - SDWebImage - SnapKit + - StreamChat (~> 4.0.0) + - StreamChatUI - SwiftDate - SwiftyAttributes - SwipeCellKit @@ -145,6 +158,7 @@ SPEC REPOS: - Cache - DateToolsSwift - Delegate + - IKEventSource - IQKeyboardCore - IQKeyboardManagerSwift - IQKeyboardNotification @@ -163,11 +177,16 @@ SPEC REPOS: - MJRefresh - Moya - NIMSDK_LITE + - Nuke - R.swift - SDWebImage - SnapKit + - Starscream + - StreamChat + - StreamChatUI - SwiftDate - SwiftyAttributes + - SwiftyGif - SwipeCellKit - UICKeyChainStore - YXArtemis_XCFramework @@ -196,6 +215,7 @@ SPEC CHECKSUMS: Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 Delegate: 0ff4467868095239ff578ab531efd8af46e62881 + IKEventSource: ababa323587c6b0c250dd54d4a48ab68fd845e8e IQKeyboardCore: 8652977ec919cf5351aa2977fedd1a6546476fbc IQKeyboardManagerSwift: 835fc9c6e4732398113406d84900ad2e8f141218 IQKeyboardNotification: eb4910401f5a0e68f97e71c62f8a0c5b7e9d535c @@ -214,16 +234,21 @@ SPEC CHECKSUMS: MJRefresh: ff9e531227924c84ce459338414550a05d2aea78 Moya: 138f0573e53411fb3dc17016add0b748dfbd78ee NIMSDK_LITE: dfefccd874ae111a49c59a93997fc1e69b721f30 + Nuke: 279f17a599fd1c83cf51de5e0e1f2db143a287b0 R.swift: f573269ca45b2ab066c082e363dd4c2b297b0d71 SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a + Starscream: 19b5533ddb925208db698f0ac508a100b884a1b9 + StreamChat: 5f849859ba70a522d43412181ce77241e4ee0f4d + StreamChatUI: f8ed5b08502a55ab16ae8f10f7b9383448e6429d SwiftDate: bbc26e26fc8c0c33fbee8c140c5e8a68293a148a SwiftyAttributes: 45fae22b22a246a0b7f0a8d2157a02bf89fb2e9a + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwipeCellKit: 3972254a826da74609926daf59b08d6c72e619ea TZImagePickerController: 456f470b5dea97b37226ec7a694994a8663340b2 UICKeyChainStore: ba3bff2c762b12db1e516f395c837dd25298b05e YXArtemis_XCFramework: d9a8b9439d7a6c757ed00ada53a6d2dd9b13f9c7 -PODFILE CHECKSUM: 8c380964208bfbf13ffc8af0ed60c019dddd76aa +PODFILE CHECKSUM: 741f44021dfbc5ef5236378dec4972f63188c9cb COCOAPODS: 1.16.2 diff --git a/Visual_Novel_iOS.xcodeproj/project.pbxproj b/Visual_Novel_iOS.xcodeproj/project.pbxproj index 352997a..040974b 100644 --- a/Visual_Novel_iOS.xcodeproj/project.pbxproj +++ b/Visual_Novel_iOS.xcodeproj/project.pbxproj @@ -115,7 +115,6 @@ isa = PBXGroup; children = ( 70FCBA512E1CEE8800B29921 /* Visual_Novel_iOS */, - 70D22BD22E21390600A71DEB /* Visual_Novel_iOSTests */, 70FCBA502E1CEE8800B29921 /* Products */, 34DBC4421D6AB48D485892A6 /* Pods */, DC9D4EE3C3EA00FA653EB4E8 /* Frameworks */, @@ -127,6 +126,7 @@ children = ( EC4549BA2E9DF999004D3972 /* Visual_Novel_iOSLevel.app */, EC4549BB2E9DF999004D3972 /* Visual_Novel_iOSTests.xctest */, + 70D22BD22E21390600A71DEB /* Visual_Novel_iOSTests */, ); name = Products; sourceTree = ""; diff --git a/Visual_Novel_iOS/AppLaunchInitial.swift b/Visual_Novel_iOS/AppLaunchInitial.swift index 4d5ebba..098e267 100644 --- a/Visual_Novel_iOS/AppLaunchInitial.swift +++ b/Visual_Novel_iOS/AppLaunchInitial.swift @@ -8,8 +8,24 @@ import Foundation //import IQKeyboardManagerSwift import IQKeyboardToolbarManager import IQKeyboardManagerSwift +import StreamChat +import StreamChatUI + +struct StreamChatRequest: Codable { + var userId = "" + var userName: String = "" + var avatarUrl = "" +} + +struct StreamChatDataModel: Codable { + var data: String? // 直接是字符串 + var code: Int? + var message: String? +} + class AppLaunchInitial{ + public func setupCommon(){ // User UserCore.shared.autoLoginTry() @@ -27,6 +43,65 @@ class AppLaunchInitial{ loadApis(excludeNoNeedLogin: false) setupEvent() + + + let nonExpiringToken: Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibGVpYV9vcmdhbmEifQ.PgEjo89vH3mZwXSiGuw8dKVo_mWme_yb-T8_1vtXjag" + // Create the user info to connect with + let userInfo = UserInfo( + id: "leia_organa", + name: "Leia Organa", + imageURL: URL(string: "https://cutt.ly/SmeFRfC") + ) + + var req = StreamChatRequest() + req.userId = "leia_organa" + req.userName = "Leia Organa" + req.avatarUrl = "https://cutt.ly/SmeFRfC" + + + let params = req.toNonNilDictionary() + + StreamChatProvider.request(.createUser(params: params), modelType: StreamChatDataModel.self) { result in + switch result { + case .success(let model): + dlog("result = \(String(describing: model?.data))") + if let value = model?.data { + IMSSEManager.shared.configure(streamURL: "http://54.223.196.180:8099/chat/ai/generateReply", + token: value) +// +// URLSSEManager.shared.start(url: "http://54.223.196.180:8099/chat/ai/generateReply") + do { + let token = try Token(rawValue: value) + ChatClient.shared.connectUser(userInfo: userInfo, token: token) { error in + dlog("ChatClient error: \(String(describing: error))") + } + } catch { + dlog("ChatClient error") + } + + } + case .failure(let error): + dlog("result = \(error)") + } + } +// createChannelId() + +// ChatClient.shared.connectUser(userInfo: userInfo, token: nonExpiringToken) { error in +// dlog("ChatClient error: \(String(describing: error))") +// } + } + + func createChannelId() { + do { + let channelController = try ChatClient.shared.channelController(createChannelWithId: ChannelId(type: .messaging, id: UUID().uuidString), name: "channelName") + channelController.synchronize { error in + if let error = error { + print(error) + } + } + } catch { + print("Channel creation failed") + } } /// excludeNoNeedLogin, 排除掉不需要登陆的。 一般使用false @@ -80,4 +155,15 @@ class AppLaunchInitial{ +} + +extension ChatClient { + static let shared: ChatClient = { + // You can grab your API Key from https://getstream.io/dashboard/ + let config = ChatClientConfig(apiKeyString: "rpwwpq5gvq3h") + // Create an instance of the `ChatClient` with the given config + let client = ChatClient(config: config) + return client + }() + } diff --git a/Visual_Novel_iOS/Src/API/Network/APIConfig.swift b/Visual_Novel_iOS/Src/API/Network/APIConfig.swift index 7d46302..9186e52 100644 --- a/Visual_Novel_iOS/Src/API/Network/APIConfig.swift +++ b/Visual_Novel_iOS/Src/API/Network/APIConfig.swift @@ -38,7 +38,7 @@ struct APIConfig { private static var headers: [String: String]? { return ["content-type": "application/json", - "accept": "application/json,text/plain"] + "accept": "application/json,text/plain,text/event-stream"] } static func apiHeaders() -> [String: String]? { diff --git a/Visual_Novel_iOS/Src/API/Network/APIProvider.swift b/Visual_Novel_iOS/Src/API/Network/APIProvider.swift index e5fca56..f388bd2 100644 --- a/Visual_Novel_iOS/Src/API/Network/APIProvider.swift +++ b/Visual_Novel_iOS/Src/API/Network/APIProvider.swift @@ -136,6 +136,55 @@ struct ResponseData: Codable { let code: Int? let message: String? let data: T? + + // 自定义解码器,处理 data 可能是字符串或对象的情况 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(Int.self, forKey: .code) + message = try container.decodeIfPresent(String.self, forKey: .message) + + // 处理 data 字段:可能是字符串、对象或 null + if container.contains(.data) { + // 先检查是否为 null + if (try? container.decodeNil(forKey: .data)) == true { + data = nil + } else { + // 尝试判断 data 的类型 + // 先尝试解码为字符串 + if let stringValue = try? container.decode(String.self, forKey: .data) { + // data 是字符串类型 + // 如果 T 是 String 类型,直接使用 + if T.self == String.self { + data = stringValue as? T + } else { + // 如果 T 不是 String,且字符串是有效的 JSON,尝试解析 + // 否则返回 nil + if let jsonData = stringValue.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: jsonData), + let jsonData2 = try? JSONSerialization.data(withJSONObject: jsonObject), + let decodedValue = try? JSONDecoder().decode(T.self, from: jsonData2) { + data = decodedValue + } else { + // 字符串不是有效的 JSON,返回 nil + data = nil + } + } + } else { + // data 是对象类型,正常解码 + data = try container.decodeIfPresent(T.self, forKey: .data) + } + } + } else { + // data 字段不存在 + data = nil + } + } + + enum CodingKeys: String, CodingKey { + case code + case message + case data + } } class ResponseContentPageData: Codable{ @@ -204,25 +253,91 @@ extension MoyaProvider { switch result { case let .success(response): do { + let contentTypeHeader = response.response?.allHeaderFields.first(where: { key, _ in + guard let keyString = key as? String else { return false } + return keyString.lowercased() == "content-type" + })?.value as? String + let isEventStream = contentTypeHeader?.lowercased().contains("text/event-stream") == true + if isEventStream { + if APIConfig.apiLogEnable { + let responseString = String(data: response.data, encoding: .utf8) ?? "" + dlog("👉⭐️\(target.path)⭐️ event-stream response:\n\(responseString)") + } + // 对于 text/event-stream,直接视为成功,交由 SSE 管理器处理 + completion(.success(nil)) + return + } + + // 调试日志:将响应数据转换为字符串用于日志输出 + if APIConfig.apiLogEnable { + let responseString = String(data: response.data, encoding: .utf8) ?? "" + if response.data.isEmpty { + dlog("👉⭐️\(target.path)⭐️ response: (空响应)") + } else if let jsonObject = try? JSONSerialization.jsonObject(with: response.data, options: .allowFragments), + let jsonData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) { + dlog("👉⭐️\(target.path)⭐️ response:\n\(jsonString)") + } else { + // 不是有效 JSON,直接输出原始字符串 + dlog("👉⭐️\(target.path)⭐️ response (非 JSON):\n\(responseString)") + } + } + + // 检查响应数据是否为空 + guard !response.data.isEmpty else { + // 空响应,尝试返回 nil 或默认值 + dlog("⚠️ \(target.path) 返回空响应") + // 对于某些接口,空响应可能表示成功 + if let emptyModel = try? JSONDecoder().decode(T.self, from: "{}".data(using: .utf8)!) { + completion(.success(emptyModel)) + } else { + completion(.success(nil)) + } + return + } + // 使用 JSONDecoder 解析 response.data let decoder = JSONDecoder() - let data = try decoder.decode(ResponseData.self, from: response.data) - - // 调试日志:将 JSON 转换为字符串用于日志输出 - if APIConfig.apiLogEnable { - let jsonObject = try JSONSerialization.jsonObject(with: response.data) - let jsonData = try JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted) - let jsonString = String(data: jsonData, encoding: .utf8) ?? String(data: response.data, encoding: .utf8) ?? "" - dlog("👉⭐️\(target.path)⭐️ response:\n\(jsonString)") + + // 先尝试解码为 ResponseData + var responseData: ResponseData + do { + responseData = try decoder.decode(ResponseData.self, from: response.data) + } catch { + // 如果解码失败,可能是后端返回的格式不匹配 ResponseData 结构 + // 尝试直接解码为 T 类型(某些 API 可能直接返回 T 类型的数据) + dlog("⚠️ 尝试直接解码为 T 类型: \(error.localizedDescription)") + if let directModel = try? decoder.decode(T.self, from: response.data) { + completion(.success(directModel)) + return + } else { + // 如果都失败了,尝试使用 allowFragments 选项解析 + if let jsonObject = try? JSONSerialization.jsonObject(with: response.data, options: .allowFragments), + let jsonData = try? JSONSerialization.data(withJSONObject: jsonObject), + let fallbackModel = try? decoder.decode(T.self, from: jsonData) { + completion(.success(fallbackModel)) + return + } + throw error + } } // let status = data.status // let code = data.errorCode // let msg = data.errorMsg - if (data.code ?? 0) == 200 { - let model = data.data - completion(.success(model)) + if (responseData.code ?? 0) == 200 { + if let model = responseData.data { + completion(.success(model)) + } else { + // data 为空,但调用方期望拿到完整结构(如 code/message/data) + // 尝试直接将整个响应解析为目标模型 + if let fallbackModel = try? decoder.decode(T.self, from: response.data) { + completion(.success(fallbackModel)) + } else { + completion(.success(nil)) + } + } } else { // var toastMsg = autoShowErrMsg // @@ -271,8 +386,23 @@ extension MoyaProvider { } } catch { - dlog("⛔️请求成功,但解析失败: \(error), Response:⛔️\(CodableHelper.jsonString(from: response.data) ?? "x")⛔️") - completion(.failure(.deserializeError)) + let responseString = String(data: response.data, encoding: .utf8) ?? "" + let responsePreview = responseString.isEmpty ? "(空响应)" : (responseString.count > 200 ? String(responseString.prefix(200)) + "..." : responseString) + + dlog("⛔️请求成功,但解析失败: \(error.localizedDescription)") + dlog("⛔️响应数据: \(responsePreview)") + dlog("⛔️响应状态码: \(response.statusCode)") + + // 对于某些接口(如 SSE 触发接口),空响应或非 JSON 响应可能表示成功 + // 检查是否是空响应且状态码为 200 + if response.data.isEmpty && response.statusCode == 200 { + dlog("ℹ️ 检测到空响应但状态码为 200,可能表示成功(如 SSE 触发接口)") + // 尝试返回 nil 或默认模型 + completion(.success(nil)) + } else { + // 其他情况返回解析错误 + completion(.failure(.deserializeError)) + } } case let .failure(error): diff --git a/Visual_Novel_iOS/Src/API/StreamChatApi.swift b/Visual_Novel_iOS/Src/API/StreamChatApi.swift new file mode 100644 index 0000000..175c16a --- /dev/null +++ b/Visual_Novel_iOS/Src/API/StreamChatApi.swift @@ -0,0 +1,68 @@ +// +// StreamChatApi.swift +// Visual_Novel_iOS +// +// Created by mh on 2025/11/18. +// + +import Moya + +let StreamChatProvider = APIConfig.useMock && UserAPI.useMock +? MoyaProvider(endpointClosure: myEndpointClosure, stubClosure: { target in + let data = target.sampleData + if(data.count > 0){ + return .delayed(seconds: 0.5) + }else{ + return .never + } +}) +: MoyaProvider(requestClosure: myRequestClosure) + +enum StreamChatApi { + static let useMock: Bool = false + + case createUser(params: [String: Any]) +} + +extension StreamChatApi: TargetType { + var baseURL: URL { + // 确保 URL 格式正确,避免强制解包导致的崩溃 + guard let url = URL(string: "http://54.223.196.180:8099") else { + fatalError("Invalid baseURL: \(APIConfig.role)") + } + return url + } + + var path: String { + switch self { + case .createUser: + return "/v1/im/user/createOrGet" + } + } + + var method: Moya.Method { + return .post + } + + var task: Task { + var mParams = [String: Any]() + switch self { + case .createUser(let params): + // 将传入的参数赋值给 mParams + mParams = params + } + return .requestParameters(parameters: mParams, encoding: JSONEncoding.default) + } + + var headers: [String : String]? { + return APIConfig.apiHeaders() + } + + var sampleData: Data { + switch self { + case .createUser: + return Data() + } + } +} + diff --git a/Visual_Novel_iOS/Src/API/StreamChatCreateApi.swift b/Visual_Novel_iOS/Src/API/StreamChatCreateApi.swift new file mode 100644 index 0000000..070d879 --- /dev/null +++ b/Visual_Novel_iOS/Src/API/StreamChatCreateApi.swift @@ -0,0 +1,69 @@ +// +// StreamChatCreateApi.swift +// Visual_Novel_iOS +// +// Created by mh on 2025/11/19. +// + +import Moya + +let StreamChatCreateProvider = APIConfig.useMock && UserAPI.useMock +? MoyaProvider(endpointClosure: myEndpointClosure, stubClosure: { target in + let data = target.sampleData + if(data.count > 0){ + return .delayed(seconds: 0.5) + }else{ + return .never + } +}) +: MoyaProvider(requestClosure: myRequestClosure) + +enum StreamChatCreateApi { + static let useMock: Bool = false + + case chatCreate(params: [String: Any]) +} + +extension StreamChatCreateApi: TargetType { + var baseURL: URL { + // 确保 URL 格式正确,避免强制解包导致的崩溃 + guard let url = URL(string: "http://54.223.196.180:8099") else { + fatalError("Invalid baseURL: \(APIConfig.role)") + } + return url + } + + var path: String { + switch self { + case .chatCreate: + return "/v1/im/user/conversation/create" + } + } + + var method: Moya.Method { + return .post + } + + var task: Task { + var mParams = [String: Any]() + switch self { + case .chatCreate(let params): + // 将传入的参数赋值给 mParams + mParams = params + } + return .requestParameters(parameters: mParams, encoding: JSONEncoding.default) + } + + var headers: [String : String]? { + return APIConfig.apiHeaders() + } + + var sampleData: Data { + switch self { + case .chatCreate: + return Data() + } + } +} + + diff --git a/Visual_Novel_iOS/Src/API/StreamChatSendMsgApi.swift b/Visual_Novel_iOS/Src/API/StreamChatSendMsgApi.swift new file mode 100644 index 0000000..2b3a7b6 --- /dev/null +++ b/Visual_Novel_iOS/Src/API/StreamChatSendMsgApi.swift @@ -0,0 +1,69 @@ +// +// StreamChatSendMsgApi.swift +// Visual_Novel_iOS +// +// Created by mh on 2025/11/19. +// + +import Moya + +let StreamChatSendMsgProvider = APIConfig.useMock && UserAPI.useMock +? MoyaProvider(endpointClosure: myEndpointClosure, stubClosure: { target in + let data = target.sampleData + if(data.count > 0){ + return .delayed(seconds: 0.5) + }else{ + return .never + } +}) +: MoyaProvider(requestClosure: myRequestClosure) + +enum StreamChatSendMsgApi { + static let useMock: Bool = false + + case sendMsg(params: [String: Any]) +} + +extension StreamChatSendMsgApi: TargetType { + var baseURL: URL { + // 确保 URL 格式正确,避免强制解包导致的崩溃 + guard let url = URL(string: "http://54.223.196.180:8099") else { + fatalError("Invalid baseURL: \(APIConfig.role)") + } + return url + } + + var path: String { + switch self { + case .sendMsg: + return "/chat/ai/generateReply" + } + } + + var method: Moya.Method { + return .post + } + + var task: Task { + var mParams = [String: Any]() + switch self { + case .sendMsg(let params): + // 将传入的参数赋值给 mParams + mParams = params + } + return .requestParameters(parameters: mParams, encoding: JSONEncoding.default) + } + + var headers: [String : String]? { + return APIConfig.apiHeaders() + } + + var sampleData: Data { + switch self { + case .sendMsg: + return Data() + } + } +} + + diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/Input/SessionInputOperateView.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/Input/SessionInputOperateView.swift index bfa54c9..b064a94 100644 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/Input/SessionInputOperateView.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/Input/SessionInputOperateView.swift @@ -8,6 +8,7 @@ import Combine import UIKit + protocol SessionInputOperateViewDelegate: AnyObject { func operateTapGiftAction() func operateVoiceAction(on: Bool) @@ -16,6 +17,9 @@ protocol SessionInputOperateViewDelegate: AnyObject { func operateTapInputFieldAction() // 拖动手势位置变化 func operateVoiceDragAction(location: CGPoint) + + // 发送消息 + func operateTextMessage(msg: String) } enum InputOperateState { @@ -297,7 +301,7 @@ class SessionInputOperateView: UIView { } @objc private func tapSendButton() { - + delegate?.operateTextMessage(msg: self.inputTextView.text) } @objc private func tapMoreButton() { diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift index ae2d76c..c02fe34 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift @@ -9,6 +9,7 @@ import Foundation import UIKit import NIMSDK import TZImagePickerController +import StreamChat extension SessionController { func setupInputView() { @@ -274,6 +275,32 @@ extension SessionController: SessionInputOperateViewDelegate{ voiceHoldView.updateCancelState(isInCancelArea: isInCancelArea) } + struct StreamChatSendMsgRequest: Codable { + var userId: String? + var characterId: String? + var channelId: String? + var message: String? + var promptTemplateId: String? + } + + struct StreamChatSendMsgModel: Codable { + var status: Int? + } + + func operateTextMessage(msg: String) { + dlog("operateTextMessage: \(msg)") + var req = StreamChatSendMsgRequest() + req.userId = "leia_organa" + req.characterId = "691d54f90c8cd949da7bb6ad" + req.channelId = self.conversationId + req.message = msg + req.promptTemplateId = "691be128b19e6a6aba44d277" + + let params = req.toNonNilDictionary() + IMSSEManager.shared.startListening(channelId: req.channelId, + payload: params) + } + func operateTapMoreAction() { view.endEditing(true) diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift index 7b3e348..837079c 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift @@ -113,13 +113,50 @@ class SessionController: CLBaseViewController { dlog("☁️❌get \(String(describing: self.conversationId)) conversation error:\(error)") } } + + struct StreamChatConnectionRequest: Codable { + var userId: String? + var userName: String? + var characterId: String? + var conversationName: String? + } + + struct StreamChatConnectionModel: Codable { + var characterId: String? + var conversationId: String? + var conversationName: String? + var userId: String? + var channelId: String? + var userName: String? + } override func viewDidLoad() { super.viewDidLoad() setupUI() // setupData() setupEvent() + + setupStreamChat() + } + + func setupStreamChat() { + var req = StreamChatConnectionRequest() + req.userId = "leia_organa" + req.userName = "Leia Organa" + req.characterId = "691d54f90c8cd949da7bb6ad" + req.conversationName = "Ai chat" + + let params = req.toNonNilDictionary() + StreamChatCreateProvider.request(.chatCreate(params: params), modelType: StreamChatConnectionModel.self) { result in + switch result { + case .success(let model): + self.conversationId = model?.channelId + dlog("StreamChatCreateProvider model: \(String(describing: self.conversationId))") + case .failure(_): + dlog("StreamChatCreateProvider failure") + } + } } override func viewDidAppear(_ animated: Bool) { diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Util/IMSSEManager.swift b/Visual_Novel_iOS/Src/Modules/Chat/Util/IMSSEManager.swift new file mode 100644 index 0000000..375f30d --- /dev/null +++ b/Visual_Novel_iOS/Src/Modules/Chat/Util/IMSSEManager.swift @@ -0,0 +1,316 @@ +// +// IMSSEManager.swift +// Visual_Novel_iOS +// +// Created by mh on 2025/11/19. +// + +import Foundation + +final class IMSSEManager: NSObject { + static let shared = IMSSEManager() + + private var streamURLString: String? + private var authToken: String? + private var currentChannelId: String? + private var currentPayload: [String: Any]? + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 60 * 60 * 24 + configuration.timeoutIntervalForResource = 60 * 60 * 24 + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpShouldUsePipelining = true + return URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + }() + + private var dataTask: URLSessionDataTask? + private var pendingEventText = "" + private var retryInterval: TimeInterval = 3 + private var reconnectWorkItem: DispatchWorkItem? + + private var valueDatas: String = "" + + private override init() { + super.init() + } + + /// 预先配置 SSE 的基础地址和 token + /// - Parameters: + /// - streamURL: SSE 服务器地址 + /// - token: 认证 token + /// - autoStart: 是否在配置后自动开始监听(如果已有 channelId)。默认为 false,需要手动调用 startListening + func configure(streamURL: String, token: String, autoStart: Bool = false) { + let wasConfigured = streamURLString != nil && authToken != nil + let wasConnected = dataTask != nil + + streamURLString = streamURL + authToken = token + + dlog("⚙️ IMSSEManager 配置完成 - streamURL: \(streamURL), token: \(token.prefix(20))...") + + if wasConnected { + dlog("🔄 IMSSEManager 检测到已有连接,重新建立连接以应用新配置") + startListening(channelId: currentChannelId, payload: currentPayload) + } else if autoStart, let channelId = currentChannelId, !channelId.isEmpty { + dlog("🚀 IMSSEManager 自动开始监听 channelId: \(channelId)") + startListening(channelId: channelId, payload: currentPayload) + } else if !wasConfigured { + dlog("ℹ️ IMSSEManager 配置完成,请调用 startListening(channelId:) 开始监听 SSE 数据") + } + } + + /// 对指定 channelId 开始监听,如果已经监听则自动复用 + /// - Parameters: + /// - channelId: 会话 channel + /// - payload: 发送给后端的 POST 参数 + func startListening(channelId: String? = nil, + payload: [String: Any]? = nil) { + guard let streamURLString = streamURLString else { + dlog("⚠️ IMSSEManager streamURL 未配置,请先调用 configure(streamURL:token:)") + return + } + + guard let token = authToken, !token.isEmpty else { + dlog("⚠️ IMSSEManager token 未配置,请先调用 configure(streamURL:token:)") + return + } + + if currentChannelId != channelId { + dlog("🔄 IMSSEManager channelId 变化: \(currentChannelId ?? "nil") -> \(channelId ?? "nil"),重建连接") + disconnect() + } else if let task = dataTask, task.state == .running { + dlog("ℹ️ IMSSEManager 已在监听 channelId: \(channelId ?? "nil")") + return + } + + currentChannelId = channelId + currentPayload = payload + pendingEventText = "" + + guard let request = buildRequest(urlString: streamURLString, + token: token, + channelId: channelId, + payload: payload) else { + return + } + + dlog("🔗 IMSSEManager 开始以 POST 方式连接 SSE: \(request.url?.absoluteString ?? "nil")") + + dataTask = session.dataTask(with: request) + dataTask?.resume() + dlog("🚀 IMSSEManager 已调用 dataTask.resume()") + } + + /// 断开连接,防止内存泄漏 + func disconnect() { + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + if let task = dataTask { + dlog("🔌 IMSSEManager 断开 SSE 连接") + task.cancel() + } + dataTask = nil + pendingEventText = "" + currentChannelId = nil + } + + /// 获取当前连接状态 + var isConnected: Bool { + return dataTask?.state == .running + } + + /// 获取当前监听的 channelId + var currentListeningChannelId: String? { + return currentChannelId + } + + private func buildRequest(urlString: String, + token: String, + channelId: String?, + payload: [String: Any]?) -> URLRequest? { + guard var components = URLComponents(string: urlString) else { + dlog("❌ IMSSEManager 无法构建 URLComponents: \(urlString)") + return nil + } +// if let channelId, !channelId.isEmpty { +// var items = components.queryItems ?? [] +// items.removeAll { $0.name == "channelId" } +// items.append(URLQueryItem(name: "channelId", value: channelId)) +// components.queryItems = items +// } + guard let url = components.url else { + dlog("❌ IMSSEManager 无法构建 URL: \(components)") + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 60 * 60 * 24 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + if let payload = payload { + if var normalized = payload as? [String: Any] { + if normalized["channelId"] == nil, let channelId = channelId { + normalized["channelId"] = channelId + } + if let body = makeJSONData(from: normalized) { + request.httpBody = body + } + } else if let body = makeJSONData(from: ["payload": payload]) { + request.httpBody = body + } + } else if let channelId = channelId { + let bodyDict: [String: Any] = ["channelId": channelId] + request.httpBody = makeJSONData(from: bodyDict) + } + + return request + } + + private func makeJSONData(from payload: [String: Any]) -> Data? { + if JSONSerialization.isValidJSONObject(payload) { + return try? JSONSerialization.data(withJSONObject: payload, options: []) + } + + var normalized: [String: Any] = [:] + for (key, value) in payload { + if JSONSerialization.isValidJSONObject([key: value]) { + normalized[key] = value + } else if let array = value as? [Any], + JSONSerialization.isValidJSONObject([key: array]) { + normalized[key] = array + } else if let dict = value as? [String: Any], + JSONSerialization.isValidJSONObject(dict) { + normalized[key] = dict + } else { + normalized[key] = "\(value)" + } + } + + guard JSONSerialization.isValidJSONObject(normalized) else { + dlog("⚠️ IMSSEManager payload 非法,无法转为 JSON") + return nil + } + + return try? JSONSerialization.data(withJSONObject: normalized, options: []) + } + + private func handleMessage(_ data: String?, event: String = "message") { + guard let data = data, !data.isEmpty else { + dlog("⚠️ IMSSEManager 收到空数据") + return + } + + dlog("📥 IMSSEManager 收到原始数据(event: \(event)): \(data)") + + if let jsonData = data.data(using: .utf8) { + do { + let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) + if let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted), + let prettyString = String(data: prettyData, encoding: .utf8) { + dlog("✅ IMSSEManager 收到 SSE JSON 数据:\n\(prettyString)") + } else { + dlog("✅ IMSSEManager 收到 SSE JSON 数据:\(jsonObject)") + } + + NotificationCenter.default.post( + name: NSNotification.Name("IMSSEDataReceived"), + object: nil, + userInfo: ["event": event, "data": jsonObject] + ) + + } catch { + dlog("⚠️ IMSSEManager 数据不是有效 JSON,作为纯文本处理: \(data)") + valueDatas.append(data) + + dlog("valueDatas == \(valueDatas)") + + NotificationCenter.default.post( + name: NSNotification.Name("IMSSEDataReceived"), + object: nil, + userInfo: ["event": event, "data": data] + ) + } + } else { + dlog("⚠️ IMSSEManager 无法将数据转换为 UTF-8") + } + } + + private func processIncomingChunk(_ data: Data) { + guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } + pendingEventText += chunk + let delimiter = "\n\n" + while let range = pendingEventText.range(of: delimiter) { + let eventBlock = String(pendingEventText[..