mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 19:41:34 +00:00
* feat(chat): show live run telemetry across clients * fix(chat): satisfy native validation gates * fix(chat): settle advertised terminal runs * fix(android): clear stale run usage on gaps * chore(i18n): align native source inventory * fix(chat): reconcile telemetry with current projections * refactor(chat): keep run state in owner extensions * chore(i18n): refresh native source inventory * test(ui): expect persistent working telemetry through streaming
300 lines
10 KiB
Swift
300 lines
10 KiB
Swift
import Foundation
|
|
|
|
enum ChatWorkingClawStance: CaseIterable, Equatable, Sendable {
|
|
case standard
|
|
case southpaw
|
|
case flurry
|
|
case spin
|
|
case shadowbox
|
|
case backflip
|
|
case zen
|
|
case drummer
|
|
case peekaboo
|
|
|
|
static let weightedStances: [(stance: Self, weight: Double)] = [
|
|
(.standard, 63),
|
|
(.southpaw, 19),
|
|
(.flurry, 5),
|
|
(.spin, 4),
|
|
(.shadowbox, 3),
|
|
(.backflip, 2),
|
|
(.zen, 2),
|
|
(.drummer, 1),
|
|
(.peekaboo, 1),
|
|
]
|
|
|
|
static func seeded(_ key: String, salt: UInt32) -> Self {
|
|
let totalWeight = self.weightedStances.reduce(0) { $0 + $1.weight }
|
|
var roll = Double((fnv1a(key) ^ salt) % 1000) / 1000 * totalWeight
|
|
for entry in self.weightedStances {
|
|
roll -= entry.weight
|
|
if roll <= 0 {
|
|
return entry.stance
|
|
}
|
|
}
|
|
return .standard
|
|
}
|
|
}
|
|
|
|
enum ChatWorkingPhrase {
|
|
static let showAfterMilliseconds = 30000
|
|
static let rotateEveryMilliseconds = 45000
|
|
|
|
/// Keep source phrases localizable; the post-merge native locale refresh
|
|
/// owns the generated catalog entries.
|
|
static let resources: [String] = [
|
|
String(localized: "Shelling"),
|
|
String(localized: "Scuttling"),
|
|
String(localized: "Clawing"),
|
|
String(localized: "Pinching"),
|
|
String(localized: "Molting"),
|
|
String(localized: "Bubbling"),
|
|
String(localized: "Tiding"),
|
|
String(localized: "Reefing"),
|
|
String(localized: "Cracking"),
|
|
String(localized: "Sifting"),
|
|
String(localized: "Brining"),
|
|
String(localized: "Nautiling"),
|
|
String(localized: "Krilling"),
|
|
String(localized: "Barnacling"),
|
|
String(localized: "Lobstering"),
|
|
String(localized: "Tidepooling"),
|
|
String(localized: "Pearling"),
|
|
String(localized: "Snapping"),
|
|
String(localized: "Surfacing"),
|
|
]
|
|
|
|
/// A constant-time stride walk keeps adjacent phrases distinct. The prime
|
|
/// list length makes every non-zero stride visit all phrases before repeating.
|
|
static func index(seed: String, elapsedMilliseconds: Int) -> Int? {
|
|
guard elapsedMilliseconds >= self.showAfterMilliseconds else { return nil }
|
|
let bucket = (elapsedMilliseconds - self.showAfterMilliseconds) / self.rotateEveryMilliseconds
|
|
return self.index(seed: seed, bucket: bucket)
|
|
}
|
|
|
|
static func index(seed: String, bucket: Int) -> Int {
|
|
let count = self.resources.count
|
|
let offset = Int(fnv1a("\(seed):offset") % UInt32(count))
|
|
let stride = 1 + Int(fnv1a("\(seed):stride") % UInt32(count - 1))
|
|
return (offset + bucket * stride) % count
|
|
}
|
|
}
|
|
|
|
enum ChatWorkingDurationFormatter {
|
|
private static let compactUnits = [
|
|
(suffix: "d", seconds: 86400),
|
|
(suffix: "h", seconds: 3600),
|
|
(suffix: "m", seconds: 60),
|
|
(suffix: "s", seconds: 1),
|
|
]
|
|
|
|
static func compact(milliseconds: Double) -> String {
|
|
let totalSeconds = self.totalSeconds(milliseconds: milliseconds)
|
|
var remainder = totalSeconds
|
|
var parts: [String] = []
|
|
for unit in self.compactUnits {
|
|
let value = remainder / unit.seconds
|
|
remainder %= unit.seconds
|
|
if value > 0 {
|
|
parts.append("\(value)\(unit.suffix)")
|
|
}
|
|
if parts.count == 2 {
|
|
break
|
|
}
|
|
}
|
|
return parts.joined(separator: " ")
|
|
}
|
|
|
|
static func full(milliseconds: Double, locale: Locale = .current) -> String {
|
|
let formatter = DateComponentsFormatter()
|
|
formatter.allowedUnits = [.day, .hour, .minute, .second]
|
|
formatter.unitsStyle = .full
|
|
formatter.maximumUnitCount = 2
|
|
formatter.zeroFormattingBehavior = .dropAll
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.locale = locale
|
|
formatter.calendar = calendar
|
|
return formatter.string(from: TimeInterval(self.totalSeconds(milliseconds: milliseconds))) ??
|
|
String(localized: "1 second", locale: locale)
|
|
}
|
|
|
|
private static func totalSeconds(milliseconds: Double) -> Int {
|
|
let finiteMilliseconds = milliseconds.isFinite ? milliseconds : 1000
|
|
let roundedSeconds = (max(1000, finiteMilliseconds) / 1000).rounded()
|
|
return max(1, Int(min(roundedSeconds, Double(Int.max / 2))))
|
|
}
|
|
}
|
|
|
|
enum ChatWorkingIdentity {
|
|
static func resolve(
|
|
sessionKey: String,
|
|
pendingRunIDs: Set<String>,
|
|
localUserMessageIDsByRunID: [String: UUID],
|
|
fallbackGeneration: UInt64) -> String
|
|
{
|
|
if let messageID = pendingRunIDs.compactMap({ localUserMessageIDsByRunID[$0]?.uuidString }).min() {
|
|
return "\(sessionKey):user:\(messageID)"
|
|
}
|
|
if let runID = pendingRunIDs.min() {
|
|
return "\(sessionKey):run:\(runID)"
|
|
}
|
|
return "\(sessionKey):generation:\(fallbackGeneration)"
|
|
}
|
|
}
|
|
|
|
struct ChatTurnRecap: Equatable, Sendable {
|
|
let runtimeMs: Double
|
|
let outputTokens: Int?
|
|
}
|
|
|
|
enum ChatTurnRecapText {
|
|
static func done(runtimeMs: Double, locale: Locale = .current) -> String {
|
|
String(
|
|
format: String(localized: "Done in %@", locale: locale),
|
|
locale: locale,
|
|
ChatWorkingDurationFormatter.full(milliseconds: runtimeMs, locale: locale))
|
|
}
|
|
|
|
static func tokens(_ outputTokens: Int?, locale: Locale = .current) -> String? {
|
|
guard let outputTokens else { return nil }
|
|
// Mirrors the web and American-English source contract: exactly one
|
|
// uses the singular key; zero and every other count use the generic key.
|
|
if outputTokens == 1 {
|
|
return String(localized: "1 token", locale: locale)
|
|
}
|
|
return String(
|
|
format: String(localized: "%@ tokens", locale: locale),
|
|
locale: locale,
|
|
outputTokens.formatted(.number.locale(locale)))
|
|
}
|
|
}
|
|
|
|
struct ChatTurnRecapSessionRow: Equatable, Sendable {
|
|
let status: String?
|
|
let endedAt: Double?
|
|
let runtimeMs: Double?
|
|
let outputTokens: Int?
|
|
|
|
init(
|
|
status: String? = nil,
|
|
endedAt: Double? = nil,
|
|
runtimeMs: Double? = nil,
|
|
outputTokens: Int? = nil)
|
|
{
|
|
self.status = status
|
|
self.endedAt = endedAt
|
|
self.runtimeMs = runtimeMs
|
|
self.outputTokens = outputTokens
|
|
}
|
|
|
|
init(_ entry: OpenClawChatSessionEntry) {
|
|
self.init(
|
|
status: entry.status,
|
|
endedAt: entry.endedAt,
|
|
runtimeMs: entry.runtimeMs,
|
|
outputTokens: entry.outputTokens)
|
|
}
|
|
}
|
|
|
|
/// Watches the turn whose working indicator was visible. Most anomalous
|
|
/// interleavings fail quiet; without a row run ID, an unrelated completion
|
|
/// inside the settle window remains an accepted cosmetic attribution risk.
|
|
struct ChatTurnRecapResolver {
|
|
private struct Watch {
|
|
var watching: Bool
|
|
var baselineKnown: Bool
|
|
var baselineEndedAt: Double?
|
|
var absorbedTerminal: Bool
|
|
var settleStartedAt: Date?
|
|
var settled: ChatTurnRecap?
|
|
}
|
|
|
|
private static let settleWindow: TimeInterval = 30
|
|
private var watches: [String: Watch] = [:]
|
|
|
|
mutating func resolve(
|
|
sessionKey: String,
|
|
indicatorVisible: Bool,
|
|
row: ChatTurnRecapSessionRow?,
|
|
now: Date = Date()) -> ChatTurnRecap?
|
|
{
|
|
var watch = self.watches[sessionKey]
|
|
let rowEndedAt = row?.endedAt
|
|
|
|
if indicatorVisible {
|
|
if watch == nil || watch?.watching == false {
|
|
watch = Watch(
|
|
watching: true,
|
|
baselineKnown: row != nil,
|
|
baselineEndedAt: rowEndedAt,
|
|
absorbedTerminal: false,
|
|
settleStartedAt: nil,
|
|
settled: nil)
|
|
} else if watch?.baselineKnown == false {
|
|
if row != nil {
|
|
watch?.baselineKnown = true
|
|
watch?.baselineEndedAt = rowEndedAt
|
|
}
|
|
} else if let rowEndedAt, rowEndedAt != watch?.baselineEndedAt {
|
|
watch?.baselineEndedAt = rowEndedAt
|
|
watch?.absorbedTerminal = true
|
|
}
|
|
self.watches[sessionKey] = watch
|
|
return nil
|
|
}
|
|
|
|
guard var watch else { return nil }
|
|
watch.watching = false
|
|
if let settled = watch.settled {
|
|
self.watches[sessionKey] = watch
|
|
return settled
|
|
}
|
|
if watch.absorbedTerminal || !watch.baselineKnown {
|
|
// Without an attributable baseline, later terminal rows could belong
|
|
// to background work, so consume this watch unresolved.
|
|
self.watches[sessionKey] = nil
|
|
return nil
|
|
}
|
|
if let settleStartedAt = watch.settleStartedAt {
|
|
if now.timeIntervalSince(settleStartedAt) > Self.settleWindow {
|
|
self.watches[sessionKey] = nil
|
|
return nil
|
|
}
|
|
} else {
|
|
watch.settleStartedAt = now
|
|
}
|
|
|
|
let isStale: Bool = if let rowEndedAt, let baselineEndedAt = watch.baselineEndedAt {
|
|
rowEndedAt <= baselineEndedAt
|
|
} else {
|
|
rowEndedAt == nil
|
|
}
|
|
if isStale {
|
|
// Terminal stamps are monotonic. Equality and regression both mean
|
|
// the watched run's terminal patch has not arrived yet.
|
|
self.watches[sessionKey] = watch
|
|
return nil
|
|
}
|
|
|
|
// Any fresh terminal concludes the watch. Only clean done rows with a
|
|
// finite runtime produce a recap; waiting longer could attach another run.
|
|
self.watches[sessionKey] = nil
|
|
guard row?.status == "done", let runtimeMs = row?.runtimeMs, runtimeMs.isFinite else {
|
|
return nil
|
|
}
|
|
let settled = ChatTurnRecap(runtimeMs: runtimeMs, outputTokens: row?.outputTokens)
|
|
watch.settled = settled
|
|
self.watches[sessionKey] = watch
|
|
return settled
|
|
}
|
|
}
|
|
|
|
private func fnv1a(_ key: String) -> UInt32 {
|
|
var hash: UInt32 = 0x811C_9DC5
|
|
for codeUnit in key.utf16 {
|
|
hash ^= UInt32(codeUnit)
|
|
hash = hash &* 0x0100_0193
|
|
}
|
|
return hash
|
|
}
|