角色聊天接入SSE数据
This commit is contained in:
parent
1758bbc9b4
commit
13161f4af3
|
|
@ -21,11 +21,14 @@ extension SessionController {
|
|||
tableView.keyboardDismissMode = .onDrag
|
||||
tableView.separatorStyle = .none
|
||||
tableView.estimatedRowHeight = 100
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedSectionFooterHeight = 0
|
||||
tableView.estimatedSectionHeaderHeight = 0
|
||||
tableView.contentInset = UIEdgeInsets(top: 40, left: 0, bottom: 20, right: 0)// UIWindow.navBarTotalHeight
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||
tableView.register(SessionAIHeadView.self, forCellReuseIdentifier: "SessionAIHeadView")
|
||||
tableView.register(StreamChatBubbleCell.self, forCellReuseIdentifier: StreamChatBubbleCell.reuseIdentifier)
|
||||
|
||||
tableView.setContentCompressionResistancePriority(UILayoutPriority(743), for: .vertical)
|
||||
|
||||
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(cancelEditing))
|
||||
|
|
@ -48,7 +51,12 @@ extension SessionController {
|
|||
}
|
||||
|
||||
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
|
||||
|
||||
}
|
||||
})
|
||||
|
|
@ -92,20 +100,26 @@ extension SessionController {
|
|||
// let y = max(table.contentSize.height - table.bounds.size.height, 0)
|
||||
// table.setContentOffset(CGPoint(x: 0, y: y), animated: animated)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {[weak self] in
|
||||
let count = self?.util.cellModels.count ?? 0
|
||||
if count > 0 {
|
||||
table.scrollToRow(at: IndexPath(row: count - 1, section: 1), at: .bottom, animated: animated)
|
||||
// Initial state
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
if table.alpha == 0{
|
||||
UIView.animate(withDuration: 0.35) {
|
||||
table.alpha = 1
|
||||
}
|
||||
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
|
||||
if count > 0 {
|
||||
table.scrollToRow(at: IndexPath(row: count - 1, section: 1), at: .bottom, animated: animated)
|
||||
}
|
||||
}
|
||||
// Initial state
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
if table.alpha == 0{
|
||||
UIView.animate(withDuration: 0.35) {
|
||||
table.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +128,7 @@ extension SessionController {
|
|||
extension SessionController {
|
||||
/// 更新某些cell
|
||||
func update(indexs: [Int]?) {
|
||||
guard isStreamChatMode == false else { return }
|
||||
guard indexs != nil else {
|
||||
return
|
||||
}
|
||||
|
|
@ -131,6 +146,7 @@ extension SessionController {
|
|||
|
||||
/// 插入某些cell
|
||||
func insert(indexs: [Int], animation: Bool, isOut: Bool, first:Bool = false) {
|
||||
guard isStreamChatMode == false else { return }
|
||||
guard indexs.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
|
@ -171,6 +187,7 @@ extension SessionController {
|
|||
}
|
||||
|
||||
func loadMoreCell(indexs: [Int]?) {
|
||||
guard isStreamChatMode == false else { return }
|
||||
if Array.realEmpty(array: indexs) {
|
||||
return
|
||||
}
|
||||
|
|
@ -230,6 +247,9 @@ extension SessionController: UITableViewDataSource {
|
|||
}
|
||||
return 1
|
||||
}
|
||||
if isStreamChatMode {
|
||||
return streamMessages.count
|
||||
}
|
||||
return self.util.cellModels.count
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +259,12 @@ extension SessionController: UITableViewDataSource {
|
|||
cell.refresh(self.aiInfo)
|
||||
return cell
|
||||
}
|
||||
if isStreamChatMode {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: StreamChatBubbleCell.reuseIdentifier, for: indexPath) as! StreamChatBubbleCell
|
||||
let message = streamMessages[indexPath.row]
|
||||
cell.configure(message: message)
|
||||
return cell
|
||||
}
|
||||
let model = self.util.cellModels[indexPath.row]
|
||||
let cell = self.cellIn(tableView: tableView, cellModel: model)
|
||||
cell.delegate = self
|
||||
|
|
@ -255,7 +281,9 @@ extension SessionController: UITableViewDelegate {
|
|||
if indexPath.section == 0{
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
if isStreamChatMode {
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
let model = self.util.cellModels[indexPath.row]
|
||||
let cellHeight = model.cellHeight()
|
||||
return cellHeight
|
||||
|
|
|
|||
|
|
@ -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(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(handleStreamChatNotification(_:)), name: NSNotification.Name("IMSSEDataReceived"), object: nil)
|
||||
|
||||
sessionNavigationView.navigationView.tapBackButtonAction = { [weak self] in
|
||||
self?.close()
|
||||
|
|
|
|||
|
|
@ -281,21 +281,44 @@ extension SessionController: SessionInputOperateViewDelegate{
|
|||
var channelId: String?
|
||||
var message: String?
|
||||
var promptTemplateId: String?
|
||||
}
|
||||
|
||||
struct StreamChatSendMsgModel: Codable {
|
||||
var status: Int?
|
||||
var modelName: String?
|
||||
}
|
||||
|
||||
func operateTextMessage(msg: String) {
|
||||
dlog("operateTextMessage: \(msg)")
|
||||
let text = msg.trimmed
|
||||
guard text.isEmpty == false else { return }
|
||||
|
||||
inputBar.clearInputDatas()
|
||||
inputEntrance.inputTextView.text = ""
|
||||
|
||||
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 = self.conversationId
|
||||
req.message = msg
|
||||
req.channelId = channelId
|
||||
req.message = text
|
||||
req.promptTemplateId = "691be128b19e6a6aba44d277"
|
||||
|
||||
req.modelName = "deepSeekV3"
|
||||
let params = req.toNonNilDictionary()
|
||||
IMSSEManager.shared.startListening(channelId: req.channelId,
|
||||
payload: params)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import SnapKit
|
|||
import TZImagePickerController
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
struct StreamChatMessageModel {
|
||||
let id: String
|
||||
var text: String
|
||||
let isSelf: Bool
|
||||
var isStreaming: Bool
|
||||
}
|
||||
|
||||
class SessionController: CLBaseViewController {
|
||||
var sessionNavigationView: SessionNavigationView!
|
||||
var bgImageView: UIImageView!
|
||||
|
|
@ -66,6 +74,10 @@ class SessionController: CLBaseViewController {
|
|||
var util: SessionUtil! = SessionUtil()
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
// MARK: - Stream Chat
|
||||
var isStreamChatMode = false
|
||||
var streamMessages: [StreamChatMessageModel] = []
|
||||
private var currentStreamingMessageId: String?
|
||||
|
||||
convenience init(accountID: String) {
|
||||
self.init()
|
||||
|
|
@ -148,11 +160,16 @@ class SessionController: CLBaseViewController {
|
|||
|
||||
let params = req.toNonNilDictionary()
|
||||
|
||||
StreamChatCreateProvider.request(.chatCreate(params: params), modelType: StreamChatConnectionModel.self) { result in
|
||||
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")
|
||||
}
|
||||
|
|
@ -193,6 +210,7 @@ class SessionController: CLBaseViewController {
|
|||
deinit {
|
||||
IMManager.shared.deleteCache(sessionID: conversationId)
|
||||
IMManager.shared.clearUnreadCountBy(ids: [conversationId])
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -550,3 +568,113 @@ 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if result.finished || (event?.lowercased().contains("done") ?? false) {
|
||||
finalizeStreamingMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOrUpdateIncomingChunk(_ chunk: String) {
|
||||
DispatchQueue.main.async {
|
||||
if let currentId = self.currentStreamingMessageId,
|
||||
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) {
|
||||
self.streamMessages[index].text += chunk
|
||||
} else {
|
||||
let message = StreamChatMessageModel(id: UUID().uuidString,
|
||||
text: chunk,
|
||||
isSelf: false,
|
||||
isStreaming: true)
|
||||
self.streamMessages.append(message)
|
||||
self.currentStreamingMessageId = message.id
|
||||
}
|
||||
|
||||
self.tableView?.reloadData()
|
||||
self.scrollToBottom(self.tableView, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func appendOutgoingStreamMessage(_ text: String) {
|
||||
let message = StreamChatMessageModel(id: UUID().uuidString,
|
||||
text: text,
|
||||
isSelf: true,
|
||||
isStreaming: false)
|
||||
streamMessages.append(message)
|
||||
tableView?.reloadData()
|
||||
scrollToBottom(tableView, animated: true)
|
||||
}
|
||||
|
||||
private func finalizeStreamingMessage() {
|
||||
DispatchQueue.main.async {
|
||||
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.currentStreamingMessageId = nil
|
||||
self.tableView?.reloadData()
|
||||
self.scrollToBottom(self.tableView, animated: true)
|
||||
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
|
||||
let text = (dict["message"] as? String)
|
||||
?? (dict["content"] as? String)
|
||||
?? (dict["data"] as? String)
|
||||
return StreamChunkResult(text: text, finished: finished)
|
||||
} else if let str = payload as? String {
|
||||
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return StreamChunkResult(text: nil, finished: false)
|
||||
}
|
||||
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let jsonObj = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||
let dict = jsonObj as? [String: Any] {
|
||||
return parseStreamChunk(dict)
|
||||
}
|
||||
|
||||
if trimmed.lowercased() == "[done]" {
|
||||
return StreamChunkResult(text: nil, finished: true)
|
||||
}
|
||||
|
||||
let containsDoneFlag = trimmed.lowercased().contains("__end__") || trimmed.lowercased().contains("<end>")
|
||||
return StreamChunkResult(text: trimmed, finished: containsDoneFlag)
|
||||
}
|
||||
return StreamChunkResult(text: nil, finished: false)
|
||||
}
|
||||
|
||||
func resetStreamingStateIfNeeded() {
|
||||
if let currentId = currentStreamingMessageId,
|
||||
let index = streamMessages.firstIndex(where: { $0.id == currentId }) {
|
||||
streamMessages[index].isStreaming = false
|
||||
}
|
||||
currentStreamingMessageId = nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
final class StreamChatBubbleCell: UITableViewCell {
|
||||
static let reuseIdentifier = "StreamChatBubbleCell"
|
||||
|
||||
private let bubbleView = UIView()
|
||||
private let messageLabel = UILabel()
|
||||
private let indicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
contentView.addSubview(bubbleView)
|
||||
bubbleView.layer.cornerRadius = 18
|
||||
bubbleView.layer.masksToBounds = true
|
||||
bubbleView.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(6)
|
||||
make.bottom.equalToSuperview().offset(-6)
|
||||
make.width.lessThanOrEqualTo(UIScreen.width * 0.75)
|
||||
}
|
||||
|
||||
bubbleView.addSubview(messageLabel)
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.font = .systemFont(ofSize: 16)
|
||||
messageLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14))
|
||||
}
|
||||
|
||||
contentView.addSubview(indicator)
|
||||
indicator.hidesWhenStopped = true
|
||||
}
|
||||
|
||||
func configure(message: StreamChatMessageModel) {
|
||||
messageLabel.text = message.text
|
||||
|
||||
if message.isSelf {
|
||||
bubbleView.backgroundColor = UIColor.c.cpn
|
||||
messageLabel.textColor = .white
|
||||
bubbleView.snp.remakeConstraints { make in
|
||||
make.top.equalToSuperview().offset(6)
|
||||
make.bottom.equalToSuperview().offset(-6)
|
||||
make.width.lessThanOrEqualTo(UIScreen.width * 0.75)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
}
|
||||
indicator.snp.remakeConstraints { make in
|
||||
make.centerY.equalTo(bubbleView)
|
||||
make.trailing.equalTo(bubbleView.snp.leading).offset(-8)
|
||||
}
|
||||
} else {
|
||||
bubbleView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
||||
messageLabel.textColor = .white
|
||||
bubbleView.snp.remakeConstraints { make in
|
||||
make.top.equalToSuperview().offset(6)
|
||||
make.bottom.equalToSuperview().offset(-6)
|
||||
make.width.lessThanOrEqualTo(UIScreen.width * 0.75)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
}
|
||||
indicator.snp.remakeConstraints { make in
|
||||
make.centerY.equalTo(bubbleView)
|
||||
make.leading.equalTo(bubbleView.snp.trailing).offset(8)
|
||||
}
|
||||
}
|
||||
|
||||
if message.isStreaming && !message.isSelf {
|
||||
indicator.startAnimating()
|
||||
} else {
|
||||
indicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue