mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 18:36:08 +00:00
149 lines
5.0 KiB
Swift
149 lines
5.0 KiB
Swift
import Foundation
|
|
|
|
struct ChatStreamingRevealState: Equatable {
|
|
struct Word: Equatable {
|
|
let characterRange: Range<Int>
|
|
let fadeStart: TimeInterval
|
|
let deadline: TimeInterval
|
|
}
|
|
|
|
var text: String
|
|
var words: [Word]
|
|
|
|
init(text: String = "", words: [Word] = []) {
|
|
self.text = text
|
|
self.words = words
|
|
}
|
|
|
|
var latestDeadline: TimeInterval? {
|
|
self.words.map(\.deadline).max()
|
|
}
|
|
}
|
|
|
|
struct ChatStreamingRevealFrame: Equatable {
|
|
struct FadingWord: Equatable {
|
|
let characterRange: Range<Int>
|
|
let opacity: Double
|
|
}
|
|
|
|
let fullyRevealedPrefixCharacterOffset: Int
|
|
let fading: [FadingWord]
|
|
}
|
|
|
|
private enum ChatStreamingRevealConstants {
|
|
static let wordInterval: TimeInterval = 0.04
|
|
static let fadeDuration: TimeInterval = 0.12
|
|
static let maximumCatchUpDuration: TimeInterval = 0.4
|
|
static let maximumFadingWords = 24
|
|
}
|
|
|
|
func step(
|
|
state: ChatStreamingRevealState,
|
|
newText: String,
|
|
now: TimeInterval) -> ChatStreamingRevealState
|
|
{
|
|
guard newText.hasPrefix(state.text) else {
|
|
// Rewrites are uncommon and can invalidate every old character range.
|
|
// Reveal the replacement immediately; later appends start a new pace.
|
|
return ChatStreamingRevealState(text: newText)
|
|
}
|
|
guard newText != state.text else {
|
|
return ChatStreamingRevealState(
|
|
text: newText,
|
|
words: state.words.filter { $0.deadline > now })
|
|
}
|
|
|
|
let oldCharacterCount = state.text.count
|
|
let ranges = chatStreamingWordRanges(in: newText)
|
|
let rangesByStart = Dictionary(uniqueKeysWithValues: ranges.map { ($0.lowerBound, $0) })
|
|
|
|
var words = state.words.compactMap { word -> ChatStreamingRevealState.Word? in
|
|
guard word.deadline > now,
|
|
let updatedRange = rangesByStart[word.characterRange.lowerBound]
|
|
else { return nil }
|
|
return ChatStreamingRevealState.Word(
|
|
characterRange: updatedRange,
|
|
fadeStart: word.fadeStart,
|
|
deadline: word.deadline)
|
|
}
|
|
|
|
let trackedStarts = Set(words.map(\.characterRange.lowerBound))
|
|
let appendedRanges = ranges.filter {
|
|
$0.lowerBound >= oldCharacterCount && !trackedStarts.contains($0.lowerBound)
|
|
}
|
|
words.append(contentsOf: appendedRanges.map {
|
|
ChatStreamingRevealState.Word(
|
|
characterRange: $0,
|
|
fadeStart: now,
|
|
deadline: now + ChatStreamingRevealConstants.fadeDuration)
|
|
})
|
|
words.sort { $0.characterRange.lowerBound < $1.characterRange.lowerBound }
|
|
|
|
if words.count > ChatStreamingRevealConstants.maximumFadingWords {
|
|
words.removeFirst(words.count - ChatStreamingRevealConstants.maximumFadingWords)
|
|
}
|
|
|
|
let spacing: TimeInterval
|
|
if words.count > 1 {
|
|
let catchUpSpacing =
|
|
(ChatStreamingRevealConstants.maximumCatchUpDuration - ChatStreamingRevealConstants.fadeDuration)
|
|
/ Double(words.count - 1)
|
|
spacing = min(ChatStreamingRevealConstants.wordInterval, catchUpSpacing)
|
|
} else {
|
|
spacing = 0
|
|
}
|
|
|
|
words = words.enumerated().map { index, word in
|
|
let scheduledStart = now + Double(index) * spacing
|
|
let scheduledDeadline = scheduledStart + ChatStreamingRevealConstants.fadeDuration
|
|
let isExistingWord = trackedStarts.contains(word.characterRange.lowerBound)
|
|
return ChatStreamingRevealState.Word(
|
|
characterRange: word.characterRange,
|
|
fadeStart: isExistingWord ? word.fadeStart : scheduledStart,
|
|
// Catch-up may move an existing deadline earlier, never later. That
|
|
// preserves or increases its current opacity across append steps.
|
|
deadline: isExistingWord ? min(word.deadline, scheduledDeadline) : scheduledDeadline)
|
|
}
|
|
|
|
return ChatStreamingRevealState(text: newText, words: words)
|
|
}
|
|
|
|
func revealedOpacities(
|
|
state: ChatStreamingRevealState,
|
|
now: TimeInterval) -> ChatStreamingRevealFrame
|
|
{
|
|
let fading = state.words.compactMap { word -> ChatStreamingRevealFrame.FadingWord? in
|
|
guard now < word.deadline else { return nil }
|
|
let duration = word.deadline - word.fadeStart
|
|
let opacity = duration > 0
|
|
? min(1, max(0, (now - word.fadeStart) / duration))
|
|
: 1
|
|
return ChatStreamingRevealFrame.FadingWord(
|
|
characterRange: word.characterRange,
|
|
opacity: opacity)
|
|
}
|
|
return ChatStreamingRevealFrame(
|
|
fullyRevealedPrefixCharacterOffset: fading.first?.characterRange.lowerBound ?? state.text.count,
|
|
fading: fading)
|
|
}
|
|
|
|
func chatStreamingWordRanges(in text: String) -> [Range<Int>] {
|
|
var ranges: [Range<Int>] = []
|
|
var wordStart: Int?
|
|
|
|
for (offset, character) in text.enumerated() {
|
|
if character.isWhitespace {
|
|
if let start = wordStart {
|
|
ranges.append(start..<offset)
|
|
wordStart = nil
|
|
}
|
|
} else if wordStart == nil {
|
|
wordStart = offset
|
|
}
|
|
}
|
|
if let wordStart {
|
|
ranges.append(wordStart..<text.count)
|
|
}
|
|
return ranges
|
|
}
|