处理聊天数据打印机效果

This commit is contained in:
mh 2025-12-01 15:18:19 +08:00
parent 600f47c18d
commit bd8703656f
2 changed files with 287 additions and 50 deletions

View File

@ -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("<end>")
return StreamChunkResult(text: trimmed, finished: containsDoneFlag)
let containsDoneFlag = normalized.lowercased().contains("__end__") || normalized.lowercased().contains("<end>")
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
}
}

View File

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