Compare commits
8 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
8559a6c35a | |
|
|
696ec2c0ec | |
|
|
8db4792e61 | |
|
|
9405f4e42c | |
|
|
bd8703656f | |
|
|
600f47c18d | |
|
|
13161f4af3 | |
|
|
1758bbc9b4 |
4
Podfile
4
Podfile
|
|
@ -39,6 +39,10 @@ target 'Visual_Novel_iOS' do
|
||||||
# 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'
|
||||||
pod 'Masonry'
|
pod 'Masonry'
|
||||||
|
|
|
||||||
27
Podfile.lock
27
Podfile.lock
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_response.imageset/Contents.json
vendored
Normal file
22
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_response.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_response.imageset/role_chat_response@2x.png
vendored
Normal file
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_response.imageset/role_chat_response@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_response.imageset/role_chat_response@3x.png
vendored
Normal file
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_response.imageset/role_chat_response@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
22
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_send.imageset/Contents.json
vendored
Normal file
22
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_send.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_send.imageset/role_chat_send@2x.png
vendored
Normal file
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_send.imageset/role_chat_send@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_send.imageset/role_chat_send@3x.png
vendored
Normal file
BIN
Visual_Novel_iOS/Assets.xcassets/Role/role_chat_bg_send.imageset/role_chat_send@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -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]? {
|
||||||
|
|
|
||||||
|
|
@ -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,26 +253,92 @@ 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)
|
|
||||||
|
|
||||||
// 调试日志:将 JSON 转换为字符串用于日志输出
|
// 先尝试解码为 ResponseData<T>
|
||||||
if APIConfig.apiLogEnable {
|
var responseData: ResponseData<T>
|
||||||
let jsonObject = try JSONSerialization.jsonObject(with: response.data)
|
do {
|
||||||
let jsonData = try JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted)
|
responseData = try decoder.decode(ResponseData<T>.self, from: response.data)
|
||||||
let jsonString = String(data: jsonData, encoding: .utf8) ?? String(data: response.data, encoding: .utf8) ?? ""
|
} catch {
|
||||||
dlog("👉⭐️\(target.path)⭐️ response:\n\(jsonString)")
|
// 如果解码失败,可能是后端返回的格式不匹配 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 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 {
|
} 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
|
// var toastMsg = autoShowErrMsg
|
||||||
//
|
//
|
||||||
// if code == .tokenExpired || code == .tokenIllegal || code == .accountIsFrozen || code == .sign_usernotexist || code == .sign_usernotLoggedIn || code == .sign_userLoginclientNotExist || code == .sign_userNotAuthorizedClient {
|
// if code == .tokenExpired || code == .tokenIllegal || code == .accountIsFrozen || code == .sign_usernotexist || code == .sign_usernotLoggedIn || code == .sign_userLoginclientNotExist || code == .sign_userNotAuthorizedClient {
|
||||||
|
|
@ -271,9 +386,24 @@ extension MoyaProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
dlog("⛔️请求成功,但解析失败: \(error), Response:⛔️\(CodableHelper.jsonString(from: response.data) ?? "x")⛔️")
|
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))
|
completion(.failure(.deserializeError))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
dlog("⛔️ \(target.path) 网络连接失败\(error)")
|
dlog("⛔️ \(target.path) 网络连接失败\(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
//
|
// // PhoneCallViewModel.swift
|
||||||
// PhoneCallViewModel.swift
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Created by Leon on 2025/8/27.
|
// Created by Leon on 2025/8/27.
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,9 +101,21 @@ extension SessionController {
|
||||||
// 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
|
||||||
|
if self?.isStreamChatMode == true {
|
||||||
|
let count = self?.streamMessages.count ?? 0
|
||||||
|
if count > 0 {
|
||||||
|
table.scrollToRow(at: IndexPath(row: count - 1, section: 1), at: .bottom, animated: animated)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let count = self?.util.cellModels.count ?? 0
|
let count = self?.util.cellModels.count ?? 0
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
table.scrollToRow(at: IndexPath(row: count - 1, section: 1), at: .bottom, animated: animated)
|
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
|
// Initial state
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||||
if table.alpha == 0{
|
if table.alpha == 0{
|
||||||
|
|
@ -105,8 +126,6 @@ extension SessionController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reload
|
// MARK: - Reload
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +75,17 @@ 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()
|
||||||
|
|
@ -114,12 +134,54 @@ class SessionController: CLBaseViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -461,6 +461,33 @@ public extension UIImage {
|
||||||
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.
|
||||||
///
|
///
|
||||||
/// - parameter compressionQuality: The quality of the resulting JPEG image, expressed as a value from 0.0 to 1.0. The value 0.0 represents the maximum compression (or lowest quality) while the value 1.0 represents the least compression (or best quality).
|
/// - parameter compressionQuality: The quality of the resulting JPEG image, expressed as a value from 0.0 to 1.0. The value 0.0 represents the maximum compression (or lowest quality) while the value 1.0 represents the least compression (or best quality).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue