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 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user