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:
Trey t
2026-04-19 15:22:59 -05:00
parent f809bc2a1d
commit c73762ab9f

View File

@@ -1,16 +1,27 @@
import SwiftUI
import SharedModels
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 {
let conversation: Conversation
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(DictionaryService.self) private var dictionary
@State private var service = ConversationService()
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var errorMessage: String?
@State private var hasStarted = false
@State private var selectedWord: WordAnnotation?
@State private var lookupCache: [String: WordAnnotation] = [:]
private var cloudContext: ModelContext { cloudModelContextProvider() }
@@ -21,8 +32,10 @@ struct ChatView: View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(messages) { message in
ChatBubble(message: message)
.id(message.id)
ChatBubble(message: message, dictionary: dictionary, lookupCache: $lookupCache) { word in
selectedWord = word
}
.id(message.id)
}
if service.isResponding {
@@ -68,6 +81,10 @@ struct ChatView: View {
}
.navigationTitle(conversation.scenario)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
ChatWordDetailSheet(word: word)
.presentationDetents([.height(200)])
}
.alert("Error", isPresented: .init(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
@@ -121,6 +138,9 @@ struct ChatView: View {
private struct ChatBubble: View {
let message: ChatMessage
let dictionary: DictionaryService
@Binding var lookupCache: [String: WordAnnotation]
let onWordTap: (WordAnnotation) -> Void
private var isUser: Bool { message.role == "user" }
@@ -129,11 +149,15 @@ private struct ChatBubble: View {
if isUser { Spacer(minLength: 60) }
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
if isUser {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.green.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
} else {
tappableBubble
}
if let correction = message.correction, !correction.isEmpty {
Text(correction)
@@ -147,4 +171,179 @@ private struct ChatBubble: View {
}
.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
}
}