Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//

import Foundation
import SwiftStreamingMarkdown

final class LLMChatInteractor: ObservableObject {
Comment thread
junyan72 marked this conversation as resolved.
/// Shared render config, also handed to `DocumentView` so on-screen styling
/// matches how each `RenderableDocument` was parsed.
let markdownConfig = MarkdownRenderConfig.default
.withShouldAnimateText(value: true)
.withImageConfig(ImageConfig(
enabled: true,
allowedImageTypes: [.assetCatalog, .bundledResource]
))

private let parser = MarkdownParserImpl()
private var nextResponseIndex = 0

/// Stream the greeting once, when the transcript is still empty.
func loadGreetingIfNeeded(into viewModel: LLMChatViewModel) async {
guard await viewModel.messages.isEmpty else { return }
await streamAssistantReply(markdown: Self.greeting, into: viewModel)
}

/// Send the current draft as a user message and stream a rotating reply.
func send(into viewModel: LLMChatViewModel) async {
guard let text = await viewModel.consumeDraft() else { return }
await viewModel.appendUserMessage(text)

let response = Self.mockResponses[nextResponseIndex]
nextResponseIndex = (nextResponseIndex + 1) % Self.mockResponses.count
await streamAssistantReply(markdown: response, into: viewModel)
}
Comment thread
junyan72 marked this conversation as resolved.

/// Simulate streaming by parsing progressively larger prefixes of `markdown`
/// and updating the same assistant bubble in place as its content grows.
private func streamAssistantReply(markdown: String, into viewModel: LLMChatViewModel) async {
let messageID = await viewModel.appendAssistantMessage(.empty)

let chunkSize = 3
var endIndex = markdown.startIndex

while endIndex < markdown.endIndex {
if Task.isCancelled { return }

endIndex = markdown.index(
endIndex,
offsetBy: chunkSize,
limitedBy: markdown.endIndex
) ?? markdown.endIndex

let snapshot = String(markdown[..<endIndex])
let document = await parser.parse(text: snapshot, config: markdownConfig)
await viewModel.updateAssistantMessage(id: messageID, document: document)

if endIndex == markdown.endIndex { break }
try? await Task.sleep(nanoseconds: 30_000_000)
}
}

private static let greeting =
"Hi! Ask me anything to see Markdown responses with rich content."

private static let mockResponses = [
"""
SwiftStreamingMarkdown is designed to render Markdown incrementally as an LLM response arrives. It supports headings, lists, tables, citations, code blocks, math, and more.

You can learn more in the [project documentation](https://github.com/microsoft/SwiftStreamingMarkdown?citationMarker=9F742443&citationTitle=SwiftStreamingMarkdown&citationA11yValue=SwiftStreamingMarkdown%20GitHub%20repository&citationId=chat-doc-1&chatItemId=llm-chat).
""",
"""
Here is an image loaded from the sample app's asset catalog:

![A mountain lake surrounded by trees](assets://Images/mountain-lake)
""",
"""
A pre-parsed Markdown view only needs a few lines:

```swift
import SwiftStreamingMarkdown
import SwiftUI

struct ResponseView: View {
let document: RenderableDocument

var body: some View {
DocumentView(renderableDocument: document)
}
}
```
""",
"""
Here is a quick feature comparison:

| Content | Supported |
| --- | --- |
| Text styles | Yes |
| Code blocks | Yes |
| Citations | Yes |
| Images | Yes |
""",
"""
You can structure an answer with several Markdown elements:

1. **Summarize** the request.
2. Provide concise implementation details.
3. Highlight identifiers such as `DocumentView`.

> Mock responses rotate each time you send a message.
"""
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//

import SwiftStreamingMarkdown
import SwiftUI

struct LLMChatView: View {
@StateObject private var viewModel = LLMChatViewModel()
@StateObject private var interactor = LLMChatInteractor()

#if os(macOS)
private let maxChatWidth: CGFloat = 640
#else
private let maxChatWidth: CGFloat = .infinity
#endif

var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 16) {
ForEach(viewModel.messages) { message in
ChatMessageRow(message: message, config: interactor.markdownConfig)
.id(message.id)
}
}
.padding()
.frame(maxWidth: maxChatWidth)
.frame(maxWidth: .infinity)
}
.defaultScrollAnchor(.bottom)
.onChange(of: viewModel.messages.count) {
guard let lastMessage = viewModel.messages.last else { return }
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
Comment thread
junyan72 marked this conversation as resolved.
}
.background(Color.systemBackground)
.safeAreaInset(edge: .bottom) {
messageComposer
}
.task {
await interactor.loadGreetingIfNeeded(into: viewModel)
}
.navigationTitle("LLM Chat")
#if canImport(UIKit)
.navigationBarTitleDisplayMode(.inline)
#endif
}

private var messageComposer: some View {
HStack(alignment: .bottom, spacing: 12) {
TextField("Message", text: $viewModel.draft, axis: .vertical)
.lineLimit(1...5)
.textFieldStyle(.plain)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.12), in: .rect(cornerRadius: 20))
.onSubmit(send)

Button(action: send) {
Image(systemName: "arrow.up")
.font(.headline)
.foregroundStyle(.white)
.frame(width: 38, height: 38)
.background(.blue, in: .circle)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSend)
.opacity(viewModel.canSend ? 1 : 0.4)
.accessibilityLabel("Send message")
}
.padding(.horizontal)
.padding(.vertical, 10)
.frame(maxWidth: maxChatWidth)
.frame(maxWidth: .infinity)
.background(.bar)
}

private func send() {
Task { await interactor.send(into: viewModel) }
}
}

private struct ChatMessageRow: View {
let message: ChatMessage
let config: MarkdownRenderConfig

var body: some View {
HStack {
if case .user = message.content {
Spacer(minLength: 48)
}

messageContent

if case .assistant = message.content {
Spacer(minLength: 48)
}
}
}

@ViewBuilder
private var messageContent: some View {
switch message.content {
case .user(let text):
Text(text)
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.blue, in: .rect(cornerRadius: 18))
case .assistant(let document):
DocumentView(renderableDocument: document, config: config)
.padding(14)
.background(Color.secondary.opacity(0.12), in: .rect(cornerRadius: 18))
.frame(maxWidth: 560, alignment: .leading)
}
}
}

#Preview {
NavigationStack {
LLMChatView()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//

import Foundation
import SwiftStreamingMarkdown

Comment thread
junyan72 marked this conversation as resolved.
/// Presentation state for `LLMChatView`: the chat transcript and draft input.
/// All send/streaming logic lives in `LLMChatInteractor`; this type only holds
/// state and exposes main-actor mutations the interactor drives.
@MainActor
final class LLMChatViewModel: ObservableObject {
@Published private(set) var messages: [ChatMessage] = []
@Published var draft = ""

var canSend: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

/// Return the trimmed draft and clear the input, or `nil` if it is blank.
func consumeDraft() -> String? {
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
draft = ""
return text
}

func appendUserMessage(_ text: String) {
messages.append(ChatMessage(content: .user(text)))
}

/// Append an assistant bubble and return its id so streaming updates can
/// target the same message as its content grows.
func appendAssistantMessage(_ document: RenderableDocument) -> UUID {
let message = ChatMessage(content: .assistant(document))
messages.append(message)
return message.id
}

func updateAssistantMessage(id: UUID, document: RenderableDocument) {
guard let index = messages.firstIndex(where: { $0.id == id }) else { return }
messages[index].content = .assistant(document)
}
}

struct ChatMessage: Identifiable {
enum Content {
case user(String)
case assistant(RenderableDocument)
}

let id = UUID()
var content: Content
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,32 @@ struct NavigationView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(Demonstration.allCases) { demo in
NavigationLink(value: demo) {
VStack(alignment: .leading, spacing: 4) {
Text(demo.rawValue)
.font(.headline)
Text(demo.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
List {
Section("Featured") {
NavigationLink {
LLMChatView()
} label: {
VStack(alignment: .leading, spacing: 4) {
Text("LLM Chat")
.font(.headline)
Text("Interactive chat with rich mock Markdown responses")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}

Section("Demonstrations") {
ForEach(Demonstration.allCases) { demo in
NavigationLink(value: demo) {
VStack(alignment: .leading, spacing: 4) {
Text(demo.rawValue)
.font(.headline)
Text(demo.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
}
Expand Down
18 changes: 6 additions & 12 deletions Sources/MarkdownText/UI/BlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,13 @@ struct SingleBlockView: View {
Group {
switch renderable {
case .heading(_, _, let contents):
HStack(spacing: 0) {
ParagraphView(contents: contents)
.transition(.opacity)
.accessibilityAddTraits(.isHeader)
Spacer()
}
ParagraphView(contents: contents)
.transition(.opacity)
.accessibilityAddTraits(.isHeader)
case .paragraph(_, let contents):
HStack(spacing: 0) {
ParagraphView(contents: contents, lineSpacing: 5)
.fixedSize(horizontal: false, vertical: true)
.transition(.opacity)
Spacer()
}
ParagraphView(contents: contents, lineSpacing: 5)
.fixedSize(horizontal: false, vertical: true)
.transition(.opacity)
case .latex(_, let latexString):
ScrollView(.horizontal) {
HStack(spacing: 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public struct CanvasView<Content: View>: View {
}

public var body: some View {
VStack {
VStack(alignment: .leading) {
content()
Spacer()
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading