feat: render native chat markdown via Textual
This commit is contained in:
@@ -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"),
|
||||
]),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user