根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:

```
chore(project): 更新项目配置文件

移除未使用的依赖项并优化构建配置,
提升项目整体性能和可维护性。
```
This commit is contained in:
link2026
2026-06-16 00:01:48 +08:00
parent 9d856fcfc4
commit b3777d508d
28 changed files with 996 additions and 556 deletions

View File

@@ -165,6 +165,12 @@ private:
TokenStreamBuf buf(onToken, &_cancel); TokenStreamBuf buf(onToken, &_cancel);
std::ostream os(&buf); std::ostream os(&buf);
if (_llm) { if (_llm) {
// 红线:本 App 每次 generate/analyze 都是一次性独立推理(无多轮对话语义)。
// MNN 的 Llm::response 默认把本轮 prompt+输出累积进 history_tokens / KV cache,
// 不 reset 的话第二次导出会把上一次的完整上下文叠加进来 → all_seq_len 暴涨、
// 冲过上下文上限 → 崩溃(用户报「再次导出死机」)。每轮先 reset 清空历史,
// 与 MLX LLMSession 的「每次 generate 无状态」保持一致。
_llm->reset();
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens); _llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
} }
buf.flush(); buf.flush();

View File

@@ -156,6 +156,7 @@ enum HealthExportPrompts {
铁律: 铁律:
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。 - 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
- 禁止编造数字、日期、症状、药物、检查结果、诊断。 - 禁止编造数字、日期、症状、药物、检查结果、诊断。
- 日期一律照搬【本地健康记录】JSON 里的完整 `date` 字段(格式 yyyy-MM-dd,即年-月-日);严禁只写年份或省略月、日。多轮对话里若把日期说得不全,一律以 JSON 的完整日期为准。
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。 - 禁止给诊断意见、用药建议、剂量建议或急诊判断。
- JSON 里没有的信息,对应小节写「无记录」。 - JSON 里没有的信息,对应小节写「无记录」。
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。 - 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
@@ -163,6 +164,7 @@ enum HealthExportPrompts {
输出要求: 输出要求:
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。 - 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
- 中文,简洁,医生 30 秒能扫完。 - 中文,简洁,医生 30 秒能扫完。
- 「相关健康日记」每条单独一行,格式为「2026-05-01:正文摘要」,日期照抄 JSON 的 date 字段,精确到日。
- 严格按以下段落: - 严格按以下段落:
# 就诊摘要 # 就诊摘要
## 本次想解决的问题 ## 本次想解决的问题

View File

@@ -14,8 +14,8 @@ nonisolated enum IntentPrompts {
分类(只能选下面其中一个): 分类(只能选下面其中一个):
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态 - "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
- "medication"录用药、拍药盒、吃了什么药 - "medication"一次用药/服药、吃了什么药、拍药盒(凡涉及「吃药/服药/用药」都归这里)
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…) - "symptom" 记录身体症状,哪里不舒服(头疼、咳嗽、发烧、头晕…),与吃药无关
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…) - "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
- "archive" 归档整份体检报告/化验单(拍报告存档) - "archive" 归档整份体检报告/化验单(拍报告存档)
- "export" 生成给医生看的身体档案/健康总结 - "export" 生成给医生看的身体档案/健康总结
@@ -24,15 +24,30 @@ nonisolated enum IntentPrompts {
规则: 规则:
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。 - 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom" - 凡是「记录/记一次用药、服药、吃药、吃了药」→ "medication",哪怕没说具体药名
- 「记录/记一次」+ 动作时,先看这个动作是什么(吃药→medication、量血压→indicator、
哪里疼→symptom),不要因为出现「记录」二字就归类成 symptom。
- 明确说出具体身体症状(头疼、咳嗽、发烧、头晕、拉肚子…)才算 "symptom";
与吃药/用药无关。只是泛泛说今天的状态、心情、饮食、睡眠、累不累、舒不舒服 → "diary"
- 既像日记又提到具体数值时,以数值为准 → "indicator" - 既像日记又提到具体数值时,以数值为准 → "indicator"
- 含否定或「忘了/没顾上」的吃药(「没吃药」「忘了吃药」「不用吃药」)不是记录用药 → "diary"
- 只有明确要「拍下/存档这份报告或化验单」时才算 "archive";只是顺口提到体检或报告
(「下周去体检」「医生说报告没问题」)不要归 archive,按日记或提醒处理。
- 拿不准、又不明确属于其它类别时,默认 "diary"(日记是最常见、最自由的入口)。
尤其 "medication""archive" 会直接打开相机,把握不大时宁可归 "diary",不要误开相机。
示例: 示例:
",12885" → {"intent":"indicator"} ",12885" → {"intent":"indicator"}
"," → {"intent":"symptom"} "," → {"intent":"symptom"}
"" → {"intent":"medication"}
"" → {"intent":"medication"}
"," → {"intent":"diary"}
"," → {"intent":"medication"} "," → {"intent":"medication"}
"," → {"intent":"diary"} "," → {"intent":"diary"}
"" → {"intent":"archive"} "" → {"intent":"archive"}
"," → {"intent":"diary"}
"" → {"intent":"diary"}
"" → {"intent":"diary"}
"" → {"intent":"reminder"} "" → {"intent":"reminder"}
"" → {"intent":"export"} "" → {"intent":"export"}

View File

@@ -1,42 +1,49 @@
import SwiftUI import SwiftUI
/// Apple Intelligence 线:, /// Apple Intelligence 线:,
/// AppAI ( AI /) /// AppAI ( AI /)
/// ///
/// :线 `Tj.Palette` AI ( /// :线 `Tj.Palette` AI (
/// Apple ),; UI §9 token /// Apple ),; UI §9 token
///
/// `TimelineView(.animation)` `.onAppear` + `repeatForever`:线
/// (tok/s 0.5s ), `repeatForever`
/// / TimelineView ,
/// ,
struct AIFlowBar: View { struct AIFlowBar: View {
var height: CGFloat = 3 var height: CGFloat = 3
/// , /// (),
var cycle: Double = 1.0 var cycle: Double = 0.6
@State private var phase: CGFloat = 0 private static let base: [Color] = [
/// :offset ,
private static let flow: [Color] = {
let base: [Color] = [
Color(red: 0.35, green: 0.47, blue: 0.98), // Color(red: 0.35, green: 0.47, blue: 0.98), //
Color(red: 0.62, green: 0.36, blue: 0.92), // Color(red: 0.62, green: 0.36, blue: 0.92), //
Color(red: 0.96, green: 0.40, blue: 0.62), // Color(red: 0.96, green: 0.40, blue: 0.62), //
Color(red: 1.00, green: 0.55, blue: 0.30), // Color(red: 1.00, green: 0.55, blue: 0.30), //
Color(red: 0.30, green: 0.80, blue: 0.92), // Color(red: 0.30, green: 0.80, blue: 0.92), //
] ]
return base + base
/// ( 11 stop,):,
/// ,
private static let gradient: Gradient = {
let colors = base + base + [base[0]]
let last = CGFloat(colors.count - 1)
return Gradient(stops: colors.enumerated().map { i, c in
Gradient.Stop(color: c, location: CGFloat(i) / last)
})
}() }()
var body: some View { var body: some View {
TimelineView(.animation) { timeline in
GeometryReader { geo in GeometryReader { geo in
let w = geo.size.width let w = geo.size.width
let t = timeline.date.timeIntervalSinceReferenceDate
let progress = CGFloat(t.truncatingRemainder(dividingBy: cycle) / cycle)
Capsule() Capsule()
.fill(LinearGradient(colors: Self.flow, .fill(LinearGradient(gradient: Self.gradient,
startPoint: .leading, endPoint: .trailing)) startPoint: .leading, endPoint: .trailing))
.frame(width: w * 2) .frame(width: w * 2)
.offset(x: phase) .offset(x: -w * progress)
.onAppear {
phase = 0
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
phase = -w
}
} }
} }
.frame(height: height) .frame(height: height)

View File

@@ -20,6 +20,12 @@ enum Tj {
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941) static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059) static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
// 线: / 绿,
// ink 线; brick ,线 +
static let teal = Color(red: 0.337, green: 0.529, blue: 0.494)
static let tealSoft = Color(red: 0.808, green: 0.878, blue: 0.863)
// :, ink ,
static let shadow = Color(red: 0.376, green: 0.345, blue: 0.298)
} }
enum Radius { enum Radius {
@@ -64,7 +70,7 @@ extension View {
RoundedRectangle(cornerRadius: radius, style: .continuous) RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0) .strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
) )
.shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05), .shadow(color: bordered ? .clear : Tj.Palette.shadow.opacity(0.06),
radius: 2, x: 0, y: 1) radius: 2, x: 0, y: 1)
} }
} }

