Files
sleepy_agent_ios/SleepyAgent/UI/Views/MessageBubble.swift
T
sleepy bbcf0c74bb Initial iOS port - Complete source code and build system
- 19 Swift source files (~4900 lines)
- Complete UI with SwiftUI (MainView, SettingsView, MessageBubble, InputBar)
- Inference layer (LlmEngine, Agent, ToolCalling, ConversationContext)
- Services (Audio, TTS, WebSearch, ModelDownload, Storage)
- Build system: Makefile, Package.swift, Podfile
- Documentation: BUILD.md, plan.md, PROJECT_STATUS.md
- Ready for Xcode build - just need LiteRT dependency added
2026-04-06 14:26:08 +02:00

365 lines
13 KiB
Swift

import SwiftUI
struct MessageBubble: View {
let message: ChatMessage
@State private var isCopied = false
var body: some View {
HStack {
if message.isUser {
Spacer(minLength: 60)
}
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
// Tool call indicator
if message.isToolCall {
Label("Tool Call", systemImage: "wrench.fill")
.font(.caption)
.foregroundColor(.orange)
.padding(.horizontal, 12)
.padding(.top, 8)
}
// Message content
messageContent
.padding(.horizontal, 12)
.padding(.vertical, message.isToolCall ? 4 : 10)
.background(backgroundColor)
.foregroundColor(foregroundColor)
.cornerRadius(16, corners: cornerRadii)
.contextMenu {
Button(action: copyText) {
Label(isCopied ? "Copied!" : "Copy", systemImage: isCopied ? "checkmark" : "doc.on.doc")
}
if !message.isUser {
Button(action: { /* Regenerate action */ }) {
Label("Regenerate", systemImage: "arrow.clockwise")
}
}
}
// Timestamp
if !message.isStreaming {
Text(formattedTime)
.font(.caption2)
.foregroundColor(.secondary)
.padding(.horizontal, 4)
}
}
if !message.isUser {
Spacer(minLength: 60)
}
}
}
@ViewBuilder
private var messageContent: some View {
if message.isUser {
// Plain text for user messages
Text(message.text)
.font(.body)
.textSelection(.enabled)
} else {
// Markdown for AI messages
MarkdownText(message.text)
.font(.body)
}
}
private var backgroundColor: Color {
if message.isToolCall {
return Color.orange.opacity(0.15)
}
return message.isUser ? Color.accentColor.opacity(0.9) : Color(.systemGray5)
}
private var foregroundColor: Color {
if message.isToolCall {
return .orange
}
return message.isUser ? .white : .primary
}
private var cornerRadii: UIRectCorner {
if message.isUser {
return [.topLeft, .topRight, .bottomLeft]
} else {
return [.topLeft, .topRight, .bottomRight]
}
}
private var formattedTime: String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: message.timestamp)
}
private func copyText() {
UIPasteboard.general.string = message.text
isCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
isCopied = false
}
}
}
// MARK: - Markdown Text View
struct MarkdownText: UIViewRepresentable {
let markdown: String
var font: UIFont = .preferredFont(forTextStyle: .body)
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isSelectable = true
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
// Use NSAttributedString with markdown parsing for iOS 15+
if let attributedString = parseMarkdown(markdown) {
textView.attributedText = attributedString
} else {
textView.text = markdown
}
}
private func parseMarkdown(_ text: String) -> NSAttributedString? {
// Basic markdown parsing using data detectors and attributes
let attributedString = NSMutableAttributedString(string: text)
// Apply base font
let range = NSRange(location: 0, length: attributedString.length)
attributedString.addAttribute(.font, value: font, range: range)
// Parse inline code `code`
parseInlineCode(in: attributedString)
// Parse bold **text**
parseBold(in: attributedString)
// Parse italic *text*
parseItalic(in: attributedString)
// Parse code blocks ```code```
parseCodeBlocks(in: attributedString)
// Parse headers
parseHeaders(in: attributedString)
// Parse links [text](url)
parseLinks(in: attributedString)
return attributedString
}
private func parseInlineCode(in string: NSMutableAttributedString) {
let pattern = "`([^`]+)`"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let codeRange = match.range(at: 1)
let fullRange = match.range
string.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.9, weight: .regular), range: codeRange)
string.addAttribute(.backgroundColor, value: UIColor.systemGray5, range: codeRange)
string.addAttribute(.foregroundColor, value: UIColor.systemRed, range: codeRange)
// Remove backticks
string.replaceCharacters(in: NSRange(location: fullRange.location, length: 1), with: "")
string.replaceCharacters(in: NSRange(location: fullRange.location + codeRange.length, length: 1), with: "")
}
}
private func parseBold(in string: NSMutableAttributedString) {
let pattern = "\\*\\*([^*]+)\\*\\*"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let boldRange = match.range(at: 1)
let fullRange = match.range
string.addAttribute(.font, value: UIFont.systemFont(ofSize: font.pointSize, weight: .bold), range: boldRange)
// Remove **
string.replaceCharacters(in: NSRange(location: fullRange.location, length: 2), with: "")
string.replaceCharacters(in: NSRange(location: fullRange.location + boldRange.length, length: 2), with: "")
}
}
private func parseItalic(in string: NSMutableAttributedString) {
let pattern = "(?<!\\*)\\*([^*]+)\\*(?!\\*)"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let italicRange = match.range(at: 1)
let fullRange = match.range
string.addAttribute(.font, value: UIFont.italicSystemFont(ofSize: font.pointSize), range: italicRange)
// Remove *
string.replaceCharacters(in: NSRange(location: fullRange.location, length: 1), with: "")
string.replaceCharacters(in: NSRange(location: fullRange.location + italicRange.length, length: 1), with: "")
}
}
private func parseCodeBlocks(in string: NSMutableAttributedString) {
let pattern = "```(?:\\w+\\n)?([^`]+)```"
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let codeRange = match.range(at: 1)
let fullRange = match.range
// Style the code block
string.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.85, weight: .regular), range: codeRange)
string.addAttribute(.backgroundColor, value: UIColor.systemGray6, range: codeRange)
// Replace the entire block with just the code
let code = (string.string as NSString).substring(with: codeRange)
string.replaceCharacters(in: fullRange, with: "\n\(code)\n")
}
}
private func parseHeaders(in string: NSMutableAttributedString) {
let pattern = "^(#{1,6})\\s*(.+)$"
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let headerRange = match.range(at: 2)
let hashesRange = match.range(at: 1)
let level = (string.string as NSString).substring(with: hashesRange).count
let fontSize = font.pointSize + CGFloat(6 - level) * 2
string.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: headerRange)
// Remove #
string.replaceCharacters(in: NSRange(location: match.range.location, length: hashesRange.length + 1), with: "")
}
}
private func parseLinks(in string: NSMutableAttributedString) {
let pattern = "\\[([^\\]]+)\\]\\(([^\\)]+)\\)"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let textRange = match.range(at: 1)
let urlRange = match.range(at: 2)
let fullRange = match.range
let urlString = (string.string as NSString).substring(with: urlRange)
if let url = URL(string: urlString) {
string.addAttribute(.link, value: url, range: textRange)
}
string.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: textRange)
string.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: textRange)
// Replace with just the text
let text = (string.string as NSString).substring(with: textRange)
string.replaceCharacters(in: fullRange, with: text)
}
}
}
// MARK: - Rounded Corner Modifier
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}
// MARK: - Chat Message Model
struct ChatMessage: Identifiable {
let id: String
var text: String
let isUser: Bool
let isToolCall: Bool
let timestamp: Date
let isStreaming: Bool
init(
id: String = UUID().uuidString,
text: String,
isUser: Bool,
isToolCall: Bool = false,
timestamp: Date = Date(),
isStreaming: Bool = false
) {
self.id = id
self.text = text
self.isUser = isUser
self.isToolCall = isToolCall
self.timestamp = timestamp
self.isStreaming = isStreaming
}
}
// MARK: - Preview
struct MessageBubble_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 16) {
MessageBubble(message: ChatMessage(
text: "Hello! How can I help you today?",
isUser: false
))
MessageBubble(message: ChatMessage(
text: "I have a question about Swift programming.",
isUser: true
))
MessageBubble(message: ChatMessage(
text: "Here's some `inline code` and **bold text**.",
isUser: false
))
MessageBubble(message: ChatMessage(
text: "Searching the web...",
isUser: false,
isToolCall: true
))
Spacer()
}
.padding()
.previewLayout(.sizeThatFits)
}
}