Files
kangkang/康康/Features/Diary/DiaryCompanionView.swift
link2026 198570186e 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
2026-07-14 13:07:15 +08:00

592 lines
26 KiB
Swift

import SwiftUI
import SwiftData
///
///
/// :(makeContextJSON) LLM,;
/// + ;(maxRounds ),
/// 稿(DiaryQuickSheet cover)
///
/// (Phase):
/// preparing token speaking awaitingUser
/// awaitingUser recording / thinking(reply) speaking awaitingUser
/// awaitingUser distilling onDraft()
/// thinking/speaking reply failed thinking
///
/// (线 #5 AI ):
/// · opening () DiaryChatPrompts.staticOpening + toast,;
/// · reply () Phase.failed,,;
/// · distill / onDraft(fallbackDraft, usedFallback: true),;
/// · () ,,
///
/// :UI AIRuntime, DiaryChatService(线 #3); AIRuntime actor
struct DiaryCompanionView: View {
/// ()稿;usedFallback=true AI
/// cover; onDraft onClose
let onDraft: (_ draft: String, _ usedFallback: Bool) -> Void
let onClose: () -> Void
/// , 180s, 120s
static let maxRecordingSeconds = 120
// MARK: -
enum Phase: Equatable {
case preparing // :makeContextJSON + openingLine ( token )
case speaking // (opening reply)
case awaitingUser // : mic /
case recording // ,
case thinking // reply token
case distilling //
case failed(String) // ( reply )
}
@Environment(\.modelContext) private var ctx
@State private var phase: Phase = .preparing
@State private var turns: [HealthExportDialogueTurn] = []
/// ; trim turns,
@State private var bubbleText = ""
@State private var liveTranscript = ""
/// ; makeContextJSON
@State private var dataJSON = "{}"
@State private var recordingSeconds = 0
@State private var lastRate: Double = 0
/// ( / ), 2.5s
@State private var toast: String?
@State private var showAbandonConfirm = false
@State private var micDeniedAlert = false
/// / onDisappear ,
@State private var chatTask: Task<Void, Never>?
/// ( +1,)
@State private var recordingWatchdog: Task<Void, Never>?
/// ,reply
@State private var pendingLatest: String?
/// .task (startOpening )
@State private var started = false
/// @State:struct View () let ,
/// stop() (),
/// @State ( DiaryQuickSheet )
@State private var dictation = SpeechDictationService()
// MARK: -
/// : thinking, speaking, listening, idle
private var avatarMood: CompanionAvatarView.Mood {
switch phase {
case .preparing, .thinking, .distilling: return .thinking
case .speaking: return .speaking
case .recording: return .listening
case .awaitingUser, .failed: return .idle
}
}
private var hasUserTurns: Bool { turns.contains { $0.role == .user } }
private var wrappedUp: Bool { DiaryChatService.isWrappedUp(turns: turns) }
// MARK: - Body
var body: some View {
VStack(spacing: 0) {
topBar
CompanionAvatarView(mood: avatarMood, size: 150)
.padding(.vertical, 14)
.animation(.snappy(duration: 0.25), value: avatarMood)
dialogueArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
controlArea
.padding(.horizontal, 20)
.padding(.top, 8)
.animation(.snappy(duration: 0.22), value: phase)
AIDisclaimerFooter()
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 10)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.task { startOpening() }
.onChange(of: toast) { _, newValue in
// : 2.5s ( toast onChange )
guard newValue != nil else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_500_000_000)
if toast == newValue { toast = nil }
}
}
.onDisappear {
chatTask?.cancel()
recordingWatchdog?.cancel()
dictation.abort()
}
.confirmationDialog(String(appLoc: "聊到一半,要放弃吗?"),
isPresented: $showAbandonConfirm,
titleVisibility: .visible) {
Button("把说过的记下来") {
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
}
Button("直接关闭", role: .destructive) { onClose() }
Button("取消", role: .cancel) {}
}
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $micDeniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
}
}
// MARK: -
private var topBar: some View {
HStack(alignment: .center, spacing: 12) {
Button(action: attemptClose) {
Image(systemName: "xmark")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(width: 44, height: 44)
.background(Circle().fill(Tj.Palette.paper))
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Circle())
}
.buttonStyle(.plain)
Spacer(minLength: 8)
VStack(spacing: 2) {
Text("和康康聊聊")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("聊完帮你整理成一条记录")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 8)
roundDots
.frame(width: 44, alignment: .trailing)
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 4)
}
/// :maxRounds , i brick
private var roundDots: some View {
HStack(spacing: 4) {
ForEach(0..<DiaryChatPrompts.maxRounds, id: \.self) { i in
Circle()
.fill(DiaryChatService.roundsUsed(in: turns) > i
? Tj.Palette.brick : Tj.Palette.line)
.frame(width: 6, height: 6)
}
}
}
// MARK: -
private var dialogueArea: some View {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 10) {
ForEach(turns) { turn in
bubbleRow(text: turn.text, isUser: turn.role == .user)
}
// : tok/s(lastRate>0 )
if !bubbleText.isEmpty {
bubbleRow(text: bubbleText, isUser: false, rate: lastRate)
}
// :,brick 稿
if phase == .recording, !liveTranscript.isEmpty {
bubbleRow(text: liveTranscript, isUser: true,
strokeColor: Tj.Palette.brick.opacity(0.5))
}
if let toast {
toastBar(toast)
}
// ( DiaryVoicePanel tail )
Color.clear.frame(height: 1).id("tail")
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
}
.onChange(of: turns.count) { _, _ in scrollToTail(proxy) }
.onChange(of: bubbleText) { _, _ in scrollToTail(proxy) }
}
}
private func scrollToTail(_ proxy: ScrollViewProxy) {
// : bubbleText token ,( DiaryVoicePanel:94-96)
proxy.scrollTo("tail", anchor: .bottom)
}
/// (assistant) paper + lineSoft ; sand2
/// - rate: >0 tok/s()
/// - strokeColor: ( brick )
@ViewBuilder
private func bubbleRow(text: String,
isUser: Bool,
rate: Double? = nil,
strokeColor: Color? = nil) -> some View {
let stroke = strokeColor ?? (isUser ? Color.clear : Tj.Palette.lineSoft)
HStack(alignment: .top, spacing: 0) {
if isUser { Spacer(minLength: 40) }
VStack(alignment: .leading, spacing: 4) {
Text(text)
.font(.tjScaled(15))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
if let rate, rate > 0 {
Text(String(format: "%.1f tok/s", rate))
.font(.tjScaled(10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(isUser ? Tj.Palette.sand2 : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(stroke, lineWidth: 1)
)
if !isUser { Spacer(minLength: 40) }
}
}
/// (amber + text2 )text ,
private func toastBar(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 4)
}
// MARK: -
@ViewBuilder
private var controlArea: some View {
Group {
switch phase {
case .preparing:
statusBlock(String(appLoc: "康康正在翻你的记录…"))
case .thinking:
VStack(spacing: 10) {
Text("康康在想…")
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
AIFlowBar().frame(maxWidth: 200)
Button("") { stopThinking() }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
}
case .distilling:
// , onDisappear ,
statusBlock(String(appLoc: "康康在帮你整理…"))
case .speaking:
// , awaitingUser
Color.clear.frame(height: 1)
case .awaitingUser:
awaitingControls
case .recording:
recordingControls
case .failed(let msg):
failedControls(msg)
}
}
.frame(maxWidth: .infinity)
.frame(minHeight: 150)
}
/// : + preparing / distilling
private func statusBlock(_ text: String) -> some View {
VStack(spacing: 12) {
Text(text)
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
AIFlowBar().frame(maxWidth: 200)
}
}
@ViewBuilder
private var awaitingControls: some View {
if wrappedUp {
// :,
Button("聊完了,帮我记下来") { finishChat() }
.buttonStyle(TjPrimaryButton())
} else {
VStack(spacing: 12) {
Button(action: startRecording) {
ZStack {
Circle().fill(Tj.Palette.ink)
.frame(width: 72, height: 72)
Image(systemName: "mic.fill")
.font(.tjScaled(26, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.contentShape(Circle())
}
.buttonStyle(.plain)
Text("点一下,说给康康听")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
if hasUserTurns {
Button("聊完了,帮我记下来") { finishChat() }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
}
}
}
}
private var recordingControls: some View {
VStack(spacing: 12) {
HStack(spacing: 14) {
Button(action: stopAndReply) {
ZStack {
Circle().fill(Tj.Palette.brick)
.frame(width: 72, height: 72)
Image(systemName: "stop.fill")
.font(.tjScaled(24, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.contentShape(Circle())
}
.buttonStyle(.plain)
Text(timeString(recordingSeconds))
.font(.tjMono(12))
.foregroundStyle(recordingSeconds >= Self.maxRecordingSeconds - 20
? Tj.Palette.brick : Tj.Palette.text3)
}
Text("说完了")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
}
private func failedControls(_ msg: String) -> some View {
VStack(spacing: 12) {
Text(msg) // (),,
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 10) {
Button("重试") { retryReply() }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
if hasUserTurns {
Button("不聊了,把原话记下来") {
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
}
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
}
}
}
.padding(.horizontal, 20)
}
private func timeString(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
// MARK: - :
/// :,
private func startOpening() {
guard !started else { return }
started = true
dataJSON = DiaryChatService.shared.makeContextJSON(in: ctx)
consumeBubbleStream(DiaryChatService.shared.openingLine(dataJSON: dataJSON),
isOpening: true)
}
/// (opening reply): token bubbleText, turns
/// ;,,
private func consumeBubbleStream(_ stream: AsyncThrowingStream<TokenChunk, Error>,
isOpening: Bool) {
chatTask = Task { @MainActor in
do {
for try await chunk in stream {
if bubbleText.isEmpty, !chunk.text.isEmpty {
withAnimation(.snappy(duration: 0.2)) { phase = .speaking }
}
bubbleText += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
//
let done = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines)
bubbleText = ""
if !done.isEmpty {
turns.append(.assistant(done))
} else if isOpening {
// :(streamWithFallback .empty,)
turns.append(.assistant(DiaryChatPrompts.staticOpening))
toast = String(appLoc: "康康没连上,先用老一套开场啦")
}
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} catch {
// ( CancellationError)
let partial = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines)
bubbleText = ""
if !partial.isEmpty {
// :,
turns.append(.assistant(partial))
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} else if isOpening {
// : + ,
turns.append(.assistant(DiaryChatPrompts.staticOpening))
toast = String(appLoc: "康康没连上,先用老一套开场啦")
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} else if error is CancellationError {
// :,
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} else {
// :
withAnimation(.snappy(duration: 0.2)) {
phase = .failed(error.localizedDescription)
}
}
}
}
}
/// :,(consumeBubbleStream awaitingUser)
private func stopThinking() {
chatTask?.cancel()
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
}
// MARK: - :
/// alert;start toast
private func startRecording() {
toast = nil
chatTask = Task { @MainActor in
guard await dictation.requestAuthorization() else {
micDeniedAlert = true
return
}
do {
liveTranscript = ""
recordingSeconds = 0
try dictation.start { partial in liveTranscript = partial }
withAnimation(.snappy(duration: 0.2)) { phase = .recording }
// + ( DiaryQuickSheet:701 , stopAndReply)
recordingWatchdog = 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 >= Self.maxRecordingSeconds {
stopAndReply()
return
}
}
}
} catch {
toast = String(appLoc: "没听清,再试一次")
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
}
}
}
/// 稿(, DiaryQuickSheet:691-695),
private func stopAndReply() {
guard phase == .recording else { return }
recordingWatchdog?.cancel()
chatTask = Task { @MainActor in
var text = (await dictation.stop())
.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
text = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !text.isEmpty else {
toast = String(appLoc: "没听清,再试一次")
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
return
}
pendingLatest = text
turns.append(.user(text))
liveTranscript = ""
withAnimation(.snappy(duration: 0.2)) { phase = .thinking }
sendReply(latest: text)
}
}
/// transcript dropLast() append ( latest ,
/// );roundsUsed assistant ,, turns
private func sendReply(latest: String) {
let prior = HealthExportDialogueTurn.transcript(from: Array(turns.dropLast()))
let stream = DiaryChatService.shared.reply(
transcript: prior,
latest: latest,
dataJSON: dataJSON,
roundsUsed: DiaryChatService.roundsUsed(in: turns)
)
consumeBubbleStream(stream, isOpening: false)
}
/// :turns , pendingLatest
private func retryReply() {
guard let latest = pendingLatest else { return }
withAnimation(.snappy(duration: 0.2)) { phase = .thinking }
sendReply(latest: latest)
}
// MARK: - :
/// :稿 / (usedFallback=true)
private func finishChat() {
withAnimation(.snappy(duration: 0.2)) { phase = .distilling }
chatTask = Task { @MainActor in
do {
let r = try await DiaryChatService.shared.distill(turns: turns)
onDraft(r.text.isEmpty ? DiaryChatService.fallbackDraft(turns: turns) : r.text,
r.text.isEmpty)
} catch {
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
}
}
}
/// :(),
private func attemptClose() {
if hasUserTurns {
showAbandonConfirm = true
} else {
onClose()
}
}
}
#Preview {
// , openingLine staticOpening ,
DiaryCompanionView(onDraft: { _, _ in }, onClose: {})
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}