View File

@@ -32,6 +32,12 @@ struct ArchiveListView: View {
@State private var filter: TimelineKind? = nil @State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom? @State private var endingSymptom: Symptom?
/// ; `.report` chip
/// (RootView tab ArchiveListView)
init(initialFilter: TimelineKind? = nil) {
_filter = State(initialValue: initialFilter)
}
@State private var selectedEntry: TimelineEntry? @State private var selectedEntry: TimelineEntry?
@State private var selectedGroup: IndicatorGroup? @State private var selectedGroup: IndicatorGroup?
@State private var route: Route? @State private var route: Route?

View File

@@ -6,6 +6,10 @@ import SwiftData
/// Qwen3 3-4 , /// Qwen3 3-4 ,
/// q LLM ; row + /// q LLM ; row +
struct DiaryQuickSheet: View { struct DiaryQuickSheet: View {
/// : 2×2 ,
/// false
var directWrite: Bool = false
@Environment(\.modelContext) private var ctx @Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -32,11 +36,10 @@ struct DiaryQuickSheet: View {
/// (question.dim), prompt /// (question.dim), prompt
@State private var coveredDims: Set<String> = [] @State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>? @State private var suggestTask: Task<Void, Never>?
/// question id;nil = /// question id questions (
@State private var fillingId: UUID? /// coveredDims,),
/// , = @State private var skippedQuestionIDs: Set<UUID> = []
@State private var fillValues: [String] = [] /// () true,
/// () true,
@State private var exhaustedNote = false @State private var exhaustedNote = false
/// sheet detent large, /// sheet detent large,
/// medium,() /// medium,()
@@ -75,6 +78,40 @@ struct DiaryQuickSheet: View {
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle } private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
private var canSubmit: Bool { hasContent } private var canSubmit: Bool { hasContent }
// MARK: - (care bar)
/// : phase + + ,
/// ,
private enum CareState {
case hidden // / ,
case prompt // ,
case thinking //
case asking(DiaryAssistService.Question) //
case caughtUp(exhausted: Bool) // ;exhausted=西
case failed(String)
}
/// / ()
private var pendingQuestions: [DiaryAssistService.Question] {
questions.filter { !$0.adopted && !skippedQuestionIDs.contains($0.id) }
}
private var currentCareQuestion: DiaryAssistService.Question? { pendingQuestions.first }
private var careState: CareState {
if voicePhase != .idle { return .hidden }
switch phase {
case .loading:
return .thinking
case .failed(let err):
return .failed(err.localizedDescription)
case .idle:
return hasContent ? .prompt : .hidden
case .ready:
if let q = currentCareQuestion { return .asking(q) }
return hasContent ? .caughtUp(exhausted: exhaustedNote) : .hidden
}
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Capsule() Capsule()
@@ -88,7 +125,7 @@ struct DiaryQuickSheet: View {
Text("健康记录") Text("健康记录")
.font(.tjH2()) .font(.tjH2())
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺") Text("记录身体状态 · 康康在一旁帮你想还能记点啥")
.font(.tjScaled( 11)) .font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
} }
@@ -100,31 +137,12 @@ struct DiaryQuickSheet: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 10) .padding(.bottom, 10)
// (2×2):()/ (MedicationLogSheet,+)/ // ( / / / ):
// ()/ (SymptomStartSheet) // ,,
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), //(/)
GridItem(.flexible(), spacing: 10)], spacing: 10) { modeSelector
modeCard(icon: "pencil", title: String(appLoc: "写日记"), .animation(.snappy(duration: 0.22), value: showModeSelector)
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
subtitle: String(appLoc: "记剂量与时间"), active: false) {
showMedicationLog = true
}
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别入药品库"), active: false) {
showMedicationScan = true
}
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
subtitle: String(appLoc: "持续追踪"), active: false) {
showSymptomStart = true
}
}
.padding(.horizontal, 20)
.padding(.bottom, 14)
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -166,6 +184,12 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1) .strokeBorder(Tj.Palette.line, lineWidth: 1)
) )
// :,
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
careBarRow(compact: true)
}
}
if voicePhase != .idle { if voicePhase != .idle {
DiaryVoicePanel( DiaryVoicePanel(
@@ -218,25 +242,11 @@ struct DiaryQuickSheet: View {
.datePickerStyle(.compact) .datePickerStyle(.compact)
.labelsHidden() .labelsHidden()
} }
// , question
Color.clear.frame(height: 1).id("assist-bottom")
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 6) .padding(.bottom, 6)
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onChange(of: questions.count) { old, new in
guard new > old else { return }
// round divider( N ,
// questions)
let roundId = "round-\(questions[old].round)"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.easeOut(duration: 0.25)) {
proxy.scrollTo(roundId, anchor: .top)
}
}
}
}
HStack(spacing: 12) { HStack(spacing: 12) {
Button("取消") { dismiss() } Button("取消") { dismiss() }
@@ -276,6 +286,14 @@ struct DiaryQuickSheet: View {
// sheet:/;() // sheet:/;()
MedicationLogSheet() MedicationLogSheet()
} }
.onAppear {
// :,,
// sheet ,
guard directWrite else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
contentFocused = true
}
}
.onDisappear { .onDisappear {
suggestTask?.cancel() suggestTask?.cancel()
voiceFlowTask?.cancel() voiceFlowTask?.cancel()
@@ -294,180 +312,32 @@ struct DiaryQuickSheet: View {
} }
} }
// MARK: - AI // MARK: - (care bar)
/// :() careState,
/// AI ,
@ViewBuilder @ViewBuilder
private var assistSection: some View { private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
// section header if !contentFocused {
if case .hidden = careState {
EmptyView()
} else {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.tjScaled( 11, weight: .semibold)) .font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick) .foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺")) sectionLabel(String(appLoc: "康康帮你记"))
Spacer() Spacer(minLength: 0)
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if lastRate > 0 { if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate)) Text(String(format: "%.1f tok/s", lastRate))
.font(.tjScaled( 10, design: .monospaced)) .font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf) .foregroundStyle(Tj.Palette.leaf)
} }
} }
careBarRow(compact: false)
// questions (,) .padding(12)
if hasQuestions {
VStack(spacing: 8) {
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
if idx == 0 || questions[idx - 1].round != q.round {
roundDivider(round: q.round,
count: questions.filter { $0.round == q.round }.count)
.id("round-\(q.round)")
}
questionRow(index: roundLocalIndex(at: idx), question: q)
}
}
AIDisclaimerFooter()
}
if exhaustedNote {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.leaf)
Text("已覆盖主要问诊维度;补充原文后可再追问")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
// ()
phaseFooter
}
}
@ViewBuilder
private var phaseFooter: some View {
switch phase {
case .idle:
assistPrimaryButton(
icon: "sparkles",
label: canRequestSuggest
? String(appLoc: "让 AI 帮我想想还能记什么")
: String(appLoc: "先写几个字,AI 来帮忙补充"),
enabled: canRequestSuggest,
prominent: true,
action: requestSuggestions
)
case .loading:
assistLoadingIndicator
case .ready:
assistPrimaryButton(
icon: "arrow.clockwise",
label: canRequestSuggest
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .failed(let err):
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
Button { requestSuggestions() } label: {
Text("重试")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
}
/// `prominent` ( brick + + ,),
/// ( .ready )
private func assistPrimaryButton(icon: String,
label: String,
enabled: Bool,
prominent: Bool = false,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
Text(label)
}
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
.foregroundStyle(prominent
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
.frame(maxWidth: .infinity)
.padding(.vertical, prominent ? 14 : 11)
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
// : contentShape (+)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
@ViewBuilder
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
if prominent {
shape
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
radius: 8, x: 0, y: 3)
} else {
shape
.strokeBorder(
enabled ? Tj.Palette.ink : Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
)
}
}
/// .loading : paper ,(Linear/Vercel )
/// ,;线 + sparkles
private var assistLoadingIndicator: some View {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text(lastRate > 0
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
: String(appLoc: "AI 生成中 · 本地推理"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button("取消") { cancelSuggestions() }
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 11)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity)
.background( .background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper) .fill(Tj.Palette.paper)
@@ -476,122 +346,126 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
) )
.overlay(alignment: .bottom) {
AIFlowBar().padding(.horizontal, 1)
} }
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) }
// AI ,(,)
if !questions.isEmpty {
AIDisclaimerFooter()
}
}
} }
/// questions list idx question, round (1-based) /// `compact = true` ();
private func roundLocalIndex(at idx: Int) -> Int { /// `compact = false` () careState
let target = questions[idx].round @ViewBuilder
var count = 0 private func careBarRow(compact: Bool) -> some View {
for i in 0...idx where questions[i].round == target { switch careState {
count += 1 case .hidden:
} EmptyView()
return count
}
/// N LLM case .prompt:
private func roundDivider(round: Int, count: Int) -> some View { Button(action: requestSuggestions) {
careCapsule(icon: "sparkles",
text: String(appLoc: "让康康帮你把这条记得更全"),
tint: Tj.Palette.brick, style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .thinking:
HStack(spacing: 8) { HStack(spacing: 8) {
HStack(spacing: 6) { Image(systemName: "sparkles")
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath") .font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick) .foregroundStyle(Tj.Palette.brick)
Text(round == 1 .symbolEffect(.pulse, options: .repeating)
? String(appLoc: "第 1 轮 · \(count)") Text(lastRate > 0
: String(appLoc: "\(round) 轮 · 基于你刚才更新的文本 · \(count)")) ? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate)
.font(.tjScaled( 11, weight: .semibold)) : String(appLoc: "康康在想想…"))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
Rectangle()
.fill(Tj.Palette.line)
.frame(height: 1)
.mask(
HStack(spacing: 3) {
ForEach(0..<60, id: \.self) { _ in
Rectangle().frame(width: 3, height: 1)
}
}
)
}
.padding(.top, round == 1 ? 0 : 6)
}
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
let adopted = question.adopted
let filling = fillingId == question.id
return VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Text("\(index).")
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
Text(question.q)
.font(.tjScaled( 13, weight: .medium)) .font(.tjScaled( 13, weight: .medium))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text) .foregroundStyle(Tj.Palette.text2)
.strikethrough(adopted, color: Tj.Palette.text3) Spacer(minLength: 0)
.fixedSize(horizontal: false, vertical: true) Button(action: cancelSuggestions) {
Spacer(minLength: 4) Text("")
if adopted {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.tjScaled( 10, weight: .bold))
Text("已采纳")
.font(.tjScaled( 11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.leaf)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.leafSoft))
} else if !filling {
Button { adopt(question) } label: {
HStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.tjScaled( 12))
Text("采纳")
.font(.tjScaled( 12, weight: .semibold)) .font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
} }
.foregroundStyle(Tj.Palette.paper) .buttonStyle(.plain)
.padding(.horizontal, 10) }
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.ink)) case .asking(let q):
HStack(spacing: 10) {
Image(systemName: "text.bubble.fill")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(q.q)
.font(.tjScaled( compact ? 13 : 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(compact ? 1 : 2)
.fixedSize(horizontal: false, vertical: !compact)
Spacer(minLength: 6)
Button { skipCurrent(q) } label: {
Text("跳过")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
Button { recordCurrent(q) } label: {
careCapsule(icon: "plus", text: String(appLoc: "记一下"),
tint: Tj.Palette.ink, style: .filled, compact: compact)
}
.buttonStyle(.plain)
}
case .caughtUp(let exhausted):
Button(action: requestSuggestions) {
careCapsule(
icon: exhausted ? "checkmark.seal.fill" : "sparkles",
text: exhausted
? String(appLoc: "主要的都帮你问到啦 · 再想想?")
: String(appLoc: "还想到几个想问你 · 再来一轮"),
tint: exhausted ? Tj.Palette.leaf : Tj.Palette.brick,
style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .failed(let msg):
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.brick)
Text(msg)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Spacer(minLength: 0)
Button(action: requestSuggestions) {
Text("重试")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
if filling {
QuestionFillPanel(
template: question.fill,
values: $fillValues,
onCommit: { assembled in commitAdoption(question, text: assembled) },
onCancel: { closeFill() }
)
} else if !question.fill.isEmpty && !adopted {
HStack(alignment: .top, spacing: 4) {
Text("将追加:")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Text(question.fill)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
} }
.padding(.leading, 22)
private enum CareCapsuleStyle { case filled, soft }
/// filled = ();soft = ()
private func careCapsule(icon: String, text: String, tint: Color,
style: CareCapsuleStyle, compact: Bool) -> some View {
HStack(spacing: 5) {
Image(systemName: icon)
.font(.tjScaled( compact ? 11 : 12, weight: .semibold))
Text(text)
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.lineLimit(1)
} }
} .foregroundStyle(style == .filled ? Tj.Palette.paper : tint)
.padding(10) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 7)
.background( .background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12)))
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .contentShape(Capsule())
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
} }
// MARK: - Actions // MARK: - Actions
@@ -603,6 +477,41 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text2) .foregroundStyle(Tj.Palette.text2)
} }
/// + +
/// / ,,
private var showModeSelector: Bool {
!directWrite && !contentFocused && !hasContent
}
/// (2×2):()/ (+)/ ()/
@ViewBuilder
private var modeSelector: some View {
if showModeSelector {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
GridItem(.flexible(), spacing: 10)], spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
subtitle: String(appLoc: "记剂量与时间"), active: false) {
showMedicationLog = true
}
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别入药品库"), active: false) {
showMedicationScan = true
}
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
subtitle: String(appLoc: "持续追踪"), active: false) {
showSymptomStart = true
}
}
.padding(.horizontal, 20)
.padding(.bottom, 14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
/// ( / / )active /// ( / / )active
/// : iPhone /// : iPhone
private func modeCard(icon: String, title: String, subtitle: String, private func modeCard(icon: String, title: String, subtitle: String,
@@ -734,18 +643,12 @@ struct DiaryQuickSheet: View {
/// AI (coveredDims) LLM, /// AI (coveredDims) LLM,
/// , /// ,
/// ****:,
/// ,
private func requestSuggestions() { private func requestSuggestions() {
suggestTask?.cancel() suggestTask?.cancel()
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims) let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
exhaustedNote = false exhaustedNote = false
phase = .loading phase = .loading
suggestTask = Task { @MainActor in suggestTask = Task { @MainActor in
@@ -819,38 +722,25 @@ struct DiaryQuickSheet: View {
phase = hasQuestions ? .ready : .idle phase = hasQuestions ? .ready : .idle
} }
/// : `[]` ;( adopted) /// :,,
/// q ; coveredDims, prompt /// `assemble(values: [])` 退
private func adopt(_ question: DiaryAssistService.Question) { /// 便, `[]`
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else { private func recordCurrent(_ question: DiaryAssistService.Question) {
// :( fill 退) let stub = question.fill.isEmpty
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill) ? question.q
return : DiaryFillTemplate.assemble(question.fill, values: [])
} appendToContent(stub)
withAnimation(.snappy(duration: 0.18)) {
fillingId = question.id
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
}
}
/// ()
private func closeFill() {
withAnimation(.snappy(duration: 0.18)) {
fillingId = nil
fillValues = []
}
}
/// :(), adopted,
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
if let idx = questions.firstIndex(where: { $0.id == question.id }) { if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true questions[idx].adopted = true
} }
// :,
contentFocused = true
} }
appendToContent(text)
fillingId = nil /// :, coveredDims,
fillValues = [] /// prompt , questions
private func skipCurrent(_ question: DiaryAssistService.Question) {
skippedQuestionIDs.insert(question.id)
} }
/// (,) /// (,)

View File

@@ -20,6 +20,11 @@ struct MedicationLogSheet: View {
@State private var dosage = "" @State private var dosage = ""
@State private var takenAt: Date = .now @State private var takenAt: Date = .now
/// () nil =
init(preselected: Medication? = nil) {
_selectedMed = State(initialValue: preselected)
}
private var resolvedName: String { private var resolvedName: String {
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines) (selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
} }

View File

@@ -2,7 +2,8 @@ import SwiftUI
import SwiftData import SwiftData
struct HomeView: View { struct HomeView: View {
var onTapArchive: () -> Void = {} /// ; filter chip( `.report`,)
var onTapArchive: (TimelineKind?) -> Void = { _ in }
@Query(sort: \Indicator.capturedAt, order: .reverse) @Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator] private var indicators: [Indicator]
@@ -16,21 +17,27 @@ struct HomeView: View {
@Query(sort: \Symptom.startedAt, order: .reverse) @Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom] private var symptoms: [Symptom]
/// sheet( C1 ) @Query private var profiles: [UserProfile]
@State private var selectedEntry: TimelineEntry? @Query private var customMetrics: [CustomMonitorMetric]
/// ( + , C1 )
/// ( + , C1 )
@State private var selectedGroup: IndicatorGroup? @State private var selectedGroup: IndicatorGroup?
private var profile: UserProfile? { profiles.first }
/// 3 :,
@MainActor @MainActor
private var recentEntries: [TimelineEntry] { private var featuredBuckets: [SeriesBucket] {
let all = let all = SeriesBucket.build(from: indicators,
TimelineEntry.aggregatedIndicators(indicators) + profile: profile,
reports.map(TimelineEntry.from(report:)) + customMetrics: customMetrics)
diaries.map(TimelineEntry.from(diary:)) + let monitor = all.filter { $0.kind == .monitor }
symptoms.map(TimelineEntry.from(symptom:)) let lab = all.filter { $0.kind == .lab }
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 } return Array((monitor + lab).prefix(3))
} }
private var ongoingSymptomCount: Int { symptoms.filter { $0.endedAt == nil }.count }
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -39,49 +46,65 @@ struct HomeView: View {
.padding(.bottom, 18) .padding(.bottom, 18)
HomeCalendarCard() HomeCalendarCard()
.padding(.bottom, 18)
overviewSection
.padding(.bottom, 18)
let buckets = featuredBuckets
if !buckets.isEmpty {
trendsSection(buckets)
.padding(.bottom, 18)
}
TodayRemindersCard() TodayRemindersCard()
OngoingSymptomsCard() OngoingSymptomsCard()
.padding(.bottom, 18) .padding(.bottom, 18)
recentSection
.padding(.bottom, 22)
archiveSection archiveSection
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 20) .padding(.bottom, 20)
} }
.background(Tj.Palette.sand.ignoresSafeArea()) .background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $selectedEntry) { entry in
if let d = TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) {
TimelineEntryDetailView(detail: d)
}
}
.sheet(item: $selectedGroup) { group in .sheet(item: $selectedGroup) { group in
IndicatorSeriesDetailView(group: group) IndicatorSeriesDetailView(group: group)
} }
} }
// MARK: -
private var greeting: some View { private var greeting: some View {
HStack(alignment: .top) { let t = TimeOfDay.current
VStack(alignment: .leading, spacing: 4) { return HStack(alignment: .center, spacing: 14) {
// : + (//),
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: t.icon)
.font(.tjScaled( 22))
.foregroundStyle(Tj.Palette.amber)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 2) {
Text(todayLine) Text(todayLine)
.font(.tjScaled( 12)) .font(.tjScaled( 11))
.tracking(1) .tracking(1)
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
Text(greetingWord) // 线,
.font(.tjTitle()) Text(t.word)
.font(.tjScaled( 28, weight: .semibold, design: .serif))
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Text(t.subtitle)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
} }
Spacer()
Spacer(minLength: 8)
TjLockChip() TjLockChip()
.padding(.top, 4) .padding(.top, 2)
} }
} }
@@ -92,84 +115,137 @@ struct HomeView: View {
return "\(day) · \(weekday)" return "\(day) · \(weekday)"
} }
private var greetingWord: String { /// :,
private enum TimeOfDay {
case morning, afternoon, evening
static var current: TimeOfDay {
switch Calendar.current.component(.hour, from: Date()) { switch Calendar.current.component(.hour, from: Date()) {
case 5..<12: return String(appLoc: "早安") case 5..<12: return .morning
case 12..<18: return String(appLoc: "下午好") case 12..<18: return .afternoon
default: return String(appLoc: "晚上好") default: return .evening
} }
} }
private var recentSection: some View { var word: String {
// ( O(m²)) body ,, .isEmpty switch self {
let entries = recentEntries case .morning: return String(appLoc: "早安")
let groups = TimelineGrouping.group(entries) case .afternoon: return String(appLoc: "下午好")
return VStack(alignment: .leading, spacing: 10) { case .evening: return String(appLoc: "晚上好")
HStack(alignment: .lastTextBaseline) { }
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text) }
Spacer()
Button(action: onTapArchive) { var subtitle: String {
Text("全部 ") switch self {
.font(.tjScaled( 12)) case .morning: return String(appLoc: "新的一天,慢慢来")
case .afternoon: return String(appLoc: "记得起身活动一下")
case .evening: return String(appLoc: "夜深了,记得早点休息")
}
}
var icon: String {
switch self {
case .morning: return "sun.max.fill"
case .afternoon: return "sun.haze.fill"
case .evening: return "moon.stars.fill"
}
}
}
// MARK: - (2×2, + ,)
private var overviewSection: some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)], spacing: 12) {
statTile(icon: "doc.fill", value: reports.count,
label: String(appLoc: "报告"), tint: Tj.Palette.ink) {
onTapArchive(.report)
}
statTile(icon: "drop.fill", value: indicators.count,
label: String(appLoc: "指标"), tint: Tj.Palette.brick) {
onTapArchive(.indicator)
}
statTile(icon: "pencil", value: diaries.count,
label: String(appLoc: "日记"), tint: Tj.Palette.leaf) {
onTapArchive(.diary)
}
statTile(icon: "waveform.path.ecg", value: symptoms.count,
label: ongoingSymptomCount > 0
? String(appLoc: "症状 · \(ongoingSymptomCount) 进行中")
: String(appLoc: "症状"),
tint: Tj.Palette.amber) {
onTapArchive(.symptom)
}
}
}
private func statTile(icon: String, value: Int, label: String,
tint: Color, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 12) {
ZStack {
Circle().fill(tint.opacity(0.15))
Image(systemName: icon)
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(tint)
}
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 1) {
Text("\(value)")
.font(.tjScaled( 22, weight: .bold, design: .rounded))
.foregroundStyle(Tj.Palette.text)
Text(label)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity)
.tjCard()
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
if entries.isEmpty { // MARK: - (线, TrendRow)
emptyRecent
} else { private func trendsSection(_ buckets: [SeriesBucket]) -> some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 10) {
ForEach(groups, id: \.section) { group in Text("健康趋势")
VStack(alignment: .leading, spacing: 8) { .font(.tjH2())
Text(group.section.label) .foregroundStyle(Tj.Palette.text)
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5) VStack(spacing: 12) {
.foregroundStyle(Tj.Palette.text3) ForEach(buckets) { bucket in
VStack(spacing: 10) {
ForEach(group.items) { entry in
Button { Button {
// ( + ); C1 selectedGroup = group(for: bucket)
guard let d = TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) else { return }
switch d {
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
default: selectedEntry = entry
}
} label: { } label: {
TimelineRow(entry: entry) TrendRow(bucket: bucket)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
} }
} }
}
} /// SeriesBucket IndicatorGroup()
} private func group(for bucket: SeriesBucket) -> IndicatorGroup {
if bucket.id == "bp" { return .bloodPressure }
if bucket.id.hasPrefix("lab:") { return .lab(key: String(bucket.id.dropFirst(4))) }
return .series(key: bucket.id)
} }
private var emptyRecent: some View { // MARK: -
HStack {
Text("还没有任何记录,点底部 + 号开始第一条")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.vertical, 14)
.padding(.horizontal, 16)
.tjCard(bordered: true)
}
private var archiveSection: some View { private var archiveSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text) Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Button(action: onTapArchive) { Button { onTapArchive(.report) } label: {
HStack(spacing: 14) { HStack(spacing: 14) {
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)")) TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
.frame(width: 56, height: 56) .frame(width: 56, height: 56)

View File

@@ -95,8 +95,7 @@ struct TodayRemindersCard: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper) .fill(Tj.Palette.paper)
) )
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04), .shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
radius: 2, x: 0, y: 1)
} }
} }

