缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 「导出身体档案」全屏 sheet。
|
||||
/// 状态机:idle → running(extractingIntent → retrieving → generating)→ completed / failed
|
||||
/// 状态机:多轮问答 → running(retrieving → generating)→ completed / failed
|
||||
struct HealthExportSheet: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -10,7 +10,8 @@ struct HealthExportSheet: View {
|
||||
/// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。
|
||||
let initialPrompt: String
|
||||
|
||||
@State private var prompt: String = ""
|
||||
@State private var turns: [HealthExportDialogueTurn] = []
|
||||
@State private var draftQuestion: String = ""
|
||||
@State private var phase: HealthExportService.Phase?
|
||||
@State private var content: String = ""
|
||||
@State private var rate: Double = 0
|
||||
@@ -18,14 +19,25 @@ struct HealthExportSheet: View {
|
||||
@State private var error: Error?
|
||||
@State private var completed: Bool = false
|
||||
@State private var copiedFlash: Bool = false
|
||||
@FocusState private var promptFocused: Bool
|
||||
@State private var answeringTurnID: UUID?
|
||||
@FocusState private var questionFocused: Bool
|
||||
|
||||
init(initialPrompt: String = "") {
|
||||
self.initialPrompt = initialPrompt
|
||||
}
|
||||
|
||||
private var isRunning: Bool { phase != nil && !completed && error == nil }
|
||||
private var isInputMode: Bool { phase == nil && !completed && error == nil }
|
||||
private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
|
||||
private var isAnswering: Bool { answeringTurnID != nil }
|
||||
private var canAsk: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
private var canGenerateReport: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -33,28 +45,20 @@ struct HealthExportSheet: View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if isInputMode {
|
||||
inputSection
|
||||
} else {
|
||||
promptEcho
|
||||
if isRunning { phaseIndicator }
|
||||
if !content.isEmpty {
|
||||
MarkdownView(text: content)
|
||||
.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)
|
||||
)
|
||||
}
|
||||
if let err = error { errorRow(err) }
|
||||
// 锚点,让流式输出自动滚到底
|
||||
Color.clear.frame(height: 1).id("bottom")
|
||||
introSection
|
||||
|
||||
ForEach(turns) { turn in
|
||||
dialogueBubble(turn)
|
||||
}
|
||||
|
||||
if isGeneratingReport { phaseIndicator }
|
||||
|
||||
if !content.isEmpty {
|
||||
reportCard
|
||||
}
|
||||
|
||||
if let err = error { errorRow(err) }
|
||||
Color.clear.frame(height: 1).id("bottom")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
@@ -64,13 +68,24 @@ struct HealthExportSheet: View {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onChange(of: turns) { _, _ in
|
||||
withAnimation(.easeOut(duration: 0.12)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
if completed {
|
||||
actionRow
|
||||
} else {
|
||||
composer
|
||||
}
|
||||
if completed { actionRow }
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.onAppear {
|
||||
if prompt.isEmpty { prompt = initialPrompt }
|
||||
if isInputMode { promptFocused = true }
|
||||
if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
|
||||
draftQuestion = initialPrompt
|
||||
}
|
||||
questionFocused = true
|
||||
}
|
||||
.onDisappear { task?.cancel() }
|
||||
}
|
||||
@@ -81,17 +96,17 @@ struct HealthExportSheet: View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button { close() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("导出身体档案")
|
||||
Text("身体档案")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("给医生看的就诊摘要")
|
||||
.font(.system(size: 11))
|
||||
Text("先问清楚,再整理给医生")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
@@ -105,82 +120,92 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input section (idle)
|
||||
// MARK: - Dialogue
|
||||
|
||||
private var inputSection: some View {
|
||||
private var introSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("说说你想给医生看什么")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text("围绕你的指标和健康日记提问")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("例:我感冒3天了,把最近一个月的健康情况给医生看")
|
||||
.font(.system(size: 12))
|
||||
Text("例:最近血压波动大吗?")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下")
|
||||
.font(.system(size: 12))
|
||||
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
if prompt.isEmpty {
|
||||
Text("在这里输入主诉……")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.allowsHitTesting(false)
|
||||
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||
.font(.tjScaled( 11))
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
|
||||
let isUser = turn.role == .user
|
||||
return HStack(alignment: .top, spacing: 8) {
|
||||
if isUser { Spacer(minLength: 44) }
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(turn.role.transcriptLabel)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
||||
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("正在查看本地记录…")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
} else {
|
||||
Text(turn.text)
|
||||
.font(.tjScaled( 14))
|
||||
.lineSpacing(3)
|
||||
.foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
TextEditor(text: $prompt)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(minHeight: 130)
|
||||
.focused($promptFocused)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
.fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
.strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
|
||||
HStack {
|
||||
Text("本地 RAG · Qwen3 1.7B · 不上传任何数据")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Button { start() } label: {
|
||||
Text("生成报告")
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||
.disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1)
|
||||
}
|
||||
if !isUser { Spacer(minLength: 44) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prompt echo (after start)
|
||||
|
||||
private var promptEcho: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "quote.opening")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(prompt)
|
||||
.font(.system(size: 13))
|
||||
private var reportCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("整理好的报告")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineLimit(3)
|
||||
MarkdownView(text: content)
|
||||
}
|
||||
.padding(12)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,11 +222,11 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
if phase == .generating && rate > 0 {
|
||||
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
} else {
|
||||
Text(phase?.label ?? "")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -213,7 +238,7 @@ struct HealthExportSheet: View {
|
||||
let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
|
||||
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
|
||||
return Text(p.label)
|
||||
.font(.system(size: 11, weight: active ? .semibold : .regular))
|
||||
.font(.tjScaled( 11, weight: active ? .semibold : .regular))
|
||||
.foregroundStyle(fg)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
@@ -222,7 +247,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
private var arrow: some View {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.font(.tjScaled( 10, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
@@ -243,7 +268,7 @@ struct HealthExportSheet: View {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text(err.localizedDescription)
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
Button { reset() } label: { Text("返回修改") }
|
||||
@@ -268,7 +293,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
ShareLink(item: content) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
@@ -279,7 +304,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
Spacer()
|
||||
Button { regenerate() } label: {
|
||||
Label("重新生成", systemImage: "arrow.clockwise")
|
||||
Label("重新整理", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
|
||||
}
|
||||
@@ -291,19 +316,100 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
.font(.tjScaled( 14))
|
||||
.lineLimit(1...4)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
)
|
||||
.focused($questionFocused)
|
||||
.disabled(isAnswering || isGeneratingReport)
|
||||
|
||||
Button { sendQuestion() } label: {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.tjScaled( 15, weight: .bold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line))
|
||||
}
|
||||
.disabled(!canAsk)
|
||||
.accessibilityLabel("发送问题")
|
||||
}
|
||||
|
||||
Button { startReportGeneration() } label: {
|
||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||
.disabled(!canGenerateReport)
|
||||
.opacity(canGenerateReport ? 1 : 0.45)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Tj.Palette.paper)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func start() {
|
||||
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !p.isEmpty else { return }
|
||||
promptFocused = false
|
||||
private func sendQuestion() {
|
||||
let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
|
||||
draftQuestion = ""
|
||||
questionFocused = false
|
||||
|
||||
let userTurn = HealthExportDialogueTurn.user(question)
|
||||
let assistantTurn = HealthExportDialogueTurn.assistant("")
|
||||
turns.append(userTurn)
|
||||
turns.append(assistantTurn)
|
||||
answeringTurnID = assistantTurn.id
|
||||
|
||||
let conversationForPrompt = turns.filter { $0.id != assistantTurn.id }
|
||||
let stream = HealthExportService.shared.answer(
|
||||
question: question,
|
||||
conversation: conversationForPrompt,
|
||||
in: ctx
|
||||
)
|
||||
task?.cancel()
|
||||
task = Task { @MainActor in
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
appendToTurn(id: assistantTurn.id, text: chunk.text)
|
||||
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||
}
|
||||
answeringTurnID = nil
|
||||
questionFocused = true
|
||||
} catch {
|
||||
answeringTurnID = nil
|
||||
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
|
||||
questionFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendToTurn(id: UUID, text: String) {
|
||||
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
|
||||
turns[idx].text += text
|
||||
}
|
||||
|
||||
private func startReportGeneration() {
|
||||
guard canGenerateReport else { return }
|
||||
questionFocused = false
|
||||
content = ""
|
||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||
error = nil
|
||||
completed = false
|
||||
phase = .extractingIntent
|
||||
phase = .retrieving
|
||||
|
||||
let stream = HealthExportService.shared.export(prompt: p, in: ctx)
|
||||
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
|
||||
task?.cancel()
|
||||
task = Task { @MainActor in
|
||||
do {
|
||||
for try await event in stream {
|
||||
@@ -326,7 +432,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
private func regenerate() {
|
||||
completed = false
|
||||
start()
|
||||
startReportGeneration()
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
@@ -337,7 +443,8 @@ struct HealthExportSheet: View {
|
||||
rate = 0
|
||||
error = nil
|
||||
completed = false
|
||||
promptFocused = true
|
||||
answeringTurnID = nil
|
||||
questionFocused = true
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
@@ -377,7 +484,7 @@ struct MarkdownView: View {
|
||||
case .h1(let s):
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(inline(s))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.font(.tjScaled( 22, weight: .bold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Rectangle()
|
||||
@@ -394,7 +501,7 @@ struct MarkdownView: View {
|
||||
.fill(Tj.Palette.brick)
|
||||
.frame(width: 3, height: 16)
|
||||
Text(inline(s))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
@@ -404,10 +511,10 @@ struct MarkdownView: View {
|
||||
if let abnormalText = Self.extractAbnormal(s) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text(inline(abnormalText))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
@@ -431,7 +538,7 @@ struct MarkdownView: View {
|
||||
.frame(width: 4, height: 4)
|
||||
.padding(.top, 6)
|
||||
Text(inline(s))
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -440,7 +547,7 @@ struct MarkdownView: View {
|
||||
|
||||
case .body(let s):
|
||||
Text(inline(s))
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.lineSpacing(3)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Reference in New Issue
Block a user