bbcf0c74bb
- 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
365 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|