feat(home): 添加首页卡通形象卡片入口 - 新增 onTapCompanion 回调属性用于处理点击卡通形象卡片事件 - 在首页添加 companionCard 视图组件,显示"和康康聊聊"功能入口 - 实现卡通形象卡片样式,包含头像、标题和描述文本 - 集成 HealthChatView 全屏覆盖页面用于健康记录问答功能 - 添加相关国际化字符串支持 BREAKING CHANGE: 新增首页功能模块,改变原有界面布局结构 ```
490 lines
20 KiB
Swift
490 lines
20 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// 「和康康聊聊」——全局健康记录问答(RAG)。
|
|
///
|
|
/// 你问,康康基于你本机的健康记录(指标 / 报告 / 症状 / 日记)检索后作答,100% 端侧。
|
|
/// 复用 `HealthExportService.answer`(检索快照 → dialogueAnswer prompt → 流式作答 + 检索可视化),
|
|
/// 卡通形象 `CompanionAvatarView` 放在顶栏当「对话对象」,情绪随生成状态切换。
|
|
///
|
|
/// 长期记忆:每轮问答落 `ChatTurn`,进场读回最近 10 轮,跨会话续聊(§12#3 本地 RAG 长期记忆)。
|
|
/// 边界:UI 不直接调 AIRuntime(红线 #3),全部经 `HealthExportService`;失败不卡屏(红线 #5)。
|
|
struct HealthChatView: View {
|
|
let onClose: () -> Void
|
|
|
|
@Environment(\.modelContext) private var ctx
|
|
|
|
/// 本场对话(含进场读回的历史 + 本次新问答)。assistant 空文本 = 正在生成的占位轮。
|
|
@State private var turns: [HealthExportDialogueTurn] = []
|
|
/// 每条 assistant 回答对应的「查阅到的记录」摘要,展示在气泡下方做 RAG 可视化。
|
|
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
|
@State private var draft = ""
|
|
/// 正在流式作答的 assistant 轮 id;非 nil = 正在回答(锁输入、形象进入思考/说话态)。
|
|
@State private var answeringTurnID: UUID?
|
|
@State private var rate: Double = 0
|
|
@State private var task: Task<Void, Never>?
|
|
@FocusState private var inputFocused: Bool
|
|
|
|
// MARK: 语音提问(复用 SpeechDictationService,与写日记同一套本机转写)
|
|
@State private var isRecording = false
|
|
/// 必须 @State:struct View 重建时普通 let 会换新实例,导致 stop() 落在没在录音的新服务上。
|
|
@State private var dictation = SpeechDictationService()
|
|
@State private var micDeniedAlert = false
|
|
|
|
private var isAnswering: Bool { answeringTurnID != nil }
|
|
|
|
/// 端侧模型就绪才能问(answer 走本地推理);未就绪时空态给下载指引。
|
|
private var modelReady: Bool { ModelStore.shared.isComplete(for: .llm) }
|
|
|
|
/// 形象情绪:录音→listening;检索/等首 token→thinking;流式吐字→speaking;其余→idle。
|
|
private var mood: CompanionAvatarView.Mood {
|
|
if isRecording { return .listening }
|
|
guard let id = answeringTurnID else { return .idle }
|
|
let streaming = turns.first { $0.id == id }?.text.isEmpty == false
|
|
return streaming ? .speaking : .thinking
|
|
}
|
|
|
|
/// 空态引导:点一下即作为问题发出,降低「不知道能问啥」的启动成本。
|
|
private let starters = [
|
|
"帮我总结一下最近的身体状况",
|
|
"我最近的血压怎么样?",
|
|
"上一次体检有哪些异常?",
|
|
"最近的血糖有变化吗?"
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
transcript
|
|
}
|
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
.safeAreaInset(edge: .bottom) { inputBar }
|
|
.onAppear(perform: loadHistory)
|
|
.onDisappear {
|
|
task?.cancel()
|
|
dictation.abort()
|
|
}
|
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $micDeniedAlert) {
|
|
Button(String(appLoc: "前往设置")) {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
|
} message: {
|
|
Text("语音提问全程在本机完成,声音和文字都不会上传。")
|
|
}
|
|
}
|
|
|
|
// MARK: - 顶栏(卡通形象 + 状态)
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 12) {
|
|
CompanionAvatarView(mood: mood, size: 44)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("康康")
|
|
.font(.tjScaled(15, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Text(headerSubtitle)
|
|
.font(.tjScaled(11))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
Button(action: onClose) {
|
|
Image(systemName: "xmark")
|
|
.font(.tjScaled(14, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
.frame(width: 32, height: 32)
|
|
.background(Circle().fill(Tj.Palette.sand2))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
.background(Tj.Palette.paper)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
|
}
|
|
}
|
|
|
|
private var headerSubtitle: String {
|
|
if isRecording { return String(appLoc: "正在听你说…") }
|
|
if isAnswering {
|
|
return rate > 0
|
|
? String(appLoc: "正在翻你的记录 · \(String(format: "%.1f", rate)) tok/s")
|
|
: String(appLoc: "正在翻你的记录…")
|
|
}
|
|
return String(appLoc: "问问你的健康记录 · 全程在本机")
|
|
}
|
|
|
|
// MARK: - 对话区
|
|
|
|
private var transcript: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
if turns.isEmpty {
|
|
emptyState
|
|
.padding(.top, 28)
|
|
.padding(.horizontal, 20)
|
|
} else {
|
|
LazyVStack(alignment: .leading, spacing: 14) {
|
|
ForEach(turns) { turn in
|
|
bubble(turn)
|
|
}
|
|
Color.clear.frame(height: 1).id(bottomAnchor)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 16)
|
|
}
|
|
}
|
|
// 每吐一个 token / 新增一轮都滚到底,保证最新内容始终可见。
|
|
.onChange(of: turns.last?.text) { _, _ in scrollToBottom(proxy) }
|
|
.onChange(of: turns.count) { _, _ in scrollToBottom(proxy) }
|
|
}
|
|
}
|
|
|
|
private let bottomAnchor = "chat.bottom"
|
|
|
|
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
proxy.scrollTo(bottomAnchor, anchor: .bottom)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func bubble(_ turn: HealthExportDialogueTurn) -> some View {
|
|
if turn.role == .user {
|
|
HStack {
|
|
Spacer(minLength: 40)
|
|
Text(turn.text)
|
|
.font(.tjScaled(14))
|
|
.foregroundStyle(Tj.Palette.paper)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
.fill(Tj.Palette.ink))
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// 空文本 + 正在回答 = 首 token 未到,显示「思考」占位,不给用户空白焦虑。
|
|
if turn.text.isEmpty && answeringTurnID == turn.id {
|
|
thinkingBubble
|
|
} else {
|
|
// 康康的回答按 Markdown 渲染(加粗 / 要点列表 / 异常值高亮),
|
|
// 复用报告页那套流式优化过的 MarkdownView(带内联缓存,逐 token 重渲不卡)。
|
|
MarkdownView(text: turn.text)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
.fill(Tj.Palette.paper))
|
|
.overlay(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
.strokeBorder(Tj.Palette.line, lineWidth: 1))
|
|
}
|
|
|
|
if let summary = turnRetrievals[turn.id], summary.totalCount > 0 {
|
|
retrievalChips(summary)
|
|
}
|
|
}
|
|
.padding(.trailing, 32)
|
|
}
|
|
}
|
|
|
|
private var thinkingBubble: some View {
|
|
HStack(spacing: 5) {
|
|
Text("正在查阅你的记录")
|
|
.font(.tjScaled(13))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
ProgressView().scaleEffect(0.7)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
.fill(Tj.Palette.paper))
|
|
.overlay(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
.strokeBorder(Tj.Palette.line, lineWidth: 1))
|
|
}
|
|
|
|
/// RAG 可视化:这条回答查阅了哪些本机记录(§12#3 端侧不可替代性的现场证据)。
|
|
private func retrievalChips(_ summary: HealthExportService.RetrievalSummary) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("查阅了本机 \(summary.totalCount) 条记录")
|
|
.font(.tjScaled(10, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
FlowChips(items: summary.chips)
|
|
}
|
|
.padding(.leading, 2)
|
|
}
|
|
|
|
// MARK: - 空态(引导起手问题)
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 16) {
|
|
CompanionAvatarView(mood: .idle, size: 110)
|
|
VStack(spacing: 6) {
|
|
Text("我是康康")
|
|
.font(.tjScaled(20, weight: .semibold, design: .serif))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Text(modelReady
|
|
? String(appLoc: "问我关于你健康记录的事,我只看你本机的数据来回答")
|
|
: String(appLoc: "先到「我的 · 模型管理」下载模型,就能和我聊了"))
|
|
.font(.tjScaled(13))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
|
|
if modelReady {
|
|
VStack(spacing: 10) {
|
|
ForEach(starters, id: \.self) { q in
|
|
Button { send(q) } label: {
|
|
HStack {
|
|
Text(q)
|
|
.font(.tjScaled(13, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Spacer()
|
|
Image(systemName: "arrow.up.right")
|
|
.font(.tjScaled(11, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.tjCard(bordered: true)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
// MARK: - 输入栏
|
|
|
|
private var inputBar: some View {
|
|
HStack(spacing: 10) {
|
|
if SpeechDictationService.isAvailable {
|
|
Button(action: toggleMic) {
|
|
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
|
|
.font(.tjScaled(15, weight: .semibold))
|
|
.foregroundStyle(isRecording ? Tj.Palette.paper : Tj.Palette.text3)
|
|
.frame(width: 40, height: 40)
|
|
.background(Circle().fill(isRecording ? Tj.Palette.brick : Tj.Palette.sand2))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isAnswering)
|
|
.opacity(isAnswering ? 0.4 : 1)
|
|
}
|
|
|
|
TextField(String(appLoc: "问问你的健康记录…"), text: $draft, axis: .vertical)
|
|
.font(.tjScaled(14))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
.lineLimit(1...4)
|
|
.focused($inputFocused)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(Capsule().fill(Tj.Palette.sand2))
|
|
.disabled(isRecording)
|
|
|
|
Button { send(draft) } label: {
|
|
Image(systemName: "arrow.up")
|
|
.font(.tjScaled(16, weight: .bold))
|
|
.foregroundStyle(Tj.Palette.paper)
|
|
.frame(width: 40, height: 40)
|
|
.background(Circle().fill(canSend ? Tj.Palette.ink : Tj.Palette.line))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!canSend)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Tj.Palette.paper)
|
|
.overlay(alignment: .top) {
|
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
|
}
|
|
}
|
|
|
|
private var canSend: Bool {
|
|
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
&& !isAnswering && !isRecording && modelReady
|
|
}
|
|
|
|
// MARK: - 发送 / 流式作答(镜像 HealthExportSheet.sendQuestion)
|
|
|
|
private func send(_ raw: String) {
|
|
let question = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !question.isEmpty, !isAnswering, modelReady else { return }
|
|
draft = ""
|
|
inputFocused = false
|
|
|
|
let userTurn = HealthExportDialogueTurn.user(question)
|
|
let assistantTurn = HealthExportDialogueTurn.assistant("")
|
|
turns.append(userTurn)
|
|
turns.append(assistantTurn)
|
|
answeringTurnID = assistantTurn.id
|
|
rate = 0
|
|
|
|
// 只把最近若干轮喂给模型:端侧小模型 context 有限,历史全塞会溢出/变慢。
|
|
let conversationForPrompt = Array(
|
|
turns.filter { $0.id != assistantTurn.id }.suffix(8)
|
|
)
|
|
let stream = HealthExportService.shared.answer(
|
|
question: question,
|
|
conversation: conversationForPrompt,
|
|
in: ctx
|
|
)
|
|
task?.cancel()
|
|
task = Task { @MainActor in
|
|
do {
|
|
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
|
|
persist(question: question, answerID: assistantTurn.id)
|
|
} catch {
|
|
answeringTurnID = nil
|
|
if error is CancellationError { return }
|
|
// 已有部分回答就保留;否则给一句友好兜底,绝不把技术异常当 AI 回答展示(红线 #5)。
|
|
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
|
|
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
turns[idx].text = String(appLoc: "这次没能回答上来,请换个说法再试一次。")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func appendToTurn(id: UUID, text: String) {
|
|
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
|
|
turns[idx].text += text
|
|
}
|
|
|
|
/// 一轮问答落库成 ChatTurn(跨会话记忆)。空回答 / 兜底话术不入库,避免污染历史。
|
|
private func persist(question: String, answerID: UUID) {
|
|
guard let answer = turns.first(where: { $0.id == answerID })?.text
|
|
.trimmingCharacters(in: .whitespacesAndNewlines), !answer.isEmpty else { return }
|
|
let turn = ChatTurn(question: question, answer: answer, decodeRate: rate)
|
|
ctx.insert(turn)
|
|
try? ctx.save()
|
|
}
|
|
|
|
// MARK: - 历史读回
|
|
|
|
/// 进场读回最近 10 轮问答,续上「长期记忆」。只在 onAppear 读一次,新问答只追加,不会重复。
|
|
private func loadHistory() {
|
|
var desc = FetchDescriptor<ChatTurn>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
|
|
desc.fetchLimit = 10
|
|
let recent = (try? ctx.fetch(desc)) ?? []
|
|
guard !recent.isEmpty else { return }
|
|
var loaded: [HealthExportDialogueTurn] = []
|
|
for ct in recent.reversed() {
|
|
loaded.append(.user(ct.question))
|
|
loaded.append(.assistant(ct.answer))
|
|
}
|
|
turns = loaded
|
|
}
|
|
|
|
// MARK: - 语音提问
|
|
|
|
private func toggleMic() {
|
|
if isRecording {
|
|
Task { @MainActor in
|
|
let final = await dictation.stop()
|
|
isRecording = false
|
|
let merged = final.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !merged.isEmpty { draft = merged }
|
|
}
|
|
} else {
|
|
Task { @MainActor in
|
|
guard await dictation.requestAuthorization() else {
|
|
micDeniedAlert = true
|
|
return
|
|
}
|
|
do {
|
|
inputFocused = false
|
|
draft = ""
|
|
try dictation.start { partial in draft = partial }
|
|
isRecording = true
|
|
} catch {
|
|
isRecording = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 检索 chip 的自适应换行排布(简单流式布局,依赖 iOS 16+ Layout)。
|
|
private struct FlowChips: View {
|
|
let items: [String]
|
|
|
|
var body: some View {
|
|
FlowLayout(spacing: 6) {
|
|
ForEach(items, id: \.self) { chip in
|
|
Text(chip)
|
|
.font(.tjScaled(10, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text2)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Capsule().fill(Tj.Palette.sand2))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 极简流式布局:逐行摆放,超出可用宽度就换行。仅用于检索 chip,不追求通用。
|
|
private struct FlowLayout: Layout {
|
|
var spacing: CGFloat = 6
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
|
let maxWidth = proposal.width ?? .infinity
|
|
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
|
|
for sub in subviews {
|
|
let size = sub.sizeThatFits(.unspecified)
|
|
if x + size.width > maxWidth, x > 0 {
|
|
x = 0
|
|
y += rowHeight + spacing
|
|
rowHeight = 0
|
|
}
|
|
x += size.width + spacing
|
|
rowHeight = max(rowHeight, size.height)
|
|
}
|
|
return CGSize(width: maxWidth == .infinity ? x : maxWidth, height: y + rowHeight)
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
|
let maxWidth = bounds.width
|
|
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
|
|
for sub in subviews {
|
|
let size = sub.sizeThatFits(.unspecified)
|
|
if x + size.width > maxWidth, x > 0 {
|
|
x = 0
|
|
y += rowHeight + spacing
|
|
rowHeight = 0
|
|
}
|
|
sub.place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y),
|
|
anchor: .topLeading, proposal: ProposedViewSize(size))
|
|
x += size.width + spacing
|
|
rowHeight = max(rowHeight, size.height)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
HealthChatView(onClose: {})
|
|
}
|