```
feat(home): 添加首页卡通形象卡片入口 - 新增 onTapCompanion 回调属性用于处理点击卡通形象卡片事件 - 在首页添加 companionCard 视图组件,显示"和康康聊聊"功能入口 - 实现卡通形象卡片样式,包含头像、标题和描述文本 - 集成 HealthChatView 全屏覆盖页面用于健康记录问答功能 - 添加相关国际化字符串支持 BREAKING CHANGE: 新增首页功能模块,改变原有界面布局结构 ```
This commit is contained in:
489
康康/Features/Chat/HealthChatView.swift
Normal file
489
康康/Features/Chat/HealthChatView.swift
Normal file
@@ -0,0 +1,489 @@
|
||||
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: {})
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
struct HomeView: View {
|
||||
/// 跳记录页;传 filter 时预选对应分类 chip(报告档案卡传 `.report`,统计磁贴按类别预选)。
|
||||
var onTapArchive: (TimelineKind?) -> Void = { _ in }
|
||||
/// 点首页卡通形象卡片 → 打开「和康康聊聊」全局健康记录问答。
|
||||
var onTapCompanion: () -> Void = {}
|
||||
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
@@ -45,6 +47,9 @@ struct HomeView: View {
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
companionCard
|
||||
.padding(.bottom, 18)
|
||||
|
||||
HomeCalendarCard()
|
||||
.padding(.bottom, 18)
|
||||
|
||||
@@ -152,6 +157,38 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 卡通形象入口(点开「和康康聊聊」全局健康记录问答)
|
||||
|
||||
private var companionCard: some View {
|
||||
Button(action: onTapCompanion) {
|
||||
HStack(spacing: 14) {
|
||||
// 首页直接「调用」会动的卡通形象康康,点一下即进入对话。
|
||||
CompanionAvatarView(mood: .idle, size: 54)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("和康康聊聊")
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("问问你的健康记录 · 全程在本机")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard(bordered: true)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - 数据概览磁贴(2×2,大数字 + 图标,点进对应分类)
|
||||
|
||||
private var overviewSection: some View {
|
||||
|
||||
@@ -3953,6 +3953,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"先到「我的 · 模型管理」下载模型,就能和我聊了" : {
|
||||
|
||||
},
|
||||
"先问清楚,再整理给医生" : {
|
||||
"localizations" : {
|
||||
@@ -9168,6 +9171,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"我是康康" : {
|
||||
|
||||
},
|
||||
"我的" : {
|
||||
"localizations" : {
|
||||
@@ -12900,6 +12906,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"查阅了本机 %lld 条记录" : {
|
||||
|
||||
},
|
||||
"标准" : {
|
||||
"localizations" : {
|
||||
@@ -13364,6 +13373,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"正在听你说…" : {
|
||||
|
||||
},
|
||||
"正在录音 · 声音不上传" : {
|
||||
"localizations" : {
|
||||
@@ -13565,6 +13577,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"正在查阅你的记录" : {
|
||||
|
||||
},
|
||||
"正在根据这些记录回答…" : {
|
||||
"localizations" : {
|
||||
@@ -13609,6 +13624,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"正在翻你的记录 · %@ tok/s" : {
|
||||
|
||||
},
|
||||
"正在翻你的记录…" : {
|
||||
|
||||
},
|
||||
"正在转写录音 · 本地 SenseVoice" : {
|
||||
"localizations" : {
|
||||
@@ -18015,6 +18036,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"语音提问全程在本机完成,声音和文字都不会上传。" : {
|
||||
|
||||
},
|
||||
"语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。" : {
|
||||
"localizations" : {
|
||||
@@ -19771,6 +19795,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"问我关于你健康记录的事,我只看你本机的数据来回答" : {
|
||||
|
||||
},
|
||||
"问诊" : {
|
||||
"localizations" : {
|
||||
@@ -19925,6 +19952,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"问问你的健康记录 · 全程在本机" : {
|
||||
|
||||
},
|
||||
"问问你的健康记录…" : {
|
||||
|
||||
},
|
||||
"隐私优先:健康数据不上传、无需注册账号" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -61,6 +61,8 @@ struct RootView: View {
|
||||
@State private var showMedicationScan = false
|
||||
/// 「记录 · 药品库」:sheet + NavigationStack 形态的药品清单管理页。
|
||||
@State private var showMedicationLibrary = false
|
||||
/// 首页卡通形象入口:「和康康聊聊」全局健康记录问答(RAG)。
|
||||
@State private var showHealthChat = false
|
||||
|
||||
/// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。
|
||||
private func route(_ intent: VoiceIntent) {
|
||||
@@ -90,7 +92,7 @@ struct RootView: View {
|
||||
case .home: HomeView(onTapArchive: { kind in
|
||||
pendingRecordsFilter = kind
|
||||
select(.records)
|
||||
})
|
||||
}, onTapCompanion: { showHealthChat = true })
|
||||
case .records: ArchiveListView(initialFilter: pendingRecordsFilter)
|
||||
case .trend: TrendsView()
|
||||
case .me: MeView()
|
||||
@@ -185,6 +187,9 @@ struct RootView: View {
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showHealthChat) {
|
||||
HealthChatView(onClose: { showHealthChat = false })
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(item: $activeFlow) { flow in
|
||||
switch flow {
|
||||
|
||||
Reference in New Issue
Block a user