角色聊天接口介入
This commit is contained in:
parent
5b52194cc3
commit
1758bbc9b4
4
Podfile
4
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'
|
||||
|
|
|
|||
27
Podfile.lock
27
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
|
||||
|
|
|
|||
|
|
@ -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 = "<group>";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}()
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]? {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,55 @@ struct ResponseData<T: Codable>: 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<T: Codable>: 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<T>.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<T>
|
||||
var responseData: ResponseData<T>
|
||||
do {
|
||||
responseData = try decoder.decode(ResponseData<T>.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):
|
||||
|
|
|
|||
|
|
@ -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<StreamChatApi>(endpointClosure: myEndpointClosure, stubClosure: { target in
|
||||
let data = target.sampleData
|
||||
if(data.count > 0){
|
||||
return .delayed(seconds: 0.5)
|
||||
}else{
|
||||
return .never
|
||||
}
|
||||
})
|
||||
: MoyaProvider<StreamChatApi>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<StreamChatCreateApi>(endpointClosure: myEndpointClosure, stubClosure: { target in
|
||||
let data = target.sampleData
|
||||
if(data.count > 0){
|
||||
return .delayed(seconds: 0.5)
|
||||
}else{
|
||||
return .never
|
||||
}
|
||||
})
|
||||
: MoyaProvider<StreamChatCreateApi>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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<StreamChatSendMsgApi>(endpointClosure: myEndpointClosure, stubClosure: { target in
|
||||
let data = target.sampleData
|
||||
if(data.count > 0){
|
||||
return .delayed(seconds: 0.5)
|
||||
}else{
|
||||
return .never
|
||||
}
|
||||
})
|
||||
: MoyaProvider<StreamChatSendMsgApi>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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[..<range.lowerBound])
|
||||
pendingEventText = String(pendingEventText[range.upperBound...])
|
||||
handleRawEventBlock(eventBlock)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRawEventBlock(_ block: String) {
|
||||
let lines = block
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { $0.isEmpty == false }
|
||||
|
||||
guard lines.isEmpty == false else { return }
|
||||
|
||||
var eventName = "message"
|
||||
var dataLines: [String] = []
|
||||
|
||||
for line in lines {
|
||||
if line.hasPrefix("event:") {
|
||||
eventName = line.replacingOccurrences(of: "event:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
} else if line.hasPrefix("data:") {
|
||||
let value = line.replacingOccurrences(of: "data:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
dataLines.append(value)
|
||||
} else if line.hasPrefix("retry:") {
|
||||
let retryString = line.replacingOccurrences(of: "retry:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
if let retryMS = Int(retryString) {
|
||||
retryInterval = max(Double(retryMS) / 1000.0, 1)
|
||||
dlog("🔁 IMSSEManager 更新 retry 为 \(retryInterval)s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let combinedData = dataLines.joined(separator: "\n")
|
||||
handleMessage(combinedData, event: eventName)
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
reconnectWorkItem?.cancel()
|
||||
guard let channelId = currentChannelId else { return }
|
||||
let payload = currentPayload
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.startListening(channelId: channelId, payload: payload)
|
||||
}
|
||||
reconnectWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: workItem)
|
||||
}
|
||||
|
||||
deinit {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
extension IMSSEManager: URLSessionDataDelegate {
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
guard dataTask == self.dataTask, data.isEmpty == false else { return }
|
||||
processIncomingChunk(data)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
dlog("❌ IMSSEManager SSE 连接完成(错误): \(error.localizedDescription)")
|
||||
} else {
|
||||
dlog("ℹ️ IMSSEManager SSE 连接完成(服务器关闭连接)")
|
||||
}
|
||||
dataTask = nil
|
||||
if error != nil {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue