Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

882 lines
37 KiB
Swift

// PR 12 — Accuracy Benchmark screen.
//
// Mirrors the "Accuracy" tab from the HTML admin panel
// (omlx/admin/templates/dashboard/_bench.html + dashboard.js accBench*).
// Wires the /admin/api/bench/accuracy/* endpoints — queue add / status /
// remove / results / reset / cancel — onto a stack of sections:
//
// Configuration — model picker, batch size segmented, extended-
// thinking toggle, and a tap-to-toggle benchmark
// grid with inline per-benchmark sample-size
// dropdowns. Hard-coded catalog mirrors the HTML
// dropdown order.
//
// Queue — visible only when the server reports a running
// bench or pending queue items. Shows the active
// model (spinner + last-progress message + cancel
// button) and each queued entry (model + comma-
// separated benchmarks + remove button).
//
// Error banner — same shape as QuantizationScreen.
//
// Results — accumulating cards keyed by `bench::model`.
// Big accuracy %, model badge, optional extended-
// thinking pill, correct/total · time. Expandable
// per-category breakdown when the server emits it.
//
// Text export — collapsible one-liner dump with copy-to-clipboard.
//
// v1 strategy: poll. 2 s while a bench is running OR the queue is non-empty,
// 8 s while idle. Per-question SSE progress isn't surfaced — only block-level
// `message + current/total` from `lastProgress`, which is the same level the
// HTML UI exposes.
import SwiftUI
import AppKit
struct AccuracyBenchScreen: View {
@Environment(AppServices.self) private var services
// VM is owned by AppServices so an in-flight queue (or in-progress
// benchmark) survives screen unloads. Same rationale as
// ThroughputBenchScreen — see AppServices.accuracyBench.
@Bindable var vm: AccuracyBenchScreenVM
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ScreenHeader(
eyebrow: String(localized: "bench.accuracy.header.eyebrow",
defaultValue: "Accuracy Benchmark",
comment: "Eyebrow label above the Accuracy Bench screen header"),
title: String(localized: "bench.accuracy.header.title",
defaultValue: "Measure model accuracy",
comment: "Accuracy Bench screen primary header"),
subtitle: String(localized: "bench.accuracy.header.subtitle",
defaultValue: "Queue benchmarks across models. Results accumulate until you reset them. Resume across app launches via the server-side queue.",
comment: "Accuracy Bench screen subtitle explaining queueing behavior")
)
ConfigurationSection(
models: vm.models,
selectedModelId: $vm.selectedModelId,
batchSize: $vm.batchSize,
enableThinking: $vm.enableThinking,
selectedBenchmarks: $vm.selectedBenchmarks,
sampleSizes: $vm.sampleSizes,
isAdding: vm.isAdding,
canSubmit: vm.canSubmit,
onSubmit: { vm.addToQueue(client: services.client) }
)
QueueSection(
status: vm.status,
onCancel: { vm.cancelRunning(client: services.client) },
onRemove: { idx in vm.removeFromQueue(client: services.client, index: idx) }
)
MessageBanner(error: vm.lastError)
ResultsSection(
results: vm.results,
onClear: { vm.resetResults(client: services.client) }
)
if !vm.results.isEmpty {
TextExportSection(results: vm.results)
}
}
// `start()` is idempotent: refreshes models + polls once, then
// restarts the poll loop (which cancels its predecessor). The
// poll task continues across screen unloads since AppServices
// owns the VM, so we don't tear it down on disappear.
.task { await vm.start(client: services.client) }
}
}
// MARK: - Benchmark catalog (mirrors the HTML dropdown order/labels)
struct BenchmarkCatalogEntry: Hashable, Identifiable {
let key: String
let displayName: String
let category: String
var id: String { key }
}
private let benchmarkCatalog: [BenchmarkCatalogEntry] = {
let knowledge = String(localized: "bench.accuracy.category.knowledge",
defaultValue: "Knowledge",
comment: "Accuracy bench catalog category: knowledge benchmarks")
let reasoning = String(localized: "bench.accuracy.category.reasoning",
defaultValue: "Reasoning",
comment: "Accuracy bench catalog category: reasoning benchmarks")
let math = String(localized: "bench.accuracy.category.math",
defaultValue: "Math",
comment: "Accuracy bench catalog category: math benchmarks")
let code = String(localized: "bench.accuracy.category.code",
defaultValue: "Code",
comment: "Accuracy bench catalog category: code benchmarks")
let safety = String(localized: "bench.accuracy.category.safety",
defaultValue: "Safety",
comment: "Accuracy bench catalog category: safety benchmarks")
// Benchmark display names are proper nouns (dataset names) shared with
// the HTML admin; only the language-tag suffixes are translated.
return [
.init(key: "mmlu", displayName: "MMLU", category: knowledge),
.init(key: "mmlu_pro", displayName: "MMLU-Pro", category: knowledge),
.init(key: "kmmlu",
displayName: String(localized: "bench.accuracy.dataset.kmmlu",
defaultValue: "KMMLU (Korean)",
comment: "Accuracy bench display name with language tag"),
category: knowledge),
.init(key: "cmmlu",
displayName: String(localized: "bench.accuracy.dataset.cmmlu",
defaultValue: "CMMLU (Chinese)",
comment: "Accuracy bench display name with language tag"),
category: knowledge),
.init(key: "jmmlu",
displayName: String(localized: "bench.accuracy.dataset.jmmlu",
defaultValue: "JMMLU (Japanese)",
comment: "Accuracy bench display name with language tag"),
category: knowledge),
.init(key: "hellaswag", displayName: "HellaSwag", category: reasoning),
.init(key: "truthfulqa", displayName: "TruthfulQA", category: reasoning),
.init(key: "arc_challenge", displayName: "ARC-Challenge", category: reasoning),
.init(key: "winogrande", displayName: "WinoGrande", category: reasoning),
.init(key: "gsm8k", displayName: "GSM8K", category: math),
.init(key: "mathqa", displayName: "MathQA", category: math),
.init(key: "humaneval", displayName: "HumanEval", category: code),
.init(key: "mbpp", displayName: "MBPP", category: code),
.init(key: "livecodebench", displayName: "LiveCodeBench", category: code),
.init(key: "bbq", displayName: "BBQ", category: safety),
.init(key: "safetybench", displayName: "SafetyBench", category: safety),
]
}()
private let sampleSizeOptions: [(Int, String)] = [
(0, String(localized: "bench.accuracy.sample_size.full",
defaultValue: "Full",
comment: "Sample-size dropdown option: full dataset")),
(50, "50"),
(100, "100"),
(200, "200"),
(500, "500"),
(1000, "1000"),
]
private let batchSizeOptions: [(Int, String)] = [
(1, "1"), (2, "2"), (4, "4"), (8, "8"), (16, "16"), (32, "32"),
]
// MARK: - Configuration
private struct ConfigurationSection: View {
let models: [ModelDTO]
@Binding var selectedModelId: String
@Binding var batchSize: Int
@Binding var enableThinking: Bool
@Binding var selectedBenchmarks: Set<String>
@Binding var sampleSizes: [String: Int]
let isAdding: Bool
let canSubmit: Bool
let onSubmit: () -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
SectionHeader(
String(localized: "bench.accuracy.section.configuration",
defaultValue: "Configuration",
comment: "Section header for the Accuracy Bench configuration block"),
subtitle: subtitleText
)
ListGroup {
Row(label: String(localized: "bench.accuracy.row.model.label",
defaultValue: "Model",
comment: "Row label for the Accuracy Bench model picker"),
sublabel: String(localized: "bench.accuracy.row.model.sub",
defaultValue: "Loaded models are listed first",
comment: "Sublabel under the Accuracy Bench model picker")) {
Popup(
selection: $selectedModelId,
width: 320,
options: modelOptions
)
}
Row(
label: String(localized: "bench.accuracy.row.batch_size.label",
defaultValue: "Batch size",
comment: "Row label for the Accuracy Bench batch-size selector"),
sublabel: String(localized: "bench.accuracy.row.batch_size.sub",
defaultValue: "Higher batches finish faster but use more memory",
comment: "Sublabel under the Accuracy Bench batch-size selector")
) {
Segmented(selection: $batchSize, options: batchSizeOptions)
}
Row(
label: String(localized: "bench.accuracy.row.thinking.label",
defaultValue: "Extended thinking",
comment: "Row label for the Accuracy Bench extended-thinking toggle"),
sublabel: String(localized: "bench.accuracy.row.thinking.sub",
defaultValue: "Enable per-question reasoning traces (slower)",
comment: "Sublabel under the Accuracy Bench extended-thinking toggle")
) {
Toggle("", isOn: $enableThinking).labelsHidden().toggleStyle(.switch)
}
FreeRow {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text(String(localized: "bench.accuracy.benchmarks.title",
defaultValue: "Benchmarks",
comment: "Inline label above the Accuracy Bench benchmark grid"))
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
Text(benchmarksSubtitle)
.font(.omlxText(11.5))
.foregroundStyle(theme.textSecondary)
Spacer(minLength: 0)
}
BenchmarkGrid(
selected: $selectedBenchmarks,
sampleSizes: $sampleSizes
)
}
}
Row(isLast: true) {
HStack {
Spacer()
Button {
onSubmit()
} label: {
if isAdding {
ProgressView()
.controlSize(.small)
.padding(.trailing, 2)
Text(String(localized: "bench.accuracy.button.adding",
defaultValue: "Adding…",
comment: "Accuracy Bench add-to-queue button label while the request is in flight"))
} else {
Label(String(localized: "bench.accuracy.button.add",
defaultValue: "Add to Queue & Run",
comment: "Accuracy Bench primary button that adds the configured run to the queue"),
systemImage: "play.fill")
.labelStyle(.titleAndIcon)
}
}
.buttonStyle(.omlx(.primary))
.disabled(!canSubmit || isAdding)
}
}
}
}
private var modelOptions: [PopupOption<String>] {
var opts = [PopupOption(value: "", label: String(localized: "bench.accuracy.model.placeholder",
defaultValue: "Select a model…",
comment: "Placeholder option in the Accuracy Bench model picker"))]
let sorted = models.sorted { (a, b) -> Bool in
if a.loaded != b.loaded { return a.loaded && !b.loaded }
return a.id.localizedCaseInsensitiveCompare(b.id) == .orderedAscending
}
opts += sorted.map { m in
let badge = m.loaded
? String(localized: "bench.accuracy.model.loaded_badge",
defaultValue: " • loaded",
comment: "Suffix appended to a loaded model's name in the Accuracy Bench picker")
: ""
return PopupOption(value: m.id, label: "\(m.id)\(badge)")
}
return opts
}
private var subtitleText: String? {
if models.isEmpty { return String(localized: "bench.accuracy.subtitle.loading_models",
defaultValue: "Loading models…",
comment: "Accuracy Bench section subtitle while models are loading") }
let count = selectedBenchmarks.count
return String(localized: "bench.accuracy.subtitle.selected_count",
defaultValue: "Benchmarks selected: \(count)",
comment: "Accuracy Bench section subtitle showing how many benchmarks the user has picked; placeholder is the count")
}
private var benchmarksSubtitle: String {
let count = selectedBenchmarks.count
if count == 0 { return String(localized: "bench.accuracy.benchmarks.subtitle.empty",
defaultValue: "Tap to select. 0 = full dataset.",
comment: "Subtitle next to the Benchmarks grid when nothing is selected") }
return String(localized: "bench.accuracy.benchmarks.subtitle.count",
defaultValue: "\(count) selected",
comment: "Subtitle next to the Benchmarks grid showing the selected count")
}
}
// MARK: - Benchmark grid
private struct BenchmarkGrid: View {
@Binding var selected: Set<String>
@Binding var sampleSizes: [String: Int]
@State private var calculatedHeight: CGFloat = 334
private struct GridHeightKey: PreferenceKey {
static let defaultValue: CGFloat = 334
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
var body: some View {
GeometryReader { geo in
let cols = geo.size.width > 600 ? 3 : 2
let layout = Array(
repeating: GridItem(.flexible(), spacing: 8),
count: cols
)
LazyVGrid(columns: layout, alignment: .leading, spacing: 8) {
ForEach(benchmarkCatalog) { entry in
BenchmarkCard(
entry: entry,
isSelected: selected.contains(entry.key),
sampleSize: binding(for: entry.key),
onToggle: { toggle(entry.key) }
)
.frame(maxHeight: .infinity, alignment: .top)
}
}
.background {
GeometryReader { contentGeo in
Color.clear
.preference(key: GridHeightKey.self, value: contentGeo.size.height)
}
}
}
.frame(height: calculatedHeight)
.onPreferenceChange(GridHeightKey.self) { height in
withAnimation {
self.calculatedHeight = height
}
}
}
private func toggle(_ key: String) {
withAnimation {
if selected.contains(key) {
selected.remove(key)
} else {
selected.insert(key)
if sampleSizes[key] == nil { sampleSizes[key] = 100 }
}
}
}
private func binding(for key: String) -> Binding<Int> {
Binding(
get: { sampleSizes[key] ?? 100 },
set: { sampleSizes[key] = $0 }
)
}
}
private struct BenchmarkCard: View {
let entry: BenchmarkCatalogEntry
let isSelected: Bool
@Binding var sampleSize: Int
let onToggle: () -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Toggle(isOn: Binding(
get: { isSelected },
set: { _ in onToggle() }
)) {
HStack {
VStack(alignment: .leading, spacing: 1) {
Text(entry.displayName)
.font(.omlxText(12.5, weight: .medium))
.foregroundStyle(theme.text)
Text(entry.category)
.font(.omlxText(10.5))
.foregroundStyle(theme.textTertiary)
}
Spacer()
}
}
if isSelected {
HStack(spacing: 6) {
Text(String(localized: "bench.accuracy.card.samples_label",
defaultValue: "Samples:",
comment: "Inline label next to the per-benchmark sample-size dropdown"))
.font(.omlxText(10.5))
.foregroundStyle(theme.textTertiary)
Popup(
selection: $sampleSize,
width: 90,
options: sampleSizeOptions
)
}
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(isSelected ? theme.controlBg : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.strokeBorder(
isSelected ? theme.inputBorder : theme.groupBorder,
lineWidth: 0.5
)
)
}
}
// MARK: - Queue
private struct QueueSection: View {
let status: AccuracyQueueStatus?
let onCancel: () -> Void
let onRemove: (Int) -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
// Use `isActivelyEvaluating` instead of bare `running`: the
// server's `running` stays True while the bench task is alive,
// including the post-result model-unload window. During that
// window the result card is already on screen, so showing a
// "Running" row with the final progress message reads as the
// task being stuck. See AccuracyQueueStatus.isActivelyEvaluating.
let activelyRunning = status?.isActivelyEvaluating == true
let queue = status?.queue ?? []
let showSection = activelyRunning || !queue.isEmpty
if showSection {
SectionHeader(String(localized: "bench.accuracy.section.queue",
defaultValue: "Queue",
comment: "Section header for the Accuracy Bench queue/in-progress block"),
subtitle: subtitle(running: activelyRunning, queue: queue))
ListGroup {
if activelyRunning {
let isLast = queue.isEmpty
FreeRow(isLast: isLast) {
RunningRow(
modelId: status?.currentModel ?? "",
progress: status?.lastProgress,
onCancel: onCancel
)
}
}
ForEach(Array(queue.enumerated()), id: \.offset) { idx, item in
FreeRow(isLast: idx == queue.count - 1) {
QueuedRow(
index: idx,
item: item,
onRemove: { onRemove(idx) }
)
}
}
}
}
}
private func subtitle(running: Bool, queue: [AccuracyQueueItem]) -> String {
let queuedCount = queue.count
let queuedPart = String(localized: "bench.accuracy.queue.subtitle.queued",
defaultValue: "\(queuedCount) queued",
comment: "Accuracy Bench queue subtitle fragment showing queued count")
if running {
return String(localized: "bench.accuracy.queue.subtitle.with_running",
defaultValue: "\(queuedPart) · 1 running",
comment: "Accuracy Bench queue subtitle when one bench is running; placeholder is the queued-count fragment")
}
if queuedCount == 0 {
return String(localized: "bench.accuracy.queue.subtitle.empty",
defaultValue: "no active runs",
comment: "Accuracy Bench queue subtitle when nothing is running or queued")
}
return queuedPart
}
}
private struct RunningRow: View {
let modelId: String
let progress: AccuracyProgressDTO?
let onCancel: () -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text(modelId.isEmpty
? String(localized: "bench.accuracy.queue.running.placeholder",
defaultValue: "Running…",
comment: "Accuracy Bench running-row title when the server hasn't reported the model id yet")
: modelId)
.font(.omlxMono(12))
.foregroundStyle(theme.text)
.lineLimit(1)
.truncationMode(.middle)
StatusPill(status: .custom(
color: theme.blueDot,
label: String(localized: "bench.accuracy.status.running",
defaultValue: "Running",
comment: "Status pill label on the Accuracy Bench running row"),
fillBg: true
))
Spacer(minLength: 6)
Button(action: onCancel) {
Label(String(localized: "common.cancel",
defaultValue: "Cancel",
comment: "Generic Cancel button label"),
systemImage: "stop.fill")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.destructive, size: .small))
}
if let line = progressLine {
Text(line)
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var progressLine: String? {
guard let p = progress else { return nil }
var bits: [String] = []
if let bench = p.benchmark, !bench.isEmpty { bits.append(bench) }
if let msg = p.message, !msg.isEmpty { bits.append(msg) }
if let cur = p.current, let tot = p.total, tot > 0 {
bits.append("\(cur)/\(tot)")
}
if let bCur = p.benchCurrent, let bTot = p.benchTotal, bTot > 0 {
bits.append("bench \(bCur)/\(bTot)")
}
return bits.isEmpty ? nil : bits.joined(separator: " · ")
}
}
private struct QueuedRow: View {
let index: Int
let item: AccuracyQueueItem
let onRemove: () -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "list.bullet.rectangle")
.font(.system(size: 12))
.foregroundStyle(theme.textTertiary)
VStack(alignment: .leading, spacing: 2) {
Text(item.modelId)
.font(.omlxMono(12))
.foregroundStyle(theme.text)
.lineLimit(1)
.truncationMode(.middle)
Text(item.benchmarks.map(displayName(for:)).joined(separator: ", "))
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 6)
Button {
onRemove()
} label: {
Image(systemName: "xmark")
.font(.system(size: 11))
}
.buttonStyle(.omlx(.plain, size: .small))
.help(String(localized: "bench.accuracy.queue.remove.help",
defaultValue: "Remove from queue",
comment: "Tooltip on the X button next to a queued Accuracy Bench entry"))
}
}
private func displayName(for key: String) -> String {
benchmarkCatalog.first(where: { $0.key == key })?.displayName ?? key
}
}
// MARK: - Results
private struct ResultsSection: View {
let results: [AccuracyResultDTO]
let onClear: () -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
if !results.isEmpty {
SectionHeader(
String(localized: "bench.accuracy.section.results",
defaultValue: "Results",
comment: "Section header for the Accuracy Bench results list"),
subtitle: String(localized: "bench.accuracy.results.subtitle",
defaultValue: "Results: \(results.count)",
comment: "Subtitle showing the number of accumulated Accuracy Bench results")
) {
Button(String(localized: "bench.accuracy.results.clear_all",
defaultValue: "Clear all",
comment: "Button in the Accuracy Bench results header that resets the result list")) { onClear() }
.buttonStyle(.omlx(.plain, size: .small))
.foregroundStyle(theme.redDot)
}
ListGroup {
ForEach(Array(results.enumerated()), id: \.element.id) { idx, result in
FreeRow(isLast: idx == results.count - 1) {
ResultCard(result: result)
}
}
}
}
}
}
private struct ResultCard: View {
let result: AccuracyResultDTO
@State private var categoriesOpen: Bool = false
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(percentText)
.font(.omlxText(22, weight: .semibold))
.foregroundStyle(accuracyColor)
.monospacedDigit()
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(benchmarkDisplay)
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
Pill(label: result.modelId, color: theme.blueDot)
if result.thinkingUsed {
Pill(label: String(localized: "bench.accuracy.result.thinking_pill",
defaultValue: "Extended thinking",
comment: "Pill on an Accuracy Bench result card when extended thinking was used"),
color: Color(rgb24: 0x5E5CE6))
}
}
Text(subtitleText)
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
}
Spacer(minLength: 0)
}
if result.categoryScores?.isEmpty == false {
Button {
withAnimation(.easeOut(duration: 0.15)) { categoriesOpen.toggle() }
} label: {
HStack(spacing: 5) {
Image(systemName: categoriesOpen ? "chevron.down" : "chevron.right")
.font(.system(size: 9, weight: .semibold))
Text(String(localized: "bench.accuracy.result.categories",
defaultValue: "Categories",
comment: "Disclosure label on an Accuracy Bench result card revealing per-category breakdowns"))
.font(.omlxText(11, weight: .medium))
}
.foregroundStyle(theme.textSecondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if categoriesOpen, let scores = result.categoryScores {
CategoriesTable(scores: scores)
}
}
}
}
private var percentText: String {
let pct = result.accuracy * 100
return String(format: "%.1f%%", pct)
}
private var accuracyColor: Color {
let pct = result.accuracy * 100
if pct >= 70 { return theme.greenDot }
if pct >= 40 { return theme.amberDot }
return theme.redDot
}
private var benchmarkDisplay: String {
benchmarkCatalog.first(where: { $0.key == result.benchmark })?.displayName
?? result.benchmark
}
private var subtitleText: String {
let time = String(format: "%.1f s", result.timeS)
return "\(result.correct) / \(result.total) · \(time)"
}
}
private struct Pill: View {
let label: String
let color: Color
@Environment(\.omlxTheme) private var theme
var body: some View {
Text(label)
.font(.omlxText(10, weight: .medium))
.foregroundStyle(color)
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
}
private struct CategoriesTable: View {
let scores: [String: Double]
@Environment(\.omlxTheme) private var theme
var body: some View {
let entries = scores.sorted { $0.value > $1.value }
VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.key) { idx, pair in
HStack(spacing: 10) {
Text(pair.key)
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
Text(String(format: "%.1f%%", pair.value * 100))
.font(.omlxMono(11))
.foregroundStyle(theme.text)
.monospacedDigit()
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.overlay(alignment: .bottom) {
if idx < entries.count - 1 {
Rectangle()
.fill(theme.rowSep)
.frame(height: 0.5)
.padding(.leading, 10)
}
}
}
}
.background(theme.codeBg)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
}
// MARK: - Text export
private struct TextExportSection: View {
let results: [AccuracyResultDTO]
@State private var isOpen: Bool = false
@State private var copied: Bool = false
@Environment(\.omlxTheme) private var theme
var body: some View {
SectionHeader(String(localized: "bench.accuracy.section.text_export",
defaultValue: "Text Export",
comment: "Section header for the Accuracy Bench text-export block"))
ListGroup {
FreeRow(isLast: true) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Button {
withAnimation(.easeOut(duration: 0.15)) { isOpen.toggle() }
} label: {
HStack(spacing: 5) {
Image(systemName: isOpen ? "chevron.down" : "chevron.right")
.font(.system(size: 9, weight: .semibold))
Text(isOpen
? String(localized: "bench.accuracy.text_export.hide",
defaultValue: "Hide text dump",
comment: "Disclosure label that hides the Accuracy Bench text export")
: String(localized: "bench.accuracy.text_export.show",
defaultValue: "Show text dump",
comment: "Disclosure label that reveals the Accuracy Bench text export"))
.font(.omlxText(11, weight: .medium))
}
.foregroundStyle(theme.textSecondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer(minLength: 0)
Button {
copyToClipboard()
} label: {
Label(copied
? String(localized: "bench.accuracy.text_export.copied",
defaultValue: "Copied",
comment: "Transient confirmation label after copying the Accuracy Bench text dump")
: String(localized: "common.copy",
defaultValue: "Copy",
comment: "Generic Copy button label"),
systemImage: copied ? "checkmark" : "document.on.document")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.normal, size: .small))
}
if isOpen {
ScrollView {
Text(textDump)
.font(.omlxMono(11))
.foregroundStyle(theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.textSelection(.enabled)
}
.frame(maxHeight: 180)
.background(theme.codeBg)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
}
}
}
.padding(.bottom, 18)
}
private var textDump: String {
results.map { r in
let pct = String(format: "%.1f%%", r.accuracy * 100)
let time = String(format: "%.1f s", r.timeS)
return "\(r.benchmark) · \(r.modelId) · \(pct) (\(r.correct)/\(r.total)) · \(time)"
}.joined(separator: "\n")
}
private func copyToClipboard() {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(textDump, forType: .string)
copied = true
Task {
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run { copied = false }
}
}
}