mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 12:46:14 +00:00
* style(swift): restore compact conditional bodies * fix(ios): remove redundant startup timeout await * chore(swift): sync native i18n inventory
266 lines
10 KiB
Swift
266 lines
10 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Snapshot of how full the active session's context window is, derived from
|
|
/// the newest usage-bearing message plus session/model metadata.
|
|
public struct OpenClawChatContextUsage: Equatable, Sendable {
|
|
public let usedTokens: Int
|
|
public let contextWindowTokens: Int?
|
|
public let totalCost: Double?
|
|
|
|
public var fractionUsed: Double? {
|
|
guard let contextWindowTokens, contextWindowTokens > 0 else { return nil }
|
|
return min(1, max(0, Double(self.usedTokens) / Double(contextWindowTokens)))
|
|
}
|
|
|
|
public var percentUsed: Int? {
|
|
self.fractionUsed.map { Int(($0 * 100).rounded()) }
|
|
}
|
|
}
|
|
|
|
enum ChatContextUsageCalculator {
|
|
/// Prefers the newest per-run usage (fresh after every reply) and falls
|
|
/// back to fresh server-side session totals when no message carries usage.
|
|
static func usage(
|
|
messages: [OpenClawChatMessage],
|
|
sessionEntry: OpenClawChatSessionEntry?,
|
|
defaults: OpenClawChatSessionsDefaults?,
|
|
modelContextWindow: Int?) -> OpenClawChatContextUsage?
|
|
{
|
|
let sessionTokens = sessionEntry?.totalTokensFresh == false ? nil : sessionEntry?.totalTokens
|
|
let usedTokens = self.latestRunTokens(in: messages) ?? sessionTokens
|
|
guard let usedTokens, usedTokens > 0 else { return nil }
|
|
let contextWindow = self.positive(sessionEntry?.contextTokens)
|
|
?? self.positive(defaults?.contextTokens)
|
|
?? self.positive(modelContextWindow)
|
|
return OpenClawChatContextUsage(
|
|
usedTokens: usedTokens,
|
|
contextWindowTokens: contextWindow,
|
|
totalCost: self.totalCost(in: messages))
|
|
}
|
|
|
|
/// Context pressure comes from the latest run: its usage already counts
|
|
/// the whole conversation the model saw, so runs are not summed.
|
|
private static func latestRunTokens(in messages: [OpenClawChatMessage]) -> Int? {
|
|
for message in messages.reversed() {
|
|
guard let usage = message.usage else { continue }
|
|
if let total = usage.total, total > 0 {
|
|
return total
|
|
}
|
|
let summed = [usage.input, usage.cacheRead, usage.cacheWrite, usage.output]
|
|
.compactMap(\.self)
|
|
.reduce(0, +)
|
|
if summed > 0 {
|
|
return summed
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func totalCost(in messages: [OpenClawChatMessage]) -> Double? {
|
|
let costs = messages.compactMap { $0.usage?.cost?.total }
|
|
guard !costs.isEmpty else { return nil }
|
|
return costs.reduce(0, +)
|
|
}
|
|
|
|
private static func positive(_ value: Int?) -> Int? {
|
|
guard let value, value > 0 else { return nil }
|
|
return value
|
|
}
|
|
}
|
|
|
|
extension OpenClawChatViewModel {
|
|
public var contextUsage: OpenClawChatContextUsage? {
|
|
let entry = self.sessions.first { $0.key == self.sessionKey } ??
|
|
self.sessions.first {
|
|
self.matchesCurrentSessionKey(incoming: $0.key, current: self.sessionKey)
|
|
}
|
|
return ChatContextUsageCalculator.usage(
|
|
messages: self.messages,
|
|
sessionEntry: entry,
|
|
defaults: self.sessionDefaults,
|
|
modelContextWindow: self.selectedModelContextWindow(sessionEntry: entry))
|
|
}
|
|
|
|
private func selectedModelContextWindow(sessionEntry: OpenClawChatSessionEntry?) -> Int? {
|
|
let selection = self.modelSelectionID != Self.defaultModelSelectionID
|
|
? self.modelSelectionID
|
|
: (sessionEntry?.model ?? self.sessionDefaults?.model)
|
|
guard let selection else { return nil }
|
|
return self.modelChoices.first {
|
|
$0.selectionID == selection || $0.modelID == selection
|
|
}?.contextWindow
|
|
}
|
|
}
|
|
|
|
struct ChatMessageUsagePresentation: Equatable {
|
|
enum Pressure: Equatable {
|
|
case normal
|
|
case warning
|
|
case danger
|
|
}
|
|
|
|
let text: String
|
|
let accessibilityValue: String
|
|
let pressure: Pressure
|
|
|
|
static func make(
|
|
message: OpenClawChatMessage,
|
|
contextWindowTokens: Int?) -> ChatMessageUsagePresentation?
|
|
{
|
|
guard message.role.lowercased() == "assistant", let usage = message.usage else { return nil }
|
|
|
|
var visualParts: [String] = []
|
|
var accessibilityParts: [String] = []
|
|
let input = self.positive(usage.input)
|
|
let output = self.positive(usage.output)
|
|
let cacheRead = self.positive(usage.cacheRead)
|
|
let cacheWrite = self.positive(usage.cacheWrite)
|
|
|
|
if let input {
|
|
visualParts.append("↑\(self.tokens(input))")
|
|
accessibilityParts.append(String(localized: "Input tokens: \(input)"))
|
|
}
|
|
if let output {
|
|
visualParts.append("↓\(self.tokens(output))")
|
|
accessibilityParts.append(String(localized: "Output tokens: \(output)"))
|
|
}
|
|
if let cacheRead {
|
|
visualParts.append("R\(self.tokens(cacheRead))")
|
|
accessibilityParts.append(String(localized: "Cache read tokens: \(cacheRead)"))
|
|
}
|
|
if let cacheWrite {
|
|
visualParts.append("W\(self.tokens(cacheWrite))")
|
|
accessibilityParts.append(String(localized: "Cache write tokens: \(cacheWrite)"))
|
|
}
|
|
if let cost = usage.cost?.total, cost > 0 {
|
|
let formattedCost = String(format: "$%.4f", locale: Locale(identifier: "en_US_POSIX"), cost)
|
|
visualParts.append(formattedCost)
|
|
accessibilityParts.append(String(localized: "Cost: \(formattedCost)"))
|
|
}
|
|
|
|
// Context pressure mirrors the Control UI prompt size. Output is response data;
|
|
// input plus cache reads/writes is the context the model received for this run.
|
|
let promptTokens = Double(input ?? 0) + Double(cacheRead ?? 0) + Double(cacheWrite ?? 0)
|
|
let contextPercent: Int?
|
|
if let contextWindowTokens, contextWindowTokens > 0, promptTokens > 0 {
|
|
let roundedPercent = (promptTokens / Double(contextWindowTokens) * 100).rounded()
|
|
contextPercent = Int(min(100, roundedPercent))
|
|
} else {
|
|
contextPercent = nil
|
|
}
|
|
let pressure = self.pressure(for: contextPercent)
|
|
if let contextPercent {
|
|
let warningPrefix = pressure == .normal ? "" : "⚠︎ "
|
|
visualParts.append("\(warningPrefix)\(contextPercent)% \(String(localized: "ctx"))")
|
|
switch pressure {
|
|
case .normal:
|
|
accessibilityParts.append(String(localized: "\(contextPercent) percent of context used"))
|
|
case .warning:
|
|
accessibilityParts.append(String(localized: "Warning: \(contextPercent) percent of context used"))
|
|
case .danger:
|
|
accessibilityParts.append(String(localized: "Critical: \(contextPercent) percent of context used"))
|
|
}
|
|
}
|
|
|
|
guard !visualParts.isEmpty else { return nil }
|
|
return ChatMessageUsagePresentation(
|
|
text: visualParts.joined(separator: " "),
|
|
accessibilityValue: accessibilityParts.joined(separator: ", "),
|
|
pressure: pressure)
|
|
}
|
|
|
|
private static func pressure(for percent: Int?) -> Pressure {
|
|
guard let percent else { return .normal }
|
|
if percent >= 90 { return .danger }
|
|
if percent >= 75 { return .warning }
|
|
return .normal
|
|
}
|
|
|
|
private static func positive(_ value: Int?) -> Int? {
|
|
guard let value, value > 0 else { return nil }
|
|
return value
|
|
}
|
|
|
|
private static func tokens(_ value: Int) -> String {
|
|
if value >= 1_000_000 {
|
|
return "\(self.trimmedDecimal(Double(value) / 1_000_000))M"
|
|
}
|
|
if value >= 1000 {
|
|
let thousands = Double(value) / 1000
|
|
if thousands >= 999.95 {
|
|
return "\(self.trimmedDecimal(Double(value) / 1_000_000))M"
|
|
}
|
|
return "\(self.trimmedDecimal(thousands))k"
|
|
}
|
|
return "\(value)"
|
|
}
|
|
|
|
private static func trimmedDecimal(_ value: Double) -> String {
|
|
String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value)
|
|
.replacingOccurrences(of: ".0", with: "")
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
/// Compact token ring for the window toolbar, mirroring the web UI's context
|
|
/// gauge: ring fill and tint track pressure, the menu carries the details.
|
|
struct ChatContextUsageIndicator: View {
|
|
let usage: OpenClawChatContextUsage
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color.secondary.opacity(0.25), lineWidth: 2.5)
|
|
Circle()
|
|
.trim(from: 0, to: max(0.02, self.usage.fractionUsed ?? 0))
|
|
.stroke(self.tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
}
|
|
.frame(width: 13, height: 13)
|
|
|
|
if let percent = self.usage.percentUsed {
|
|
Text("\(percent)%")
|
|
.font(OpenClawChatTypography.captionSemiBold)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("Context usage")
|
|
.accessibilityValue(self.accessibilityValue)
|
|
}
|
|
|
|
private var tint: Color {
|
|
guard let percent = usage.percentUsed else { return .secondary }
|
|
if percent >= 90 { return Color(nsColor: .systemRed) }
|
|
if percent >= 75 { return Color(nsColor: .systemOrange) }
|
|
return Color(nsColor: .systemGreen)
|
|
}
|
|
|
|
private var accessibilityValue: String {
|
|
if let percent = self.usage.percentUsed {
|
|
return "\(percent) percent of the context window used"
|
|
}
|
|
return "\(self.usage.usedTokens) tokens used"
|
|
}
|
|
}
|
|
|
|
enum ChatContextUsageFormatter {
|
|
static func tokens(_ value: Int) -> String {
|
|
if value >= 1_000_000 {
|
|
return String(format: "%.1fM", Double(value) / 1_000_000)
|
|
}
|
|
if value >= 1000 {
|
|
return String(format: "%.1fk", Double(value) / 1000)
|
|
}
|
|
return "\(value)"
|
|
}
|
|
|
|
static func cost(_ value: Double) -> String {
|
|
String(format: "$%.2f", value)
|
|
}
|
|
}
|
|
#endif
|