根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
``` docs(readme): 更新文档说明 - 添加了项目使用指南 - 完善了API接口说明 - 修正了一些文字错误 ``` 注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
This commit is contained in:
@@ -117,11 +117,17 @@ struct HealthExportRow: View {
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
static func relativeDate(_ d: Date) -> String {
|
||||
/// 复用单个 formatter:RelativeDateTimeFormatter 初始化较贵,列表每行每次重绘都 new 会累积开销。
|
||||
/// 用系统 Locale.current(与原实现一致),进程内不变,可安全缓存。
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.locale = Locale.current
|
||||
f.unitsStyle = .full
|
||||
return f.localizedString(for: d, relativeTo: .now)
|
||||
return f
|
||||
}()
|
||||
|
||||
static func relativeDate(_ d: Date) -> String {
|
||||
relativeFormatter.localizedString(for: d, relativeTo: .now)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ struct HealthExportSheet: View {
|
||||
@State private var error: Error?
|
||||
@State private var completed: Bool = false
|
||||
@State private var copiedFlash: Bool = false
|
||||
@State private var lastScrollAt: Date = .distantPast // 流式滚动节流时间戳
|
||||
@State private var answeringTurnID: UUID?
|
||||
@State private var retrieval: HealthExportService.RetrievalSummary?
|
||||
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
||||
@@ -57,7 +58,7 @@ struct HealthExportSheet: View {
|
||||
header
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
LazyVStack(alignment: .leading, spacing: 18) {
|
||||
introSection
|
||||
|
||||
ForEach(turns) { turn in
|
||||
@@ -76,15 +77,15 @@ struct HealthExportSheet: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.onChange(of: content) { _, _ in
|
||||
withAnimation(.easeOut(duration: 0.12)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
// 流式期间 content / turns 每 token 都变,逐次动画滚动会造成布局抖动;
|
||||
// 节流到 ~8Hz,并在生成完成时补一次滚动确保停在底部。
|
||||
.onChange(of: content) { _, _ in throttledScrollToBottom(proxy) }
|
||||
.onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) }
|
||||
.onChange(of: completed) { _, done in
|
||||
if done { scrollToBottom(proxy) }
|
||||
}
|
||||
.onChange(of: turns) { _, _ in
|
||||
withAnimation(.easeOut(duration: 0.12)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
.onChange(of: answeringTurnID) { _, id in
|
||||
if id == nil { scrollToBottom(proxy) }
|
||||
}
|
||||
}
|
||||
if completed {
|
||||
@@ -358,7 +359,7 @@ struct HealthExportSheet: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text(err.localizedDescription)
|
||||
Text(friendlyMessage(for: err))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
@@ -504,8 +505,16 @@ struct HealthExportSheet: View {
|
||||
questionFocused = true
|
||||
} catch {
|
||||
answeringTurnID = nil
|
||||
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
|
||||
questionFocused = true
|
||||
if error is CancellationError { return }
|
||||
#if DEBUG
|
||||
print("[HealthExport] answer failed: \(error)")
|
||||
#endif
|
||||
// 已有部分回答就保留;否则给一句友好兜底,绝不把技术异常当成 AI 的回答展示。
|
||||
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
|
||||
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
turns[idx].text = "这次没能回答上来,请换个说法再试一次。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,6 +561,10 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if error is CancellationError { return }
|
||||
#if DEBUG
|
||||
print("[HealthExport] export failed: \(error)")
|
||||
#endif
|
||||
self.error = error
|
||||
self.phase = nil
|
||||
}
|
||||
@@ -600,6 +613,27 @@ struct HealthExportSheet: View {
|
||||
task?.cancel()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
// MARK: - 滚动
|
||||
|
||||
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||
lastScrollAt = Date()
|
||||
withAnimation(.easeOut(duration: 0.12)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
/// 节流滚动:流式 token 高频触发,限制到约 8Hz,避免每 token 一次动画布局。
|
||||
private func throttledScrollToBottom(_ proxy: ScrollViewProxy) {
|
||||
guard Date().timeIntervalSince(lastScrollAt) > 0.12 else { return }
|
||||
scrollToBottom(proxy)
|
||||
}
|
||||
|
||||
/// 把推理过程中抛出的技术异常翻译成用户能看懂的一句话(取消不算错误)。
|
||||
private func friendlyMessage(for error: Error) -> String {
|
||||
if error is CancellationError { return "" }
|
||||
return "这次没能生成成功,请稍后重试。"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 检索结果 chips(本地 RAG 可视化)
|
||||
@@ -757,15 +791,30 @@ struct MarkdownView: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 行内样式缓存:流式生成时整段会被反复重渲染,缓存命中可避免对同一行重复跑昂贵的
|
||||
/// markdown 解析。键为行文本,稳定的历史行命中缓存,只有正在增长的尾行才真正解析。
|
||||
private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } }
|
||||
private static let inlineCache: NSCache<NSString, AttrBox> = {
|
||||
let c = NSCache<NSString, AttrBox>()
|
||||
c.countLimit = 256
|
||||
return c
|
||||
}()
|
||||
|
||||
private func inline(_ s: String) -> AttributedString {
|
||||
// 快路径:整段报告里绝大多数行(标题、普通条目、纯正文)都没有内联标记,
|
||||
// 直接走纯文本,跳过 AttributedString(markdown:) —— 这是流式期间最大的一笔开销。
|
||||
if !s.contains(where: { $0 == "*" || $0 == "_" || $0 == "[" || $0 == "`" }) {
|
||||
return AttributedString(s)
|
||||
}
|
||||
let key = s as NSString
|
||||
if let hit = Self.inlineCache.object(forKey: key) { return hit.value }
|
||||
// **bold** / *italic* / [text](url) 走 AttributedString markdown 解析
|
||||
if let attr = try? AttributedString(
|
||||
let attr = (try? AttributedString(
|
||||
markdown: s,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return attr
|
||||
}
|
||||
return AttributedString(s)
|
||||
)) ?? AttributedString(s)
|
||||
Self.inlineCache.setObject(AttrBox(attr), forKey: key)
|
||||
return attr
|
||||
}
|
||||
|
||||
// MARK: - 行级解析
|
||||
|
||||
Reference in New Issue
Block a user