diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Phone/ViewModel/PhoneCallViewModel.swift b/Visual_Novel_iOS/Src/Modules/Chat/Phone/ViewModel/PhoneCallViewModel.swift index c85f043..131a316 100644 --- a/Visual_Novel_iOS/Src/Modules/Chat/Phone/ViewModel/PhoneCallViewModel.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Phone/ViewModel/PhoneCallViewModel.swift @@ -1,5 +1,4 @@ -// -// PhoneCallViewModel.swift +// // PhoneCallViewModel.swift // // Created by Leon on 2025/8/27. 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 7af038f..422d1e5 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Adapter.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController+Adapter.swift @@ -263,6 +263,9 @@ extension SessionController: UITableViewDataSource { let cell = tableView.dequeueReusableCell(withIdentifier: StreamChatBubbleCell.reuseIdentifier, for: indexPath) as! StreamChatBubbleCell let message = streamMessages[indexPath.row] cell.configure(message: message) + cell.onLongPress = { [weak self] in + self?.copyStreamMessage(at: indexPath.row) + } return cell } let model = self.util.cellModels[indexPath.row] @@ -338,6 +341,15 @@ extension SessionController: UITableViewDelegate { 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: "复制成功") + } } diff --git a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift index 8ec076a..fdfa663 100755 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/SessionController.swift @@ -84,6 +84,7 @@ class SessionController: CLBaseViewController { private var typingCharactersPerSecond: CGFloat = 20 private var lastDisplayLinkTimestamp: CFTimeInterval = 0 private var shouldFinishTypingAfterTarget = false + private var rawStreamingBuffer: String = "" convenience init(accountID: String) { self.init() @@ -588,6 +589,7 @@ extension SessionController { private struct StreamChunkResult { let text: String? let finished: Bool + let isRawString: Bool } private func processStreamChatPayload(_ payload: Any?, event: String?) { @@ -595,7 +597,7 @@ extension SessionController { let result = parseStreamChunk(payload) if let text = result.text, text.isEmpty == false { - appendOrUpdateIncomingChunk(text) + appendOrUpdateIncomingChunk(text, isRawString: result.isRawString) } 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 guard let self = self, chunk.isEmpty == false else { return } @@ -612,7 +614,13 @@ extension SessionController { if let currentId = self.currentStreamingMessageId, let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) { - self.typingTargetText = self.mergeText(current: self.typingTargetText, incoming: chunk) + if isRawString { + self.rawStreamingBuffer += chunk + self.typingTargetText = self.rawStreamingBuffer + } else { + self.typingTargetText = self.mergeText(current: self.typingTargetText, incoming: chunk) + self.rawStreamingBuffer = self.typingTargetText + } messageIndex = index } else { let message = StreamChatMessageModel(id: UUID().uuidString, @@ -621,7 +629,13 @@ extension SessionController { isStreaming: true) self.streamMessages.append(message) self.currentStreamingMessageId = message.id - self.typingTargetText = chunk + if isRawString { + self.rawStreamingBuffer = chunk + self.typingTargetText = self.rawStreamingBuffer + } else { + self.rawStreamingBuffer = chunk + self.typingTargetText = chunk + } self.typingDisplayedLength = 0 self.shouldFinishTypingAfterTarget = false messageIndex = self.streamMessages.count - 1 @@ -689,17 +703,17 @@ extension SessionController { if let dict = payload as? [String: Any] { let finished = (dict["finished"] as? Bool) ?? (dict["done"] as? Bool) ?? false 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: []), 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 { let normalized = str.trimmingCharacters(in: .whitespacesAndNewlines) if normalized.isEmpty { - return StreamChunkResult(text: nil, finished: false) + return StreamChunkResult(text: nil, finished: false, isRawString: true) } if let data = normalized.data(using: .utf8), @@ -708,13 +722,13 @@ extension SessionController { } 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("") - 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() { @@ -831,6 +845,7 @@ extension SessionController { typingDisplayedLength = 0 shouldFinishTypingAfterTarget = false currentStreamingMessageId = nil + rawStreamingBuffer = "" } /// 将 SSE 片段安全拼接,尽量避免重复,但绝不丢字 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 61d7504..2c73cd0 100644 --- a/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift +++ b/Visual_Novel_iOS/Src/Modules/Chat/Session/View/StreamChatBubbleCell.swift @@ -6,6 +6,12 @@ final class StreamChatBubbleCell: UITableViewCell { private let bubbleView = UIView() 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?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -32,6 +38,7 @@ final class StreamChatBubbleCell: UITableViewCell { bubbleView.layer.masksToBounds = true bubbleView.setContentHuggingPriority(.defaultLow, for: .horizontal) bubbleView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + contentView.addGestureRecognizer(longPressGesture) bubbleView.addSubview(messageLabel) messageLabel.numberOfLines = 0 @@ -80,4 +87,9 @@ final class StreamChatBubbleCell: UITableViewCell { layoutIfNeeded() } } + + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + onLongPress?() + } }