缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
@@ -30,7 +30,6 @@ struct ArchiveListView: View {
|
||||
@State private var filter: TimelineKind? = nil
|
||||
@State private var endingSymptom: Symptom?
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
@State private var showExportSheet = false
|
||||
@State private var route: Route?
|
||||
|
||||
@MainActor
|
||||
@@ -110,9 +109,6 @@ struct ArchiveListView: View {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExportSheet) {
|
||||
HealthExportSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -150,35 +146,23 @@ struct ArchiveListView: View {
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Menu {
|
||||
Button {
|
||||
showExportSheet = true
|
||||
} label: {
|
||||
Label("生成新导出", systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
if !exports.isEmpty {
|
||||
Button {
|
||||
route = .exports
|
||||
} label: {
|
||||
Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath")
|
||||
if !exports.isEmpty {
|
||||
Button { route = .exports } label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
Text("导出历史")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "doc.text.below.ecg")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text("导出身体档案")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,19 +201,19 @@ struct ArchiveListView: View {
|
||||
ZStack {
|
||||
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 16))
|
||||
.font(.tjScaled( 16))
|
||||
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(reminderCountLabel)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if !reminderTitlePreview.isEmpty {
|
||||
Text(reminderTitleLine)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -238,7 +222,7 @@ struct ArchiveListView: View {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
@@ -265,7 +249,7 @@ struct ArchiveListView: View {
|
||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -282,14 +266,14 @@ struct ArchiveListView: View {
|
||||
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
||||
HStack {
|
||||
Text(section.label)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Rectangle()
|
||||
.fill(Tj.Palette.lineSoft)
|
||||
.frame(height: 1)
|
||||
Text("\(count)")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -303,7 +287,7 @@ struct ArchiveListView: View {
|
||||
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||
.frame(width: 240, height: 140)
|
||||
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ struct HealthExportDetailView: View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button { dismiss() } 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))
|
||||
@@ -62,7 +62,7 @@ struct HealthExportDetailView: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(Self.absoluteDate(export.createdAt))
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
@@ -81,13 +81,13 @@ struct HealthExportDetailView: View {
|
||||
TjBadge(text: export.modelTag, style: .neutral)
|
||||
if export.decodeRate > 0 {
|
||||
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
Spacer()
|
||||
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
|
||||
Text("\(Self.shortDate(from)) — \(Self.shortDate(to))")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -96,10 +96,10 @@ struct HealthExportDetailView: View {
|
||||
private var promptBlock: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "quote.opening")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(export.prompt)
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
.padding(12)
|
||||
@@ -119,7 +119,7 @@ struct HealthExportDetailView: View {
|
||||
|
||||
ShareLink(item: export.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)
|
||||
@@ -134,7 +134,7 @@ struct HealthExportDetailView: View {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
|
||||
|
||||
@@ -57,7 +57,7 @@ struct HealthExportListView: View {
|
||||
.font(.tjTitle(24))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count) 份"))
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
@@ -88,22 +88,22 @@ struct HealthExportRow: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top) {
|
||||
Text(export.promptPreview)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(Self.relativeDate(export.createdAt))
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if export.decodeRate > 0 {
|
||||
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
@@ -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