处理聊天数据打印机效果
This commit is contained in:
parent
600f47c18d
commit
bd8703656f
|
|
@ -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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue