处理聊天数据一致性问题
This commit is contained in:
parent
bd8703656f
commit
9405f4e42c
|
|
@ -1,5 +1,4 @@
|
||||||
//
|
// // PhoneCallViewModel.swift
|
||||||
// PhoneCallViewModel.swift
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Created by Leon on 2025/8/27.
|
// Created by Leon on 2025/8/27.
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,9 @@ extension SessionController: UITableViewDataSource {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: StreamChatBubbleCell.reuseIdentifier, for: indexPath) as! StreamChatBubbleCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: StreamChatBubbleCell.reuseIdentifier, for: indexPath) as! StreamChatBubbleCell
|
||||||
let message = streamMessages[indexPath.row]
|
let message = streamMessages[indexPath.row]
|
||||||
cell.configure(message: message)
|
cell.configure(message: message)
|
||||||
|
cell.onLongPress = { [weak self] in
|
||||||
|
self?.copyStreamMessage(at: indexPath.row)
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
let model = self.util.cellModels[indexPath.row]
|
let model = self.util.cellModels[indexPath.row]
|
||||||
|
|
@ -338,6 +341,15 @@ extension SessionController: UITableViewDelegate {
|
||||||
header.ignoredScrollViewContentInsetTop = tableView.contentInset.top
|
header.ignoredScrollViewContentInsetTop = tableView.contentInset.top
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func copyStreamMessage(at row: Int) {
|
||||||
|
guard isStreamChatMode,
|
||||||
|
streamMessages.indices.contains(row) else { return }
|
||||||
|
let text = streamMessages[row].text
|
||||||
|
guard text.isEmpty == false else { return }
|
||||||
|
UIPasteboard.general.string = text
|
||||||
|
Hud.toast(str: "复制成功")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ class SessionController: CLBaseViewController {
|
||||||
private var typingCharactersPerSecond: CGFloat = 20
|
private var typingCharactersPerSecond: CGFloat = 20
|
||||||
private var lastDisplayLinkTimestamp: CFTimeInterval = 0
|
private var lastDisplayLinkTimestamp: CFTimeInterval = 0
|
||||||
private var shouldFinishTypingAfterTarget = false
|
private var shouldFinishTypingAfterTarget = false
|
||||||
|
private var rawStreamingBuffer: String = ""
|
||||||
|
|
||||||
convenience init(accountID: String) {
|
convenience init(accountID: String) {
|
||||||
self.init()
|
self.init()
|
||||||
|
|
@ -588,6 +589,7 @@ extension SessionController {
|
||||||
private struct StreamChunkResult {
|
private struct StreamChunkResult {
|
||||||
let text: String?
|
let text: String?
|
||||||
let finished: Bool
|
let finished: Bool
|
||||||
|
let isRawString: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processStreamChatPayload(_ payload: Any?, event: String?) {
|
private func processStreamChatPayload(_ payload: Any?, event: String?) {
|
||||||
|
|
@ -595,7 +597,7 @@ extension SessionController {
|
||||||
let result = parseStreamChunk(payload)
|
let result = parseStreamChunk(payload)
|
||||||
|
|
||||||
if let text = result.text, text.isEmpty == false {
|
if let text = result.text, text.isEmpty == false {
|
||||||
appendOrUpdateIncomingChunk(text)
|
appendOrUpdateIncomingChunk(text, isRawString: result.isRawString)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.finished || (event?.lowercased().contains("done") ?? false) {
|
if result.finished || (event?.lowercased().contains("done") ?? false) {
|
||||||
|
|
@ -603,7 +605,7 @@ extension SessionController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func appendOrUpdateIncomingChunk(_ chunk: String) {
|
private func appendOrUpdateIncomingChunk(_ chunk: String, isRawString: Bool) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self, chunk.isEmpty == false else { return }
|
guard let self = self, chunk.isEmpty == false else { return }
|
||||||
|
|
||||||
|
|
@ -612,7 +614,13 @@ extension SessionController {
|
||||||
|
|
||||||
if let currentId = self.currentStreamingMessageId,
|
if let currentId = self.currentStreamingMessageId,
|
||||||
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) {
|
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) {
|
||||||
|
if isRawString {
|
||||||
|
self.rawStreamingBuffer += chunk
|
||||||
|
self.typingTargetText = self.rawStreamingBuffer
|
||||||
|
} else {
|
||||||
self.typingTargetText = self.mergeText(current: self.typingTargetText, incoming: chunk)
|
self.typingTargetText = self.mergeText(current: self.typingTargetText, incoming: chunk)
|
||||||
|
self.rawStreamingBuffer = self.typingTargetText
|
||||||
|
}
|
||||||
messageIndex = index
|
messageIndex = index
|
||||||
} else {
|
} else {
|
||||||
let message = StreamChatMessageModel(id: UUID().uuidString,
|
let message = StreamChatMessageModel(id: UUID().uuidString,
|
||||||
|
|
@ -621,7 +629,13 @@ extension SessionController {
|
||||||
isStreaming: true)
|
isStreaming: true)
|
||||||
self.streamMessages.append(message)
|
self.streamMessages.append(message)
|
||||||
self.currentStreamingMessageId = message.id
|
self.currentStreamingMessageId = message.id
|
||||||
|
if isRawString {
|
||||||
|
self.rawStreamingBuffer = chunk
|
||||||
|
self.typingTargetText = self.rawStreamingBuffer
|
||||||
|
} else {
|
||||||
|
self.rawStreamingBuffer = chunk
|
||||||
self.typingTargetText = chunk
|
self.typingTargetText = chunk
|
||||||
|
}
|
||||||
self.typingDisplayedLength = 0
|
self.typingDisplayedLength = 0
|
||||||
self.shouldFinishTypingAfterTarget = false
|
self.shouldFinishTypingAfterTarget = false
|
||||||
messageIndex = self.streamMessages.count - 1
|
messageIndex = self.streamMessages.count - 1
|
||||||
|
|
@ -689,17 +703,17 @@ extension SessionController {
|
||||||
if let dict = payload as? [String: Any] {
|
if let dict = payload as? [String: Any] {
|
||||||
let finished = (dict["finished"] as? Bool) ?? (dict["done"] as? Bool) ?? false
|
let finished = (dict["finished"] as? Bool) ?? (dict["done"] as? Bool) ?? false
|
||||||
if let text = extractText(from: dict) {
|
if let text = extractText(from: dict) {
|
||||||
return StreamChunkResult(text: text, finished: finished)
|
return StreamChunkResult(text: text, finished: finished, isRawString: false)
|
||||||
}
|
}
|
||||||
if let data = try? JSONSerialization.data(withJSONObject: dict, options: []),
|
if let data = try? JSONSerialization.data(withJSONObject: dict, options: []),
|
||||||
let jsonString = String(data: data, encoding: .utf8) {
|
let jsonString = String(data: data, encoding: .utf8) {
|
||||||
return StreamChunkResult(text: jsonString, finished: finished)
|
return StreamChunkResult(text: jsonString, finished: finished, isRawString: false)
|
||||||
}
|
}
|
||||||
return StreamChunkResult(text: nil, finished: finished)
|
return StreamChunkResult(text: nil, finished: finished, isRawString: false)
|
||||||
} else if let str = payload as? String {
|
} else if let str = payload as? String {
|
||||||
let normalized = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalized = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if normalized.isEmpty {
|
if normalized.isEmpty {
|
||||||
return StreamChunkResult(text: nil, finished: false)
|
return StreamChunkResult(text: nil, finished: false, isRawString: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let data = normalized.data(using: .utf8),
|
if let data = normalized.data(using: .utf8),
|
||||||
|
|
@ -708,13 +722,13 @@ extension SessionController {
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized.lowercased() == "[done]" {
|
if normalized.lowercased() == "[done]" {
|
||||||
return StreamChunkResult(text: nil, finished: true)
|
return StreamChunkResult(text: nil, finished: true, isRawString: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let containsDoneFlag = normalized.lowercased().contains("__end__") || normalized.lowercased().contains("<end>")
|
let containsDoneFlag = normalized.lowercased().contains("__end__") || normalized.lowercased().contains("<end>")
|
||||||
return StreamChunkResult(text: str, finished: containsDoneFlag)
|
return StreamChunkResult(text: str, finished: containsDoneFlag, isRawString: true)
|
||||||
}
|
}
|
||||||
return StreamChunkResult(text: nil, finished: false)
|
return StreamChunkResult(text: nil, finished: false, isRawString: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetStreamingStateIfNeeded() {
|
func resetStreamingStateIfNeeded() {
|
||||||
|
|
@ -831,6 +845,7 @@ extension SessionController {
|
||||||
typingDisplayedLength = 0
|
typingDisplayedLength = 0
|
||||||
shouldFinishTypingAfterTarget = false
|
shouldFinishTypingAfterTarget = false
|
||||||
currentStreamingMessageId = nil
|
currentStreamingMessageId = nil
|
||||||
|
rawStreamingBuffer = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将 SSE 片段安全拼接,尽量避免重复,但绝不丢字
|
/// 将 SSE 片段安全拼接,尽量避免重复,但绝不丢字
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ final class StreamChatBubbleCell: UITableViewCell {
|
||||||
|
|
||||||
private let bubbleView = UIView()
|
private let bubbleView = UIView()
|
||||||
private let messageLabel = UILabel()
|
private let messageLabel = UILabel()
|
||||||
|
var onLongPress: (() -> Void)?
|
||||||
|
private lazy var longPressGesture: UILongPressGestureRecognizer = {
|
||||||
|
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||||
|
gesture.minimumPressDuration = 0.4
|
||||||
|
return gesture
|
||||||
|
}()
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
|
@ -32,6 +38,7 @@ final class StreamChatBubbleCell: UITableViewCell {
|
||||||
bubbleView.layer.masksToBounds = true
|
bubbleView.layer.masksToBounds = true
|
||||||
bubbleView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
bubbleView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
bubbleView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
bubbleView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
|
contentView.addGestureRecognizer(longPressGesture)
|
||||||
|
|
||||||
bubbleView.addSubview(messageLabel)
|
bubbleView.addSubview(messageLabel)
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
|
|
@ -80,4 +87,9 @@ final class StreamChatBubbleCell: UITableViewCell {
|
||||||
layoutIfNeeded()
|
layoutIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||||
|
guard gesture.state == .began else { return }
|
||||||
|
onLongPress?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue