缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。

当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
link2026
2026-06-07 14:17:18 +08:00
parent 074d99715d
commit 77a4ee1c37
66 changed files with 2676 additions and 548 deletions

View File

@@ -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)