mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 07:24:00 +00:00
537 lines
18 KiB
Swift
537 lines
18 KiB
Swift
import Foundation
|
|
import Markdown
|
|
import SwiftUI
|
|
|
|
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
|
case standard
|
|
case compact
|
|
}
|
|
|
|
@MainActor
|
|
struct ChatMarkdownRenderer: View {
|
|
enum Context {
|
|
case user
|
|
case assistant
|
|
}
|
|
|
|
struct InlineMathTypography {
|
|
static let body = Self(size: OpenClawChatTypography.bodySize, relativeTo: .body)
|
|
static let callout = Self(size: 16, relativeTo: .callout)
|
|
|
|
let size: CGFloat
|
|
let relativeTo: Font.TextStyle
|
|
}
|
|
|
|
let snapshot: ChatMarkdownRenderSnapshot
|
|
let context: Context
|
|
let variant: ChatMarkdownVariant
|
|
let font: Font
|
|
let textColor: Color
|
|
var reveal: ChatMarkdownProseReveal?
|
|
|
|
@ScaledMetric private var inlineMathFontSize: CGFloat
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
init(
|
|
text: String,
|
|
context: Context,
|
|
variant: ChatMarkdownVariant,
|
|
font: Font,
|
|
textColor: Color,
|
|
inlineMathTypography: InlineMathTypography = .body,
|
|
isComplete: Bool = true)
|
|
{
|
|
self.init(
|
|
snapshot: ChatMarkdownRenderSnapshot(text: text, isComplete: isComplete),
|
|
context: context,
|
|
variant: variant,
|
|
font: font,
|
|
textColor: textColor,
|
|
inlineMathTypography: inlineMathTypography)
|
|
}
|
|
|
|
init(
|
|
snapshot: ChatMarkdownRenderSnapshot,
|
|
context: Context,
|
|
variant: ChatMarkdownVariant,
|
|
font: Font,
|
|
textColor: Color,
|
|
inlineMathTypography: InlineMathTypography = .body,
|
|
reveal: ChatMarkdownProseReveal? = nil)
|
|
{
|
|
self.snapshot = snapshot
|
|
self.context = context
|
|
self.variant = variant
|
|
self.font = font
|
|
self.textColor = textColor
|
|
self.reveal = reveal
|
|
self._inlineMathFontSize = ScaledMetric(
|
|
wrappedValue: inlineMathTypography.size,
|
|
relativeTo: inlineMathTypography.relativeTo)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ForEach(Array(self.snapshot.blocks.enumerated()), id: \.offset) { entry in
|
|
self.blockView(entry.element, index: entry.offset)
|
|
}
|
|
|
|
if !self.snapshot.images.isEmpty {
|
|
InlineImageList(images: self.snapshot.images)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func blockView(_ block: ChatMarkdownRenderedBlock, index: Int) -> some View {
|
|
switch block {
|
|
case let .prose(prose):
|
|
self.proseText(prose, index: index)
|
|
.font(self.font)
|
|
.foregroundStyle(self.textColor)
|
|
.tint(self.linkColor)
|
|
.textSelection(.enabled)
|
|
.lineSpacing(self.variant == .compact ? 2 : 4)
|
|
.modifier(ChatInlineMathAccessibilityModifier(label: prose.inlineAccessibilityText))
|
|
case let .code(code):
|
|
ChatCodeBlockView(block: code)
|
|
case let .math(math):
|
|
ChatMathBlockView(block: math, textColor: self.textColor)
|
|
case let .table(table):
|
|
ChatMarkdownTableView(table: table)
|
|
}
|
|
}
|
|
|
|
private func proseText(_ prose: ChatMarkdownProse, index: Int) -> SwiftUI.Text {
|
|
guard let reveal = self.reveal, reveal.blockIndex == index else {
|
|
return prose.renderedText(
|
|
fontSize: self.inlineMathFontSize,
|
|
textColor: self.textColor,
|
|
colorScheme: self.colorScheme)
|
|
}
|
|
return prose.revealedText(
|
|
frame: revealedOpacities(state: reveal.state, now: reveal.now),
|
|
textColor: self.textColor)
|
|
}
|
|
|
|
private var linkColor: Color {
|
|
self.context == .user ? self.textColor : OpenClawChatTheme.accent
|
|
}
|
|
}
|
|
|
|
struct ChatMarkdownProseReveal {
|
|
let blockIndex: Int
|
|
let state: ChatStreamingRevealState
|
|
let now: TimeInterval
|
|
}
|
|
|
|
@MainActor
|
|
struct ChatMarkdownRenderSnapshot {
|
|
let blocks: [ChatMarkdownRenderedBlock]
|
|
let images: [ChatMarkdownPreprocessor.InlineImage]
|
|
|
|
init(text: String, isComplete: Bool, preparesReveal: Bool = false) {
|
|
let processed = ChatMarkdownPreprocessor.preprocess(markdown: text)
|
|
self.blocks = ChatMarkdownBlockSegmenter.segments(
|
|
markdown: processed.cleaned,
|
|
isComplete: isComplete).map { block in
|
|
switch block {
|
|
case let .prose(markdown):
|
|
.prose(ChatMarkdownProse(
|
|
markdown: markdown,
|
|
isComplete: isComplete,
|
|
preparesReveal: preparesReveal))
|
|
case let .code(code):
|
|
.code(code)
|
|
case let .math(math):
|
|
.math(math)
|
|
case let .table(table):
|
|
.table(table)
|
|
}
|
|
}
|
|
self.images = processed.images
|
|
}
|
|
|
|
var lastProseIndex: Int? {
|
|
self.blocks.lastIndex {
|
|
if case .prose = $0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ChatMarkdownRenderedBlock {
|
|
case prose(ChatMarkdownProse)
|
|
case code(ChatCodeBlock)
|
|
case math(ChatMathBlock)
|
|
case table(ChatMarkdownTable)
|
|
}
|
|
|
|
@MainActor
|
|
struct ChatMarkdownProse {
|
|
struct TailPiece {
|
|
let attributed: AttributedString
|
|
let wordRange: Range<Int>?
|
|
}
|
|
|
|
let attributed: AttributedString
|
|
let plainText: String
|
|
let prefix: AttributedString
|
|
let tail: [TailPiece]
|
|
let inlineContent: [InlineContent]?
|
|
|
|
enum InlineContent {
|
|
case text(AttributedString)
|
|
case math(ChatInlineMathSpan)
|
|
}
|
|
|
|
init(markdown: String, isComplete: Bool, preparesReveal: Bool) {
|
|
// preparesReveal belongs only to the streaming snapshot. Keep that path
|
|
// on its single AttributedString build; completed snapshots alone split
|
|
// prose for inline image interpolation.
|
|
let inlineContent = isComplete && !preparesReveal
|
|
? Self.makeInlineContent(markdown: markdown)
|
|
: nil
|
|
let attributed = inlineContent == nil
|
|
? Self.parseMarkdown(markdown)
|
|
: AttributedString()
|
|
let plainText = preparesReveal ? String(attributed.characters) : ""
|
|
let wordRanges = preparesReveal
|
|
? Array(chatStreamingWordRanges(in: plainText).suffix(24))
|
|
: []
|
|
let tailStart = wordRanges.first?.lowerBound ?? plainText.count
|
|
|
|
self.attributed = attributed
|
|
self.plainText = plainText
|
|
self.inlineContent = inlineContent
|
|
if preparesReveal {
|
|
self.prefix = Self.slice(attributed, characterRange: 0..<tailStart)
|
|
self.tail = Self.tailPieces(
|
|
attributed: attributed,
|
|
textLength: plainText.count,
|
|
wordRanges: wordRanges,
|
|
tailStart: tailStart)
|
|
} else {
|
|
self.prefix = AttributedString()
|
|
self.tail = []
|
|
}
|
|
}
|
|
|
|
var inlineMathLatex: [String] {
|
|
self.inlineContent?.compactMap { content in
|
|
if case let .math(span) = content {
|
|
return span.latex
|
|
}
|
|
return nil
|
|
} ?? []
|
|
}
|
|
|
|
var inlineAccessibilityText: String? {
|
|
guard let inlineContent else { return nil }
|
|
return inlineContent.reduce(into: "") { text, content in
|
|
switch content {
|
|
case let .text(attributed):
|
|
text += String(attributed.characters)
|
|
case let .math(span):
|
|
text += span.latex
|
|
}
|
|
}
|
|
}
|
|
|
|
func renderedText(
|
|
fontSize: CGFloat,
|
|
textColor: Color,
|
|
colorScheme: ColorScheme) -> SwiftUI.Text
|
|
{
|
|
guard let inlineContent else { return SwiftUI.Text(self.attributed) }
|
|
return inlineContent.reduce(SwiftUI.Text("")) { text, content in
|
|
switch content {
|
|
case let .text(attributed):
|
|
return text + SwiftUI.Text(attributed)
|
|
case let .math(span):
|
|
guard let rendered = ChatInlineMathImageCache.image(
|
|
latex: span.latex,
|
|
fontSize: fontSize,
|
|
textColor: textColor,
|
|
colorScheme: colorScheme)
|
|
else { return text + SwiftUI.Text(span.source) }
|
|
#if os(macOS)
|
|
let image = Image(nsImage: rendered.image)
|
|
#else
|
|
let image = Image(uiImage: rendered.image)
|
|
#endif
|
|
return text + SwiftUI.Text(image).baselineOffset(rendered.baselineOffset)
|
|
}
|
|
}
|
|
}
|
|
|
|
func revealedText(frame: ChatStreamingRevealFrame, textColor: Color) -> SwiftUI.Text {
|
|
self.tail.reduce(SwiftUI.Text(self.prefix)) { text, piece in
|
|
var attributed = piece.attributed
|
|
if let wordRange = piece.wordRange,
|
|
let fading = frame.fading.first(where: { $0.characterRange == wordRange })
|
|
{
|
|
attributed.foregroundColor = textColor.opacity(fading.opacity)
|
|
}
|
|
return text + SwiftUI.Text(attributed)
|
|
}
|
|
}
|
|
|
|
private static func makeInlineContent(markdown: String) -> [InlineContent]? {
|
|
let pieces = ChatInlineMathScanner.pieces(in: markdown)
|
|
guard pieces.contains(where: { piece in
|
|
if case .markdown = piece {
|
|
return false
|
|
}
|
|
return true
|
|
}) else { return nil }
|
|
|
|
var substitutedMarkdown = ""
|
|
var replacements: [InlineReplacement] = []
|
|
var markerValue: UInt32 = 0xE000
|
|
var occupiedMarkerValues = Set(markdown.unicodeScalars.map(\.value))
|
|
for piece in pieces {
|
|
switch piece {
|
|
case let .markdown(source):
|
|
substitutedMarkdown += source
|
|
case let .literal(source):
|
|
substitutedMarkdown += Self.markdownEscapedLiteral(source)
|
|
case let .math(latex, source):
|
|
guard ChatMathParseCache.mathList(latex: latex) != nil else {
|
|
substitutedMarkdown += Self.markdownEscapedLiteral(source)
|
|
continue
|
|
}
|
|
let marker = Self.nextMarker(
|
|
startingAt: &markerValue,
|
|
occupiedValues: &occupiedMarkerValues)
|
|
substitutedMarkdown.append(marker)
|
|
replacements.append(InlineReplacement(
|
|
marker: marker,
|
|
span: ChatInlineMathSpan(latex: latex, source: source)))
|
|
}
|
|
}
|
|
|
|
let attributed = self.parseMarkdown(substitutedMarkdown)
|
|
var content: [InlineContent] = []
|
|
var cursor = attributed.startIndex
|
|
for replacement in replacements {
|
|
guard let markerIndex = attributed.characters[cursor...]
|
|
.firstIndex(of: replacement.marker)
|
|
else { return nil }
|
|
if cursor < markerIndex {
|
|
content.append(.text(AttributedString(attributed[cursor..<markerIndex])))
|
|
}
|
|
let markerEnd = attributed.characters.index(after: markerIndex)
|
|
let attributes = attributed[markerIndex..<markerEnd].runs.first?.attributes
|
|
if attributes?.link != nil {
|
|
// Text(Image) cannot carry an AttributedString link. Keep math
|
|
// used as a link label literal so the destination remains
|
|
// visible and tappable instead of silently dropping the link.
|
|
var literal = AttributedString(replacement.span.source)
|
|
if let attributes {
|
|
literal.mergeAttributes(attributes)
|
|
}
|
|
content.append(.text(literal))
|
|
} else {
|
|
content.append(.math(replacement.span))
|
|
}
|
|
cursor = markerEnd
|
|
}
|
|
if cursor < attributed.endIndex {
|
|
content.append(.text(AttributedString(attributed[cursor...])))
|
|
}
|
|
return content
|
|
}
|
|
|
|
private struct InlineReplacement {
|
|
let marker: Character
|
|
let span: ChatInlineMathSpan
|
|
}
|
|
|
|
private static func nextMarker(
|
|
startingAt value: inout UInt32,
|
|
occupiedValues: inout Set<UInt32>) -> Character
|
|
{
|
|
while occupiedValues.contains(value) {
|
|
value += 1
|
|
}
|
|
guard let scalar = UnicodeScalar(value) else {
|
|
preconditionFailure("inline math marker range exhausted")
|
|
}
|
|
occupiedValues.insert(value)
|
|
let marker = Character(String(scalar))
|
|
value += 1
|
|
return marker
|
|
}
|
|
|
|
private static func markdownEscapedLiteral(_ source: String) -> String {
|
|
source.reduce(into: "") { escaped, character in
|
|
if character.unicodeScalars.count == 1,
|
|
let scalar = character.unicodeScalars.first,
|
|
scalar.isASCII,
|
|
(33...47).contains(scalar.value) ||
|
|
(58...64).contains(scalar.value) ||
|
|
(91...96).contains(scalar.value) ||
|
|
(123...126).contains(scalar.value)
|
|
{
|
|
escaped.append("\\")
|
|
}
|
|
escaped.append(character)
|
|
}
|
|
}
|
|
|
|
private static func parseMarkdown(_ markdown: String) -> AttributedString {
|
|
let displayMarkdown = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
|
|
let options = AttributedString.MarkdownParsingOptions(
|
|
interpretedSyntax: .full,
|
|
failurePolicy: .returnPartiallyParsedIfPossible)
|
|
return (try? AttributedString(markdown: displayMarkdown, options: options))
|
|
?? AttributedString(displayMarkdown)
|
|
}
|
|
|
|
private static func tailPieces(
|
|
attributed: AttributedString,
|
|
textLength: Int,
|
|
wordRanges: [Range<Int>],
|
|
tailStart: Int) -> [TailPiece]
|
|
{
|
|
guard !wordRanges.isEmpty else { return [] }
|
|
var pieces: [TailPiece] = []
|
|
var cursor = tailStart
|
|
for wordRange in wordRanges {
|
|
if cursor < wordRange.lowerBound {
|
|
pieces.append(TailPiece(
|
|
attributed: self.slice(attributed, characterRange: cursor..<wordRange.lowerBound),
|
|
wordRange: nil))
|
|
}
|
|
pieces.append(TailPiece(
|
|
attributed: self.slice(attributed, characterRange: wordRange),
|
|
wordRange: wordRange))
|
|
cursor = wordRange.upperBound
|
|
}
|
|
if cursor < textLength {
|
|
pieces.append(TailPiece(
|
|
attributed: self.slice(attributed, characterRange: cursor..<textLength),
|
|
wordRange: nil))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
private static func slice(
|
|
_ attributed: AttributedString,
|
|
characterRange: Range<Int>) -> AttributedString
|
|
{
|
|
let lower = attributed.characters.index(
|
|
attributed.startIndex,
|
|
offsetBy: characterRange.lowerBound)
|
|
let upper = attributed.characters.index(
|
|
attributed.startIndex,
|
|
offsetBy: characterRange.upperBound)
|
|
return AttributedString(attributed[lower..<upper])
|
|
}
|
|
}
|
|
|
|
private struct ChatInlineMathAccessibilityModifier: ViewModifier {
|
|
let label: String?
|
|
|
|
func body(content: Content) -> some View {
|
|
if let label {
|
|
content.accessibilityLabel(Text(label))
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fenced code, display math, and GFM tables are split out by `ChatMarkdownBlockSegmenter`
|
|
/// before this runs, so prose only needs chat-style soft-break preservation.
|
|
enum ChatMarkdownDisplayPreprocessor {
|
|
static func preserveChatSoftBreaks(in markdown: String) -> String {
|
|
let normalized = markdown.replacingOccurrences(of: "\r\n", with: "\n")
|
|
let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
|
guard lines.count > 1 else { return normalized }
|
|
let codeLines = self.codeLineIndices(in: normalized)
|
|
|
|
var output = ""
|
|
for index in lines.indices {
|
|
output += lines[index]
|
|
|
|
guard index < lines.index(before: lines.endIndex) else {
|
|
continue
|
|
}
|
|
|
|
if !codeLines.contains(index),
|
|
!codeLines.contains(index + 1),
|
|
self.shouldPreserveSoftBreak(after: lines[index], before: lines[index + 1])
|
|
{
|
|
output += " \n"
|
|
} else {
|
|
output += "\n"
|
|
}
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
private static func codeLineIndices(in markdown: String) -> Set<Int> {
|
|
guard markdown.contains("```")
|
|
|| markdown.contains("~~~")
|
|
|| markdown.hasPrefix(" ")
|
|
|| markdown.contains("\n ")
|
|
else { return [] }
|
|
|
|
var indices = Set<Int>()
|
|
func collect(from markup: any Markup) {
|
|
if markup is Markdown.CodeBlock, let range = markup.range {
|
|
indices.formUnion((range.lowerBound.line - 1)..<range.upperBound.line)
|
|
}
|
|
for child in markup.children {
|
|
collect(from: child)
|
|
}
|
|
}
|
|
collect(from: Document(parsing: markdown))
|
|
return indices
|
|
}
|
|
|
|
private static func shouldPreserveSoftBreak(after line: String, before nextLine: String) -> Bool {
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let nextTrimmed = nextLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, !nextTrimmed.isEmpty else { return false }
|
|
guard !self.hasMarkdownHardBreak(line) else { return false }
|
|
guard !ChatMarkdownBlockSyntax.startsBlock(line), !ChatMarkdownBlockSyntax.startsBlock(nextLine) else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private static func hasMarkdownHardBreak(_ line: String) -> Bool {
|
|
line.hasSuffix("\\") || line.hasSuffix(" ")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct InlineImageList: View {
|
|
let images: [ChatMarkdownPreprocessor.InlineImage]
|
|
|
|
var body: some View {
|
|
ForEach(self.images, id: \.id) { item in
|
|
if let img = item.image {
|
|
OpenClawPlatformImageFactory.image(img)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxHeight: 260)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
|
} else {
|
|
Text(item.label.isEmpty ? "Image" : item.label)
|
|
.font(OpenClawChatTypography.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|