@@ -14,36 +14,62 @@ struct ChatMarkdownRenderer: View {
1414 case assistant
1515 }
1616
17- let text : String
17+ let snapshot : ChatMarkdownRenderSnapshot
1818 let context : Context
1919 let variant : ChatMarkdownVariant
2020 let font : Font
2121 let textColor : Color
22- /// False while the message is still streaming: trailing open fences and
23- /// growing tables then stay on the cheap plain-text path.
24- var isComplete : Bool = true
22+ var reveal : ChatMarkdownProseReveal ?
23+
24+ init (
25+ text: String ,
26+ context: Context ,
27+ variant: ChatMarkdownVariant ,
28+ font: Font ,
29+ textColor: Color ,
30+ isComplete: Bool = true )
31+ {
32+ self . init (
33+ snapshot: ChatMarkdownRenderSnapshot ( text: text, isComplete: isComplete) ,
34+ context: context,
35+ variant: variant,
36+ font: font,
37+ textColor: textColor)
38+ }
39+
40+ init (
41+ snapshot: ChatMarkdownRenderSnapshot ,
42+ context: Context ,
43+ variant: ChatMarkdownVariant ,
44+ font: Font ,
45+ textColor: Color ,
46+ reveal: ChatMarkdownProseReveal ? = nil )
47+ {
48+ self . snapshot = snapshot
49+ self . context = context
50+ self . variant = variant
51+ self . font = font
52+ self . textColor = textColor
53+ self . reveal = reveal
54+ }
2555
2656 var body : some View {
27- let processed = ChatMarkdownPreprocessor . preprocess ( markdown: self . text)
28- let blocks = ChatMarkdownBlockSegmenter . segments (
29- markdown: processed. cleaned,
30- isComplete: self . isComplete)
3157 VStack ( alignment: . leading, spacing: 10 ) {
32- ForEach ( Array ( blocks. enumerated ( ) ) , id: \. offset) { entry in
33- self . blockView ( entry. element)
58+ ForEach ( Array ( self . snapshot . blocks. enumerated ( ) ) , id: \. offset) { entry in
59+ self . blockView ( entry. element, index : entry . offset )
3460 }
3561
36- if !processed . images. isEmpty {
37- InlineImageList ( images: processed . images)
62+ if !self . snapshot . images. isEmpty {
63+ InlineImageList ( images: self . snapshot . images)
3864 }
3965 }
4066 }
4167
4268 @ViewBuilder
43- private func blockView( _ block: ChatMarkdownBlock ) -> some View {
69+ private func blockView( _ block: ChatMarkdownRenderedBlock , index : Int ) -> some View {
4470 switch block {
45- case let . prose( markdown ) :
46- Text ( self . markdownText ( ChatMarkdownDisplayPreprocessor . preserveChatSoftBreaks ( in : markdown ) ) )
71+ case let . prose( prose ) :
72+ self . proseText ( prose , index : index )
4773 . font ( self . font)
4874 . foregroundStyle ( self . textColor)
4975 . tint ( self . linkColor)
@@ -58,15 +84,154 @@ struct ChatMarkdownRenderer: View {
5884 }
5985 }
6086
87+ private func proseText( _ prose: ChatMarkdownProse , index: Int ) -> SwiftUI . Text {
88+ guard let reveal = self . reveal, reveal. blockIndex == index else {
89+ return SwiftUI . Text ( prose. attributed)
90+ }
91+ return prose. revealedText (
92+ frame: revealedOpacities ( state: reveal. state, now: reveal. now) ,
93+ textColor: self . textColor)
94+ }
95+
6196 private var linkColor : Color {
6297 self . context == . user ? self . textColor : OpenClawChatTheme . accent
6398 }
99+ }
100+
101+ struct ChatMarkdownProseReveal {
102+ let blockIndex : Int
103+ let state : ChatStreamingRevealState
104+ let now : TimeInterval
105+ }
106+
107+ struct ChatMarkdownRenderSnapshot {
108+ let blocks : [ ChatMarkdownRenderedBlock ]
109+ let images : [ ChatMarkdownPreprocessor . InlineImage ]
110+
111+ init ( text: String , isComplete: Bool , preparesReveal: Bool = false ) {
112+ let processed = ChatMarkdownPreprocessor . preprocess ( markdown: text)
113+ self . blocks = ChatMarkdownBlockSegmenter . segments (
114+ markdown: processed. cleaned,
115+ isComplete: isComplete) . map { block in
116+ switch block {
117+ case let . prose( markdown) :
118+ . prose( ChatMarkdownProse ( markdown: markdown, preparesReveal: preparesReveal) )
119+ case let . code( code) :
120+ . code( code)
121+ case let . math( math) :
122+ . math( math)
123+ case let . table( table) :
124+ . table( table)
125+ }
126+ }
127+ self . images = processed. images
128+ }
129+
130+ var lastProseIndex : Int ? {
131+ self . blocks. lastIndex {
132+ if case . prose = $0 { return true }
133+ return false
134+ }
135+ }
136+ }
137+
138+ enum ChatMarkdownRenderedBlock {
139+ case prose( ChatMarkdownProse )
140+ case code( ChatCodeBlock )
141+ case math( ChatMathBlock )
142+ case table( ChatMarkdownTable )
143+ }
144+
145+ struct ChatMarkdownProse {
146+ struct TailPiece {
147+ let attributed : AttributedString
148+ let wordRange : Range < Int > ?
149+ }
64150
65- private func markdownText( _ markdown: String ) -> AttributedString {
151+ let attributed : AttributedString
152+ let plainText : String
153+ let prefix : AttributedString
154+ let tail : [ TailPiece ]
155+
156+ init ( markdown: String , preparesReveal: Bool ) {
157+ let displayMarkdown = ChatMarkdownDisplayPreprocessor . preserveChatSoftBreaks ( in: markdown)
66158 let options = AttributedString . MarkdownParsingOptions (
67159 interpretedSyntax: . full,
68160 failurePolicy: . returnPartiallyParsedIfPossible)
69- return ( try ? AttributedString ( markdown: markdown, options: options) ) ?? AttributedString ( markdown)
161+ let attributed = ( try ? AttributedString ( markdown: displayMarkdown, options: options) )
162+ ?? AttributedString ( displayMarkdown)
163+ let plainText = preparesReveal ? String ( attributed. characters) : " "
164+ let wordRanges = preparesReveal
165+ ? Array ( chatStreamingWordRanges ( in: plainText) . suffix ( 24 ) )
166+ : [ ]
167+ let tailStart = wordRanges. first? . lowerBound ?? plainText. count
168+
169+ self . attributed = attributed
170+ self . plainText = plainText
171+ if preparesReveal {
172+ self . prefix = Self . slice ( attributed, characterRange: 0 ..< tailStart)
173+ self . tail = Self . tailPieces (
174+ attributed: attributed,
175+ textLength: plainText. count,
176+ wordRanges: wordRanges,
177+ tailStart: tailStart)
178+ } else {
179+ self . prefix = AttributedString ( )
180+ self . tail = [ ]
181+ }
182+ }
183+
184+ func revealedText( frame: ChatStreamingRevealFrame , textColor: Color ) -> SwiftUI . Text {
185+ self . tail. reduce ( SwiftUI . Text ( self . prefix) ) { text, piece in
186+ var attributed = piece. attributed
187+ if let wordRange = piece. wordRange,
188+ let fading = frame. fading. first ( where: { $0. characterRange == wordRange } )
189+ {
190+ attributed. foregroundColor = textColor. opacity ( fading. opacity)
191+ }
192+ return text + SwiftUI. Text ( attributed)
193+ }
194+ }
195+
196+ private static func tailPieces(
197+ attributed: AttributedString ,
198+ textLength: Int ,
199+ wordRanges: [ Range < Int > ] ,
200+ tailStart: Int ) -> [ TailPiece ]
201+ {
202+ guard !wordRanges. isEmpty else { return [ ] }
203+ var pieces : [ TailPiece ] = [ ]
204+ var cursor = tailStart
205+ for wordRange in wordRanges {
206+ if cursor < wordRange. lowerBound {
207+ pieces. append ( TailPiece (
208+ attributed: self . slice ( attributed, characterRange: cursor..< wordRange. lowerBound) ,
209+ wordRange: nil ) )
210+ }
211+ pieces. append ( TailPiece (
212+ attributed: self . slice ( attributed, characterRange: wordRange) ,
213+ wordRange: wordRange) )
214+ cursor = wordRange. upperBound
215+ }
216+ if cursor < textLength {
217+ pieces. append ( TailPiece (
218+ attributed: self . slice ( attributed, characterRange: cursor..< textLength) ,
219+ wordRange: nil ) )
220+ }
221+ return pieces
222+ }
223+
224+ private static func slice(
225+ _ attributed: AttributedString ,
226+ characterRange: Range < Int > ) -> AttributedString
227+ {
228+ let lower = attributed. characters. index (
229+ attributed. startIndex,
230+ offsetBy: characterRange. lowerBound)
231+ let upper = attributed. characters. index (
232+ attributed. startIndex,
233+ offsetBy: characterRange. upperBound)
234+ return AttributedString ( attributed [ lower..< upper] )
70235 }
71236}
72237
0 commit comments