Files
kangkang/康康/Features/Chat/HealthChatView.swift
link2026 ef5364d8c3 ```
feat(home): 添加首页卡通形象卡片入口

- 新增 onTapCompanion 回调属性用于处理点击卡通形象卡片事件
- 在首页添加 companionCard 视图组件,显示"和康康聊聊"功能入口
- 实现卡通形象卡片样式,包含头像、标题和描述文本
- 集成 HealthChatView 全屏覆盖页面用于健康记录问答功能
- 添加相关国际化字符串支持

BREAKING CHANGE: 新增首页功能模块,改变原有界面布局结构
```
2026-07-19 19:40:02 +08:00

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;/ tokenthinking;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: {})
}