View File

@@ -4,11 +4,18 @@ import SwiftUI
/// ; AI (prepare/generate) /// ; AI (prepare/generate)
struct InferenceSettingsView: View { struct InferenceSettingsView: View {
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue @AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
@State private var modelService = ModelDownloadService.shared
private var selected: EnginePreference { private var selected: EnginePreference {
EnginePreference(rawValue: engineRaw) ?? .auto EnginePreference(rawValue: engineRaw) ?? .auto
} }
/// (MNN MLX )
private var modelReady: Bool {
modelService.states[.mnnLLM]?.phase == .ready
|| modelService.states[.llm]?.phase == .ready
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 12) { VStack(spacing: 12) {
@@ -26,12 +33,74 @@ struct InferenceSettingsView: View {
} }
sme2Card sme2Card
selfTestSection
noteCard noteCard
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 20) .padding(.vertical, 20)
} }
.background(Tj.Palette.sand.ignoresSafeArea()) .background(Tj.Palette.sand.ignoresSafeArea())
.onAppear { modelService.refreshStates() }
}
/// : prompt,
///
@ViewBuilder
private var selfTestSection: some View {
if modelReady {
NavigationLink {
ModelSelfTestView()
} label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: "gauge.with.needle")
.font(.tjScaled(18))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("性能自检")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled(13, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard()
}
.buttonStyle(.plain)
} else {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: "gauge.with.needle")
.font(.tjScaled(18))
.foregroundStyle(Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("性能自检")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("模型未就绪,前往「模型管理」下载后可用")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
Spacer()
}
.padding(14)
.tjCard()
.opacity(0.55)
}
} }
private func engineRow(_ engine: EnginePreference) -> some View { private func engineRow(_ engine: EnginePreference) -> some View {

View File

@@ -28,19 +28,6 @@ struct ModelManagementView: View {
actionButtons actionButtons
.padding(.top, 4) .padding(.top, 4)
if service.states[.mnnLLM]?.phase == .ready || service.states[.llm]?.phase == .ready {
NavigationLink {
ModelSelfTestView()
} label: {
HStack(spacing: 6) {
Image(systemName: "gauge.with.needle")
Text("性能自检")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
if let importError { if let importError {
Text(importError) Text(importError)
.font(.tjScaled( 12)) .font(.tjScaled( 12))

View File

@@ -175,6 +175,8 @@ private struct MedicationEditSheet: View {
@State private var hydrated = false @State private var hydrated = false
/// ;nil = /// ;nil =
@State private var viewerStart: PhotoIndex? @State private var viewerStart: PhotoIndex?
/// : MedicationLogSheet,
@State private var showLog = false
private var isEditing: Bool { existing != nil } private var isEditing: Bool { existing != nil }
private var canSave: Bool { private var canSave: Bool {
@@ -184,6 +186,29 @@ private struct MedicationEditSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
if isEditing {
Section {
Button { showLog = true } label: {
HStack(spacing: 10) {
Image(systemName: "pills.circle.fill")
.font(.tjScaled( 18))
.foregroundStyle(Tj.Palette.ink)
Text("记录一次服用")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} footer: {
Text("记某次吃药的剂量和时间,会进「记录 · 用药」时间线。不提供剂量建议。")
}
}
if let m = existing, !m.assets.isEmpty { if let m = existing, !m.assets.isEmpty {
Section { Section {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@@ -252,6 +277,9 @@ private struct MedicationEditSheet: View {
MedicationPhotoViewer(assets: m.assets, startIndex: start.index) MedicationPhotoViewer(assets: m.assets, startIndex: start.index)
} }
} }
.sheet(isPresented: $showLog) {
MedicationLogSheet(preselected: existing)
}
} }
} }

View File

@@ -62,7 +62,7 @@ struct SingleShotCameraView: View {
Spacer() Spacer()
Text("拍一张含目标指标的照片 · 拍完再框选") Text("轻点画面对焦 · 拍完再框选")
.font(.tjScaled( 13, weight: .medium)) .font(.tjScaled( 13, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -187,6 +187,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
private var previewLayer: AVCaptureVideoPreviewLayer? private var previewLayer: AVCaptureVideoPreviewLayer?
private var setupDone = false private var setupDone = false
private var captureCompletion: ((UIImage?) -> Void)? private var captureCompletion: ((UIImage?) -> Void)?
/// , lockForConfiguration
private var device: AVCaptureDevice?
/// ;,
private weak var focusIndicator: UIView?
override func didMoveToWindow() { override func didMoveToWindow() {
super.didMoveToWindow() super.didMoveToWindow()
@@ -205,6 +209,20 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
return return
} }
session.addInput(input) session.addInput(input)
self.device = device
// :/,,
// ;
if (try? device.lockForConfiguration()) != nil {
if device.isFocusModeSupported(.continuousAutoFocus) {
device.focusMode = .continuousAutoFocus
}
if device.isAutoFocusRangeRestrictionSupported {
device.autoFocusRangeRestriction = .near
}
device.unlockForConfiguration()
}
if session.canAddOutput(output) { session.addOutput(output) } if session.canAddOutput(output) { session.addOutput(output) }
session.commitConfiguration() session.commitConfiguration()
@@ -215,6 +233,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
self.previewLayer = preview self.previewLayer = preview
applyPortrait(preview.connection) applyPortrait(preview.connection)
// :,
let tap = UITapGestureRecognizer(target: self, action: #selector(handleFocusTap(_:)))
addGestureRecognizer(tap)
DispatchQueue.global(qos: .userInitiated).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning() self?.session.startRunning()
} }
@@ -233,6 +255,61 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
previewLayer?.frame = bounds previewLayer?.frame = bounds
} }
// MARK: -
@objc private func handleFocusTap(_ gr: UITapGestureRecognizer) {
guard let previewLayer, device != nil else { return }
let point = gr.location(in: self)
// ( videoGravity/)
let devicePoint = previewLayer.captureDevicePointConverted(fromLayerPoint: point)
focus(at: devicePoint)
showFocusIndicator(at: point)
}
/// /,;
private func focus(at devicePoint: CGPoint) {
guard let device, (try? device.lockForConfiguration()) != nil else { return }
if device.isFocusPointOfInterestSupported {
device.focusPointOfInterest = devicePoint
}
if device.isFocusModeSupported(.autoFocus) {
device.focusMode = .autoFocus // ,
}
if device.isExposurePointOfInterestSupported {
device.exposurePointOfInterest = devicePoint
}
if device.isExposureModeSupported(.autoExpose) {
device.exposureMode = .autoExpose
}
device.unlockForConfiguration()
}
///
private func showFocusIndicator(at point: CGPoint) {
focusIndicator?.removeFromSuperview()
let box = UIView(frame: CGRect(x: 0, y: 0, width: 76, height: 76))
box.center = point
box.backgroundColor = .clear
box.layer.borderColor = UIColor.systemYellow.cgColor
box.layer.borderWidth = 1.5
box.layer.cornerRadius = 6
box.isUserInteractionEnabled = false
box.alpha = 0
box.transform = CGAffineTransform(scaleX: 1.35, y: 1.35)
addSubview(box)
focusIndicator = box
UIView.animate(withDuration: 0.2, animations: {
box.alpha = 1
box.transform = .identity
}, completion: { _ in
UIView.animate(withDuration: 0.3, delay: 0.7, options: []) {
box.alpha = 0
} completion: { _ in
box.removeFromSuperview()
}
})
}
func capture(completion: @escaping (UIImage?) -> Void) { func capture(completion: @escaping (UIImage?) -> Void) {
guard session.isRunning else { completion(nil); return } guard session.isRunning else { completion(nil); return }
captureCompletion = completion captureCompletion = completion

View File

@@ -250,9 +250,15 @@ struct VoiceCommandSheet: View {
private func finishRecording() { private func finishRecording() {
guard phase == .recording else { return } guard phase == .recording else { return }
ticker?.cancel() ticker?.cancel()
// :stop() /,
// ,退
let live = transcript
phase = .classifying phase = .classifying
Task { Task {
let text = await dictation.stop() let finalText = await dictation.stop()
let text = finalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? live
: finalText
transcript = text transcript = text
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { guard !trimmed.isEmpty else {

View File

@@ -93,8 +93,7 @@ struct OngoingSymptomsCard: View {
) )
} }
) )
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04), .shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
radius: 2, x: 0, y: 1)
} }
private func severityDot(_ value: Int) -> some View { private func severityDot(_ value: Int) -> some View {

View File

@@ -133,7 +133,7 @@ extension SeriesBucket {
id: "lab:\(latest.name)", id: "lab:\(latest.name)",
seriesKey: "lab:\(latest.name)", seriesKey: "lab:\(latest.name)",
label: nil, label: nil,
color: Tj.Palette.ink, color: Tj.Palette.teal,
points: points, points: points,
referenceRange: parseRange(latest.range) referenceRange: parseRange(latest.range)
) )
@@ -172,7 +172,7 @@ extension SeriesBucket {
id: key, id: key,
seriesKey: key, seriesKey: key,
label: nil, label: nil,
color: Tj.Palette.ink, color: Tj.Palette.teal,
points: sorted.compactMap { point(from: $0) }, points: sorted.compactMap { point(from: $0) },
referenceRange: range referenceRange: range
) )
@@ -200,7 +200,7 @@ extension SeriesBucket {
id: "bp.systolic", id: "bp.systolic",
seriesKey: "bp.systolic", seriesKey: "bp.systolic",
label: String(appLoc: "收缩"), label: String(appLoc: "收缩"),
color: Tj.Palette.brick, color: Tj.Palette.teal,
points: sysItems.compactMap { point(from: $0) }, points: sysItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: sysField, profile: profile) referenceRange: m.effectiveRange(for: sysField, profile: profile)
) )

View File

@@ -111,6 +111,24 @@ struct SeriesChartCard: View {
} }
} }
// 线,线,
//(线,)
if bucket.lines.count == 1, let line = bucket.lines.first {
ForEach(line.points) { p in
// 线: AreaMark 线(0/)
// ,
AreaMark(
x: .value("时间", p.date),
yStart: .value("基线", (valueDomain ?? 0...1).lowerBound),
yEnd: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(LinearGradient(
colors: [line.color.opacity(0.16), line.color.opacity(0)],
startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
}
}
// 线 + // 线 +
ForEach(bucket.lines) { line in ForEach(bucket.lines) { line in
ForEach(line.points) { p in ForEach(line.points) { p in
@@ -119,8 +137,10 @@ struct SeriesChartCard: View {
y: .value(line.label ?? bucket.title, p.value) y: .value(line.label ?? bucket.title, p.value)
) )
.foregroundStyle(line.color) .foregroundStyle(line.color)
.interpolationMethod(.catmullRom) // monotone:, catmullRom
.lineStyle(StrokeStyle(lineWidth: 2)) .interpolationMethod(.monotone)
// + ,线
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
} }
.symbol { .symbol {
Circle() Circle()

View File

@@ -179,6 +179,23 @@ struct TrendDetailView: View {
.foregroundStyle(line.color.opacity(0.08)) .foregroundStyle(line.color.opacity(0.08))
} }
} }
// 线,线,
//(线,)
if filteredLines.count == 1, let line = filteredLines.first {
ForEach(line.points) { p in
// 线: AreaMark 线(0/),
// ,
AreaMark(
x: .value("时间", p.date),
yStart: .value("基线", (valueDomain ?? 0...1).lowerBound),
yEnd: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(LinearGradient(
colors: [line.color.opacity(0.16), line.color.opacity(0)],
startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
}
}
ForEach(filteredLines) { line in ForEach(filteredLines) { line in
ForEach(line.points) { p in ForEach(line.points) { p in
LineMark( LineMark(
@@ -187,8 +204,10 @@ struct TrendDetailView: View {
series: .value("series", line.id) series: .value("series", line.id)
) )
.foregroundStyle(line.color) .foregroundStyle(line.color)
.interpolationMethod(.catmullRom) // monotone:,
.lineStyle(StrokeStyle(lineWidth: 2)) .interpolationMethod(.monotone)
// + ,线
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
PointMark( PointMark(
x: .value("时间", p.date), x: .value("时间", p.date),
y: .value(line.label ?? bucket.title, p.value) y: .value(line.label ?? bucket.title, p.value)
@@ -421,6 +440,24 @@ private struct TrendInsightCard: View {
.font(.tjScaled( 12, weight: .semibold)) .font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2) .foregroundStyle(Tj.Palette.text2)
Spacer() Spacer()
// :(),
//
if !running {
Button { Task { await load(force: true) } } label: {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.tjScaled( 11, weight: .semibold))
Text(text == nil ? String(appLoc: "解读") : String(appLoc: "重新解读"))
.font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.sand2))
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
} }
if let text { if let text {
Text(text) Text(text)
@@ -435,15 +472,14 @@ private struct TrendInsightCard: View {
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
AIFlowBar() AIFlowBar()
} else if let failedMessage { } else if let failedMessage {
HStack {
Text(failedMessage) Text(failedMessage)
.font(.tjScaled( 12)) .font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
Spacer() } else {
Button("重试") { Task { await load(force: true) } } // ():,
.font(.tjScaled( 12, weight: .medium)) Text("点右上「解读」生成本地趋势解读")
.foregroundStyle(Tj.Palette.ink) .font(.tjScaled( 12))
} .foregroundStyle(Tj.Palette.text3)
} }
} }
.padding(14) .padding(14)

View File

@@ -54,6 +54,19 @@ struct TrendRow: View {
private var sparkline: some View { private var sparkline: some View {
Chart { Chart {
// 线,线,
if bucket.lines.count == 1, let line = bucket.lines.first {
ForEach(line.points) { p in
AreaMark(
x: .value("t", p.date),
y: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(LinearGradient(
colors: [line.color.opacity(0.18), line.color.opacity(0)],
startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
}
}
ForEach(bucket.lines) { line in ForEach(bucket.lines) { line in
ForEach(line.points) { p in ForEach(line.points) { p in
LineMark( LineMark(
@@ -62,8 +75,9 @@ struct TrendRow: View {
series: .value("s", line.id) series: .value("s", line.id)
) )
.foregroundStyle(line.color) .foregroundStyle(line.color)
.interpolationMethod(.catmullRom) // monotone + :线,
.lineStyle(StrokeStyle(lineWidth: 1.6)) .interpolationMethod(.monotone)
.lineStyle(StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round))
} }
} }
// //

View File

@@ -3022,6 +3022,9 @@
} }
} }
} }
},
"健康趋势" : {
}, },
"像扫描文档一样翻页拍摄" : { "像扫描文档一样翻页拍摄" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -3427,6 +3430,7 @@
}, },
"最近记录" : { "最近记录" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -4590,6 +4594,9 @@
} }
} }
} }
},
"基线" : {
}, },
"填写%@" : { "填写%@" : {
@@ -4732,6 +4739,9 @@
} }
} }
} }
},
"夜深了,记得早点休息" : {
}, },
"大" : { "大" : {
@@ -6825,9 +6835,6 @@
} }
} }
} }
},
"拍一张含目标指标的照片 · 拍完再框选" : {
}, },
"拍到的局部" : { "拍到的局部" : {
@@ -7956,6 +7963,9 @@
} }
} }
} }
},
"新的一天,慢慢来" : {
}, },
"无参考范围" : { "无参考范围" : {
"localizations" : { "localizations" : {
@@ -9139,6 +9149,9 @@
} }
} }
} }
},
"模型未就绪,前往「模型管理」下载后可用" : {
}, },
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : { "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : {
"localizations" : { "localizations" : {
@@ -9633,6 +9646,9 @@
}, },
"添加药品" : { "添加药品" : {
},
"点右上「解读」生成本地趋势解读" : {
}, },
"点图放大" : { "点图放大" : {
@@ -9971,6 +9987,9 @@
} }
} }
} }
},
"用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s" : {
}, },
"用于自动判定 正常/偏高/偏低" : { "用于自动判定 正常/偏高/偏低" : {
"localizations" : { "localizations" : {
@@ -10093,6 +10112,9 @@
} }
} }
} }
},
"症状 · %lld 进行中" : {
}, },
"症状 · 已结束" : { "症状 · 已结束" : {
"localizations" : { "localizations" : {
@@ -11091,6 +11113,9 @@
}, },
"解析失败:%@" : { "解析失败:%@" : {
},
"解读" : {
}, },
"解锁康康,查看你的健康档案" : { "解锁康康,查看你的健康档案" : {
"localizations" : { "localizations" : {
@@ -11160,6 +11185,9 @@
} }
} }
} }
},
"记录一次服用" : {
}, },
"记录什么?" : { "记录什么?" : {
"localizations" : { "localizations" : {
@@ -11325,6 +11353,12 @@
} }
} }
} }
},
"记得起身活动一下" : {
},
"记某次吃药的剂量和时间,会进「记录 · 用药」时间线。不提供剂量建议。" : {
}, },
"记症状" : { "记症状" : {
@@ -11976,6 +12010,9 @@
}, },
"轻点打开新建菜单,长按语音直达" : { "轻点打开新建菜单,长按语音直达" : {
},
"轻点画面对焦 · 拍完再框选" : {
}, },
"载脂蛋白 A1" : { "载脂蛋白 A1" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -12218,6 +12255,7 @@
} }
}, },
"还没有任何记录,点底部 + 号开始第一条" : { "还没有任何记录,点底部 + 号开始第一条" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -12544,6 +12582,9 @@
} }
} }
} }
},
"重新解读" : {
}, },
"重新识别" : { "重新识别" : {
"localizations" : { "localizations" : {

View File

@@ -42,10 +42,14 @@ struct RootView: View {
@State private var tab: TjTab = .home @State private var tab: TjTab = .home
/// push : tab trailing , leading /// push : tab trailing , leading
@State private var pushEdge: Edge = .trailing @State private var pushEdge: Edge = .trailing
/// chip `.report`, tab
@State private var pendingRecordsFilter: TimelineKind?
@State private var showRecordSheet = false @State private var showRecordSheet = false
@State private var activeFlow: ActiveFlow? @State private var activeFlow: ActiveFlow?
@State private var showSymptomStart = false @State private var showSymptomStart = false
@State private var showDiary = false @State private var showDiary = false
/// : sheet ,
@State private var diaryDirectWrite = false
@State private var showIndicator = false @State private var showIndicator = false
@State private var showReminders = false @State private var showReminders = false
@State private var showHealthExport = false @State private var showHealthExport = false
@@ -59,7 +63,7 @@ struct RootView: View {
/// ( RecordSheet onPick ) /// ( RecordSheet onPick )
private func route(_ intent: VoiceIntent) { private func route(_ intent: VoiceIntent) {
switch intent { switch intent {
case .diary: showDiary = true case .diary: diaryDirectWrite = true; showDiary = true
case .medication: showMedicationScan = true case .medication: showMedicationScan = true
case .symptom: showSymptomStart = true case .symptom: showSymptomStart = true
case .indicator: showIndicator = true case .indicator: showIndicator = true
@@ -81,8 +85,11 @@ struct RootView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
Group { Group {
switch tab { switch tab {
case .home: HomeView(onTapArchive: { select(.records) }) case .home: HomeView(onTapArchive: { kind in
case .records: ArchiveListView() pendingRecordsFilter = kind
select(.records)
})
case .records: ArchiveListView(initialFilter: pendingRecordsFilter)
case .trend: TrendsView() case .trend: TrendsView()
case .me: MeView() case .me: MeView()
} }
@@ -92,7 +99,11 @@ struct RootView: View {
.transition(.push(from: pushEdge)) .transition(.push(from: pushEdge))
TabBar(active: tab, TabBar(active: tab,
onTap: { select($0) }, onTap: {
// tab , .report
if $0 == .records { pendingRecordsFilter = nil }
select($0)
},
onTapRecord: { showRecordSheet = true }, onTapRecord: { showRecordSheet = true },
onLongPressRecord: { showVoiceCommand = true }) onLongPressRecord: { showVoiceCommand = true })
} }
@@ -110,7 +121,7 @@ struct RootView: View {
case .quick: activeFlow = .quick case .quick: activeFlow = .quick
case .archive: activeFlow = .archive case .archive: activeFlow = .archive
case .symptom: showSymptomStart = true case .symptom: showSymptomStart = true
case .diary: showDiary = true case .diary: diaryDirectWrite = false; showDiary = true
case .indicator: showIndicator = true case .indicator: showIndicator = true
case .reminder: showReminders = true case .reminder: showReminders = true
case .healthExport: showHealthExport = true case .healthExport: showHealthExport = true
@@ -123,7 +134,7 @@ struct RootView: View {
SymptomStartSheet() SymptomStartSheet()
} }
.sheet(isPresented: $showDiary) { .sheet(isPresented: $showDiary) {
DiaryQuickSheet() DiaryQuickSheet(directWrite: diaryDirectWrite)
} }
.sheet(isPresented: $showIndicator) { .sheet(isPresented: $showIndicator) {
// : VL () // : VL ()
@@ -232,7 +243,7 @@ private struct TabBar: View {
.fill(Tj.Palette.lineSoft) .fill(Tj.Palette.lineSoft)
.frame(height: 1) .frame(height: 1)
} }
.shadow(color: Tj.Palette.ink.opacity(0.05), radius: 10, x: 0, y: -2) .shadow(color: Tj.Palette.shadow.opacity(0.07), radius: 10, x: 0, y: -2)
} }
private func tabItem(_ t: TjTab) -> some View { private func tabItem(_ t: TjTab) -> some View {
@@ -273,8 +284,8 @@ private struct TabBar: View {
Circle() Circle()
.strokeBorder(Tj.Palette.paper, lineWidth: 2) .strokeBorder(Tj.Palette.paper, lineWidth: 2)
) )
.shadow(color: Tj.Palette.ink.opacity(0.18), .shadow(color: Tj.Palette.shadow.opacity(0.20),
radius: 4, x: 0, y: 2) radius: 5, x: 0, y: 2)
Image(systemName: "plus") Image(systemName: "plus")
.font(.tjScaled( 16, weight: .semibold)) .font(.tjScaled( 16, weight: .semibold))

View File

@@ -29,7 +29,7 @@ struct LockScreenView: View {
.foregroundStyle(Tj.Palette.ink) .foregroundStyle(Tj.Palette.ink)
} }
.frame(width: 92, height: 92) .frame(width: 92, height: 92)
.shadow(color: Tj.Palette.ink.opacity(0.06), radius: 12, y: 4) .shadow(color: Tj.Palette.shadow.opacity(0.08), radius: 12, y: 4)
VStack(spacing: 6) { VStack(spacing: 6) {
Text("康康 已锁定") Text("康康 已锁定")

View File

@@ -64,27 +64,61 @@ struct DiaryAssistService {
} }
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions) let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
var collected = ""
// MNN JSON / {"questions":} ( MNN MLX )
// , §10.5退, AI
var lastRate: Double = 0 var lastRate: Double = 0
var parsedButEmpty = false
var lastRaw = ""
for _ in 0..<2 {
try Task.checkCancellation()
var collected = ""
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream { for try await chunk in stream {
collected += chunk.text collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
} }
lastRaw = collected
if let questions = Self.parseQuestions(from: collected) {
if !questions.isEmpty {
return (Array(questions.prefix(4)), lastRate)
}
parsedButEmpty = true // JSON :, .empty
}
}
// ,,便
#if DEBUG
print("[DiaryAssistService] 解析失败,原始输出 = \(lastRaw)")
#endif
throw parsedButEmpty ? AssistError.empty : AssistError.parseFailed("非 JSON 输出")
}
// 1. <think>...</think>( HealthExportService ) /// ( §3.2 退):
let stripped = HealthExportService.stripThinkBlocks(collected) /// `<think>` JSON `{"questions":[]}`,
// 2. JSON( CaptureService.extractJSONObject)+ /// 退 `[{}]`(MNN ) nil(/)
let jsonStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped)) /// `[]`( nil : .empty .parseFailed)
guard let data = jsonStr.data(using: .utf8), static func parseQuestions(from raw: String) -> [Question]? {
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), let stripped = HealthExportService.stripThinkBlocks(raw)
let dict = obj as? [String: Any] else {
throw AssistError.parseFailed("非 JSON 输出") var rawQuestions: [[String: Any]]?
// {"questions":[]}
let objStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
if let data = objStr.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let arr = dict["questions"] as? [[String: Any]] {
rawQuestions = arr
} }
guard let rawQuestions = dict["questions"] as? [[String: Any]] else { // 退:, [{},{}]
throw AssistError.parseFailed("缺少 questions 字段") if rawQuestions == nil {
let arrStr = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: stripped))
if let data = arrStr.data(using: .utf8),
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] {
rawQuestions = arr
} }
let questions = rawQuestions.compactMap { d -> Question? in }
guard let rawQuestions else { return nil }
return rawQuestions.compactMap { d -> Question? in
guard let q = (d["q"] as? String)? guard let q = (d["q"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else { .trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
return nil return nil
@@ -95,8 +129,6 @@ struct DiaryAssistService {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return Question(q: q, fill: fill, dim: dim) return Question(q: q, fill: fill, dim: dim)
} }
guard !questions.isEmpty else { throw AssistError.empty }
return (Array(questions.prefix(4)), lastRate)
} }
/// 稿稿(spec 2026-06-10-voice-diary) /// 稿稿(spec 2026-06-10-voice-diary)

View File

@@ -124,7 +124,8 @@ final class SpeechDictationService {
/// ,( 1.5s, partial),稿 /// ,( 1.5s, partial),稿
/// partial (spec :) /// partial (spec :)
func stop() async -> String { func stop() async -> String {
guard isRecording else { return "" } // ( final / ):,
guard isRecording else { return latestText }
isRecording = false isRecording = false
audioEngine.stop() audioEngine.stop()

View File

@@ -6,7 +6,7 @@ enum VoiceIntent: String, CaseIterable, Sendable {
} }
/// :LLM(MNN/SME2 ),6 退(§3.2) /// :LLM(MNN/SME2 ),6 退(§3.2)
/// nil,UI / /// diary(), diary
/// , OCRService enum ;UI AIRuntime(§3.1) /// , OCRService enum ;UI AIRuntime(§3.1)
/// nonisolated: MainActor, + await,线() /// nonisolated: MainActor, + await,线()
nonisolated enum VoiceIntentService { nonisolated enum VoiceIntentService {
@@ -58,23 +58,44 @@ nonisolated enum VoiceIntentService {
// MARK: - 退(,) // MARK: - 退(,)
/// : reminder, reminder /// : reminder, reminder
static func keywordMatch(_ text: String) -> VoiceIntent? { /// symptom ****(),///
/// , diary
static func keywordMatch(_ text: String) -> VoiceIntent {
let t = text.lowercased() let t = text.lowercased()
// archive ****( / / ),
// , archive
let rules: [(VoiceIntent, [String])] = [ let rules: [(VoiceIntent, [String])] = [
(.reminder, ["提醒", "别忘", "闹钟"]), (.reminder, ["提醒", "别忘", "闹钟"]),
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]), (.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
(.archive, ["报告", "化验单", "体检", "归档"]), (.archive, ["化验单", "化验报告", "检查报告", "检验报告", "体检报告", "归档", "存档"]),
(.export, ["身体档案", "给医生", "健康总结", "导出"]), (.export, ["身体档案", "给医生", "健康总结", "导出"]),
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标", (.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
"高压", "低压"]), "高压", "低压"]),
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "", "", (.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼",
"咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]), "咳嗽", "发烧", "发热", "头晕", "恶心", "拉肚子"]),
(.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]),
] ]
for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) { for (intent, keys) in rules {
for key in keys where t.contains(key) {
// ( / )****:
// , diary
if intent == .medication || intent == .archive, isNegated(t, keyword: key) {
continue
}
return intent return intent
} }
return nil }
// :
return .diary
}
/// /,,
/// ,
private static let negationMarkers: Set<Character> = ["", "", "", "", "", "", ""]
static func isNegated(_ text: String, keyword: String) -> Bool {
guard let range = text.range(of: keyword) else { return false }
let preceding = text[..<range.lowerBound].suffix(2)
return preceding.contains { negationMarkers.contains($0) }
} }
} }

View File

@@ -0,0 +1,60 @@
import Testing
import Foundation
@testable import
/// AI `DiaryAssistService.parseQuestions`
/// JSON : MNN ,
/// nil( suggest /), []
/// parseQuestions @MainActor struct , @MainActor
@MainActor
struct DiaryAssistParseTests {
@Test func parsesStandardWrappedJSON() {
let raw = #"{"questions":[{"dim":"","q":"?","fill":"[],"},{"dim":"","q":"?","fill":"[]"}]}"#
let qs = DiaryAssistService.parseQuestions(from: raw)
#expect(qs?.count == 2)
#expect(qs?.first?.dim == "起病诱因")
}
@Test func parsesMarkdownFenced() {
let raw = """
```json
{"questions":[{"dim":"","q":"?","fill":"[]"}]}
```
"""
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
}
@Test func parsesThinkWrapped() {
let raw = "<think>用户头痛,该问起病诱因</think>{\"questions\":[{\"dim\":\"起病诱因\",\"q\":\"何时开始?\",\"fill\":\"从[时间]\"}]}"
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
}
@Test func parsesBareArrayWithoutWrapper() {
// MNN {"questions":},
let raw = #"[{"dim":"","q":"?","fill":"[]"},{"dim":"","q":"?","fill":"[]"}]"#
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 2)
}
@Test func repairsTrailingCommaAndSmartQuotes() {
// + :repairJSON
let raw = "{“questions”:[{“dim”:“用药过敏”,“q”:“在吃什么药?”,“fill”:“在服[药名],”},]}"
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
}
@Test func emptyQuestionsArrayReturnsEmptyNotNil() {
// JSON : []( .empty, .parseFailed)
let qs = DiaryAssistService.parseQuestions(from: #"{"questions":[]}"#)
#expect(qs != nil)
#expect(qs?.isEmpty == true)
}
@Test func proseReturnsNil() {
#expect(DiaryAssistService.parseQuestions(from: "我觉得你可以多问问睡眠情况。") == nil)
}
@Test func unterminatedThinkOnlyReturnsNil() {
// JSON :strip nil( suggest )
#expect(DiaryAssistService.parseQuestions(from: "<think>嗯,用户写了头痛,我应该问") == nil)
}
}

View File

@@ -47,7 +47,33 @@ struct VoiceIntentServiceTests {
#expect(VoiceIntentService.keywordMatch("写个日记") == .diary) #expect(VoiceIntentService.keywordMatch("写个日记") == .diary)
} }
@Test func gibberishReturnsNil() { @Test func unmatchedDefaultsToDiary() {
#expect(VoiceIntentService.keywordMatch("啦啦啦啦") == nil) // ,()
#expect(VoiceIntentService.keywordMatch("啦啦啦啦") == .diary)
#expect(VoiceIntentService.keywordMatch("今天感觉不太舒服") == .diary)
#expect(VoiceIntentService.keywordMatch("有点难受") == .diary)
}
// MARK: - ()
@Test func negatedMedicationDoesNotOpenCamera() {
// / / medication(), diary
#expect(VoiceIntentService.keywordMatch("今天太忙,忘了吃药") == .diary)
#expect(VoiceIntentService.keywordMatch("我今天没吃药") == .diary)
#expect(VoiceIntentService.keywordMatch("医生说先不用吃药") == .diary)
}
@Test func casualReportMentionDoesNotOpenCamera() {
// /,,
#expect(VoiceIntentService.keywordMatch("下周打算去做个体检") == .diary)
#expect(VoiceIntentService.keywordMatch("医生说我报告没什么大问题") == .diary)
}
@Test func genuineCameraIntentsStillMatch() {
// /
#expect(VoiceIntentService.keywordMatch("拍个药盒") == .medication)
#expect(VoiceIntentService.keywordMatch("我吃了降压药,记一下") == .medication)
#expect(VoiceIntentService.keywordMatch("把体检报告存进去") == .archive)
#expect(VoiceIntentService.keywordMatch("这张化验单归档") == .archive)
} }
} }