Compare commits

..

8 Commits
test ... main

Author SHA1 Message Date
mh 8559a6c35a role聊天快捷滚动到底部 2025-12-08 14:07:44 +08:00
mh 696ec2c0ec role角色聊天交互优化 2025-12-02 15:38:11 +08:00
mh 8db4792e61 处理聊天UI细节问题 2025-12-02 14:27:09 +08:00
mh 9405f4e42c 处理聊天数据一致性问题 2025-12-01 18:22:33 +08:00
mh bd8703656f 处理聊天数据打印机效果 2025-12-01 15:18:19 +08:00
mh 600f47c18d 角色聊天滚动到底部 2025-12-01 11:29:21 +08:00
mh 13161f4af3 角色聊天接入SSE数据 2025-11-27 10:50:06 +08:00
mh 1758bbc9b4 角色聊天接口介入 2025-11-20 18:52:53 +08:00
31 changed files with 1615 additions and 114 deletions

View File

@ -38,6 +38,10 @@ target 'Visual_Novel_iOS' do
pod 'AWSS3' pod 'AWSS3'
# pod 'URLNavigator' # pod 'URLNavigator'
pod 'BytePlusRTC', '~> 3.58.1' pod 'BytePlusRTC', '~> 3.58.1'
pod 'StreamChat', '~> 4.0.0'
pod 'StreamChatUI'
pod 'IKEventSource', '3.0.1'
# OC # OC
pod 'SDWebImage' pod 'SDWebImage'

View File

