Add tappable word lookup to chat bubbles
Assistant messages now render each word as a button. Tap shows a sheet with base form, English translation, and part of speech. Dictionary lookup first; falls back to Foundation Models (@Generable ChatWordInfo) for words not in the local dictionary. Results cached per-session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,27 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SharedModels
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
private struct ChatWordInfo {
|
||||||
|
@Guide(description: "Dictionary base form") var baseForm: String
|
||||||
|
@Guide(description: "English translation") var english: String
|
||||||
|
@Guide(description: "Part of speech") var partOfSpeech: String
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
let conversation: Conversation
|
let conversation: Conversation
|
||||||
|
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(DictionaryService.self) private var dictionary
|
||||||
@State private var service = ConversationService()
|
@State private var service = ConversationService()
|
||||||
@State private var messages: [ChatMessage] = []
|
@State private var messages: [ChatMessage] = []
|
||||||
@State private var inputText = ""
|
@State private var inputText = ""
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var hasStarted = false
|
@State private var hasStarted = false
|
||||||
|
@State private var selectedWord: WordAnnotation?
|
||||||
|
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||||
|
|
||||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
@@ -21,8 +32,10 @@ struct ChatView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 12) {
|
||||||
ForEach(messages) { message in
|
ForEach(messages) { message in
|
||||||
ChatBubble(message: message)
|
ChatBubble(message: message, dictionary: dictionary, lookupCache: $lookupCache) { word in
|
||||||
.id(message.id)
|
selectedWord = word
|
||||||
|
}
|
||||||
|
.id(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if service.isResponding {
|
if service.isResponding {
|
||||||
@@ -68,6 +81,10 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(conversation.scenario)
|
.navigationTitle(conversation.scenario)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(item: $selectedWord) { word in
|
||||||
|
ChatWordDetailSheet(word: word)
|
||||||
|
.presentationDetents([.height(200)])
|
||||||
|
}
|
||||||
.alert("Error", isPresented: .init(
|
.alert("Error", isPresented: .init(
|
||||||
get: { errorMessage != nil },
|
get: { errorMessage != nil },
|
||||||
set: { if !$0 { errorMessage = nil } }
|
set: { if !$0 { errorMessage = nil } }
|
||||||
@@ -121,6 +138,9 @@ struct ChatView: View {
|
|||||||
|
|
||||||
private struct ChatBubble: View {
|
private struct ChatBubble: View {
|
||||||
let message: ChatMessage
|
let message: ChatMessage
|
||||||
|
let dictionary: DictionaryService
|
||||||
|
@Binding var lookupCache: [String: WordAnnotation]
|
||||||
|
let onWordTap: (WordAnnotation) -> Void
|
||||||
|
|
||||||
private var isUser: Bool { message.role == "user" }
|
private var isUser: Bool { message.role == "user" }
|
||||||
|
|
||||||
@@ -129,11 +149,15 @@ private struct ChatBubble: View {
|
|||||||
if isUser { Spacer(minLength: 60) }
|
if isUser { Spacer(minLength: 60) }
|
||||||
|
|
||||||
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
|
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
|
||||||
Text(message.content)
|
if isUser {
|
||||||
.font(.body)
|
Text(message.content)
|
||||||
.padding(.horizontal, 14)
|
.font(.body)
|
||||||
.padding(.vertical, 10)
|
.padding(.horizontal, 14)
|
||||||
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
|
.padding(.vertical, 10)
|
||||||
|
.background(.green.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
} else {
|
||||||
|
tappableBubble
|
||||||
|
}
|
||||||
|
|
||||||
if let correction = message.correction, !correction.isEmpty {
|
if let correction = message.correction, !correction.isEmpty {
|
||||||
Text(correction)
|
Text(correction)
|
||||||
@@ -147,4 +171,179 @@ private struct ChatBubble: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tappableBubble: some View {
|
||||||
|
let words = message.content.components(separatedBy: " ")
|
||||||
|
return ChatFlowLayout(spacing: 0) {
|
||||||
|
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
|
||||||
|
ChatWordButton(word: word, dictionary: dictionary, cache: lookupCache) { annotation in
|
||||||
|
if annotation.english.isEmpty {
|
||||||
|
lookupWordAsync(annotation.word)
|
||||||
|
} else {
|
||||||
|
onWordTap(annotation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lookupWordAsync(_ word: String) {
|
||||||
|
// Try dictionary first
|
||||||
|
if let entry = dictionary.lookup(word) {
|
||||||
|
let annotation = WordAnnotation(word: word, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
|
||||||
|
lookupCache[word] = annotation
|
||||||
|
onWordTap(annotation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading then AI lookup
|
||||||
|
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: ""))
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let session = LanguageModelSession(instructions: "You are a Spanish dictionary. Provide base form, English translation, and part of speech.")
|
||||||
|
let response = try await session.respond(to: "Word: \"\(word)\"", generating: ChatWordInfo.self)
|
||||||
|
let info = response.content
|
||||||
|
let annotation = WordAnnotation(word: word, baseForm: info.baseForm, english: info.english, partOfSpeech: info.partOfSpeech)
|
||||||
|
lookupCache[word] = annotation
|
||||||
|
onWordTap(annotation)
|
||||||
|
} catch {
|
||||||
|
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Word Button
|
||||||
|
|
||||||
|
private struct ChatWordButton: View {
|
||||||
|
let word: String
|
||||||
|
let dictionary: DictionaryService
|
||||||
|
let cache: [String: WordAnnotation]
|
||||||
|
let onTap: (WordAnnotation) -> Void
|
||||||
|
|
||||||
|
private var cleaned: String {
|
||||||
|
word.lowercased()
|
||||||
|
.trimmingCharacters(in: .punctuationCharacters)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var annotation: WordAnnotation? {
|
||||||
|
if let cached = cache[cleaned] { return cached }
|
||||||
|
if let entry = dictionary.lookup(cleaned) {
|
||||||
|
return WordAnnotation(word: cleaned, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
onTap(annotation ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: ""))
|
||||||
|
} label: {
|
||||||
|
Text(word + " ")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.underline(annotation != nil, color: .teal.opacity(0.3))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Word Detail Sheet
|
||||||
|
|
||||||
|
private struct ChatWordDetailSheet: View {
|
||||||
|
let word: WordAnnotation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Text(word.word)
|
||||||
|
.font(.title2.bold())
|
||||||
|
Spacer()
|
||||||
|
if !word.partOfSpeech.isEmpty {
|
||||||
|
Text(word.partOfSpeech)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.fill.tertiary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if word.english == "Looking up..." {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Looking up word...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||||
|
HStack {
|
||||||
|
Text("Base form:")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(word.baseForm)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !word.english.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Text("English:")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(word.english)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Flow Layout
|
||||||
|
|
||||||
|
private struct ChatFlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 0
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||||
|
var height: CGFloat = 0
|
||||||
|
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
||||||
|
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||||
|
return CGSize(width: proposal.width ?? 0, height: height)
|
||||||
|
}
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||||
|
var y = bounds.minY; var idx = 0
|
||||||
|
for row in rows {
|
||||||
|
var x = bounds.minX; let rh = row.map { $0.height }.max() ?? 0
|
||||||
|
for size in row {
|
||||||
|
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width; idx += 1
|
||||||
|
}
|
||||||
|
y += rh + spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||||
|
let mw = proposal.width ?? .infinity; var rows: [[CGSize]] = [[]]; var cw: CGFloat = 0
|
||||||
|
for sv in subviews {
|
||||||
|
let s = sv.sizeThatFits(.unspecified)
|
||||||
|
if cw + s.width > mw && !rows[rows.count - 1].isEmpty { rows.append([]); cw = 0 }
|
||||||
|
rows[rows.count - 1].append(s); cw += s.width
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user