refactor: consolidate chat markdown rendering
This commit is contained in:
@@ -26,6 +26,7 @@ Sources/Voice/VoiceTab.swift
|
|||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||||
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Textual
|
||||||
|
|
||||||
|
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
||||||
|
case standard
|
||||||
|
case compact
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ChatMarkdownRenderer: View {
|
||||||
|
enum Context {
|
||||||
|
case user
|
||||||
|
case assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: String
|
||||||
|
let context: Context
|
||||||
|
let variant: ChatMarkdownVariant
|
||||||
|
let font: Font
|
||||||
|
let textColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
StructuredText(markdown: processed.cleaned)
|
||||||
|
.modifier(ChatMarkdownStyle(
|
||||||
|
variant: self.variant,
|
||||||
|
context: self.context,
|
||||||
|
font: self.font,
|
||||||
|
textColor: self.textColor))
|
||||||
|
|
||||||
|
if !processed.images.isEmpty {
|
||||||
|
InlineImageList(images: processed.images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChatMarkdownStyle: ViewModifier {
|
||||||
|
let variant: ChatMarkdownVariant
|
||||||
|
let context: ChatMarkdownRenderer.Context
|
||||||
|
let font: Font
|
||||||
|
let textColor: Color
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.font(self.font)
|
||||||
|
.foregroundStyle(self.textColor)
|
||||||
|
.textual.structuredTextStyle(self.variant == .compact ? .default : .gitHub)
|
||||||
|
.textual.inlineStyle(self.inlineStyle)
|
||||||
|
.textual.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inlineStyle: InlineStyle {
|
||||||
|
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
||||||
|
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
||||||
|
return InlineStyle()
|
||||||
|
.code(.monospaced, .fontScale(codeScale))
|
||||||
|
.link(.foregroundColor(linkColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private struct InlineImageList: View {
|
||||||
|
let images: [ChatMarkdownPreprocessor.InlineImage]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Textual
|
|
||||||
|
|
||||||
private enum ChatUIConstants {
|
private enum ChatUIConstants {
|
||||||
static let bubbleMaxWidth: CGFloat = 560
|
static let bubbleMaxWidth: CGFloat = 560
|
||||||
@@ -138,10 +137,16 @@ private struct ChatBubbleShape: InsettableShape {
|
|||||||
struct ChatMessageBubble: View {
|
struct ChatMessageBubble: View {
|
||||||
let message: ClawdbotChatMessage
|
let message: ClawdbotChatMessage
|
||||||
let style: ClawdbotChatView.Style
|
let style: ClawdbotChatView.Style
|
||||||
|
let markdownVariant: ChatMarkdownVariant
|
||||||
let userAccent: Color?
|
let userAccent: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
|
ChatMessageBody(
|
||||||
|
message: self.message,
|
||||||
|
isUser: self.isUser,
|
||||||
|
style: self.style,
|
||||||
|
markdownVariant: self.markdownVariant,
|
||||||
|
userAccent: self.userAccent)
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
@@ -155,6 +160,7 @@ private struct ChatMessageBody: View {
|
|||||||
let message: ClawdbotChatMessage
|
let message: ClawdbotChatMessage
|
||||||
let isUser: Bool
|
let isUser: Bool
|
||||||
let style: ClawdbotChatView.Style
|
let style: ClawdbotChatView.Style
|
||||||
|
let markdownVariant: ChatMarkdownVariant
|
||||||
let userAccent: Color?
|
let userAccent: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -170,9 +176,14 @@ private struct ChatMessageBody: View {
|
|||||||
isUser: self.isUser)
|
isUser: self.isUser)
|
||||||
}
|
}
|
||||||
} else if self.isUser {
|
} else if self.isUser {
|
||||||
ChatMarkdownView(text: text, textColor: textColor, font: .system(size: 14))
|
ChatMarkdownRenderer(
|
||||||
|
text: text,
|
||||||
|
context: .user,
|
||||||
|
variant: self.markdownVariant,
|
||||||
|
font: .system(size: 14),
|
||||||
|
textColor: textColor)
|
||||||
} else {
|
} else {
|
||||||
ChatAssistantTextBody(text: text)
|
ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.inlineAttachments.isEmpty {
|
if !self.inlineAttachments.isEmpty {
|
||||||
@@ -584,64 +595,22 @@ private struct TypingDots: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct ChatMarkdownView: View {
|
|
||||||
let text: String
|
|
||||||
let textColor: Color
|
|
||||||
let font: Font
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct ChatAssistantTextBody: View {
|
private struct ChatAssistantTextBody: View {
|
||||||
let text: String
|
let text: String
|
||||||
|
let markdownVariant: ChatMarkdownVariant
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let segments = AssistantTextParser.segments(from: self.text)
|
let segments = AssistantTextParser.segments(from: self.text)
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(segments) { segment in
|
ForEach(segments) { segment in
|
||||||
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
||||||
ChatMarkdownView(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
|
ChatMarkdownRenderer(
|
||||||
}
|
text: segment.text,
|
||||||
}
|
context: .assistant,
|
||||||
}
|
variant: self.markdownVariant,
|
||||||
}
|
font: font,
|
||||||
|
textColor: ClawdbotChatTheme.assistantText)
|
||||||
@MainActor
|
|
||||||
private struct InlineImageList: View {
|
|
||||||
let images: [ChatMarkdownPreprocessor.InlineImage]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
|
private let markdownVariant: ChatMarkdownVariant
|
||||||
private let userAccent: Color?
|
private let userAccent: Color?
|
||||||
|
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
@@ -42,11 +43,13 @@ public struct ClawdbotChatView: View {
|
|||||||
viewModel: ClawdbotChatViewModel,
|
viewModel: ClawdbotChatViewModel,
|
||||||
showsSessionSwitcher: Bool = false,
|
showsSessionSwitcher: Bool = false,
|
||||||
style: Style = .standard,
|
style: Style = .standard,
|
||||||
|
markdownVariant: ChatMarkdownVariant = .standard,
|
||||||
userAccent: Color? = nil)
|
userAccent: Color? = nil)
|
||||||
{
|
{
|
||||||
self._viewModel = State(initialValue: viewModel)
|
self._viewModel = State(initialValue: viewModel)
|
||||||
self.showsSessionSwitcher = showsSessionSwitcher
|
self.showsSessionSwitcher = showsSessionSwitcher
|
||||||
self.style = style
|
self.style = style
|
||||||
|
self.markdownVariant = markdownVariant
|
||||||
self.userAccent = userAccent
|
self.userAccent = userAccent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +154,11 @@ public struct ClawdbotChatView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var messageListRows: some View {
|
private var messageListRows: some View {
|
||||||
ForEach(self.visibleMessages) { msg in
|
ForEach(self.visibleMessages) { msg in
|
||||||
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
|
ChatMessageBubble(
|
||||||
|
message: msg,
|
||||||
|
style: self.style,
|
||||||
|
markdownVariant: self.markdownVariant,
|
||||||
|
userAccent: self.userAccent)
|
||||||
.frame(
|
.frame(
|
||||||
maxWidth: .infinity,
|
maxWidth: .infinity,
|
||||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import ClawdbotChatUI
|
||||||
|
|
||||||
|
@Suite("ChatMarkdownPreprocessor")
|
||||||
|
struct ChatMarkdownPreprocessorTests {
|
||||||
|
@Test func extractsDataURLImages() {
|
||||||
|
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
|
||||||
|
let markdown = """
|
||||||
|
Hello
|
||||||
|
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||||
|
|
||||||
|
#expect(result.cleaned == "Hello")
|
||||||
|
#expect(result.images.count == 1)
|
||||||
|
#expect(result.images.first?.image != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user