角色聊天接入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.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,9 +100,17 @@ 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
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{
@ -105,8 +121,6 @@ extension SessionController {
}
}
}
}
}
// MARK: - Reload
@ -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

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

View File

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

View File

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

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