feat(Ask): 检索过程可视化 — RAG 命中记录以 chips 展示,生成前先看见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
link2026
2026-06-10 06:42:59 +08:00
parent a65c63947b
commit 3f9a2af279
3 changed files with 143 additions and 6 deletions

View File

@@ -20,6 +20,8 @@ struct HealthExportSheet: View {
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@State private var answeringTurnID: UUID?
@State private var retrieval: HealthExportService.RetrievalSummary?
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
@FocusState private var questionFocused: Bool
//
@@ -234,9 +236,14 @@ struct HealthExportSheet: View {
Text(turn.role.transcriptLabel)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
if !isUser, let summary = turnRetrievals[turn.id] {
RetrievalChipsView(summary: summary)
}
if turn.id == answeringTurnID && turn.text.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("正在查看本地记录…")
Text(turnRetrievals[turn.id] == nil
? "正在查看本地记录…"
: "正在根据这些记录回答…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
AIFlowBar()
@@ -298,6 +305,9 @@ struct HealthExportSheet: View {
arrow
phasePill(.generating)
}
if let retrieval {
RetrievalChipsView(summary: retrieval)
}
if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.tjScaled( 11, design: .monospaced))
@@ -477,9 +487,18 @@ struct HealthExportSheet: View {
task?.cancel()
task = Task { @MainActor in
do {
for try await chunk in stream {
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
for try await event in stream {
switch event {
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) {
turnRetrievals[assistantTurn.id] = summary
}
case .token(let chunk):
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .phaseChanged, .completed:
break
}
}
answeringTurnID = nil
questionFocused = true
@@ -512,6 +531,7 @@ struct HealthExportSheet: View {
rate = 0 // , tok/s
error = nil
completed = false
retrieval = nil
phase = .retrieving
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
@@ -522,6 +542,8 @@ struct HealthExportSheet: View {
switch event {
case .phaseChanged(let ph):
phase = ph
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
case .token(let chunk):
content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
@@ -549,6 +571,7 @@ struct HealthExportSheet: View {
rate = 0
completed = false
content = ""
retrieval = nil
}
private func reset() {
@@ -560,6 +583,8 @@ struct HealthExportSheet: View {
error = nil
completed = false
answeringTurnID = nil
retrieval = nil
turnRetrievals = [:]
questionFocused = true
}
@@ -577,6 +602,44 @@ struct HealthExportSheet: View {
}
}
// MARK: - chips( RAG )
/// RAG :N + chips
/// ( embedding) (§12 3)
private struct RetrievalChipsView: View {
let summary: HealthExportService.RetrievalSummary
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if summary.totalCount == 0 {
Text("本地档案中暂无相关记录,将仅按你的描述整理")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
} else {
Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.leaf)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
Text(chip)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
}
}
.padding(.vertical, 1)
}
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// MARK: - Markdown ()
/// Markdown ,

View File

@@ -35,8 +35,56 @@ struct HealthExportService {
}
}
/// RAG UI (§12 3)
struct RetrievalSummary: Sendable, Equatable {
var chips: [String]
var indicatorCount: Int
var reportCount: Int
var symptomCount: Int
var diaryCount: Int
var totalCount: Int { indicatorCount + reportCount + symptomCount + diaryCount }
/// (), cap "+N",
static func groupedChips(_ names: [String], cap: Int = 8) -> [String] {
var order: [String] = []
var counts: [String: Int] = [:]
for n in names {
if counts[n] == nil { order.append(n) }
counts[n, default: 0] += 1
}
var chips = order.map { name -> String in
let c = counts[name] ?? 1
return c > 1 ? "\(name) ×\(c)" : name
}
if chips.count > cap {
let overflow = chips.count - cap
chips = Array(chips.prefix(cap)) + ["+\(overflow)"]
}
return chips
}
@MainActor
static func from(snapshot: Snapshot) -> RetrievalSummary {
var chips = groupedChips(snapshot.indicators.map(\.name), cap: 8)
chips += snapshot.reports.prefix(3).map(\.title)
chips += snapshot.symptoms.prefix(3).map(\.name)
if !snapshot.diaries.isEmpty {
chips.append(String(appLoc: "日记 ×\(snapshot.diaries.count)"))
}
return RetrievalSummary(
chips: chips,
indicatorCount: snapshot.indicators.count,
reportCount: snapshot.reports.count,
symptomCount: snapshot.symptoms.count,
diaryCount: snapshot.diaries.count
)
}
}
enum Event {
case phaseChanged(Phase)
case retrieved(RetrievalSummary)
case token(TokenChunk)
case completed(persistentID: PersistentIdentifier)
// .failed stream throw, Event
@@ -80,6 +128,7 @@ struct HealthExportService {
// Phase 2:
continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
try Task.checkCancellation()
// Phase 3:
@@ -178,9 +227,10 @@ struct HealthExportService {
}
/// ,
/// : .retrieved(), .token ; .phaseChanged / .completed
func answer(question: String,
conversation: [HealthExportDialogueTurn],
in modelContext: ModelContext) -> AsyncThrowingStream<TokenChunk, Error> {
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
@@ -191,6 +241,7 @@ struct HealthExportService {
}
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
let prompt = HealthExportPrompts.dialogueAnswer(
@@ -209,7 +260,7 @@ struct HealthExportService {
if clean.count > displayed.count, clean.hasPrefix(displayed) {
let delta = String(clean.dropFirst(displayed.count))
displayed = clean
continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate))
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
} else if clean != displayed {
displayed = clean
}
@@ -245,6 +296,7 @@ struct HealthExportService {
continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
try Task.checkCancellation()