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

```
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);
std::ostream os(&buf);
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);
}
buf.flush();

View File

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

View File

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

View File

@@ -1,42 +1,49 @@
import SwiftUI
/// Apple Intelligence 线:,
/// AppAI ( AI /)
/// AppAI ( AI /)
///
/// :线 `Tj.Palette` AI (
/// Apple ),; UI §9 token
///
/// `TimelineView(.animation)` `.onAppear` + `repeatForever`:线
/// (tok/s 0.5s ), `repeatForever`
/// / TimelineView ,
/// ,
struct AIFlowBar: View {
var height: CGFloat = 3
/// ,
var cycle: Double = 1.0
/// (),
var cycle: Double = 0.6
@State private var phase: CGFloat = 0
/// :offset ,
private static let flow: [Color] = {
let base: [Color] = [
private static let base: [Color] = [
Color(red: 0.35, green: 0.47, blue: 0.98), //
Color(red: 0.62, green: 0.36, blue: 0.92), //
Color(red: 0.96, green: 0.40, blue: 0.62), //
Color(red: 1.00, green: 0.55, blue: 0.30), //
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 {
TimelineView(.animation) { timeline in
GeometryReader { geo in
let w = geo.size.width
let t = timeline.date.timeIntervalSinceReferenceDate
let progress = CGFloat(t.truncatingRemainder(dividingBy: cycle) / cycle)
Capsule()
.fill(LinearGradient(colors: Self.flow,
.fill(LinearGradient(gradient: Self.gradient,
startPoint: .leading, endPoint: .trailing))
.frame(width: w * 2)
.offset(x: phase)
.onAppear {
phase = 0
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
phase = -w
}
.offset(x: -w * progress)
}
}
.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 leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
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 {
@@ -64,7 +70,7 @@ extension View {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.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)
}
}

View File

@@ -32,6 +32,12 @@ struct ArchiveListView: View {
@State private var filter: TimelineKind? = nil
@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 selectedGroup: IndicatorGroup?
@State private var route: Route?

View File

@@ -6,6 +6,10 @@ import SwiftData
/// Qwen3 3-4 ,
/// q LLM ; row +
struct DiaryQuickSheet: View {
/// : 2×2 ,
/// false
var directWrite: Bool = false
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@@ -32,11 +36,10 @@ struct DiaryQuickSheet: View {
/// (question.dim), prompt
@State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>?
/// question id;nil =
@State private var fillingId: UUID?
/// , =
@State private var fillValues: [String] = []
/// () true,
/// question id questions (
/// coveredDims,),
@State private var skippedQuestionIDs: Set<UUID> = []
/// () true,
@State private var exhaustedNote = false
/// sheet detent large,
/// medium,()
@@ -75,6 +78,40 @@ struct DiaryQuickSheet: View {
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
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 {
VStack(spacing: 0) {
Capsule()
@@ -88,7 +125,7 @@ struct DiaryQuickSheet: View {
Text("健康记录")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
Text("记录身体状态 · 康康在一旁帮你想还能记点啥")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
@@ -100,31 +137,12 @@ struct DiaryQuickSheet: View {
.padding(.horizontal, 20)
.padding(.bottom, 10)
// (2×2):()/ (MedicationLogSheet,+)/
// ()/ (SymptomStartSheet)
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)
// ( / / / ):
// ,,
//(/)
modeSelector
.animation(.snappy(duration: 0.22), value: showModeSelector)
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
@@ -166,6 +184,12 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
// :,
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
careBarRow(compact: true)
}
}
if voicePhase != .idle {
DiaryVoicePanel(
@@ -218,25 +242,11 @@ struct DiaryQuickSheet: View {
.datePickerStyle(.compact)
.labelsHidden()
}
// , question
Color.clear.frame(height: 1).id("assist-bottom")
}
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
.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) {
Button("取消") { dismiss() }
@@ -276,6 +286,14 @@ struct DiaryQuickSheet: View {
// sheet:/;()
MedicationLogSheet()
}
.onAppear {
// :,,
// sheet ,
guard directWrite else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
contentFocused = true
}
}
.onDisappear {
suggestTask?.cancel()
voiceFlowTask?.cancel()
@@ -294,180 +312,32 @@ struct DiaryQuickSheet: View {
}
}
// MARK: - AI
// MARK: - (care bar)
/// :() careState,
/// AI ,
@ViewBuilder
private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) {
// section header
if !contentFocused {
if case .hidden = careState {
EmptyView()
} else {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
Spacer()
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
sectionLabel(String(appLoc: "康康帮你记"))
Spacer(minLength: 0)
if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
// questions (,)
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)
careBarRow(compact: false)
.padding(12)
.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(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
@@ -476,122 +346,126 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.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)
private func roundLocalIndex(at idx: Int) -> Int {
let target = questions[idx].round
var count = 0
for i in 0...idx where questions[i].round == target {
count += 1
}
return count
}
/// `compact = true` ();
/// `compact = false` () careState
@ViewBuilder
private func careBarRow(compact: Bool) -> some View {
switch careState {
case .hidden:
EmptyView()
/// N LLM
private func roundDivider(round: Int, count: Int) -> some View {
case .prompt:
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: 6) {
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
.font(.tjScaled( 11, weight: .semibold))
Image(systemName: "sparkles")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(round == 1
? String(appLoc: "第 1 轮 · \(count)")
: String(appLoc: "\(round) 轮 · 基于你刚才更新的文本 · \(count)"))
.font(.tjScaled( 11, weight: .semibold))
.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)
.symbolEffect(.pulse, options: .repeating)
Text(lastRate > 0
? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate)
: String(appLoc: "康康在想想…"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
.strikethrough(adopted, color: Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
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("采纳")
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button(action: cancelSuggestions) {
Text("")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.ink))
.buttonStyle(.plain)
}
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)
}
}
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)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.foregroundStyle(style == .filled ? Tj.Palette.paper : tint)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12)))
.contentShape(Capsule())
}
// MARK: - Actions
@@ -603,6 +477,41 @@ struct DiaryQuickSheet: View {
.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
/// : iPhone
private func modeCard(icon: String, title: String, subtitle: String,
@@ -734,18 +643,12 @@ struct DiaryQuickSheet: View {
/// AI (coveredDims) LLM,
/// ,
/// ****:,
/// ,
private func requestSuggestions() {
suggestTask?.cancel()
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
exhaustedNote = false
phase = .loading
suggestTask = Task { @MainActor in
@@ -819,38 +722,25 @@ struct DiaryQuickSheet: View {
phase = hasQuestions ? .ready : .idle
}
/// : `[]` ;( adopted)
/// q ; coveredDims, prompt
private func adopt(_ question: DiaryAssistService.Question) {
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
// :( fill 退)
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
return
}
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) {
/// :,,
/// `assemble(values: [])` 退
/// 便, `[]`
private func recordCurrent(_ question: DiaryAssistService.Question) {
let stub = question.fill.isEmpty
? question.q
: DiaryFillTemplate.assemble(question.fill, values: [])
appendToContent(stub)
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true
}
// :,
contentFocused = true
}
appendToContent(text)
fillingId = nil
fillValues = []
/// :, coveredDims,
/// 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 takenAt: Date = .now
/// () nil =
init(preselected: Medication? = nil) {
_selectedMed = State(initialValue: preselected)
}
private var resolvedName: String {
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
}

View File

@@ -2,7 +2,8 @@ import SwiftUI
import SwiftData
struct HomeView: View {
var onTapArchive: () -> Void = {}
/// ; filter chip( `.report`,)
var onTapArchive: (TimelineKind?) -> Void = { _ in }
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@@ -16,21 +17,27 @@ struct HomeView: View {
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
/// sheet( C1 )
@State private var selectedEntry: TimelineEntry?
/// ( + , C1 )
@Query private var profiles: [UserProfile]
@Query private var customMetrics: [CustomMonitorMetric]
/// ( + , C1 )
@State private var selectedGroup: IndicatorGroup?
private var profile: UserProfile? { profiles.first }
/// 3 :,
@MainActor
private var recentEntries: [TimelineEntry] {
let all =
TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
private var featuredBuckets: [SeriesBucket] {
let all = SeriesBucket.build(from: indicators,
profile: profile,
customMetrics: customMetrics)
let monitor = all.filter { $0.kind == .monitor }
let lab = all.filter { $0.kind == .lab }
return Array((monitor + lab).prefix(3))
}
private var ongoingSymptomCount: Int { symptoms.filter { $0.endedAt == nil }.count }
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
@@ -39,49 +46,65 @@ struct HomeView: View {
.padding(.bottom, 18)
HomeCalendarCard()
.padding(.bottom, 18)
overviewSection
.padding(.bottom, 18)
let buckets = featuredBuckets
if !buckets.isEmpty {
trendsSection(buckets)
.padding(.bottom, 18)
}
TodayRemindersCard()
OngoingSymptomsCard()
.padding(.bottom, 18)
recentSection
.padding(.bottom, 22)
archiveSection
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
.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
IndicatorSeriesDetailView(group: group)
}
}
// MARK: -
private var greeting: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
let t = TimeOfDay.current
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)
.font(.tjScaled( 12))
.font(.tjScaled( 11))
.tracking(1)
.foregroundStyle(Tj.Palette.text3)
Text(greetingWord)
.font(.tjTitle())
// 线,
Text(t.word)
.font(.tjScaled( 28, weight: .semibold, design: .serif))
.foregroundStyle(Tj.Palette.text)
Text(t.subtitle)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
}
Spacer()
Spacer(minLength: 8)
TjLockChip()
.padding(.top, 4)
.padding(.top, 2)
}
}
@@ -92,84 +115,137 @@ struct HomeView: View {
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()) {
case 5..<12: return String(appLoc: "早安")
case 12..<18: return String(appLoc: "下午好")
default: return String(appLoc: "晚上好")
case 5..<12: return .morning
case 12..<18: return .afternoon
default: return .evening
}
}
private var recentSection: some View {
// ( O(m²)) body ,, .isEmpty
let entries = recentEntries
let groups = TimelineGrouping.group(entries)
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .lastTextBaseline) {
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
Button(action: onTapArchive) {
Text("全部 ")
.font(.tjScaled( 12))
var word: String {
switch self {
case .morning: return String(appLoc: "早安")
case .afternoon: return String(appLoc: "下午好")
case .evening: return String(appLoc: "晚上好")
}
}
var subtitle: String {
switch self {
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)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity)
.tjCard()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
if entries.isEmpty {
emptyRecent
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(groups, id: \.section) { group in
VStack(alignment: .leading, spacing: 8) {
Text(group.section.label)
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 10) {
ForEach(group.items) { entry in
// MARK: - (线, TrendRow)
private func trendsSection(_ buckets: [SeriesBucket]) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("健康趋势")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
VStack(spacing: 12) {
ForEach(buckets) { bucket in
Button {
// ( + ); C1
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
}
selectedGroup = group(for: bucket)
} label: {
TimelineRow(entry: entry)
TrendRow(bucket: bucket)
}
.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 {
HStack {
Text("还没有任何记录,点底部 + 号开始第一条")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.vertical, 14)
.padding(.horizontal, 16)
.tjCard(bordered: true)
}
// MARK: -
private var archiveSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Button(action: onTapArchive) {
Button { onTapArchive(.report) } label: {
HStack(spacing: 14) {
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
.frame(width: 56, height: 56)

View File

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

View File

@@ -4,11 +4,18 @@ import SwiftUI
/// ; AI (prepare/generate)
struct InferenceSettingsView: View {
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
@State private var modelService = ModelDownloadService.shared
private var selected: EnginePreference {
EnginePreference(rawValue: engineRaw) ?? .auto
}
/// (MNN MLX )
private var modelReady: Bool {
modelService.states[.mnnLLM]?.phase == .ready
|| modelService.states[.llm]?.phase == .ready
}
var body: some View {
ScrollView {
VStack(spacing: 12) {
@@ -26,12 +33,74 @@ struct InferenceSettingsView: View {
}
sme2Card
selfTestSection
noteCard
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.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 {

View File

@@ -28,19 +28,6 @@ struct ModelManagementView: View {
actionButtons
.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 {
Text(importError)
.font(.tjScaled( 12))

View File

@@ -175,6 +175,8 @@ private struct MedicationEditSheet: View {
@State private var hydrated = false
/// ;nil =
@State private var viewerStart: PhotoIndex?
/// : MedicationLogSheet,
@State private var showLog = false
private var isEditing: Bool { existing != nil }
private var canSave: Bool {
@@ -184,6 +186,29 @@ private struct MedicationEditSheet: View {
var body: some View {
NavigationStack {
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 {
Section {
ScrollView(.horizontal, showsIndicators: false) {
@@ -252,6 +277,9 @@ private struct MedicationEditSheet: View {
MedicationPhotoViewer(assets: m.assets, startIndex: start.index)
}
}
.sheet(isPresented: $showLog) {
MedicationLogSheet(preselected: existing)
}
}
}

View File

@@ -62,7 +62,7 @@ struct SingleShotCameraView: View {
Spacer()
Text("拍一张含目标指标的照片 · 拍完再框选")
Text("轻点画面对焦 · 拍完再框选")
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
@@ -187,6 +187,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
private var previewLayer: AVCaptureVideoPreviewLayer?
private var setupDone = false
private var captureCompletion: ((UIImage?) -> Void)?
/// , lockForConfiguration
private var device: AVCaptureDevice?
/// ;,
private weak var focusIndicator: UIView?
override func didMoveToWindow() {
super.didMoveToWindow()
@@ -205,6 +209,20 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
return
}
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) }
session.commitConfiguration()
@@ -215,6 +233,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
self.previewLayer = preview
applyPortrait(preview.connection)
// :,
let tap = UITapGestureRecognizer(target: self, action: #selector(handleFocusTap(_:)))
addGestureRecognizer(tap)
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
}
@@ -233,6 +255,61 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
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) {
guard session.isRunning else { completion(nil); return }
captureCompletion = completion

View File

@@ -250,9 +250,15 @@ struct VoiceCommandSheet: View {
private func finishRecording() {
guard phase == .recording else { return }
ticker?.cancel()
// :stop() /,
// ,退
let live = transcript
phase = .classifying
Task {
let text = await dictation.stop()
let finalText = await dictation.stop()
let text = finalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? live
: finalText
transcript = text
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
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),
radius: 2, x: 0, y: 1)
.shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
}
private func severityDot(_ value: Int) -> some View {

View File

@@ -133,7 +133,7 @@ extension SeriesBucket {
id: "lab:\(latest.name)",
seriesKey: "lab:\(latest.name)",
label: nil,
color: Tj.Palette.ink,
color: Tj.Palette.teal,
points: points,
referenceRange: parseRange(latest.range)
)
@@ -172,7 +172,7 @@ extension SeriesBucket {
id: key,
seriesKey: key,
label: nil,
color: Tj.Palette.ink,
color: Tj.Palette.teal,
points: sorted.compactMap { point(from: $0) },
referenceRange: range
)
@@ -200,7 +200,7 @@ extension SeriesBucket {
id: "bp.systolic",
seriesKey: "bp.systolic",
label: String(appLoc: "收缩"),
color: Tj.Palette.brick,
color: Tj.Palette.teal,
points: sysItems.compactMap { point(from: $0) },
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(line.points) { p in
@@ -119,8 +137,10 @@ struct SeriesChartCard: View {
y: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(line.color)
.interpolationMethod(.catmullRom)
.lineStyle(StrokeStyle(lineWidth: 2))
// monotone:, catmullRom
.interpolationMethod(.monotone)
// + ,线
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
}
.symbol {
Circle()

View File

@@ -179,6 +179,23 @@ struct TrendDetailView: View {
.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(line.points) { p in
LineMark(
@@ -187,8 +204,10 @@ struct TrendDetailView: View {
series: .value("series", line.id)
)
.foregroundStyle(line.color)
.interpolationMethod(.catmullRom)
.lineStyle(StrokeStyle(lineWidth: 2))
// monotone:,
.interpolationMethod(.monotone)
// + ,线
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
PointMark(
x: .value("时间", p.date),
y: .value(line.label ?? bucket.title, p.value)
@@ -421,6 +440,24 @@ private struct TrendInsightCard: View {
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
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 {
Text(text)
@@ -435,15 +472,14 @@ private struct TrendInsightCard: View {
.foregroundStyle(Tj.Palette.text3)
AIFlowBar()
} else if let failedMessage {
HStack {
Text(failedMessage)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button("重试") { Task { await load(force: true) } }
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
} else {
// ():,
Text("点右上「解读」生成本地趋势解读")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(14)

View File

@@ -54,6 +54,19 @@ struct TrendRow: View {
private var sparkline: some View {
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(line.points) { p in
LineMark(
@@ -62,8 +75,9 @@ struct TrendRow: View {
series: .value("s", line.id)
)
.foregroundStyle(line.color)
.interpolationMethod(.catmullRom)
.lineStyle(StrokeStyle(lineWidth: 1.6))
// monotone + :线,
.interpolationMethod(.monotone)
.lineStyle(StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round))
}
}
//

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ struct LockScreenView: View {
.foregroundStyle(Tj.Palette.ink)
}
.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) {
Text("康康 已锁定")

View File

@@ -64,27 +64,61 @@ struct DiaryAssistService {
}
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
var collected = ""
// MNN JSON / {"questions":} ( MNN MLX )
// , §10.5退, AI
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)
for try await chunk in stream {
collected += chunk.text
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 )
let stripped = HealthExportService.stripThinkBlocks(collected)
// 2. JSON( CaptureService.extractJSONObject)+
let jsonStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
guard let data = jsonStr.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else {
throw AssistError.parseFailed("非 JSON 输出")
/// ( §3.2 退):
/// `<think>` JSON `{"questions":[]}`,
/// 退 `[{}]`(MNN ) nil(/)
/// `[]`( nil : .empty .parseFailed)
static func parseQuestions(from raw: String) -> [Question]? {
let stripped = HealthExportService.stripThinkBlocks(raw)
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)?
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
return nil
@@ -95,8 +129,6 @@ struct DiaryAssistService {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
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)

View File

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

View File

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