diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Adapter.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Adapter.swift index e6ef58d..7af038f 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Adapter.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Adapter.swift @@ -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 diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Event.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Event.swift index bce6d41..9e47866 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Event.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Event.swift @@ -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() diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift index c02fe34..b02d7ae 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Input.swift @@ -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) diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift index 837079c..6f65b49 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift @@ -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,7 +74,11 @@ class SessionController: CLBaseViewController { var util: SessionUtil! = SessionUtil() var cancellables = Set() - + // MARK: - Stream Chat + var isStreamChatMode = false + var streamMessages: [StreamChatMessageModel] = [] + private var currentStreamingMessageId: String? + convenience init(accountID: String) { self.init() accountId = accountID @@ -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("") + 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 + } +} diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift new file mode 100644 index 0000000..86d3d95 --- /dev/null +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift @@ -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() + } + } +}