mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 17:11:13 +00:00
315 lines
11 KiB
Swift
315 lines
11 KiB
Swift
import SwiftMath
|
|
import SwiftUI
|
|
#if os(macOS)
|
|
import AppKit
|
|
#else
|
|
import UIKit
|
|
#endif
|
|
|
|
@MainActor
|
|
struct ChatCodeBlockView: View {
|
|
let block: ChatCodeBlock
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let language = self.block.language {
|
|
Text(language)
|
|
.font(OpenClawChatTypography.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
Text(self.attributedCode)
|
|
.font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote))
|
|
.foregroundStyle(OpenClawChatTheme.assistantText)
|
|
.lineSpacing(2)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(OpenClawChatTheme.subtleCard)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
|
|
}
|
|
|
|
private var attributedCode: AttributedString {
|
|
// Open (still-streaming) fences skip highlighting so each delta stays
|
|
// cheap; completed blocks hit the content-keyed highlight cache.
|
|
guard self.block.isComplete else { return AttributedString(self.block.code) }
|
|
return ChatCodeHighlightCache.highlighted(
|
|
code: self.block.code,
|
|
languageId: self.block.language)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct ChatMathBlockView: View {
|
|
let block: ChatMathBlock
|
|
let textColor: Color
|
|
|
|
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = OpenClawChatTypography.bodySize
|
|
|
|
var body: some View {
|
|
if self.block.isComplete,
|
|
ChatMathParseCache.mathList(latex: self.block.latex) != nil
|
|
{
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
ChatMathPlatformView(
|
|
latex: self.block.latex,
|
|
fontSize: self.fontSize,
|
|
textColor: self.textColor)
|
|
.fixedSize()
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(self.block.latex)
|
|
}
|
|
.defaultScrollAnchor(.center)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
ChatCodeBlockView(block: ChatCodeBlock(
|
|
language: nil,
|
|
code: self.block.latex,
|
|
isComplete: false))
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
@MainActor
|
|
private struct ChatMathPlatformView: NSViewRepresentable {
|
|
let latex: String
|
|
let fontSize: CGFloat
|
|
let textColor: Color
|
|
|
|
func makeNSView(context: Context) -> MTMathUILabel {
|
|
MTMathUILabel()
|
|
}
|
|
|
|
func updateNSView(_ view: MTMathUILabel, context: Context) {
|
|
self.configure(view)
|
|
}
|
|
|
|
private func configure(_ view: MTMathUILabel) {
|
|
view.displayErrorInline = false
|
|
view.labelMode = .display
|
|
view.textAlignment = .center
|
|
view.fontSize = self.fontSize
|
|
view.textColor = NSColor(self.textColor)
|
|
if view.latex != self.latex {
|
|
view.latex = self.latex
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
@MainActor
|
|
private struct ChatMathPlatformView: UIViewRepresentable {
|
|
let latex: String
|
|
let fontSize: CGFloat
|
|
let textColor: Color
|
|
|
|
func makeUIView(context: Context) -> MTMathUILabel {
|
|
MTMathUILabel()
|
|
}
|
|
|
|
func updateUIView(_ view: MTMathUILabel, context: Context) {
|
|
self.configure(view)
|
|
}
|
|
|
|
private func configure(_ view: MTMathUILabel) {
|
|
view.displayErrorInline = false
|
|
view.labelMode = .display
|
|
view.textAlignment = .center
|
|
view.fontSize = self.fontSize
|
|
view.textColor = UIColor(self.textColor)
|
|
if view.latex != self.latex {
|
|
view.latex = self.latex
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
@MainActor
|
|
struct ChatMarkdownTableView: View {
|
|
let table: ChatMarkdownTable
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 7) {
|
|
GridRow {
|
|
ForEach(self.table.header.indices, id: \.self) { column in
|
|
// One cell per column carries the GFM alignment.
|
|
self.cell(self.table.header[column], isHeader: true)
|
|
.gridColumnAlignment(self.columnAlignment(column))
|
|
}
|
|
}
|
|
Divider()
|
|
ForEach(self.table.rows.indices, id: \.self) { rowIndex in
|
|
GridRow {
|
|
ForEach(self.table.rows[rowIndex].indices, id: \.self) { column in
|
|
self.cell(self.table.rows[rowIndex][column], isHeader: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(OpenClawChatTheme.subtleCard)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
|
|
}
|
|
|
|
private func cell(_ text: String, isHeader: Bool) -> some View {
|
|
Text(self.inlineMarkdown(text))
|
|
.font(isHeader ? OpenClawChatTypography.footnoteSemiBold : OpenClawChatTypography.footnote)
|
|
.foregroundStyle(OpenClawChatTheme.assistantText)
|
|
.textSelection(.enabled)
|
|
}
|
|
|
|
private func inlineMarkdown(_ text: String) -> AttributedString {
|
|
let options = AttributedString.MarkdownParsingOptions(
|
|
interpretedSyntax: .inlineOnlyPreservingWhitespace,
|
|
failurePolicy: .returnPartiallyParsedIfPossible)
|
|
return (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text)
|
|
}
|
|
|
|
private func columnAlignment(_ column: Int) -> HorizontalAlignment {
|
|
guard column < self.table.alignments.count else { return .leading }
|
|
switch self.table.alignments[column] {
|
|
case .leading: return .leading
|
|
case .center: return .center
|
|
case .trailing: return .trailing
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct ChatMarkdownListView: View {
|
|
let list: ChatMarkdownList
|
|
let context: ChatMarkdownRenderer.Context
|
|
let variant: ChatMarkdownVariant
|
|
let font: Font
|
|
let textColor: Color
|
|
let inlineMathTypography: ChatMarkdownRenderer.InlineMathTypography
|
|
|
|
var body: some View {
|
|
Grid(alignment: .topLeading, horizontalSpacing: 8, verticalSpacing: 7) {
|
|
ForEach(self.list.items.indices, id: \.self) { index in
|
|
GridRow(alignment: .top) {
|
|
self.marker(for: self.list.items[index], at: index)
|
|
.frame(minWidth: 18, alignment: .trailing)
|
|
.padding(.top, 1)
|
|
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
if self.list.items[index].content.isEmpty {
|
|
ChatMarkdownRenderer.styledText(" ", font: self.font)
|
|
} else {
|
|
ForEach(self.list.items[index].content.indices, id: \.self) { contentIndex in
|
|
self.content(self.list.items[index].content[contentIndex])
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func marker(for item: ChatMarkdownListItem, at index: Int) -> some View {
|
|
let marker = self.list.marker(for: item, at: index)
|
|
HStack(spacing: 4) {
|
|
if let text = marker.text {
|
|
ChatMarkdownRenderer.styledText(text, font: self.font)
|
|
.foregroundStyle(self.textColor)
|
|
.monospacedDigit()
|
|
.accessibilityLabel(self.markerAccessibilityLabel(at: index))
|
|
}
|
|
if let checkbox = marker.checkbox {
|
|
Image(systemName: checkbox == .checked ? "checkmark.square.fill" : "square")
|
|
.font(self.font)
|
|
.foregroundStyle(self.textColor)
|
|
.accessibilityLabel(Text(self.checkboxAccessibilityLabel(checkbox)))
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
|
|
private func checkboxAccessibilityLabel(
|
|
_ checkbox: ChatMarkdownListItem.Checkbox) -> LocalizedStringKey
|
|
{
|
|
switch checkbox {
|
|
case .checked: "Completed"
|
|
case .unchecked: "Pending"
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(_ content: ChatMarkdownListItemContent) -> some View {
|
|
switch content {
|
|
case let .markdown(markdown):
|
|
self.markdownRenderer(markdown)
|
|
case let .code(code):
|
|
ChatCodeBlockView(block: code)
|
|
case let .list(list):
|
|
self.nestedListView(list)
|
|
}
|
|
}
|
|
|
|
func markdownRenderer(_ markdown: String) -> ChatMarkdownRenderer {
|
|
ChatMarkdownRenderer(
|
|
text: markdown,
|
|
context: self.context,
|
|
variant: self.variant,
|
|
font: self.font,
|
|
textColor: self.textColor,
|
|
inlineMathTypography: self.inlineMathTypography)
|
|
}
|
|
|
|
func nestedListView(_ list: ChatMarkdownList) -> ChatMarkdownListView {
|
|
ChatMarkdownListView(
|
|
list: list,
|
|
context: self.context,
|
|
variant: self.variant,
|
|
font: self.font,
|
|
textColor: self.textColor,
|
|
inlineMathTypography: self.inlineMathTypography)
|
|
}
|
|
|
|
private func markerAccessibilityLabel(at index: Int) -> Text {
|
|
switch self.list.kind {
|
|
case .unordered:
|
|
return Text("List item")
|
|
case let .ordered(start):
|
|
let itemNumber = start + UInt(index)
|
|
return Text("Item") + Text(verbatim: " \(itemNumber)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ChatMarkdownListMarker: Equatable {
|
|
let text: String?
|
|
let checkbox: ChatMarkdownListItem.Checkbox?
|
|
}
|
|
|
|
extension ChatMarkdownList {
|
|
func marker(for item: ChatMarkdownListItem, at index: Int) -> ChatMarkdownListMarker {
|
|
let text: String? = switch self.kind {
|
|
case .unordered where item.checkbox != nil:
|
|
nil
|
|
case .unordered:
|
|
"•"
|
|
case let .ordered(start):
|
|
"\(start + UInt(index))."
|
|
}
|
|
return ChatMarkdownListMarker(text: text, checkbox: item.checkbox)
|
|
}
|
|
}
|