feat: render native chat markdown via Textual

This commit is contained in:
Peter Steinberger
2026-01-16 09:02:21 +00:00
parent 6e53c061ff
commit 1791c1a765
9 changed files with 97 additions and 229 deletions

View File

@@ -5,7 +5,7 @@ import PackageDescription
let package = Package(
name: "ClawdbotKit",
platforms: [
.iOS(.v17),
.iOS(.v18),
.macOS(.v15),
],
products: [
@@ -14,6 +14,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
],
targets: [
.target(
@@ -29,7 +30,13 @@ let package = Package(
]),
.target(
name: "ClawdbotChatUI",
dependencies: ["ClawdbotKit"],
dependencies: [
"ClawdbotKit",
.product(
name: "Textual",
package: "textual",
condition: .when(platforms: [.macOS, .iOS])),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),

View File

@@ -0,0 +1,51 @@
import Foundation
enum ChatMarkdownPreprocessor {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Result {
let cleaned: String
let images: [InlineImage]
}
static func preprocess(markdown raw: String) -> Result {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return Result(cleaned: raw, images: [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: raw, images: []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return Result(cleaned: normalized, images: images.reversed())
}
}

View File

@@ -1,114 +0,0 @@
import Foundation
enum ChatMarkdownSplitter {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Block: Identifiable {
enum Kind: Equatable {
case text
case code(language: String?)
}
let id = UUID()
let kind: Kind
let text: String
}
struct SplitResult {
let blocks: [Block]
let images: [InlineImage]
}
static func split(markdown raw: String) -> SplitResult {
let extracted = self.extractInlineImages(from: raw)
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
return SplitResult(blocks: blocks, images: extracted.images)
}
private static func splitCodeBlocks(from raw: String) -> [Block] {
var blocks: [Block] = []
var buffer: [String] = []
var inCode = false
var codeLang: String?
var codeLines: [String] = []
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
inCode = false
codeLang = nil
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
buffer.removeAll(keepingCapacity: true)
inCode = true
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
if codeLang?.isEmpty == true { codeLang = nil }
}
continue
}
if inCode {
codeLines.append(line)
} else {
buffer.append(line)
}
}
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
}
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
}
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return (raw, [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return (raw, []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (normalized, images.reversed())
}
}

View File

@@ -1,6 +1,7 @@
import ClawdbotKit
import Foundation
import SwiftUI
import Textual
private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 560
@@ -169,37 +170,7 @@ private struct ChatMessageBody: View {
isUser: self.isUser)
}
} else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text)
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.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(.footnote)
.foregroundStyle(.secondary)
}
}
}
ChatMarkdownView(text: text, textColor: textColor, font: .system(size: 14))
} else {
ChatAssistantTextBody(text: text)
}
@@ -613,26 +584,22 @@ private struct TypingDots: View {
}
@MainActor
private struct MarkdownTextView: View {
private struct ChatMarkdownView: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let normalized = self.text.replacingOccurrences(
of: "(?<!\\n)\\n(?!\\n)",
with: " ",
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
StructuredText(markdown: processed.cleaned)
.font(self.font)
.foregroundStyle(self.textColor)
.textual.textSelection(.enabled)
if !processed.images.isEmpty {
InlineImageList(images: processed.images)
}
}
}
}
@@ -646,80 +613,36 @@ private struct ChatAssistantTextBody: View {
VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
ChatMarkdownView(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
}
}
}
}
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
let font: Font
private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.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(.footnote)
.foregroundStyle(.secondary)
}
if images.isEmpty {
EmptyView()
} else {
ForEach(images, id: \.id) { item in
if let img = item.image {
ClawdbotPlatformImageFactory.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(.footnote)
.foregroundStyle(.secondary)
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let language, !language.isEmpty {
Text(language)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}