根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:

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

- 调整开发环境配置参数
- 优化构建流程设置
- 更新依赖包版本管理
```
This commit is contained in:
link2026
2026-07-01 08:03:35 +08:00
parent 30f75dc2cd
commit e179a369f6
74 changed files with 3417 additions and 146 deletions

View File

@@ -366,13 +366,21 @@ struct ArchiveListView: View {
.buttonStyle(.plain)
}
/// (2026-06-28 ):
/// (//)· (/)· 线 + ,
/// TimelineKind()
private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
ForEach(TimelineKind.allCases) { kind in
chip(label: kind.label, selected: filter == kind) {
filter = filter == kind ? nil : kind
allChip
ForEach(TimelineKind.Group.allCases) { group in
groupSeparator
// ():,
if group.kinds.count > 1 {
groupCaption(group.caption)
}
ForEach(group.kinds) { kind in
kindChip(kind)
}
}
}
@@ -380,23 +388,53 @@ struct ArchiveListView: View {
}
}
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
private var allChip: some View {
let selected = filter == nil
return Button { filter = nil } label: {
Text(String(appLoc: "全部"))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
)
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
}
/// : + , accent ; accent
private func kindChip(_ kind: TimelineKind) -> some View {
let selected = filter == kind
return Button { filter = selected ? nil : kind } label: {
HStack(spacing: 5) {
Image(systemName: kind.icon)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(selected ? Tj.Palette.paper : kind.accent)
Text(kind.label)
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Capsule().fill(selected ? kind.accent : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
}
private var groupSeparator: some View {
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(Tj.Palette.lineSoft)
.frame(width: 1, height: 18)
.padding(.horizontal, 2)
}
private func groupCaption(_ text: String) -> some View {
Text(text)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
HStack {
Text(section.label)

View File

@@ -0,0 +1,130 @@
import SwiftUI
import AVFoundation
import Combine
/// Vault (2026-06-28)/ + +
/// Vault ,App ;(/),
/// seek():,demo
struct ConsultationAudioPlayer: View {
let asset: Asset
@State private var player: AVAudioPlayer?
@State private var isPlaying = false
@State private var currentTime: Double = 0
@State private var duration: Double = 0
@State private var loadFailed = false
/// /;()AVAudioPlayer isPlaying false
private let ticker = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "waveform")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
Text(String(appLoc: "问诊录音"))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(String(appLoc: "本机存储"))
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
if loadFailed {
Text("录音无法读取")
.font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
HStack(spacing: 12) {
Button(action: togglePlay) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.tjScaled( 16, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 40, height: 40)
.background(Circle().fill(Tj.Palette.ink2))
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 6) {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Tj.Palette.sand2)
Capsule().fill(Tj.Palette.ink2)
.frame(width: geo.size.width * progressFraction)
}
}
.frame(height: 5)
HStack {
Text(Self.timeText(currentTime))
Spacer()
Text(Self.timeText(duration))
}
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.onAppear(perform: load)
.onDisappear {
player?.stop()
isPlaying = false
}
.onReceive(ticker) { _ in
guard let p = player, isPlaying else { return }
currentTime = p.currentTime
if !p.isPlaying { // :
isPlaying = false
currentTime = 0
p.currentTime = 0
}
}
}
private var progressFraction: CGFloat {
guard duration > 0 else { return 0 }
return CGFloat(min(max(currentTime / duration, 0), 1))
}
private func load() {
guard player == nil, !loadFailed else { return }
do {
let url = try FileVault.shared.absoluteURL(forReading: asset.relativePath)
let p = try AVAudioPlayer(contentsOf: url)
p.prepareToPlay()
player = p
duration = p.duration
} catch {
loadFailed = true
}
}
private func togglePlay() {
guard let p = player else { return }
if isPlaying {
p.pause()
isPlaying = false
} else {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
try? AVAudioSession.sharedInstance().setActive(true)
p.play()
isPlaying = true
}
}
private static func timeText(_ seconds: Double) -> String {
let s = Int(seconds.rounded())
return String(format: "%d:%02d", s / 60, s % 60)
}
}

View File

@@ -0,0 +1,657 @@
import SwiftUI
import SwiftData
/// sheet(2026-06-28)
///
/// : (`ConsultationRecorder`) AI
/// (`DiaryAssistService.organizeConsultation`) tag DiaryEntry
/// ( audio/m4a Asset Vault)
///
/// 线:UI AIRuntime( DiaryAssistService); + ;
/// AI 退(#5);,
/// (/) 退,
struct ConsultationSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
enum Phase: Equatable { case idle, recording, transcribing, organizing, review }
@State private var phase: Phase = .idle
@State private var liveTranscript = ""
@State private var recordingSeconds = 0
/// (稿;)
@State private var note = ""
/// :退,
@State private var rawTranscript = ""
/// (tmp m4a) importFile Vault; onDisappear
@State private var audioTempURL: URL?
@State private var createdAt: Date = .now
@State private var decodeRate: Double = 0
@State private var voiceNote: String?
@State private var deniedAlert = false
@State private var saved = false
@State private var recordTask: Task<Void, Never>?
@State private var organizeTask: Task<Void, Never>?
@State private var watchdog: Task<Void, Never>?
/// @State:struct View let , stop()
/// ( DiaryQuickSheet.dictation )
@State private var recorder = ConsultationRecorder()
@FocusState private var noteFocused: Bool
/// 10 ()
private let maxSeconds = 600
/// (,)
private var manualMode: Bool { !ConsultationRecorder.isAvailable }
private var canSave: Bool {
!note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
VStack(spacing: 0) {
grabber
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
if let note = voiceNote { noteBanner(note) }
phaseContent
}
.padding(.horizontal, 20)
.padding(.top, 4)
.padding(.bottom, 8)
}
.scrollDismissesKeyboard(.interactively)
bottomBar
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onDisappear(perform: cleanup)
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $deniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("问诊录音全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
}
}
// MARK: - Header
private var grabber: some View {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
}
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
Text("记录问诊")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("录音 → 本机转写 → AI 整理成问诊小结")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
}
// MARK: - Phase content
@ViewBuilder
private var phaseContent: some View {
switch phase {
case .idle:
if manualMode { manualEntry } else { idleStart }
case .recording:
recordingCard
case .transcribing:
transcribingCard
case .organizing:
organizingCard
case .review:
reviewArea
}
}
/// : + ( / / )
private var idleStart: some View {
VStack(spacing: 18) {
Button(action: startRecording) {
VStack(spacing: 10) {
ZStack {
Circle().fill(Tj.Palette.ink2)
.frame(width: 76, height: 76)
.shadow(color: Tj.Palette.ink2.opacity(0.3), radius: 12, y: 4)
Image(systemName: "mic.fill")
.font(.tjScaled( 30, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
}
Text("开始录音")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 22)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 10) {
bullet("stethoscope", String(appLoc: "看医生时录下来,事后不怕记不全"))
bullet("waveform", String(appLoc: "录音结束后在本机转写,原声存进加密档案"))
bullet("sparkles", String(appLoc: "康康自动整理成主诉/医生建议/复查小节"))
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
Text("仅供个人记录,不构成诊断或用药建议。")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
.padding(.top, 6)
}
private func bullet(_ icon: String, _ text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: icon)
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
.frame(width: 20)
Text(text)
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
}
/// : + +
private var recordingCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.variableColor.iterative, options: .repeating)
Text("正在录音 · 声音不上传")
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Text(Self.timeText(recordingSeconds))
.font(.tjScaled( 13, design: .monospaced))
.foregroundStyle(recordingSeconds >= maxSeconds - 30 ? Tj.Palette.brick : Tj.Palette.text3)
}
// 线(SenseVoice): + ,
VStack(spacing: 10) {
Image(systemName: "waveform")
.font(.tjScaled( 44, weight: .regular))
.foregroundStyle(Tj.Palette.ink2)
.symbolEffect(.variableColor.iterative, options: .repeating)
Text("正在录音,结束后会在本机把整段录音转成文字")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, minHeight: 150)
.padding(.vertical, 8)
Button(action: stopAndOrganize) {
HStack(spacing: 8) {
Image(systemName: "stop.circle.fill")
Text("结束并整理")
}
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
/// :线( SenseVoice,退),
private var transcribingCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.variableColor.iterative, options: .repeating)
Text(SenseVoiceASRService.isAvailable
? String(appLoc: "正在转写录音 · 本地 SenseVoice")
: String(appLoc: "正在转写录音 · 本机识别"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
Text("录音已结束,正在本机把整段录音转成文字,请稍候…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
AIFlowBar()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
/// AI : + + ()
private var organizingCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text(decodeRate > 0
? String(format: String(appLoc: "正在整理问诊小结 · %.1f tok/s"), decodeRate)
: String(appLoc: "正在整理问诊小结 · 本地推理"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button(String(appLoc: "用原话")) { cancelOrganize() }
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.buttonStyle(.plain)
}
Text(rawTranscript)
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(6)
AIFlowBar()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
/// : + + ( / ) +
private var reviewArea: some View {
VStack(alignment: .leading, spacing: 14) {
if audioTempURL != nil {
HStack(spacing: 6) {
Image(systemName: "waveform.circle.fill")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.ink2)
Text("录音已保存,可在记录详情里回放")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(String(appLoc: "问诊小结"))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
Spacer()
if decodeRate > 0 {
Text(String(format: "%.1f tok/s", decodeRate))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
TextField(String(appLoc: "整理后的问诊小结,可在这里修改…"),
text: $note, axis: .vertical)
.font(.tjScaled( 15))
.lineLimit(6...16)
.focused($noteFocused)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
HStack(spacing: 10) {
if !rawTranscript.isEmpty && note != rawTranscript {
reviewChip("arrow.uturn.backward", String(appLoc: "改用原话")) {
withAnimation(.snappy(duration: 0.18)) { note = rawTranscript }
}
}
if !rawTranscript.isEmpty {
reviewChip("arrow.clockwise", String(appLoc: "重新整理")) { reorganize() }
}
Spacer(minLength: 0)
}
}
VStack(alignment: .leading, spacing: 8) {
Text(String(appLoc: "时间"))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
AIDisclaimerFooter()
}
}
private func reviewChip(_ icon: String, _ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 4) {
Image(systemName: icon).font(.tjScaled( 10, weight: .semibold))
Text(label).font(.tjScaled( 11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
/// ():
private var manualEntry: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("本机暂不支持录音转写,可手动记录这次问诊")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
TextField(String(appLoc: "记录这次问诊:主诉、医生说了什么、用药、复查…"),
text: $note, axis: .vertical)
.font(.tjScaled( 15))
.lineLimit(6...16)
.focused($noteFocused)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
.padding(.top, 6)
}
// MARK: - Bottom bar
@ViewBuilder
private var bottomBar: some View {
switch phase {
case .idle where manualMode:
HStack(spacing: 8) {
Button(String(appLoc: "取消")) { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
Button(String(appLoc: "保存")) { save() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
.disabled(!canSave)
.opacity(canSave ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
case .idle:
// :, header ,
EmptyView()
case .review:
HStack(spacing: 8) {
Button(String(appLoc: "重新录音")) { restartRecording() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
Button(String(appLoc: "保存")) { save() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
.disabled(!canSave)
.opacity(canSave ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
case .recording, .transcribing, .organizing:
EmptyView()
}
}
private func noteBanner(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.amber)
Text(text).font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
}
// MARK: - Actions
private func startRecording() {
voiceNote = nil
noteFocused = false
recordTask = Task { @MainActor in
guard await recorder.requestAuthorization() else {
deniedAlert = true
return
}
do {
liveTranscript = ""
recordingSeconds = 0
try recorder.start { partial in liveTranscript = partial }
withAnimation(.snappy(duration: 0.2)) { phase = .recording }
watchdog = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled, phase == .recording else { return }
recordingSeconds += 1
if recordingSeconds >= maxSeconds { stopAndOrganize(); return }
}
}
} catch {
#if DEBUG
print("[Consultation] recorder start failed: \(error)")
#endif
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
phase = .idle
}
}
}
private func stopAndOrganize() {
guard phase == .recording else { return }
watchdog?.cancel()
organizeTask = Task { @MainActor in
let result = await recorder.stop()
audioTempURL = result.audioTempURL
// (SFSpeech,) SenseVoice / 退稿
var sfFallback = result.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
if sfFallback.isEmpty {
sfFallback = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
}
// 线: SenseVoice(MNN), / / 退 SFSpeech(线 #5)
withAnimation(.snappy(duration: 0.2)) { phase = .transcribing }
var transcript = ""
if SenseVoiceASRService.isAvailable, let url = result.audioTempURL {
transcript = (try? await SenseVoiceASRService.shared.transcribe(audioFileURL: url)) ?? ""
}
if Task.isCancelled { return }
if transcript.isEmpty { transcript = sfFallback }
rawTranscript = transcript
guard !transcript.isEmpty else {
// :;()
voiceNote = String(appLoc: "没听清,可手动补充或重新录音")
note = ""
withAnimation(.snappy(duration: 0.2)) { phase = .review }
return
}
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
await runOrganize(transcript: transcript)
}
}
/// LLM ;,/退(线 #5)
private func runOrganize(transcript: String) async {
do {
let organized = try await DiaryAssistService.shared.organizeConsultation(transcript: transcript)
guard !Task.isCancelled else { return }
note = organized.text
decodeRate = organized.decodeRate
} catch is CancellationError {
return // cancelOrganize 退
} catch {
note = transcript
voiceNote = String(appLoc: "AI 整理没成功,已填入录音原文")
}
withAnimation(.snappy(duration: 0.2)) { phase = .review }
}
private func cancelOrganize() {
guard phase == .organizing else { return }
organizeTask?.cancel()
note = rawTranscript
withAnimation(.snappy(duration: 0.2)) { phase = .review }
}
/// : LLM()
private func reorganize() {
guard !rawTranscript.isEmpty else { return }
organizeTask?.cancel()
decodeRate = 0
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
organizeTask = Task { @MainActor in
await runOrganize(transcript: rawTranscript)
}
}
/// :/,
private func restartRecording() {
organizeTask?.cancel()
if let url = audioTempURL { try? FileManager.default.removeItem(at: url) }
audioTempURL = nil
note = ""
rawTranscript = ""
liveTranscript = ""
decodeRate = 0
voiceNote = nil
withAnimation(.snappy(duration: 0.2)) { phase = .idle }
}
private func save() {
let content = note.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
let entry = DiaryEntry(content: content,
createdAt: createdAt,
tags: [DiaryEntry.consultationTag])
ctx.insert(entry)
// Vault Asset(:)
if let temp = audioTempURL,
let savedAsset = try? FileVault.shared.importFile(at: temp, preferredExtension: "m4a") {
let asset = Asset(relativePath: savedAsset.relativePath,
mimeType: "audio/m4a",
bytes: savedAsset.bytes)
ctx.insert(asset)
entry.assets.append(asset)
audioTempURL = nil // ,cleanup
}
try? ctx.save()
saved = true
dismiss()
}
/// :
private func cleanup() {
recordTask?.cancel()
organizeTask?.cancel()
watchdog?.cancel()
recorder.abort()
if !saved, let url = audioTempURL {
try? FileManager.default.removeItem(at: url)
}
}
private static func timeText(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview {
ConsultationSheet()
.modelContainer(for: [DiaryEntry.self, Asset.self], inMemory: true)
}

View File

@@ -1,9 +1,13 @@
import SwiftUI
/// : MNN(CPU/SME2,) MLX(GPU,), SME2
/// : Gemma-3n( 4bit), MLX/GPU
/// MNN/SME2 (Gemma-3n MNN ), MNN
/// ; AI (prepare/generate)
struct InferenceSettingsView: View {
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
// AI(Gemini) key CloudAI ,@AppStorage
@AppStorage("cloud_ai_gemini_enabled") private var cloudEnabled = false
@AppStorage("cloud_ai_gemini_key") private var cloudKey = ""
@State private var modelService = ModelDownloadService.shared
/// , push
@State private var showSelfTest = false
@@ -12,10 +16,9 @@ struct InferenceSettingsView: View {
EnginePreference(rawValue: engineRaw) ?? .auto
}
/// (MNN MLX )
/// (Gemma-3n,MLX)
private var modelReady: Bool {
modelService.states[.mnnLLM]?.phase == .ready
|| modelService.states[.llm]?.phase == .ready
modelService.states[.llm]?.phase == .ready
}
var body: some View {
@@ -34,8 +37,8 @@ struct InferenceSettingsView: View {
engineRow(engine)
}
sme2Card
selfTestSection
cloudSection
noteCard
}
.padding(.horizontal, 16)
@@ -134,11 +137,11 @@ struct InferenceSettingsView: View {
.disabled(!available)
}
/// .auto ;
/// .auto ;MLX MNN (Gemma-3n MNN ),
private func isAvailable(_ engine: EnginePreference) -> Bool {
switch engine {
case .auto: return true
case .mnn: return InferenceEngine.mnn.isAvailable
case .mnn: return false
case .mlx: return InferenceEngine.mlx.isAvailable
}
}
@@ -154,52 +157,74 @@ struct InferenceSettingsView: View {
private func subtitle(_ engine: EnginePreference, available: Bool) -> String {
switch engine {
case .auto:
// ,
let resolved = engine.resolved
if resolved == .mnn {
return InferenceEngine.cpuSupportsSME2
? String(appLoc: "按本机配置选择 · 当前 MNN + SME2")
: String(appLoc: "按本机配置选择 · 当前 MNN(NEON)")
} else {
return String(appLoc: "按本机配置选择 · 当前 MLX(MNN 不可用)")
}
// Gemma-3n,auto MLX
return String(appLoc: "按本机配置选择 · 当前 MLX · GPU")
case .mnn:
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
return InferenceEngine.cpuSupportsSME2
? String(appLoc: "端侧 CPU + SME2 加速 · 挑战赛考核路径")
: String(appLoc: "端侧 CPU(本机无 SME2,NEON 回退)")
return String(appLoc: "已停用:Gemma-3n 无 MNN 转换模型,统一走 MLX")
case .mlx:
return String(appLoc: "Metal GPU · 兜底 / 对照")
return String(appLoc: "Metal GPU · 端侧推理 Gemma-3n E2B")
}
}
private var sme2Card: some View {
let sme2 = InferenceEngine.cpuSupportsSME2
return HStack(spacing: 12) {
ZStack {
Circle().fill(sme2 ? Tj.Palette.leafSoft : Tj.Palette.sand2)
Image(systemName: sme2 ? "checkmark.seal.fill" : "minus.circle")
.font(.tjScaled(18))
.foregroundStyle(sme2 ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Arm SME2")
.font(.tjScaled(15, weight: .medium))
private var cloudConfigured: Bool {
cloudEnabled && !cloudKey.trimmingCharacters(in: .whitespaces).isEmpty
}
/// AI(Gemini)hybrid :(),
/// AI Studio key , / / Google Gemini
private var cloudSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("云端 AI · Gemini")
.font(.tjTitle())
.foregroundStyle(Tj.Palette.text)
Text(sme2 ? String(appLoc: "本设备支持,MNN 已启用 SME2 加速")
: String(appLoc: "本设备不支持(需 A19/iPhone 17+)"))
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
Spacer()
.padding(.top, 10)
VStack(alignment: .leading, spacing: 12) {
Toggle(isOn: $cloudEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("启用云端增强")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("默认端侧 Gemma-3n;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
}
.tint(Tj.Palette.leaf)
if cloudEnabled {
SecureField(String(appLoc: "粘贴 Google AI Studio 的 API Key"), text: $cloudKey)
.font(.tjScaled(14))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(RoundedRectangle(cornerRadius: Tj.Radius.md).fill(Tj.Palette.sand2))
HStack(spacing: 6) {
Image(systemName: cloudConfigured ? "checkmark.seal.fill" : "exclamationmark.circle")
.font(.tjScaled(13))
.foregroundStyle(cloudConfigured ? Tj.Palette.leaf : Tj.Palette.text3)
Text(cloudConfigured
? String(appLoc: "已就绪 · \(AIRuntime.cloudLabel)")
: String(appLoc: "请填入 API Key(aistudio.google.com 免费获取)"))
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
Text("Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma-3n,功能不中断。")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(14)
.tjCard()
}
.padding(14)
.tjCard()
}
private var noteCard: some View {
Text("MNN 在端侧 CPU 上以 Arm SME2 指令集加速 Qwen 推理(本地、不上云)。切换后下一次 AI 调用生效。")
Text("隐私优先:默认端侧 Gemma-3n(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -141,7 +141,7 @@ struct ModelManagementView: View {
} else if allReady {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
Text("Qwen3.5-2B 已就绪")
Text("Gemma-3n E2B 已就绪")
}
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.leaf)
@@ -205,9 +205,9 @@ struct ModelManagementView: View {
private func subtitle(_ kind: ModelKind) -> String {
switch kind {
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答(MLX 兜底)")
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答 / 拍照识别 · MLX 端侧推理")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
case .mnnLLM: return String(appLoc: "文本解读 + 拍照识别 · MNN + SME2 端侧加速")
case .mnnLLM: return String(appLoc: "已停用 · 旧 MNN 路径")
}
}

View File

@@ -177,7 +177,7 @@ struct ModelSelfTestView: View {
}
}
}
Text("在「我的 · 推理引擎」切换引擎后再跑一次,即可对比 SME2 与 GPU")
Text("Gemma-3n E2B 在端侧 MLX(Metal GPU)推理,100% 本地、不上云")
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}

View File

@@ -1,7 +1,7 @@
import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary, consultation
var id: String { rawValue }
/// RecordSheet () enum ,
@@ -9,7 +9,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
/// `.symptom`() `.diary`(),;
/// `.medicationLibrary`()/,Tab ,
/// (,)
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
static let displayOrder: [RecordKind] = [.diary, .consultation, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
/// pill( subtitle,"/")
/// :,( ProfileEditView presets )
@@ -24,6 +24,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return String(appLoc: "身体档案")
case .archive: return String(appLoc: "体检报告归档")
case .diary: return String(appLoc: "健康日记")
case .consultation: return String(appLoc: "记录问诊")
case .symptom: return String(appLoc: "记录症状")
case .reminder: return String(appLoc: "开启一个提醒")
case .medicationLibrary: return String(appLoc: "药品库")
@@ -36,6 +37,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
case .consultation: return String(appLoc: "看医生时录音,本机转写并整理成小结")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
@@ -48,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return "doc.text.below.ecg"
case .archive: return "doc.fill"
case .diary: return "heart.text.square"
case .consultation: return "stethoscope"
case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge"
case .medicationLibrary: return "pills.fill"
@@ -60,6 +63,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return Tj.Palette.ink
case .archive: return Tj.Palette.ink
case .diary: return Tj.Palette.leaf
case .consultation: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf
case .medicationLibrary: return Tj.Palette.ink

View File

@@ -3,36 +3,69 @@ import SwiftData
import Foundation
enum TimelineKind: String, CaseIterable, Identifiable {
case diary, symptom, indicator, medication, report
case diary, symptom, consultation, indicator, medication, report
var id: String { rawValue }
var label: String {
switch self {
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
case .medication: return String(appLoc: "用药")
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
case .consultation: return String(appLoc: "问诊")
case .medication: return String(appLoc: "用药")
}
}
var icon: String {
switch self {
case .indicator: return "drop.fill"
case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg"
case .diary: return "pencil"
case .medication: return "pills.fill"
case .indicator: return "drop.fill"
case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg"
case .diary: return "pencil"
case .consultation: return "stethoscope"
case .medication: return "pills.fill"
}
}
var accent: Color {
switch self {
case .indicator: return Tj.Palette.brick
case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf
case .medication: return Tj.Palette.ink
case .indicator: return Tj.Palette.brick
case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf
case .consultation: return Tj.Palette.ink2
case .medication: return Tj.Palette.ink
}
}
}
extension TimelineKind {
/// (2026-06-28):,,
/// / /
/// TimelineKind,
enum Group: String, CaseIterable, Identifiable {
case selfLog // :(/)
case clinical // :()
case medication //
var id: String { rawValue }
/// ()(), ArchiveListView
var caption: String {
switch self {
case .selfLog: return String(appLoc: "自述")
case .clinical: return String(appLoc: "检查")
case .medication: return String(appLoc: "用药")
}
}
var kinds: [TimelineKind] {
switch self {
case .selfLog: return [.diary, .symptom, .consultation]
case .clinical: return [.indicator, .report]
case .medication: return [.medication]
}
}
}
}
@@ -165,17 +198,28 @@ struct TimelineEntry: Identifiable, Hashable {
}
}
/// tag () .medication ,
/// id "diary-" :TimelineDetail.resolve diaries
/// DiaryEntry tag : .consultation,/ .medication,
/// id "diary-" :TimelineDetail.resolve diaries
static func from(diary d: DiaryEntry) -> TimelineEntry {
let isMed = d.isMedicationLog
let kind: TimelineKind
let subtitle: String
if d.isConsultation {
kind = .consultation
subtitle = String(appLoc: "问诊记录")
} else if d.isMedicationLog {
kind = .medication
subtitle = String(appLoc: "用药记录")
} else {
kind = .diary
subtitle = String(appLoc: "文字日记")
}
return TimelineEntry(
id: "diary-\(d.persistentModelID)",
kind: isMed ? .medication : .diary,
kind: kind,
date: d.createdAt,
title: d.content.firstLine(),
subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
trailing: nil,
subtitle: subtitle,
trailing: d.isConsultation && d.audioAsset != nil ? String(appLoc: "录音") : nil,
trailingIsAlert: false,
isOngoing: false
)

View File

@@ -22,8 +22,8 @@ enum TimelineDetail {
case .report:
return reports.first { "report-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.report)
case .diary, .medication:
// tag DiaryEntry,
case .diary, .medication, .consultation:
// / tag DiaryEntry, diaries
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.diary)
case .symptom:
@@ -201,7 +201,9 @@ struct TimelineEntryDetailView: View {
case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情")
case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
case .diary(let d):
if d.isConsultation { return String(appLoc: "问诊详情") }
return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情")
}
}
@@ -377,7 +379,9 @@ struct TimelineEntryDetailView: View {
@ViewBuilder
private func diaryBody(_ d: DiaryEntry) -> some View {
if d.isMedicationLog {
if d.isConsultation {
consultationBody(d)
} else if d.isMedicationLog {
medicationBody(d)
} else {
VStack(alignment: .leading, spacing: 16) {
@@ -398,6 +402,42 @@ struct TimelineEntryDetailView: View {
}
}
// MARK: - ( + )
/// (tag ):(),
/// + (header TjLockChip );AI 稿,/
private func consultationBody(_ d: DiaryEntry) -> some View {
VStack(alignment: .leading, spacing: 16) {
if let audio = d.audioAsset {
ConsultationAudioPlayer(asset: audio)
}
card {
HStack(spacing: 6) {
Image(systemName: "stethoscope")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
Text(String(appLoc: "问诊小结"))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
divider
Text(d.content)
.font(.tjScaled( 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Text("内容由本机语音识别 + AI 整理,仅供个人回看,可能与原话有出入,不构成诊断或用药建议。")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - 使(// + )
/// 使(tag ): [] · + ,