角色聊天接入SSE数据

This commit is contained in:
mh 2025-11-27 10:50:06 +08:00
parent 1758bbc9b4
commit 13161f4af3
5 changed files with 281 additions and 22 deletions

View File

@ -21,11 +21,14 @@ extension SessionController {
tableView.keyboardDismissMode = .onDrag tableView.keyboardDismissMode = .onDrag
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.estimatedRowHeight = 100 tableView.estimatedRowHeight = 100
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedSectionFooterHeight = 0 tableView.estimatedSectionFooterHeight = 0
tableView.estimatedSectionHeaderHeight = 0 tableView.estimatedSectionHeaderHeight = 0
tableView.contentInset = UIEdgeInsets(top: 40, left: 0, bottom: 20, right: 0)// UIWindow.navBarTotalHeight tableView.contentInset = UIEdgeInsets(top: 40, left: 0, bottom: 20, right: 0)// UIWindow.navBarTotalHeight
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.register(SessionAIHeadView.self, forCellReuseIdentifier: "SessionAIHeadView") tableView.register(SessionAIHeadView.self, forCellReuseIdentifier: "SessionAIHeadView")
tableView.register(StreamChatBubbleCell.self, forCellReuseIdentifier: StreamChatBubbleCell.reuseIdentifier)
tableView.setContentCompressionResistancePriority(UILayoutPriority(743), for: .vertical) tableView.setContentCompressionResistancePriority(UILayoutPriority(743), for: .vertical)
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(cancelEditing)) let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(cancelEditing))
@ -48,7 +51,12 @@ extension SessionController {
} }
let header = RefreshHeaderAnimator.init(refreshingBlock: {[weak self] in let header = RefreshHeaderAnimator.init(refreshingBlock: {[weak self] in
self?.loadMessages { _, _ in guard let self = self else { return }
guard self.isStreamChatMode == false else {
self.tableView.mj_header?.endRefreshing()
return
}
self.loadMessages { _, _ in
} }
}) })
@ -92,9 +100,17 @@ 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)
}
}
// 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 +121,6 @@ extension SessionController {
} }
} }
} }
}
} }
// MARK: - Reload // MARK: - Reload
@ -114,6 +128,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 +146,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 +187,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 +247,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 +259,12 @@ 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)
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 +281,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

View File

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

View File

@ -281,21 +281,44 @@ extension SessionController: SessionInputOperateViewDelegate{
var channelId: String? var channelId: String?
var message: String? var message: String?
var promptTemplateId: String? var promptTemplateId: String?
} var modelName: String?
struct StreamChatSendMsgModel: Codable {
var status: Int?
} }
func operateTextMessage(msg: String) { func operateTextMessage(msg: String) {
dlog("operateTextMessage: \(msg)") 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() var req = StreamChatSendMsgRequest()
req.userId = "leia_organa" req.userId = "leia_organa"
req.characterId = "691d54f90c8cd949da7bb6ad" req.characterId = "691d54f90c8cd949da7bb6ad"
req.channelId = self.conversationId req.channelId = channelId
req.message = msg req.message = text
req.promptTemplateId = "691be128b19e6a6aba44d277" req.promptTemplateId = "691be128b19e6a6aba44d277"
req.modelName = "deepSeekV3"
let params = req.toNonNilDictionary() let params = req.toNonNilDictionary()
IMSSEManager.shared.startListening(channelId: req.channelId, IMSSEManager.shared.startListening(channelId: req.channelId,
payload: params) payload: params)

View File

@ -4,6 +4,14 @@ import SnapKit
import TZImagePickerController import TZImagePickerController
import UIKit import UIKit
import Combine import Combine
struct StreamChatMessageModel {
let id: String
var text: String
let isSelf: Bool
var isStreaming: Bool
}
class SessionController: CLBaseViewController { class SessionController: CLBaseViewController {
var sessionNavigationView: SessionNavigationView! var sessionNavigationView: SessionNavigationView!
var bgImageView: UIImageView! var bgImageView: UIImageView!
@ -66,6 +74,10 @@ 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?
convenience init(accountID: String) { convenience init(accountID: String) {
self.init() self.init()
@ -148,11 +160,16 @@ class SessionController: CLBaseViewController {
let params = req.toNonNilDictionary() 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 { switch result {
case .success(let model): case .success(let model):
guard let self = self else { return }
self.conversationId = model?.channelId self.conversationId = model?.channelId
self.isStreamChatMode = true
dlog("StreamChatCreateProvider model: \(String(describing: self.conversationId))") dlog("StreamChatCreateProvider model: \(String(describing: self.conversationId))")
DispatchQueue.main.async {
self.tableView?.reloadData()
}
case .failure(_): case .failure(_):
dlog("StreamChatCreateProvider failure") dlog("StreamChatCreateProvider failure")
} }
@ -193,6 +210,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)
} }
} }
@ -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
}
}

View File

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