@ -28,6 +28,7 @@ PODS:
- Cache (6.0.0) - Cache (6.0.0)
- DateToolsSwift (5.0.0) - DateToolsSwift (5.0.0)
- Delegate (1.3.0) - Delegate (1.3.0)
- IKEventSource (3.0.1)
- IQKeyboardCore (1.0.8) - IQKeyboardCore (1.0.8)
- IQKeyboardManagerSwift (8.0.1): - IQKeyboardManagerSwift (8.0.1):
- IQKeyboardManagerSwift/Appearance (= 8.0.1) - IQKeyboardManagerSwift/Appearance (= 8.0.1)
@ -85,13 +86,22 @@ PODS:
- YXArtemis_XCFramework - YXArtemis_XCFramework
- NIMSDK_LITE/NOS (10.9.52): - NIMSDK_LITE/NOS (10.9.52):
- YXArtemis_XCFramework - YXArtemis_XCFramework
- Nuke (10.7.1)
- R.swift (7.8.0) - R.swift (7.8.0)
- SDWebImage (5.21.3): - SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3) - SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3) - SDWebImage/Core (5.21.3)
- SnapKit (5.7.1) - 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) - SwiftDate (7.0.0)
- SwiftyAttributes (5.4.0) - SwiftyAttributes (5.4.0)
- SwiftyGif (5.4.5)
- SwipeCellKit (2.7.1) - SwipeCellKit (2.7.1)
- TZImagePickerController (3.8.9): - TZImagePickerController (3.8.9):
- TZImagePickerController/Basic (= 3.8.9) - TZImagePickerController/Basic (= 3.8.9)
@ -109,6 +119,7 @@ DEPENDENCIES:
- BytePlusRTC (~> 3.58.1) - BytePlusRTC (~> 3.58.1)
- Cache - Cache
- DateToolsSwift - DateToolsSwift
- IKEventSource (= 3.0.1)
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
- JXPagingView/Paging - JXPagingView/Paging
- JXSegmentedView - JXSegmentedView
@ -123,6 +134,8 @@ DEPENDENCIES:
- R.swift - R.swift
- SDWebImage - SDWebImage
- SnapKit - SnapKit
- StreamChat (~> 4.0.0)
- StreamChatUI
- SwiftDate - SwiftDate
- SwiftyAttributes - SwiftyAttributes
- SwipeCellKit - SwipeCellKit
@ -145,6 +158,7 @@ SPEC REPOS:
- Cache - Cache
- DateToolsSwift - DateToolsSwift
- Delegate - Delegate
- IKEventSource
- IQKeyboardCore - IQKeyboardCore
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
- IQKeyboardNotification - IQKeyboardNotification
@ -163,11 +177,16 @@ SPEC REPOS:
- MJRefresh - MJRefresh
- Moya - Moya
- NIMSDK_LITE - NIMSDK_LITE
- Nuke
- R.swift - R.swift
- SDWebImage - SDWebImage
- SnapKit - SnapKit
- Starscream
- StreamChat
- StreamChatUI
- SwiftDate - SwiftDate
- SwiftyAttributes - SwiftyAttributes
- SwiftyGif
- SwipeCellKit - SwipeCellKit
- UICKeyChainStore - UICKeyChainStore
- YXArtemis_XCFramework - YXArtemis_XCFramework
@ -196,6 +215,7 @@ SPEC CHECKSUMS:
Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
Delegate: 0ff4467868095239ff578ab531efd8af46e62881 Delegate: 0ff4467868095239ff578ab531efd8af46e62881
IKEventSource: ababa323587c6b0c250dd54d4a48ab68fd845e8e
IQKeyboardCore: 8652977ec919cf5351aa2977fedd1a6546476fbc IQKeyboardCore: 8652977ec919cf5351aa2977fedd1a6546476fbc
IQKeyboardManagerSwift: 835fc9c6e4732398113406d84900ad2e8f141218 IQKeyboardManagerSwift: 835fc9c6e4732398113406d84900ad2e8f141218
IQKeyboardNotification: eb4910401f5a0e68f97e71c62f8a0c5b7e9d535c IQKeyboardNotification: eb4910401f5a0e68f97e71c62f8a0c5b7e9d535c
@ -214,16 +234,21 @@ SPEC CHECKSUMS:
MJRefresh: ff9e531227924c84ce459338414550a05d2aea78 MJRefresh: ff9e531227924c84ce459338414550a05d2aea78
Moya: 138f0573e53411fb3dc17016add0b748dfbd78ee Moya: 138f0573e53411fb3dc17016add0b748dfbd78ee
NIMSDK_LITE: dfefccd874ae111a49c59a93997fc1e69b721f30 NIMSDK_LITE: dfefccd874ae111a49c59a93997fc1e69b721f30
Nuke: 279f17a599fd1c83cf51de5e0e1f2db143a287b0
R.swift: f573269ca45b2ab066c082e363dd4c2b297b0d71 R.swift: f573269ca45b2ab066c082e363dd4c2b297b0d71
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
Starscream: 19b5533ddb925208db698f0ac508a100b884a1b9
StreamChat: 5f849859ba70a522d43412181ce77241e4ee0f4d
StreamChatUI: f8ed5b08502a55ab16ae8f10f7b9383448e6429d
SwiftDate: bbc26e26fc8c0c33fbee8c140c5e8a68293a148a SwiftDate: bbc26e26fc8c0c33fbee8c140c5e8a68293a148a
SwiftyAttributes: 45fae22b22a246a0b7f0a8d2157a02bf89fb2e9a SwiftyAttributes: 45fae22b22a246a0b7f0a8d2157a02bf89fb2e9a
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
SwipeCellKit: 3972254a826da74609926daf59b08d6c72e619ea SwipeCellKit: 3972254a826da74609926daf59b08d6c72e619ea
TZImagePickerController: 456f470b5dea97b37226ec7a694994a8663340b2 TZImagePickerController: 456f470b5dea97b37226ec7a694994a8663340b2
UICKeyChainStore: ba3bff2c762b12db1e516f395c837dd25298b05e UICKeyChainStore: ba3bff2c762b12db1e516f395c837dd25298b05e
YXArtemis_XCFramework: d9a8b9439d7a6c757ed00ada53a6d2dd9b13f9c7 YXArtemis_XCFramework: d9a8b9439d7a6c757ed00ada53a6d2dd9b13f9c7
PODFILE CHECKSUM: 8c380964208bfbf13ffc8af0ed60c019dddd76aa PODFILE CHECKSUM: 741f44021dfbc5ef5236378dec4972f63188c9cb
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@ -115,7 +115,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
70FCBA512E1CEE8800B29921 /* Visual_Novel_iOS */, 70FCBA512E1CEE8800B29921 /* Visual_Novel_iOS */,
70D22BD22E21390600A71DEB /* Visual_Novel_iOSTests */,
70FCBA502E1CEE8800B29921 /* Products */, 70FCBA502E1CEE8800B29921 /* Products */,
34DBC4421D6AB48D485892A6 /* Pods */, 34DBC4421D6AB48D485892A6 /* Pods */,
DC9D4EE3C3EA00FA653EB4E8 /* Frameworks */, DC9D4EE3C3EA00FA653EB4E8 /* Frameworks */,
@ -127,6 +126,7 @@
children = ( children = (
EC4549BA2E9DF999004D3972 /* Visual_Novel_iOSLevel.app */, EC4549BA2E9DF999004D3972 /* Visual_Novel_iOSLevel.app */,
EC4549BB2E9DF999004D3972 /* Visual_Novel_iOSTests.xctest */, EC4549BB2E9DF999004D3972 /* Visual_Novel_iOSTests.xctest */,
70D22BD22E21390600A71DEB /* Visual_Novel_iOSTests */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";

View File

@ -8,8 +8,24 @@ import Foundation
//import IQKeyboardManagerSwift //import IQKeyboardManagerSwift
import IQKeyboardToolbarManager import IQKeyboardToolbarManager
import IQKeyboardManagerSwift 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{ class AppLaunchInitial{
public func setupCommon(){ public func setupCommon(){
// User // User
UserCore.shared.autoLoginTry() UserCore.shared.autoLoginTry()
@ -27,6 +43,65 @@ class AppLaunchInitial{
loadApis(excludeNoNeedLogin: false) loadApis(excludeNoNeedLogin: false)
setupEvent() 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 /// 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
}()
} }

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "role_chat_response@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "role_chat_response@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "role_chat_send@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "role_chat_send@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -38,7 +38,7 @@ struct APIConfig {
private static var headers: [String: String]? { private static var headers: [String: String]? {
return ["content-type": "application/json", return ["content-type": "application/json",
"accept": "application/json,text/plain"] "accept": "application/json,text/plain,text/event-stream"]
} }
static func apiHeaders() -> [String: String]? { static func apiHeaders() -> [String: String]? {

View File

@ -136,6 +136,55 @@ struct ResponseData<T: Codable>: Codable {
let code: Int? let code: Int?
let message: String? let message: String?
let data: T? 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{ class ResponseContentPageData<T: Codable>: Codable{
@ -204,25 +253,91 @@ extension MoyaProvider {
switch result { switch result {
case let .success(response): case let .success(response):
do { 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 // 使 JSONDecoder response.data
let decoder = JSONDecoder() let decoder = JSONDecoder()
let data = try decoder.decode(ResponseData<T>.self, from: response.data)
// ResponseData<T>
// JSON var responseData: ResponseData<T>
if APIConfig.apiLogEnable { do {
let jsonObject = try JSONSerialization.jsonObject(with: response.data) responseData = try decoder.decode(ResponseData<T>.self, from: response.data)
let jsonData = try JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted) } catch {
let jsonString = String(data: jsonData, encoding: .utf8) ?? String(data: response.data, encoding: .utf8) ?? "" // ResponseData
dlog("👉⭐️\(target.path)⭐️ response:\n\(jsonString)") // 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 status = data.status
// let code = data.errorCode // let code = data.errorCode
// let msg = data.errorMsg // let msg = data.errorMsg
if (data.code ?? 0) == 200 { if (responseData.code ?? 0) == 200 {
let model = data.data if let model = responseData.data {
completion(.success(model)) 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 { } else {
// var toastMsg = autoShowErrMsg // var toastMsg = autoShowErrMsg
// //
@ -271,8 +386,23 @@ extension MoyaProvider {
} }
} catch { } catch {
dlog("⛔️请求成功,但解析失败: \(error), Response\(CodableHelper.jsonString(from: response.data) ?? "x")⛔️") let responseString = String(data: response.data, encoding: .utf8) ?? ""
completion(.failure(.deserializeError)) 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): case let .failure(error):

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
// // // PhoneCallViewModel.swift
// PhoneCallViewModel.swift
// //
// Created by Leon on 2025/8/27. // Created by Leon on 2025/8/27.

View File

@ -8,6 +8,7 @@
import Combine import Combine
import UIKit import UIKit
protocol SessionInputOperateViewDelegate: AnyObject { protocol SessionInputOperateViewDelegate: AnyObject {
func operateTapGiftAction() func operateTapGiftAction()
func operateVoiceAction(on: Bool) func operateVoiceAction(on: Bool)
@ -16,6 +17,9 @@ protocol SessionInputOperateViewDelegate: AnyObject {
func operateTapInputFieldAction() func operateTapInputFieldAction()
// //
func operateVoiceDragAction(location: CGPoint) func operateVoiceDragAction(location: CGPoint)
//
func operateTextMessage(msg: String)
} }
enum InputOperateState { enum InputOperateState {
@ -281,6 +285,16 @@ class SessionInputOperateView: UIView {
} }
} }
/// +
func resetInputState() {
inputTextView.text = ""
inputTextView.setContentOffset(.zero, animated: false)
inputTextView.snp.updateConstraints { make in
make.height.equalTo(minTextViewHeight)
}
layoutIfNeeded()
}
// MARK: - Action // MARK: - Action
@objc private func tapGiftButton() { @objc private func tapGiftButton() {
@ -297,7 +311,7 @@ class SessionInputOperateView: UIView {
} }
@objc private func tapSendButton() { @objc private func tapSendButton() {
delegate?.operateTextMessage(msg: self.inputTextView.text)
} }
@objc private func tapMoreButton() { @objc private func tapMoreButton() {

View File

@ -21,11 +21,14 @@ extension SessionController {
tableView.keyboardDismissMode = .onDrag tableView.keyboardDismissMode = .onDrag
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.estimatedRowHeight = 100 tableView.estimatedRowHeight = 100
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedSectionFooterHeight = 0 tableView.estimatedSectionFooterHeight = 0
tableView.estimatedSectionHeaderHeight = 0 tableView.estimatedSectionHeaderHeight = 0
tableView.contentInset = UIEdgeInsets(top: 40, left: 0, bottom: 20, right: 0)// UIWindow.navBarTotalHeight tableView.contentInset = UIEdgeInsets(top: 40, left: 0, bottom: 20, right: 0)// UIWindow.navBarTotalHeight
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.register(SessionAIHeadView.self, forCellReuseIdentifier: "SessionAIHeadView") tableView.register(SessionAIHeadView.self, forCellReuseIdentifier: "SessionAIHeadView")
tableView.register(StreamChatBubbleCell.self, forCellReuseIdentifier: StreamChatBubbleCell.reuseIdentifier)
tableView.setContentCompressionResistancePriority(UILayoutPriority(743), for: .vertical) tableView.setContentCompressionResistancePriority(UILayoutPriority(743), for: .vertical)
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(cancelEditing)) let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(cancelEditing))
@ -48,7 +51,12 @@ extension SessionController {
} }
let header = RefreshHeaderAnimator.init(refreshingBlock: {[weak self] in let header = RefreshHeaderAnimator.init(refreshingBlock: {[weak self] in
self?.loadMessages { _, _ in guard let self = self else { return }
guard self.isStreamChatMode == false else {
self.tableView.mj_header?.endRefreshing()
return
}
self.loadMessages { _, _ in
} }
}) })
@ -80,6 +88,7 @@ extension SessionController {
DispatchQueue.main.async {[weak self] in DispatchQueue.main.async {[weak self] in
self?.scrollToBottom(self?.tableView) self?.scrollToBottom(self?.tableView)
self?.updateScrToBottomButtonVisibility()
} }
} }
@ -91,21 +100,31 @@ extension SessionController {
let delaySeconds = delay ?? 0 let delaySeconds = delay ?? 0
// let y = max(table.contentSize.height - table.bounds.size.height, 0) // let y = max(table.contentSize.height - table.bounds.size.height, 0)
// table.setContentOffset(CGPoint(x: 0, y: y), animated: animated) // table.setContentOffset(CGPoint(x: 0, y: y), animated: animated)
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {[weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {[weak self] in
let count = self?.util.cellModels.count ?? 0 if self?.isStreamChatMode == true {
if count > 0 { let count = self?.streamMessages.count ?? 0
table.scrollToRow(at: IndexPath(row: count - 1, section: 1), at: .bottom, animated: animated) if count > 0 {
// Initial state table.scrollToRow(at: IndexPath(row: count - 1, section: 1), at: .bottom, animated: animated)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { }
if table.alpha == 0{ } else {
UIView.animate(withDuration: 0.35) { let count = self?.util.cellModels.count ?? 0
table.alpha = 1 if count > 0 {
} table.scrollToRow(at: IndexPath(row: count - 1, section: 1), at: .bottom, animated: animated)
}
}
//
DispatchQueue.main.asyncAfter(deadline: .now() + (animated ? 0.3 : 0.1)) {
self?.updateScrToBottomButtonVisibility()
}
// Initial state
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
if table.alpha == 0{
UIView.animate(withDuration: 0.35) {
table.alpha = 1
} }
} }
} }
} }
} }
} }
@ -114,6 +133,7 @@ extension SessionController {
extension SessionController { extension SessionController {
/// cell /// cell
func update(indexs: [Int]?) { func update(indexs: [Int]?) {
guard isStreamChatMode == false else { return }
guard indexs != nil else { guard indexs != nil else {
return return
} }
@ -131,6 +151,7 @@ extension SessionController {
/// cell /// cell
func insert(indexs: [Int], animation: Bool, isOut: Bool, first:Bool = false) { func insert(indexs: [Int], animation: Bool, isOut: Bool, first:Bool = false) {
guard isStreamChatMode == false else { return }
guard indexs.count > 0 else { guard indexs.count > 0 else {
return return
} }
@ -171,6 +192,7 @@ extension SessionController {
} }
func loadMoreCell(indexs: [Int]?) { func loadMoreCell(indexs: [Int]?) {
guard isStreamChatMode == false else { return }
if Array.realEmpty(array: indexs) { if Array.realEmpty(array: indexs) {
return return
} }
@ -230,6 +252,9 @@ extension SessionController: UITableViewDataSource {
} }
return 1 return 1
} }
if isStreamChatMode {
return streamMessages.count
}
return self.util.cellModels.count return self.util.cellModels.count
} }
@ -239,6 +264,15 @@ extension SessionController: UITableViewDataSource {
cell.refresh(self.aiInfo) cell.refresh(self.aiInfo)
return cell return cell
} }
if isStreamChatMode {
let cell = tableView.dequeueReusableCell(withIdentifier: StreamChatBubbleCell.reuseIdentifier, for: indexPath) as! StreamChatBubbleCell
let message = streamMessages[indexPath.row]
cell.configure(message: message)
cell.onLongPress = { [weak self] in
self?.copyStreamMessage(at: indexPath.row)
}
return cell
}
let model = self.util.cellModels[indexPath.row] let model = self.util.cellModels[indexPath.row]
let cell = self.cellIn(tableView: tableView, cellModel: model) let cell = self.cellIn(tableView: tableView, cellModel: model)
cell.delegate = self cell.delegate = self
@ -255,7 +289,9 @@ extension SessionController: UITableViewDelegate {
if indexPath.section == 0{ if indexPath.section == 0{
return UITableView.automaticDimension return UITableView.automaticDimension
} }
if isStreamChatMode {
return UITableView.automaticDimension
}
let model = self.util.cellModels[indexPath.row] let model = self.util.cellModels[indexPath.row]
let cellHeight = model.cellHeight() let cellHeight = model.cellHeight()
return cellHeight return cellHeight
@ -264,7 +300,7 @@ extension SessionController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
//... updateScrToBottomButtonVisibility()
} }
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
@ -310,6 +346,57 @@ extension SessionController: UITableViewDelegate {
header.ignoredScrollViewContentInsetTop = tableView.contentInset.top header.ignoredScrollViewContentInsetTop = tableView.contentInset.top
} }
} }
private func copyStreamMessage(at row: Int) {
guard isStreamChatMode,
streamMessages.indices.contains(row) else { return }
let text = streamMessages[row].text
guard text.isEmpty == false else { return }
UIPasteboard.general.string = text
Hud.toast(str: "复制成功")
}
/// /
func updateScrToBottomButtonVisibility() {
guard let tableView = tableView, let scrToBottomBtn = scrToBottomBtn else { return }
//
let hasContent: Bool
if isStreamChatMode {
hasContent = streamMessages.count > 0
} else {
hasContent = util.cellModels.count > 0
}
guard hasContent else {
scrToBottomBtn.isHidden = true
return
}
//
let contentHeight = tableView.contentSize.height
let tableViewHeight = tableView.bounds.height
let offsetY = tableView.contentOffset.y
let contentInsetTop = tableView.contentInset.top
let contentInsetBottom = tableView.contentInset.bottom
// inset
let actualContentHeight = contentHeight - contentInsetTop - contentInsetBottom
let actualTableViewHeight = tableViewHeight - contentInsetTop - contentInsetBottom
// tableView
guard actualContentHeight > actualTableViewHeight else {
scrToBottomBtn.isHidden = true
return
}
//
let distanceFromBottom = actualContentHeight - (offsetY - contentInsetTop) - actualTableViewHeight
// 50pt
//
scrToBottomBtn.isHidden = distanceFromBottom <= 50
}
} }

View File

@ -17,7 +17,7 @@ extension SessionController: SessionCellDelegate {
NotificationCenter.default.addObserver(self, selector: #selector(notifyChatSettingUpdated), name: AppNotificationName.chatSettingUpdated.notificationName, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(notifyChatSettingUpdated), name: AppNotificationName.chatSettingUpdated.notificationName, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(notifiyRelationHiddenUpdate), name: AppNotificationName.heartbeatRelationHiddenUpdate.notificationName, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(notifiyRelationHiddenUpdate), name: AppNotificationName.heartbeatRelationHiddenUpdate.notificationName, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(notifiyRelationInfoUpdate), name: AppNotificationName.aiRoleRelationInfoUpdated.notificationName, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(notifiyRelationInfoUpdate), name: AppNotificationName.aiRoleRelationInfoUpdated.notificationName, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStreamChatNotification(_:)), name: NSNotification.Name("IMSSEDataReceived"), object: nil)
sessionNavigationView.navigationView.tapBackButtonAction = { [weak self] in sessionNavigationView.navigationView.tapBackButtonAction = { [weak self] in
self?.close() self?.close()

View File

@ -9,6 +9,7 @@ import Foundation
import UIKit import UIKit
import NIMSDK import NIMSDK
import TZImagePickerController import TZImagePickerController
import StreamChat
extension SessionController { extension SessionController {
func setupInputView() { func setupInputView() {
@ -49,6 +50,11 @@ extension SessionController {
let scrToBottomBtn = UIButton(type: .custom) let scrToBottomBtn = UIButton(type: .custom)
scrToBottomBtn.setImage(UIImage(named: "chat_scr_bottom"), for: .normal) scrToBottomBtn.setImage(UIImage(named: "chat_scr_bottom"), for: .normal)
scrToBottomBtn.addTarget(self, action: #selector(scrToBottomBtnTap), for: .touchUpInside)
scrToBottomBtn.isHidden = true //
//
self.scrToBottomBtn = scrToBottomBtn
let stackView = UIStackView(arrangedSubviews: [scrToBottomBtn, exchangeBtn]) let stackView = UIStackView(arrangedSubviews: [scrToBottomBtn, exchangeBtn])
stackView.spacing = 10.0 stackView.spacing = 10.0
@ -141,7 +147,14 @@ extension SessionController {
} }
// MARK: - Helper // MARK: - Helper
@objc func scrToBottomBtnTap() {
let lastSection = tableView.numberOfSections - 1
let lastRow = tableView.numberOfRows(inSection: lastSection) - 1
if lastSection >= 0, lastRow >= 0 {
let ip = IndexPath(row: lastRow, section: lastSection)
tableView.scrollToRow(at: ip, at: .bottom, animated: true)
}
}
func hideAllBottomViews(except:[UIView]? = nil){ func hideAllBottomViews(except:[UIView]? = nil){
let viewsExcept = except ?? [] let viewsExcept = except ?? []
@ -152,7 +165,7 @@ extension SessionController {
} }
func hideOperateView(){ func hideOperateView(){
hideAllBottomViews(except: [inputEntrance]) hideAllBottomViews(except: [inputEntrance, toolView])
} }
// MARK: - Functions // MARK: - Functions
@ -274,6 +287,55 @@ extension SessionController: SessionInputOperateViewDelegate{
voiceHoldView.updateCancelState(isInCancelArea: isInCancelArea) voiceHoldView.updateCancelState(isInCancelArea: isInCancelArea)
} }
struct StreamChatSendMsgRequest: Codable {
var userId: String?
var characterId: String?
var channelId: String?
var message: String?
var promptTemplateId: String?
var modelName: String?
}
func operateTextMessage(msg: String) {
dlog("operateTextMessage: \(msg)")
let text = msg.trimmed
guard text.isEmpty == false else { return }
inputBar.clearInputDatas()
inputEntrance.resetInputState()
if isStreamChatMode {
appendOutgoingStreamMessage(text)
sendStreamChatMessage(text: text)
} else {
let message = IMMessageMaker.msgWithText(text)
let canSend = dealWillSendMessage(message: message)
if canSend {
util.sendMessage(message: message)
}
}
}
private func sendStreamChatMessage(text: String) {
guard let channelId = conversationId else {
dlog("⚠️ Stream chat channelId missing")
return
}
resetStreamingStateIfNeeded()
var req = StreamChatSendMsgRequest()
req.userId = "leia_organa"
req.characterId = "691d54f90c8cd949da7bb6ad"
req.channelId = channelId
req.message = text
req.promptTemplateId = "691be128b19e6a6aba44d277"
req.modelName = "deepSeekV3"
let params = req.toNonNilDictionary()
IMSSEManager.shared.startListening(channelId: req.channelId,
payload: params)
}
func operateTapMoreAction() { func operateTapMoreAction() {
view.endEditing(true) view.endEditing(true)

View File

@ -4,6 +4,14 @@ import SnapKit
import TZImagePickerController import TZImagePickerController
import UIKit import UIKit
import Combine import Combine
struct StreamChatMessageModel {
let id: String
var text: String
let isSelf: Bool
var isStreaming: Bool
}
class SessionController: CLBaseViewController { class SessionController: CLBaseViewController {
var sessionNavigationView: SessionNavigationView! var sessionNavigationView: SessionNavigationView!
var bgImageView: UIImageView! var bgImageView: UIImageView!
@ -23,6 +31,7 @@ class SessionController: CLBaseViewController {
// MARK: BottomViews // MARK: BottomViews
var bottomViewsStackV : InputStackView! var bottomViewsStackV : InputStackView!
var toolView: UIView! var toolView: UIView!
var scrToBottomBtn: UIButton!
var inputEntrance: SessionInputOperateView! var inputEntrance: SessionInputOperateView!
var inputBar: SessionInputView! var inputBar: SessionInputView!
var moreView: IMMoreItemView! var moreView: IMMoreItemView!
@ -66,7 +75,18 @@ class SessionController: CLBaseViewController {
var util: SessionUtil! = SessionUtil() var util: SessionUtil! = SessionUtil()
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
// MARK: - Stream Chat
var isStreamChatMode = false
var streamMessages: [StreamChatMessageModel] = []
private var currentStreamingMessageId: String?
private var displayLink: CADisplayLink?
private var typingTargetText: String = ""
private var typingDisplayedLength: Int = 0
private var typingCharactersPerSecond: CGFloat = 20
private var lastDisplayLinkTimestamp: CFTimeInterval = 0
private var shouldFinishTypingAfterTarget = false
private var rawStreamingBuffer: String = ""
convenience init(accountID: String) { convenience init(accountID: String) {
self.init() self.init()
accountId = accountID accountId = accountID
@ -113,13 +133,55 @@ class SessionController: CLBaseViewController {
dlog("❌get \(String(describing: self.conversationId)) conversation error:\(error)") 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setupUI() setupUI()
// setupData() // setupData()
setupEvent() 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) { [weak self] result in
switch result {
case .success(let model):
guard let self = self else { return }
self.conversationId = model?.channelId
self.isStreamChatMode = true
dlog("StreamChatCreateProvider model: \(String(describing: self.conversationId))")
DispatchQueue.main.async {
self.tableView?.reloadData()
}
case .failure(_):
dlog("StreamChatCreateProvider failure")
}
}
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
@ -156,6 +218,7 @@ class SessionController: CLBaseViewController {
deinit { deinit {
IMManager.shared.deleteCache(sessionID: conversationId) IMManager.shared.deleteCache(sessionID: conversationId)
IMManager.shared.clearUnreadCountBy(ids: [conversationId]) IMManager.shared.clearUnreadCountBy(ids: [conversationId])
NotificationCenter.default.removeObserver(self)
} }
} }
@ -189,7 +252,7 @@ extension SessionController {
} }
return v return v
}() }()
// bgImageView.image = UIImage(named: "egpic")?.cropImageTop(with: 1 / UIScreen.aspectRatio) bgImageView.image = UIImage(named: "egpic")?.cropImageTop(with: 1 / UIScreen.aspectRatio)
overlay = { overlay = {
let v = GradientView(colors: [UIColor.c.cbn.withAlphaComponent(1), UIColor.c.cbn.withAlphaComponent(0), UIColor.c.cbn.withAlphaComponent(0), UIColor.c.cbn.withAlphaComponent(1)], gradientType: .topToBottom) let v = GradientView(colors: [UIColor.c.cbn.withAlphaComponent(1), UIColor.c.cbn.withAlphaComponent(0), UIColor.c.cbn.withAlphaComponent(0), UIColor.c.cbn.withAlphaComponent(1)], gradientType: .topToBottom)
@ -212,7 +275,8 @@ extension SessionController {
swipeBgView = { swipeBgView = {
let bgView = UIView() let bgView = UIView()
bgView.alpha = 0.0 bgView.alpha = 0.0
bgView.backgroundColor = UIColor.init(white: 0.0, alpha: 0.8) // bgView.backgroundColor = UIColor.init(white: 0.0, alpha: 0.8)
bgView.backgroundColor = .clear
view.addSubview(bgView) view.addSubview(bgView)
bgView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(bgViewTap))) bgView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(bgViewTap)))
bgView.snp.makeConstraints { make in bgView.snp.makeConstraints { make in
@ -513,3 +577,359 @@ extension SessionController {
} }
} }
} }
// MARK: - Stream Chat Helpers
extension SessionController {
@objc func handleStreamChatNotification(_ notification: Notification) {
guard isStreamChatMode else { return }
let payload = notification.userInfo?["data"]
let event = notification.userInfo?["event"] as? String
processStreamChatPayload(payload, event: event)
}
private struct StreamChunkResult {
let text: String?
let finished: Bool
let isRawString: Bool
}
private func processStreamChatPayload(_ payload: Any?, event: String?) {
guard let payload else { return }
let result = parseStreamChunk(payload)
if let text = result.text, text.isEmpty == false {
appendOrUpdateIncomingChunk(text, isRawString: result.isRawString)
}
if result.finished || (event?.lowercased().contains("done") ?? false) {
finalizeStreamingMessage()
}
}
private func appendOrUpdateIncomingChunk(_ chunk: String, isRawString: Bool) {
DispatchQueue.main.async { [weak self] in
guard let self = self, chunk.isEmpty == false else { return }
var messageIndex: Int
var didInsertRow = false
if let currentId = self.currentStreamingMessageId,
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) {
if isRawString {
self.rawStreamingBuffer += chunk
self.typingTargetText = self.rawStreamingBuffer
} else {
self.typingTargetText = self.mergeText(current: self.typingTargetText, incoming: chunk)
self.rawStreamingBuffer = self.typingTargetText
}
messageIndex = index
} else {
let message = StreamChatMessageModel(id: UUID().uuidString,
text: "",
isSelf: false,
isStreaming: true)
self.streamMessages.append(message)
self.currentStreamingMessageId = message.id
if isRawString {
self.rawStreamingBuffer = chunk
self.typingTargetText = self.rawStreamingBuffer
} else {
self.rawStreamingBuffer = chunk
self.typingTargetText = chunk
}
self.typingDisplayedLength = 0
self.shouldFinishTypingAfterTarget = false
messageIndex = self.streamMessages.count - 1
didInsertRow = true
}
guard let tableView = self.tableView else { return }
let indexPath = IndexPath(row: messageIndex, section: 1)
if didInsertRow {
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.insertRows(at: [indexPath], with: .none)
tableView.endUpdates()
}
}
self.startDisplayLinkIfNeeded()
self.smoothScrollToLatest()
self.updateScrToBottomButtonVisibility()
}
}
func appendOutgoingStreamMessage(_ text: String) {
let message = StreamChatMessageModel(id: UUID().uuidString,
text: text,
isSelf: true,
isStreaming: false)
streamMessages.append(message)
guard let tableView = tableView else { return }
let indexPath = IndexPath(row: streamMessages.count - 1, section: 1)
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.insertRows(at: [indexPath], with: .none)
tableView.endUpdates()
}
smoothScrollToLatest()
updateScrToBottomButtonVisibility()
}
private func finalizeStreamingMessage() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
guard let currentId = self.currentStreamingMessageId,
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) else {
IMSSEManager.shared.disconnect()
return
}
self.streamMessages[index].isStreaming = false
self.shouldFinishTypingAfterTarget = true
if self.typingTargetText.isEmpty {
self.typingTargetText = self.streamMessages[index].text
}
if self.typingDisplayedLength >= self.typingTargetText.count {
self.finishTypingSequence()
} else {
self.startDisplayLinkIfNeeded()
}
self.updateScrToBottomButtonVisibility()
IMSSEManager.shared.disconnect()
}
}
private func parseStreamChunk(_ payload: Any) -> StreamChunkResult {
if let dict = payload as? [String: Any] {
let finished = (dict["finished"] as? Bool) ?? (dict["done"] as? Bool) ?? false
if let text = extractText(from: dict) {
return StreamChunkResult(text: text, finished: finished, isRawString: false)
}
if let data = try? JSONSerialization.data(withJSONObject: dict, options: []),
let jsonString = String(data: data, encoding: .utf8) {
return StreamChunkResult(text: jsonString, finished: finished, isRawString: false)
}
return StreamChunkResult(text: nil, finished: finished, isRawString: false)
} else if let str = payload as? String {
let normalized = str.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty {
return StreamChunkResult(text: nil, finished: false, isRawString: true)
}
if let data = normalized.data(using: .utf8),
let jsonObj = try? JSONSerialization.jsonObject(with: data, options: []) {
return parseStreamChunk(jsonObj)
}
if normalized.lowercased() == "[done]" {
return StreamChunkResult(text: nil, finished: true, isRawString: true)
}
let containsDoneFlag = normalized.lowercased().contains("__end__") || normalized.lowercased().contains("<end>")
return StreamChunkResult(text: str, finished: containsDoneFlag, isRawString: true)
}
return StreamChunkResult(text: nil, finished: false, isRawString: false)
}
func resetStreamingStateIfNeeded() {
if let currentId = currentStreamingMessageId,
let index = streamMessages.firstIndex(where: { $0.id == currentId }) {
streamMessages[index].isStreaming = false
}
cleanupTypingState()
}
private func smoothScrollToLatest() {
guard let tableView = tableView else { return }
let contentHeight = tableView.contentSize.height
let tableViewHeight = tableView.bounds.height
let offsetY = tableView.contentOffset.y
let distanceFromBottom = contentHeight - offsetY - tableViewHeight
let shouldAutoScroll = distanceFromBottom <= 200 || contentHeight <= tableViewHeight
guard shouldAutoScroll else { return }
let lastIndex = streamMessages.count - 1
guard lastIndex >= 0 else { return }
let indexPath = IndexPath(row: lastIndex, section: 1)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: false)
}
private func startDisplayLinkIfNeeded() {
guard displayLink == nil else { return }
lastDisplayLinkTimestamp = 0
let link = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
lastDisplayLinkTimestamp = 0
}
@objc private func handleDisplayLink(_ link: CADisplayLink) {
guard let currentId = currentStreamingMessageId,
let index = streamMessages.firstIndex(where: { $0.id == currentId }),
typingTargetText.isEmpty == false else {
stopDisplayLink()
return
}
let delta: CFTimeInterval
if lastDisplayLinkTimestamp == 0 {
delta = link.duration
} else {
delta = link.timestamp - lastDisplayLinkTimestamp
}
lastDisplayLinkTimestamp = link.timestamp
let rawCharacters = typingCharactersPerSecond * CGFloat(delta)
let charactersThisFrame = max(1, Int(floor(rawCharacters)))
guard charactersThisFrame > 0 else { return }
let newLength = min(typingTargetText.count, typingDisplayedLength + charactersThisFrame)
guard newLength > typingDisplayedLength else { return }
typingDisplayedLength = newLength
let newText = String(typingTargetText.prefix(newLength))
streamMessages[index].text = newText
if let tableView = tableView {
let indexPath = IndexPath(row: index, section: 1)
if let cell = tableView.cellForRow(at: indexPath) as? StreamChatBubbleCell {
cell.updateText(newText)
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.endUpdates()
}
} else {
UIView.performWithoutAnimation {
tableView.reloadRows(at: [indexPath], with: .none)
}
}
smoothScrollToLatest()
}
if typingDisplayedLength == typingTargetText.count {
if shouldFinishTypingAfterTarget || streamMessages[index].isStreaming == false {
finishTypingSequence()
} else {
stopDisplayLink()
}
}
}
private func finishTypingSequence() {
guard let currentId = currentStreamingMessageId,
let index = streamMessages.firstIndex(where: { $0.id == currentId }) else {
cleanupTypingState()
return
}
streamMessages[index].text = typingTargetText
if let tableView = tableView {
let indexPath = IndexPath(row: index, section: 1)
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.reloadRows(at: [indexPath], with: .none)
tableView.endUpdates()
}
smoothScrollToLatest()
updateScrToBottomButtonVisibility()
}
cleanupTypingState()
}
private func cleanupTypingState() {
stopDisplayLink()
typingTargetText = ""
typingDisplayedLength = 0
shouldFinishTypingAfterTarget = false
currentStreamingMessageId = nil
rawStreamingBuffer = ""
}
/// SSE
private func mergeText(current: String, incoming: String) -> String {
if current.isEmpty { return incoming }
if incoming.isEmpty { return current }
//
if incoming.hasPrefix(current) { return incoming }
if current.hasPrefix(incoming) { return current }
//
if current.contains(incoming) { return current }
if incoming.contains(current) { return incoming }
//
return current + incoming
}
private func extractText(from dictionary: [String: Any]) -> String? {
let candidateKeys = ["message", "content", "data", "text", "value", "delta"]
for key in candidateKeys {
if let value = dictionary[key] as? String, value.isEmpty == false {
return value
}
}
if let nestedData = dictionary["data"] as? [String: Any],
let text = extractText(from: nestedData) {
return text
}
if let nestedDelta = dictionary["delta"] as? [String: Any],
let text = extractText(from: nestedDelta) {
return text
}
if let resultDict = dictionary["result"] as? [String: Any],
let text = extractText(from: resultDict) {
return text
}
if let choices = dictionary["choices"] as? [[String: Any]] {
for choice in choices {
if let text = extractText(from: choice) {
return text
}
if let delta = choice["delta"] as? [String: Any],
let text = extractText(from: delta) {
return text
}
}
}
if let segments = dictionary["segments"] as? [[String: Any]] {
var combined = ""
for segment in segments {
if let text = extractText(from: segment) {
combined += text
}
}
if combined.isEmpty == false {
return combined
}
}
if let contentArray = dictionary["content"] as? [[String: Any]] {
var combined = ""
for item in contentArray {
if let text = extractText(from: item) {
combined += text
}
}
if combined.isEmpty == false {
return combined
}
}
return nil
}
}

View File

@ -174,7 +174,7 @@ class SessionNavigationView: UIView {
make.leading.equalToSuperview() make.leading.equalToSuperview()
make.trailing.equalToSuperview() make.trailing.equalToSuperview()
make.height.equalTo(UIWindow.navBarTotalHeight) make.height.equalTo(UIWindow.navBarTotalHeight)
make.bottom.equalToSuperview().offset(-40) make.bottom.equalToSuperview().offset(0)
} }
} }

View File

@ -0,0 +1,140 @@
import UIKit
import SnapKit
final class StreamChatBubbleCell: UITableViewCell {
static let reuseIdentifier = "StreamChatBubbleCell"
private let bubbleView = UIView()
private let messageLabel = UILabel()
var onLongPress: (() -> Void)?
lazy var sendBgImgView: UIImageView = {
guard let originalImage = UIImage(named: "role_chat_bg_send") else {
return UIImageView()
}
// focus
// focus
let imageSize = originalImage.size
let focusX = imageSize.width - 20.0 // 20pt
let focusY = 20.0 // 20pt
let stretchedImage = originalImage.makeStretchable(from: originalImage, focus: CGPoint(x: focusX, y: focusY))
let imgV = UIImageView(image: stretchedImage)
imgV.contentMode = .scaleToFill
return imgV
}()
lazy var respBgImgView: UIImageView = {
guard let originalImage = UIImage(named: "role_chat_bg_response") else {
return UIImageView()
}
// focus
// focus
let imageSize = originalImage.size
let focusX = 20.0 // 20pt
let focusY = 20.0 // 20pt
let stretchedImage = originalImage.makeStretchable(from: originalImage, focus: CGPoint(x: focusX, y: focusY))
let imgV = UIImageView(image: stretchedImage)
imgV.contentMode = .scaleToFill
return imgV
}()
private lazy var longPressGesture: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.4
return gesture
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
setupViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let maxWidth = UIScreen.width * 2.0 / 3.0 - 28
if messageLabel.preferredMaxLayoutWidth != maxWidth {
messageLabel.preferredMaxLayoutWidth = maxWidth
}
}
private func setupViews() {
contentView.addSubview(bubbleView)
bubbleView.addSubview(sendBgImgView)
bubbleView.addSubview(respBgImgView)
bubbleView.setContentHuggingPriority(.defaultLow, for: .horizontal)
bubbleView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
contentView.addGestureRecognizer(longPressGesture)
bubbleView.addSubview(messageLabel)
messageLabel.numberOfLines = 0
messageLabel.font = .systemFont(ofSize: 16)
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .left
messageLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
messageLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
messageLabel.preferredMaxLayoutWidth = UIScreen.width * 2.0 / 3.0 - 28
}
func configure(message: StreamChatMessageModel) {
messageLabel.text = message.text
respBgImgView.isHidden = message.isSelf
sendBgImgView.isHidden = !message.isSelf
if message.isSelf {
//
messageLabel.textColor = .white
bubbleView.backgroundColor = .clear
bubbleView.snp.remakeConstraints { make in
make.top.equalToSuperview().offset(6)
make.bottom.equalToSuperview().offset(-6)
make.width.lessThanOrEqualTo(UIScreen.width * 2.0 / 3.0)
make.width.greaterThanOrEqualTo(40)
make.trailing.equalToSuperview().offset(-16)
}
sendBgImgView.snp.remakeConstraints { make in
make.edges.equalToSuperview()
}
} else {
//
messageLabel.textColor = UIColor(hex: "#333333")
bubbleView.backgroundColor = .clear
bubbleView.snp.remakeConstraints { make in
make.top.equalToSuperview().offset(6)
make.bottom.equalToSuperview().offset(-6)
make.width.lessThanOrEqualTo(UIScreen.width * 2.0 / 3.0)
make.width.greaterThanOrEqualTo(60)
make.leading.equalToSuperview().offset(16)
}
respBgImgView.snp.remakeConstraints { make in
make.edges.equalToSuperview()
}
}
messageLabel.snp.remakeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14))
}
}
func updateText(_ text: String) {
UIView.performWithoutAnimation {
messageLabel.text = text
setNeedsLayout()
layoutIfNeeded()
}
}
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
onLongPress?()
}
}

View File

@ -158,8 +158,8 @@ class ChatFontCell: ChatSettingBaseCell, CellConfigurable {
} }
} }
override init(reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
configureViews() configureViews()
} }

View File

@ -52,8 +52,9 @@ class ChatResponseTokenCell: ChatSettingBaseCell, CellConfigurable {
countLab.text = row.count countLab.text = row.count
} }
override init(reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
configureViews() configureViews()
} }

View File

@ -8,7 +8,7 @@
import UIKit import UIKit
import SnapKit import SnapKit
class ChatSettingBaseCell: UITableViewHeaderFooterView { class ChatSettingBaseCell: UITableViewCell {
var containerHeightConstraint: Constraint? var containerHeightConstraint: Constraint?
@ -19,8 +19,9 @@ class ChatSettingBaseCell: UITableViewHeaderFooterView {
return view return view
}() }()
override init(reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews() setupViews()
} }

View File

@ -112,8 +112,8 @@ class ChatSwipeCell: ChatSettingBaseCell, CellConfigurable {
} }
} }
override init(reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
configureViews() configureViews()
} }

View File

@ -1,51 +0,0 @@
//
// SectionTextCell.swift
// Visual_Novel_iOS
//
// Created by mh on 2025/11/13.
//
import UIKit
struct SectionTextRow: RowModel {
let title: String
var cellReuseID: String { "SectionTextCell" }
func cellHeight(tableWidth: CGFloat) -> CGFloat {
return UITableView.automaticDimension
}
}
class SectionTextCell: UITableViewHeaderFooterView, CellConfigurable {
lazy var titleLab: UILabel = {
let lab = UILabel()
lab.font = UIFont.boldSystemFont(ofSize: 14)
lab.textColor = UIColor(hex: "#333333")
return lab
}()
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
configureViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configureViews() {
contentView.addSubview(titleLab)
titleLab.snp.makeConstraints { make in
make.left.equalToSuperview().inset(20)
make.top.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
}
func configure(with row: any RowModel) {
guard let row = row as? SectionTextRow else { return }
titleLab.text = row.title
}
}

View File

@ -57,20 +57,14 @@ class ChatSettingSwipeView: CLContainer {
]) ])
rows = [ rows = [
[ImageRow(icon: "", title: "")], [modelRow, ImageRow(icon: "role_text_mode", title: "Short Text Mode", showAvatar: false, showArrow: false, showSwitch: true)],
[modelRow], [ImageRow(icon: "role_voice", title: "Voice actor", showAvatar: true, showArrow: true, showSwitch: false, voiceActorItems: createVoiceActorItems()), ImageRow(icon: "role_talk", title: "Play dialogue only", showAvatar: false, showArrow: false, showSwitch: true)],
[ImageRow(icon: "role_text_mode", title: "Short Text Mode", showAvatar: false, showArrow: false, showSwitch: true)],
[ImageRow(icon: "role_voice", title: "Voice actor", showAvatar: true, showArrow: true, showSwitch: false, voiceActorItems: createVoiceActorItems())],
[ImageRow(icon: "role_talk", title: "Play dialogue only", showAvatar: false, showArrow: false, showSwitch: true)],
[TokenRow(count: "2500")], [TokenRow(count: "2500")],
[FontRow(count: "20", icon: "role_font", title: "Font size")], [FontRow(count: "20", icon: "role_font", title: "Font size"), ImageRow(icon: "role_chat_mode", title: "Chat Mode", showAvatar: false, showArrow: true, showSwitch: false, chatModeItems: createChatModeItems()), buttleRow],
[ImageRow(icon: "role_chat_mode", title: "Chat Mode", showAvatar: false, showArrow: true, showSwitch: false, chatModeItems: createChatModeItems())],
[buttleRow],
[BackgroundRow(count: 50)], [BackgroundRow(count: 50)],
[HistoryRow(time: "", icon: "", title: "", itemCount: 30)] [HistoryRow(time: "", icon: "", title: "", itemCount: 30)]
] ]
setupViews() setupViews()
} }
@ -122,7 +116,6 @@ class ChatSettingSwipeView: CLContainer {
tableView.register(ChatButtleCollectionCell.self, forCellReuseIdentifier: "ChatButtleCollectionCell") tableView.register(ChatButtleCollectionCell.self, forCellReuseIdentifier: "ChatButtleCollectionCell")
tableView.register(ChatModeContainerCell.self, forCellReuseIdentifier: "ChatModeContainerCell") tableView.register(ChatModeContainerCell.self, forCellReuseIdentifier: "ChatModeContainerCell")
tableView.register(VoiceActorContainerCell.self, forCellReuseIdentifier: "VoiceActorContainerCell") tableView.register(VoiceActorContainerCell.self, forCellReuseIdentifier: "VoiceActorContainerCell")
tableView.register(SectionTextCell.self, forHeaderFooterViewReuseIdentifier: "SectionTextCell")
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
return tableView return tableView
}() }()
@ -367,10 +360,7 @@ extension ChatSettingSwipeView: UITableViewDelegate, UITableViewDataSource {
} }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let model = rows[section].first, model is ImageRow { return rows[section].count
return 1
}
return 0
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

View File

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

View File

@ -460,6 +460,33 @@ public extension UIImage {
func pngBase64String() -> String? { func pngBase64String() -> String? {
return pngData()?.base64EncodedString() return pngData()?.base64EncodedString()
} }
/// UIImage
/// ** resizable Auto Layout
func makeStretchable(from image: UIImage,
focus: CGPoint) -> UIImage {
let scale = image.scale
let w = image.size.width * scale
let h = image.size.height * scale
let fx = focus.x * scale
let fy = focus.y * scale
// cap
let capLeft = min(fx, w - fx) * 0.5
let capRight = w - capLeft
let capTop = min(fy, h - fy) * 0.5
let capBottom = h - capTop
let insets = UIEdgeInsets(top: capTop,
left: capLeft,
bottom: capBottom,
right: capRight)
//
return image.resizableImage(withCapInsets: insets,
resizingMode: .stretch)
}
/// Base 64 encoded JPEG data of the image. /// Base 64 encoded JPEG data of the image.
/// ///