From bd8703656f47046355affa12638ff487b95470c2 Mon Sep 17 00:00:00 2001 From: mh <729263080@qq.com> Date: Mon, 1 Dec 2025 15:18:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E8=81=8A=E5=A4=A9=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=89=93=E5=8D=B0=E6=9C=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Session/SessionController.swift | 282 ++++++++++++++++-- .../Session/View/StreamChatBubbleCell.swift | 55 ++-- 2 files changed, 287 insertions(+), 50 deletions(-) diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift index 6f65b49..8ec076a 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift @@ -78,6 +78,12 @@ class SessionController: CLBaseViewController { 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 convenience init(accountID: String) { self.init() @@ -598,21 +604,43 @@ extension SessionController { } private func appendOrUpdateIncomingChunk(_ chunk: String) { - DispatchQueue.main.async { + 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 }) { - self.streamMessages[index].text += chunk + self.typingTargetText = self.mergeText(current: self.typingTargetText, incoming: chunk) + messageIndex = index } else { let message = StreamChatMessageModel(id: UUID().uuidString, - text: chunk, + text: "", isSelf: false, isStreaming: true) self.streamMessages.append(message) self.currentStreamingMessageId = message.id + self.typingTargetText = chunk + self.typingDisplayedLength = 0 + self.shouldFinishTypingAfterTarget = false + messageIndex = self.streamMessages.count - 1 + didInsertRow = true } - self.tableView?.reloadData() - self.scrollToBottom(self.tableView, animated: 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() } } @@ -622,21 +650,37 @@ extension SessionController { isSelf: true, isStreaming: false) streamMessages.append(message) - tableView?.reloadData() - scrollToBottom(tableView, animated: true) + 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() } private func finalizeStreamingMessage() { - DispatchQueue.main.async { + 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.currentStreamingMessageId = nil - self.tableView?.reloadData() - self.scrollToBottom(self.tableView, animated: true) + self.shouldFinishTypingAfterTarget = true + + if self.typingTargetText.isEmpty { + self.typingTargetText = self.streamMessages[index].text + } + + if self.typingDisplayedLength >= self.typingTargetText.count { + self.finishTypingSequence() + } else { + self.startDisplayLinkIfNeeded() + } + IMSSEManager.shared.disconnect() } } @@ -644,28 +688,31 @@ extension SessionController { 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) + if let text = extractText(from: dict) { + return StreamChunkResult(text: text, finished: finished) + } + if let data = try? JSONSerialization.data(withJSONObject: dict, options: []), + let jsonString = String(data: data, encoding: .utf8) { + return StreamChunkResult(text: jsonString, finished: finished) + } + return StreamChunkResult(text: nil, finished: finished) } else if let str = payload as? String { - let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { + let normalized = str.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.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 let data = normalized.data(using: .utf8), + let jsonObj = try? JSONSerialization.jsonObject(with: data, options: []) { + return parseStreamChunk(jsonObj) } - if trimmed.lowercased() == "[done]" { + if normalized.lowercased() == "[done]" { return StreamChunkResult(text: nil, finished: true) } - let containsDoneFlag = trimmed.lowercased().contains("__end__") || trimmed.lowercased().contains("") - return StreamChunkResult(text: trimmed, finished: containsDoneFlag) + let containsDoneFlag = normalized.lowercased().contains("__end__") || normalized.lowercased().contains("") + return StreamChunkResult(text: str, finished: containsDoneFlag) } return StreamChunkResult(text: nil, finished: false) } @@ -675,6 +722,193 @@ extension SessionController { 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() + } + cleanupTypingState() + } + + private func cleanupTypingState() { + stopDisplayLink() + typingTargetText = "" + typingDisplayedLength = 0 + shouldFinishTypingAfterTarget = false currentStreamingMessageId = nil } + + /// 将 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 + } } diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift index 86d3d95..61d7504 100644 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift @@ -6,7 +6,6 @@ final class StreamChatBubbleCell: UITableViewCell { 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) @@ -19,25 +18,29 @@ final class StreamChatBubbleCell: UITableViewCell { 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.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.setContentHuggingPriority(.defaultLow, for: .horizontal) + bubbleView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) 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 + 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) { @@ -49,32 +52,32 @@ final class StreamChatBubbleCell: UITableViewCell { bubbleView.snp.remakeConstraints { make in make.top.equalToSuperview().offset(6) make.bottom.equalToSuperview().offset(-6) - make.width.lessThanOrEqualTo(UIScreen.width * 0.75) + make.width.lessThanOrEqualTo(UIScreen.width * 2.0 / 3.0) + make.width.greaterThanOrEqualTo(60) 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.width.lessThanOrEqualTo(UIScreen.width * 2.0 / 3.0) + make.width.greaterThanOrEqualTo(60) 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() + 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() } } }