diff --git a/README.md b/README.md index 4768019..39bd6fe 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ TokenStepSwift/dist/TokenStep.app Developer ID 签名: ```bash -TOKENSTEP_VERSION=0.1.45 \ +TOKENSTEP_VERSION=0.1.46 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ ./script/package_release.sh ``` @@ -157,7 +157,7 @@ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ 签名 + Apple 公证: ```bash -TOKENSTEP_VERSION=0.1.45 \ +TOKENSTEP_VERSION=0.1.46 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize diff --git a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift index 7078f56..208948a 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift @@ -581,92 +581,124 @@ struct CodexQuotaWindow: Equatable, Identifiable, Codable { struct TokenRankLeaderboard: Equatable { var fetchedAt: Date - var board: String var range: String + var client: String + var usageMode: String + var totalTokens: Int + var totalRankedUsers: Int + var topLimit: Int var entries: [TokenRankEntry] var topEntry: TokenRankEntry? { entries.first } - func entry(matching userID: String) -> TokenRankEntry? { - let normalizedID = TokenStepSettings.cleanedTokenRankUserID(userID) - guard !normalizedID.isEmpty else { return nil } - return entries.first { $0.userID == normalizedID } + func entry(matching userID: Int) -> TokenRankEntry? { + entries.first { $0.userID == userID } } } struct TokenRankEntry: Decodable, Equatable, Identifiable { - var id: String { userID } + var id: Int { userID } var rank: Int - var userID: String + var userID: Int var name: String - var avatar: String? - var score: Int - var cost: Double - var byTool: [String: Int] + var avatarURL: String? + var totalTokens: Int + var callCount: Int + var sessionCount: Int + var clients: [String: Int] + var models: [String: Int] enum CodingKeys: String, CodingKey { case rank - case userID = "userId" - case name - case avatar - case score - case cost - case byTool + case user + case totalTokens = "total_tokens" + case callCount = "call_count" + case sessionCount = "session_count" + case clients + case models } init( rank: Int, - userID: String, + userID: Int, name: String, - avatar: String?, - score: Int, - cost: Double, - byTool: [String: Int] + avatarURL: String?, + totalTokens: Int, + callCount: Int, + sessionCount: Int, + clients: [String: Int], + models: [String: Int] ) { self.rank = rank self.userID = userID self.name = name - self.avatar = avatar - self.score = score - self.cost = cost - self.byTool = byTool + self.avatarURL = avatarURL + self.totalTokens = totalTokens + self.callCount = callCount + self.sessionCount = sessionCount + self.clients = clients + self.models = models } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + let user = try container.decode(TokenRankPublicUser.self, forKey: .user) rank = try container.decode(Int.self, forKey: .rank) - userID = try Self.decodeFlexibleString(from: container, forKey: .userID) - name = try container.decodeIfPresent(String.self, forKey: .name) ?? L("匿名用户") - avatar = try container.decodeIfPresent(String.self, forKey: .avatar) - score = try container.decode(Int.self, forKey: .score) - cost = try container.decodeIfPresent(Double.self, forKey: .cost) ?? 0 - byTool = try container.decodeIfPresent([String: Int].self, forKey: .byTool) ?? [:] - } - - private static func decodeFlexibleString( - from container: KeyedDecodingContainer, - forKey key: CodingKeys - ) throws -> String { - if let value = try? container.decode(String.self, forKey: key) { - return value - } - if let value = try? container.decode(Int.self, forKey: key) { - return String(value) - } - if let value = try? container.decode(Int64.self, forKey: key) { - return String(value) - } - return "" + userID = user.id + name = user.name + avatarURL = user.avatarURL + totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) ?? 0 + callCount = try container.decodeIfPresent(Int.self, forKey: .callCount) ?? 0 + sessionCount = try container.decodeIfPresent(Int.self, forKey: .sessionCount) ?? 0 + clients = try container.decodeIfPresent([String: Int].self, forKey: .clients) ?? [:] + models = try container.decodeIfPresent([String: Int].self, forKey: .models) ?? [:] + } +} + +private struct TokenRankPublicUser: Decodable { + var id: Int + var name: String + var avatarURL: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case avatarURL = "avatar_url" } } struct TokenRankLeaderboardResponse: Decodable { - var status: Int? - var board: String + var success: Bool + var data: TokenRankLeaderboardPayload +} + +struct TokenRankLeaderboardPayload: Decodable { var range: String - var entries: [TokenRankEntry] + var client: String + var usageMode: String + var totalTokens: Int + var totalRankedUsers: Int + var topLimit: Int + var rows: [TokenRankEntry] + + enum CodingKeys: String, CodingKey { + case range + case client + case usageMode = "usage_mode" + case totalTokens = "total_tokens" + case totalRankedUsers = "total_ranked_users" + case topLimit = "top_limit" + case rows + } +} + +struct AgentWorkRankIdentity: Equatable, Identifiable { + var id: Int + var name: String + var avatarURL: String? + var lastSyncedAt: Date? } enum TokenIslandDisplayPlacement: String, CaseIterable, Identifiable, Codable { @@ -764,9 +796,8 @@ struct TokenStepSettings: Codable { var tokenIslandEnabled: Bool var tokenIslandPlacement: TokenIslandDisplayPlacement var showCodexQuota: Bool - var showTokenRank: Bool + var showAgentWorkRank: Bool var showExperimentalAgentSources: Bool - var tokenRankUserID: String var language: TokenStepLanguage var skippedUpdateVersion: String? @@ -781,9 +812,8 @@ struct TokenStepSettings: Codable { case tokenIslandEnabled = "token_island_enabled" case tokenIslandPlacement = "token_island_placement" case showCodexQuota = "show_codex_quota" - case showTokenRank = "show_token_rank" + case showAgentWorkRank = "show_agent_work_rank" case showExperimentalAgentSources = "show_experimental_agent_sources" - case tokenRankUserID = "token_rank_user_id" case language case skippedUpdateVersion = "skipped_update_version" } @@ -799,9 +829,8 @@ struct TokenStepSettings: Codable { tokenIslandEnabled: false, tokenIslandPlacement: .menuBar, showCodexQuota: false, - showTokenRank: false, + showAgentWorkRank: false, showExperimentalAgentSources: false, - tokenRankUserID: "", language: .system, skippedUpdateVersion: nil ) @@ -817,9 +846,8 @@ struct TokenStepSettings: Codable { tokenIslandEnabled: Bool, tokenIslandPlacement: TokenIslandDisplayPlacement, showCodexQuota: Bool, - showTokenRank: Bool, + showAgentWorkRank: Bool, showExperimentalAgentSources: Bool, - tokenRankUserID: String, language: TokenStepLanguage, skippedUpdateVersion: String? ) { @@ -833,19 +861,12 @@ struct TokenStepSettings: Codable { self.tokenIslandEnabled = tokenIslandEnabled self.tokenIslandPlacement = tokenIslandPlacement self.showCodexQuota = showCodexQuota - self.showTokenRank = showTokenRank + self.showAgentWorkRank = showAgentWorkRank self.showExperimentalAgentSources = showExperimentalAgentSources - self.tokenRankUserID = Self.cleanedTokenRankUserID(tokenRankUserID) self.language = language self.skippedUpdateVersion = skippedUpdateVersion } - static func cleanedTokenRankUserID(_ value: String) -> String { - value - .trimmingCharacters(in: .whitespacesAndNewlines) - .filter(\.isNumber) - } - init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let defaults = TokenStepSettings.defaults @@ -867,10 +888,8 @@ struct TokenStepSettings: Codable { tokenIslandPlacement = defaults.tokenIslandPlacement } showCodexQuota = try container.decodeIfPresent(Bool.self, forKey: .showCodexQuota) ?? defaults.showCodexQuota - showTokenRank = try container.decodeIfPresent(Bool.self, forKey: .showTokenRank) ?? defaults.showTokenRank + showAgentWorkRank = try container.decodeIfPresent(Bool.self, forKey: .showAgentWorkRank) ?? defaults.showAgentWorkRank showExperimentalAgentSources = try container.decodeIfPresent(Bool.self, forKey: .showExperimentalAgentSources) ?? defaults.showExperimentalAgentSources - let decodedTokenRankUserID = try container.decodeIfPresent(String.self, forKey: .tokenRankUserID) ?? defaults.tokenRankUserID - tokenRankUserID = Self.cleanedTokenRankUserID(decodedTokenRankUserID) language = try container.decodeIfPresent(TokenStepLanguage.self, forKey: .language) ?? defaults.language skippedUpdateVersion = try container.decodeIfPresent(String.self, forKey: .skippedUpdateVersion) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift index 4775275..a9c0bcb 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift @@ -231,9 +231,8 @@ enum DataService { tokenIslandEnabled: placement != .menuBar, tokenIslandPlacement: placement, showCodexQuota: settings.showCodexQuota, - showTokenRank: settings.showTokenRank, + showAgentWorkRank: settings.showAgentWorkRank, showExperimentalAgentSources: settings.showExperimentalAgentSources, - tokenRankUserID: TokenStepSettings.cleanedTokenRankUserID(settings.tokenRankUserID), language: settings.language, skippedUpdateVersion: settings.skippedUpdateVersion ) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/TokenRankService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/TokenRankService.swift index 07ad670..c2368c4 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/TokenRankService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/TokenRankService.swift @@ -1,51 +1,116 @@ import Foundation -enum TokenRankService { - static let defaultBoard = "total" +enum AgentWorkRankService { + static let defaultClient = "all" static let defaultRange = "today" - static let cacheTTL: TimeInterval = 120 - static let leaderboardPageURL = URL(string: "https://scys.com/tokenrank/")! + static let defaultUsageMode = "all" + static let cacheTTL: TimeInterval = 5 * 60 + static let leaderboardPageURL = URL(string: "https://www.zhenganhuo.com/token-rank")! + static let myPageURL = URL(string: "https://www.zhenganhuo.com/token-rank/me")! - private static let leaderboardAPIURL = URL(string: "https://scys.com/tokenrank/api/subapp/leaderboard")! - - static func userPageURL(userID: String) -> URL? { - let cleanedID = TokenStepSettings.cleanedTokenRankUserID(userID) - guard !cleanedID.isEmpty else { return nil } - return URL(string: "https://scys.com/tokenrank/u/")?.appendingPathComponent(cleanedID) - } + private static let leaderboardAPIURL = URL( + string: "https://www.zhenganhuo.com/api/token-rank/leaderboard.php" + )! static func fetchLeaderboard( - board: String = defaultBoard, - range: String = defaultRange + client: String = defaultClient, + range: String = defaultRange, + usageMode: String = defaultUsageMode ) async throws -> TokenRankLeaderboard { var components = URLComponents(url: leaderboardAPIURL, resolvingAgainstBaseURL: false) components?.queryItems = [ - URLQueryItem(name: "board", value: board), - URLQueryItem(name: "range", value: range) + URLQueryItem(name: "client", value: client), + URLQueryItem(name: "range", value: range), + URLQueryItem(name: "usage_mode", value: usageMode) ] guard let url = components?.url else { throw TokenRankServiceError.invalidURL } - let (data, response) = try await URLSession.shared.data(from: url) + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 12 + let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { throw TokenRankServiceError.unavailable } + return try decodeLeaderboard(data: data) + } + static func decodeLeaderboard( + data: Data, + fetchedAt: Date = Date() + ) throws -> TokenRankLeaderboard { let decoded = try JSONDecoder().decode(TokenRankLeaderboardResponse.self, from: data) - guard decoded.status ?? 0 == 0 else { + guard decoded.success else { throw TokenRankServiceError.unavailable } - + let payload = decoded.data return TokenRankLeaderboard( - fetchedAt: Date(), - board: decoded.board, - range: decoded.range, - entries: decoded.entries + fetchedAt: fetchedAt, + range: payload.range, + client: payload.client, + usageMode: payload.usageMode, + totalTokens: payload.totalTokens, + totalRankedUsers: payload.totalRankedUsers, + topLimit: payload.topLimit, + entries: payload.rows + ) + } + + static func loadLocalIdentity( + clientStateURL: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".token-rank/client-state.json") + ) -> AgentWorkRankIdentity? { + guard let data = try? Data(contentsOf: clientStateURL), + let state = try? JSONDecoder().decode(LocalClientState.self, from: data), + let user = state.user, + user.id > 0 + else { + return nil + } + return AgentWorkRankIdentity( + id: user.id, + name: user.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? L("匿名用户") + : user.name, + avatarURL: user.avatarURL, + lastSyncedAt: state.lastSuccessfulSyncAt.flatMap(parseDate) ) } + + private static func parseDate(_ value: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: value) { + return date + } + return ISO8601DateFormatter().date(from: value) + } +} + +private struct LocalClientState: Decodable { + var user: LocalClientUser? + var lastSuccessfulSyncAt: String? + + enum CodingKeys: String, CodingKey { + case user + case lastSuccessfulSyncAt = "last_successful_sync_at" + } +} + +private struct LocalClientUser: Decodable { + var id: Int + var name: String + var avatarURL: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case avatarURL = "avatar_url" + } } enum TokenRankServiceError: LocalizedError { diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift index 291979d..b269710 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift @@ -33,7 +33,7 @@ enum UsageCollector { ? collectHermesUsage(databaseURL: hermesDatabaseURL) : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) let workBuddy = includeExperimentalAgentSources - ? discoverWorkBuddyUsage(rootURLs: workBuddyRootURLs) + ? collectWorkBuddyUsage(rootURLs: workBuddyRootURLs, modifiedSince: sourceCutoff) : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) cache.files = cache.files.filter { livePaths.contains($0.key) } saveCache(cache) @@ -47,7 +47,7 @@ enum UsageCollector { ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped) } let records = recordsInHistoryWindow( - deduped.records + zCode.records + hermes.records, + deduped.records + zCode.records + hermes.records + workBuddy.records, historyDays: historyDays, now: Date() ) @@ -133,14 +133,14 @@ enum UsageCollector { ? hermesDatabaseURL.map { collectHermesUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0)) : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) let workBuddy = includeExperimentalAgentSources - ? discoverWorkBuddyUsage(rootURLs: workBuddyRootURLs ?? []) + ? collectWorkBuddyUsage(rootURLs: workBuddyRootURLs ?? [], modifiedSince: nil) : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) let deduped = deduplicateCrossSource( nativeRecords: codex.records + claude.records, proxyRecords: ccSwitch.records ) ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped) - let allRecords = deduped.records + zCode.records + hermes.records + let allRecords = deduped.records + zCode.records + hermes.records + workBuddy.records let records = historyDays.map { recordsInHistoryWindow(allRecords, historyDays: $0, now: now) } ?? allRecords @@ -1123,23 +1123,122 @@ enum UsageCollector { ) } - private static func discoverWorkBuddyUsage(rootURLs: [URL]? = nil) -> CollectorResult { + private static func collectWorkBuddyUsage( + rootURLs: [URL]? = nil, + modifiedSince cutoffDate: Date? + ) -> CollectorResult { let home = FileManager.default.homeDirectoryForCurrentUser let roots = rootURLs ?? [ - home.appendingPathComponent(".workbuddy", isDirectory: true), + home.appendingPathComponent(".workbuddy/projects", isDirectory: true), home.appendingPathComponent("Library/Application Support/WorkBuddyExtension", isDirectory: true) ] - let discovered = roots.filter { FileManager.default.fileExists(atPath: $0.path) } + let discoveredRoots = roots.filter { FileManager.default.fileExists(atPath: $0.path) } + let files = discoveredRoots.flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) } + var records: [UsageRecord] = [] + + for file in files { + var lineNumber = 0 + try? forEachLine(in: file, matchingAny: ["\"usage\"", "\"rawUsage\""]) { line in + lineNumber += 1 + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let timestamp = object["timestamp"], + let day = dayString(fromEpoch: timestamp), + let usage = workBuddyUsage(from: object), + usage.totalTokens > 0 + else { + return + } + + let providerData = object["providerData"] as? [String: Any] + let recordType = object["type"] as? String + records.append(UsageRecord( + date: day, + timestamp: isoString(fromEpoch: timestamp), + tool: "WorkBuddy", + model: modelKey( + providerData?["requestModelId"] as? String + ?? providerData?["requestModelName"] as? String + ?? providerData?["model"] as? String + ), + usage: usage, + source: .workbuddy, + requestID: nonEmptyString(providerData?["conversationRequestId"] as? String), + sessionID: nonEmptyString(object["sessionId"] as? String), + sourcePath: file.path, + lineNumber: lineNumber, + modelRequestCount: 1, + toolCallCount: recordType == "function_call" ? 1 : 0 + )) + } + } + + let status: String + if discoveredRoots.isEmpty { + status = "missing" + } else if files.isEmpty { + status = "discovered_no_usage" + } else if records.isEmpty { + status = "missing_valid_rows" + } else { + status = "ok" + } return CollectorResult( - records: [], + records: records, source: SourceInfo( - status: discovered.isEmpty ? "missing" : "discovered_no_usage", - files: discovered.count, - records: 0 + status: status, + files: files.count, + records: records.count ) ) } + private static func workBuddyUsage(from object: [String: Any]) -> TokenUsageCounts? { + let message = object["message"] as? [String: Any] + let providerData = object["providerData"] as? [String: Any] + let usage = message?["usage"] as? [String: Any] + ?? providerData?["rawUsage"] as? [String: Any] + ?? providerData?["usage"] as? [String: Any] + guard let usage else { return nil } + + let rawInput = firstIntegerValue( + in: usage, + keys: ["input_tokens", "inputTokens", "prompt_tokens"] + ) + let output = firstIntegerValue( + in: usage, + keys: ["output_tokens", "outputTokens", "completion_tokens"] + ) + let cacheRead = firstIntegerValue( + in: usage, + keys: ["cache_read_input_tokens", "cached_tokens", "prompt_cache_hit_tokens"] + ) + let reasoning = firstIntegerValue( + in: usage, + keys: ["reasoning_tokens", "completion_thinking_tokens"] + ) + let explicitTotal = firstIntegerValue( + in: usage, + keys: ["total_tokens", "totalTokens"] + ) + return canonicalUsageCounts( + rawInputTokens: rawInput, + outputTokens: output, + cacheReadInputTokens: cacheRead, + reasoningOutputTokens: reasoning, + inputIncludesCachedTokens: true, + explicitTotalTokens: explicitTotal, + explicitTotalIsAuthoritative: true + ) + } + + private static func firstIntegerValue(in object: [String: Any], keys: [String]) -> Int { + for key in keys where object.keys.contains(key) { + return max(0, integerValue(object[key] as Any)) + } + return 0 + } + private static func deduplicateCrossSource( nativeRecords: [UsageRecord], proxyRecords: [UsageRecord] @@ -1503,7 +1602,7 @@ enum UsageCollector { private static func isAgentWorkRecord(_ record: UsageRecord) -> Bool { switch record.source { - case .nativeCodex, .nativeCodexSQLite, .nativeClaudeCode, .ccSwitchProxy, .zcode, .hermes: + case .nativeCodex, .nativeCodexSQLite, .nativeClaudeCode, .ccSwitchProxy, .zcode, .hermes, .workbuddy: return true case .unknown: return false @@ -2325,6 +2424,7 @@ private enum UsageRecordSource: String, Codable { case ccSwitchProxy case zcode case hermes + case workbuddy case unknown } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index 20c7099..1a07385 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -13,6 +13,7 @@ final class AppState: ObservableObject { @Published private(set) var claudeQuota: CodexQuotaSnapshot = .unavailable @Published private(set) var isRefreshingTokenRank = false @Published private(set) var tokenRank: TokenRankLeaderboard? + @Published private(set) var agentWorkRankIdentity: AgentWorkRankIdentity? @Published private(set) var tokenRankError: String? @Published private(set) var isDownloadingUpdate = false @Published private(set) var updateDownloadProgress = 0.0 @@ -33,6 +34,7 @@ final class AppState: ObservableObject { applyDefaultAutostartIfNeeded() configureTimer() refreshCodexQuota() + refreshTokenRank() scheduleDeferredUpdateCheck() } @@ -130,7 +132,7 @@ final class AppState: ObservableObject { codexQuota = .unavailable claudeQuota = .unavailable } - if !loadedSettings.showTokenRank { + if !loadedSettings.showAgentWorkRank { clearTokenRankState() } autostartEnabled = AutostartService.isEnabled @@ -294,24 +296,16 @@ final class AppState: ObservableObject { } } - func setTokenRankVisible(_ visible: Bool) { - settings.showTokenRank = visible + func setAgentWorkRankVisible(_ visible: Bool) { + settings.showAgentWorkRank = visible saveSettingsAndReload() - if settings.showTokenRank { + if settings.showAgentWorkRank { refreshTokenRank(force: true) } else { clearTokenRankState() } } - func setTokenRankUserID(_ userID: String) { - settings.tokenRankUserID = TokenStepSettings.cleanedTokenRankUserID(userID) - saveSettingsAndReload() - if settings.showTokenRank, tokenRank == nil { - refreshTokenRank(force: true) - } - } - func setExperimentalAgentSourcesVisible(_ visible: Bool) { settings.showExperimentalAgentSources = visible saveSettingsAndReload() @@ -319,32 +313,33 @@ final class AppState: ObservableObject { } func refreshTokenRank(force: Bool = false) { - guard settings.showTokenRank else { + guard settings.showAgentWorkRank else { clearTokenRankState() return } guard !isRefreshingTokenRank else { return } if !force, let fetchedAt = tokenRank?.fetchedAt, - Date().timeIntervalSince(fetchedAt) < TokenRankService.cacheTTL { + Date().timeIntervalSince(fetchedAt) < AgentWorkRankService.cacheTTL { return } + agentWorkRankIdentity = AgentWorkRankService.loadLocalIdentity() isRefreshingTokenRank = true Task { defer { isRefreshingTokenRank = false } do { - let leaderboard = try await TokenRankService.fetchLeaderboard() - guard settings.showTokenRank else { + let leaderboard = try await AgentWorkRankService.fetchLeaderboard() + guard settings.showAgentWorkRank else { clearTokenRankState() return } tokenRank = leaderboard tokenRankError = nil } catch { - guard settings.showTokenRank else { + guard settings.showAgentWorkRank else { clearTokenRankState() return } @@ -358,15 +353,11 @@ final class AppState: ObservableObject { } func openTokenRankLeaderboardPage() { - NSWorkspace.shared.open(TokenRankService.leaderboardPageURL) + NSWorkspace.shared.open(AgentWorkRankService.leaderboardPageURL) } func openTokenRankUserPage() { - guard let url = TokenRankService.userPageURL(userID: settings.tokenRankUserID) else { - openTokenRankLeaderboardPage() - return - } - NSWorkspace.shared.open(url) + NSWorkspace.shared.open(AgentWorkRankService.myPageURL) } func setAutoUpdateEnabled(_ enabled: Bool) { @@ -482,6 +473,7 @@ final class AppState: ObservableObject { private func clearTokenRankState() { tokenRank = nil + agentWorkRankIdentity = nil tokenRankError = nil isRefreshingTokenRank = false } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index aed523e..3df95ec 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -263,11 +263,15 @@ enum TokenStepLocalization { "Agent 剩余额度": "Agent Quota", "Codex 额度显示": "Show Codex Quota", "Agent 额度显示": "Show Agent Quota", - "生财 Token 榜单": "Shengcai Token Rank", - "生财榜单显示": "Show Shengcai Rank", - "我的生财 userId": "My Shengcai userId", - "可选,填写后显示我的排名": "Optional, shows my rank", - "点击卡片": "Card click", + "Agent 消耗榜": "Agent Usage Rank", + "Agent 消耗榜显示": "Show Agent Usage Rank", + "尚未关联": "Not linked", + "已自动关联": "Automatically linked", + "安装 Token Rank 后自动识别": "Detected after Token Rank is installed", + "数据同步": "Data sync", + "我的消耗": "My Usage", + "打开榜单": "Open Rank", + "全部工具": "All tools", "打开榜单页": "Open leaderboard", "打开个人页": "Open profile", "默认关闭": "Off by default", @@ -287,6 +291,7 @@ enum TokenStepLocalization { "上榜:": "Ranked:", "实验 Agent 来源": "Experimental Agent Sources", "启用 ZCode / Hermes": "Enable ZCode / Hermes", + "启用 ZCode / Hermes / WorkBuddy": "Enable ZCode / Hermes / WorkBuddy", "只读取本地 usage 字段,不读取对话正文。": "Only reads local usage fields, never message content.", "已计入实验统计": "Included as experimental", "已发现,暂不可统计": "Found, not countable yet", @@ -310,6 +315,7 @@ enum TokenStepLocalization { "%d 天后重置": "Resets in %d days", "刚刚": "Just now", "%d 分钟前": "%d min ago", + "%d 小时前": "%d hr ago", "暂未读取到 Codex 额度": "Codex quota unavailable", "暂未读取到 Claude Code 额度": "Claude Code quota unavailable", "暂未读取到 Agent 额度": "Agent quota unavailable", @@ -610,11 +616,15 @@ enum TokenStepLocalization { "Agent 剩余额度": "Agent 剩餘額度", "Codex 额度显示": "顯示 Codex 額度", "Agent 额度显示": "顯示 Agent 額度", - "生财 Token 榜单": "生財 Token 榜單", - "生财榜单显示": "顯示生財榜單", - "我的生财 userId": "我的生財 userId", - "可选,填写后显示我的排名": "可選,填寫後顯示我的排名", - "点击卡片": "點擊卡片", + "Agent 消耗榜": "Agent 消耗榜", + "Agent 消耗榜显示": "顯示 Agent 消耗榜", + "尚未关联": "尚未關聯", + "已自动关联": "已自動關聯", + "安装 Token Rank 后自动识别": "安裝 Token Rank 後自動識別", + "数据同步": "資料同步", + "我的消耗": "我的消耗", + "打开榜单": "開啟榜單", + "全部工具": "全部工具", "打开榜单页": "開啟榜單頁", "打开个人页": "開啟個人頁", "默认关闭": "預設關閉", @@ -634,6 +644,7 @@ enum TokenStepLocalization { "上榜:": "上榜:", "实验 Agent 来源": "實驗 Agent 來源", "启用 ZCode / Hermes": "啟用 ZCode / Hermes", + "启用 ZCode / Hermes / WorkBuddy": "啟用 ZCode / Hermes / WorkBuddy", "只读取本地 usage 字段,不读取对话正文。": "只讀取本地 usage 欄位,不讀取對話正文。", "已计入实验统计": "已計入實驗統計", "已发现,暂不可统计": "已發現,暫不可統計", @@ -657,6 +668,7 @@ enum TokenStepLocalization { "%d 天后重置": "%d 天後重置", "刚刚": "剛剛", "%d 分钟前": "%d 分鐘前", + "%d 小时前": "%d 小時前", "暂未读取到 Codex 额度": "暫未讀取到 Codex 額度", "暂未读取到 Claude Code 额度": "暫未讀取到 Claude Code 額度", "暂未读取到 Agent 额度": "暫未讀取到 Agent 額度", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift index 8858a3a..53ef17d 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift @@ -660,6 +660,8 @@ func tokenToolColor(_ tool: String) -> Color { return Color(red: 0.50, green: 0.28, blue: 0.92) case "ZCode": return Color(red: 0.20, green: 0.52, blue: 0.92) + case "WorkBuddy": + return Color(red: 0.94, green: 0.63, blue: 0.16) case "Codex via CC Switch", "Claude Code via CC Switch", "Gemini via CC Switch": return Color(red: 0.10, green: 0.64, blue: 0.72) default: @@ -668,7 +670,7 @@ func tokenToolColor(_ tool: String) -> Color { } func orderedToolEntries(_ tools: [String: Int]) -> [(name: String, tokens: Int)] { - let preferred = ["Codex", "Claude Code", "ZCode", "Hermes", "Hermes Agent", "Codex via CC Switch", "Claude Code via CC Switch"] + let preferred = ["Codex", "Claude Code", "ZCode", "Hermes", "Hermes Agent", "WorkBuddy", "Codex via CC Switch", "Claude Code via CC Switch"] var entries: [(name: String, tokens: Int)] = preferred.compactMap { name in guard let value = tools[name], value > 0 else { return nil } return (name, value) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift index 47e0292..4d7fa69 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift @@ -23,7 +23,7 @@ struct PopoverTokenRankCard: View { let dx = abs(value.location.x - value.startLocation.x) let dy = abs(value.location.y - value.startLocation.y) guard dx < 4, dy < 4 else { return } - if hasConfiguredUserID, userRankFrame.contains(value.location) { + if appState.agentWorkRankIdentity != nil, userRankFrame.contains(value.location) { appState.openTokenRankUserPage() } else { appState.openTokenRankLeaderboardPage() @@ -40,7 +40,7 @@ struct PopoverTokenRankCard: View { Circle() .fill(Color.tokenGreen) .frame(width: 8, height: 8) - Text(L("生财 Token 榜单")) + Text(L("Agent 消耗榜")) .font(.callout.weight(.heavy)) .foregroundStyle(Color.tokenInk) Spacer() @@ -49,7 +49,7 @@ struct PopoverTokenRankCard: View { .controlSize(.small) .scaleEffect(0.72) } else if let fetchedAt = appState.tokenRank?.fetchedAt { - Text(fetchedText(fetchedAt)) + Text(relativeTime(fetchedAt)) .font(.caption2.weight(.bold)) .foregroundStyle(.secondary) } @@ -57,54 +57,40 @@ struct PopoverTokenRankCard: View { } private var userRankContent: some View { - mainContent - .background( - GeometryReader { proxy in - Color.clear.preference( - key: TokenRankUserRowFrameKey.self, - value: proxy.frame(in: .named("tokenRankCard")) - ) - } - ) - .help(hasConfiguredUserID ? L("打开个人页") : L("打开榜单页")) - } - - private var mainContent: some View { HStack(alignment: .center, spacing: 12) { - mainIcon - mainText + Image(systemName: mainSymbol) + .font(.system(size: 18, weight: .heavy)) + .foregroundStyle(mainTint) + .frame(width: 38, height: 38) + .background(mainTint.opacity(0.16), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(mainTitle) + .font(.title3.weight(.heavy)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + .minimumScaleFactor(0.78) + Text(mainSubtitle) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) - mainArrow + Image(systemName: "arrow.up.right") + .font(.system(size: 13, weight: .heavy)) + .foregroundStyle(Color.tokenInk.opacity(0.48)) } .contentShape(Rectangle()) - } - - private var mainIcon: some View { - Image(systemName: mainSymbol) - .font(.system(size: 18, weight: .heavy)) - .foregroundStyle(mainTint) - .frame(width: 38, height: 38) - .background(mainTint.opacity(0.16), in: Circle()) - } - - private var mainText: some View { - VStack(alignment: .leading, spacing: 4) { - Text(mainTitle) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - .lineLimit(1) - .minimumScaleFactor(0.78) - Text(mainSubtitle) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - private var mainArrow: some View { - Image(systemName: "arrow.up.right") - .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: TokenRankUserRowFrameKey.self, + value: proxy.frame(in: .named("tokenRankCard")) + ) + } + ) + .help(appState.agentWorkRankIdentity == nil ? L("打开榜单页") : L("打开个人页")) } @ViewBuilder @@ -126,13 +112,12 @@ struct PopoverTokenRankCard: View { } else { HStack(spacing: 8) { if let entry = currentUserEntry { - TokenRankMetaPill(label: L("今日 Token"), value: TokenStepFormat.tokens(entry.score, compact: true)) + TokenRankMetaPill(label: L("今日 Token"), value: TokenStepFormat.tokens(entry.totalTokens, compact: true)) } else if let topEntry = appState.tokenRank?.topEntry { - TokenRankMetaPill(label: L("今日榜首"), value: "#\(topEntry.rank) \(TokenStepFormat.tokens(topEntry.score, compact: true))") + TokenRankMetaPill(label: L("今日榜首"), value: TokenStepFormat.tokens(topEntry.totalTokens, compact: true)) } else { TokenRankMetaPill(label: L("今日榜单"), value: L("等待同步")) } - TokenRankMetaPill(label: L("上榜:"), value: rankingThresholdText) } @@ -146,46 +131,40 @@ struct PopoverTokenRankCard: View { } private var currentUserEntry: TokenRankEntry? { - appState.tokenRank?.entry(matching: appState.settings.tokenRankUserID) - } - - private var hasConfiguredUserID: Bool { - !appState.settings.tokenRankUserID.isEmpty + guard let identity = appState.agentWorkRankIdentity else { return nil } + return appState.tokenRank?.entry(matching: identity.id) } private var mainTitle: String { if let entry = currentUserEntry { return LFormat("今日排名 #%d", entry.rank) } - if hasConfiguredUserID, appState.tokenRank != nil { + if appState.agentWorkRankIdentity == nil { + return L("尚未关联") + } + if appState.tokenRank != nil { return L("今日未上榜") } - if appState.tokenRank == nil, appState.tokenRankError != nil { + if appState.tokenRankError != nil { return L("榜单暂不可用") } - return L("生财榜单") + return L("Agent 消耗榜") } private var mainSubtitle: String { if let entry = currentUserEntry { - return entry.name + return "\(entry.name) · \(primaryClientName(entry))" } - if hasConfiguredUserID, appState.tokenRank != nil { - return L("点击查看今日榜单") + if let identity = appState.agentWorkRankIdentity { + return identity.name } - if appState.tokenRank == nil, appState.tokenRankError != nil { - return L("点击打开榜单页") - } - return L("点击查看今日榜单") + return L("安装 Token Rank 后自动识别") } private var mainSymbol: String { - if currentUserEntry != nil { - return "trophy.fill" - } - if appState.tokenRank == nil, appState.tokenRankError != nil { - return "exclamationmark.triangle.fill" - } + if currentUserEntry != nil { return "trophy.fill" } + if appState.agentWorkRankIdentity == nil { return "person.crop.circle.badge.questionmark" } + if appState.tokenRank == nil, appState.tokenRankError != nil { return "exclamationmark.triangle.fill" } return "list.number" } @@ -194,24 +173,33 @@ struct PopoverTokenRankCard: View { } private var rankingThresholdText: String { - guard let entry = rankingThresholdEntry else { - return L("等待同步") - } - return TokenStepFormat.tokens(entry.score, compact: true) + guard let entry = rankingThresholdEntry else { return L("等待同步") } + return TokenStepFormat.tokens(entry.totalTokens, compact: true) } private var rankingThresholdEntry: TokenRankEntry? { - guard let entries = appState.tokenRank?.entries, !entries.isEmpty else { - return nil + guard let leaderboard = appState.tokenRank, !leaderboard.entries.isEmpty else { return nil } + return leaderboard.entries.first { $0.rank == leaderboard.topLimit } + ?? leaderboard.entries.max { $0.rank < $1.rank } + } + + private func primaryClientName(_ entry: TokenRankEntry) -> String { + guard let client = entry.clients.max(by: { $0.value < $1.value })?.key else { + return L("全部工具") + } + switch client { + case "codex": return "Codex" + case "claude": return "Claude Code" + case "workbuddy": return "WorkBuddy" + case "zcode": return "ZCode" + case "hermes": return "Hermes" + default: return client } - return entries.first { $0.rank == 100 } ?? entries.max { $0.rank < $1.rank } } - private func fetchedText(_ date: Date) -> String { + private func relativeTime(_ date: Date) -> String { let seconds = max(0, Int(Date().timeIntervalSince(date).rounded())) - if seconds < 60 { - return L("刚刚") - } + if seconds < 60 { return L("刚刚") } return LFormat("%d 分钟前", max(1, seconds / 60)) } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift index 52afe49..52c35f5 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift @@ -23,7 +23,7 @@ struct PopoverPanelView: View { if appState.settings.showCodexQuota { PopoverQuotaCard() } - if appState.settings.showTokenRank { + if appState.settings.showAgentWorkRank { PopoverTokenRankCard() } if let update = appState.availableUpdate { diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift index 7f54ee3..d0ebda3 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift @@ -93,49 +93,52 @@ struct SettingsTokenRankCard: View { @EnvironmentObject private var appState: AppState var body: some View { - SettingsCard(title: L("生财 Token 榜单"), symbol: "list.number") { + SettingsCard(title: L("Agent 消耗榜"), symbol: "list.number") { VStack(alignment: .leading, spacing: 13) { SettingsToggleRow( - title: L("生财榜单显示"), + title: L("Agent 消耗榜显示"), isOn: Binding( - get: { appState.settings.showTokenRank }, - set: { appState.setTokenRankVisible($0) } + get: { appState.settings.showAgentWorkRank }, + set: { appState.setAgentWorkRankVisible($0) } ) ) - if appState.settings.showTokenRank { - VStack(alignment: .leading, spacing: 6) { - Text(L("我的生财 userId")) - .font(.caption.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.72)) - - HStack(spacing: 8) { - Image(systemName: "person.text.rectangle") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(Color.tokenGreen) - - TextField( - L("可选,填写后显示我的排名"), - text: Binding( - get: { appState.settings.tokenRankUserID }, - set: { appState.setTokenRankUserID($0) } - ) - ) - .textFieldStyle(.plain) - .font(.callout.weight(.semibold)) - } - .padding(.horizontal, 12) - .frame(height: 38) - .background(Color.tokenTrack.opacity(0.30), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(Color.black.opacity(0.045))) - } + if appState.settings.showAgentWorkRank { + StatusLine( + symbol: appState.agentWorkRankIdentity == nil ? "person.crop.circle.badge.questionmark" : "checkmark.circle.fill", + title: appState.agentWorkRankIdentity == nil ? L("尚未关联") : L("已自动关联"), + value: identityText, + tint: appState.agentWorkRankIdentity == nil ? .orange : .tokenGreen + ) StatusLine( - symbol: "arrow.up.right.circle.fill", - title: L("点击卡片"), - value: appState.settings.tokenRankUserID.isEmpty ? L("打开榜单页") : L("打开个人页"), + symbol: "arrow.triangle.2.circlepath", + title: L("数据同步"), + value: syncText, tint: .tokenGreen ) + + HStack(spacing: 8) { + Button { + appState.openTokenRankUserPage() + } label: { + Label(L("我的消耗"), systemImage: "person.crop.circle") + .font(.caption.weight(.heavy)) + .frame(maxWidth: .infinity) + .frame(height: 32) + } + .buttonStyle(SettingsSecondaryButtonStyle()) + + Button { + appState.openTokenRankLeaderboardPage() + } label: { + Label(L("打开榜单"), systemImage: "list.number") + .font(.caption.weight(.heavy)) + .frame(maxWidth: .infinity) + .frame(height: 32) + } + .buttonStyle(SettingsSecondaryButtonStyle()) + } } else { StatusLine( symbol: "eye.slash.fill", @@ -149,6 +152,27 @@ struct SettingsTokenRankCard: View { } } } + + private var identityText: String { + guard let identity = appState.agentWorkRankIdentity else { + return L("安装 Token Rank 后自动识别") + } + return "\(identity.name) · ID \(identity.id)" + } + + private var syncText: String { + guard let date = appState.agentWorkRankIdentity?.lastSyncedAt else { + return L("等待同步") + } + return relativeTime(date) + } + + private func relativeTime(_ date: Date) -> String { + let minutes = max(0, Int(Date().timeIntervalSince(date) / 60)) + if minutes < 1 { return L("刚刚") } + if minutes < 60 { return LFormat("%d 分钟前", minutes) } + return LFormat("%d 小时前", minutes / 60) + } } struct SettingsExperimentalAgentSourcesCard: View { @@ -158,7 +182,7 @@ struct SettingsExperimentalAgentSourcesCard: View { SettingsCard(title: L("实验 Agent 来源"), symbol: "point.3.connected.trianglepath.dotted", height: 282) { VStack(alignment: .leading, spacing: 13) { SettingsToggleRow( - title: L("启用 ZCode / Hermes"), + title: L("启用 ZCode / Hermes / WorkBuddy"), isOn: Binding( get: { appState.settings.showExperimentalAgentSources }, set: { appState.setExperimentalAgentSourcesVisible($0) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift index 3fb5f9d..51b8909 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift @@ -164,8 +164,7 @@ struct SettingsView: View { appState.setRequireVerifiedUpdates(TokenStepSettings.defaults.requireVerifiedUpdates) appState.setTokenIslandPlacement(TokenStepSettings.defaults.tokenIslandPlacement) appState.setCodexQuotaVisible(TokenStepSettings.defaults.showCodexQuota) - appState.setTokenRankVisible(TokenStepSettings.defaults.showTokenRank) - appState.setTokenRankUserID(TokenStepSettings.defaults.tokenRankUserID) + appState.setAgentWorkRankVisible(TokenStepSettings.defaults.showAgentWorkRank) appState.setExperimentalAgentSourcesVisible(TokenStepSettings.defaults.showExperimentalAgentSources) appState.setAutostart(true) } label: { diff --git a/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift b/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift index 52080d9..6d966bf 100644 --- a/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift +++ b/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift @@ -157,9 +157,8 @@ struct AgentWorkCardRender { tokenIslandEnabled: false, tokenIslandPlacement: .menuBar, showCodexQuota: false, - showTokenRank: false, + showAgentWorkRank: false, showExperimentalAgentSources: true, - tokenRankUserID: "", language: .zhHans, skippedUpdateVersion: nil ) diff --git a/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift index 212cd5a..ec1d6bc 100644 --- a/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift +++ b/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift @@ -11,6 +11,7 @@ struct CCSwitchProxyFixtureCheck { try runAmbiguousFuzzyDedupeCheck() try runShanghaiHistoryWindowCheck() try runExperimentalAgentChecks() + try runAgentWorkRankChecks() try runLegacyAgentWorkDecodeCheck() print("Usage collector fixture checks passed") } @@ -340,7 +341,15 @@ struct CCSwitchProxyFixtureCheck { let hermesDB = try makeHermesFixtureDatabase() let workBuddyRoot = FileManager.default.temporaryDirectory .appendingPathComponent("TokenStepWorkBuddyFixture-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: workBuddyRoot, withIntermediateDirectories: true) + let workBuddyProject = workBuddyRoot.appendingPathComponent("projects/example", isDirectory: true) + try FileManager.default.createDirectory(at: workBuddyProject, withIntermediateDirectories: true) + try """ + {"type":"function_call","timestamp":1717200000000,"sessionId":"wb-session","message":{"usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"cache_read_input_tokens":80}},"providerData":{"requestModelId":"hy3","conversationRequestId":"wb-request-1"}} + """.write( + to: workBuddyProject.appendingPathComponent("session.jsonl"), + atomically: true, + encoding: .utf8 + ) defer { try? FileManager.default.removeItem(at: zCodeDB.deletingLastPathComponent()) try? FileManager.default.removeItem(at: hermesDB.deletingLastPathComponent()) @@ -368,18 +377,19 @@ struct CCSwitchProxyFixtureCheck { try assertEqual(snapshot.sources["ZCode"]?.records, 1, "zcode source records") try assertEqual(snapshot.sources["Hermes Agent"]?.status, "ok", "hermes source status") try assertEqual(snapshot.sources["Hermes Agent"]?.records, 1, "hermes source records") - try assertEqual(snapshot.sources["WorkBuddy"]?.status, "discovered_no_usage", "workbuddy discovered status") - try assertEqual(snapshot.sources["WorkBuddy"]?.records, 0, "workbuddy records") - try assertEqual(snapshot.totals.tokens, 72, "experimental total tokens") + try assertEqual(snapshot.sources["WorkBuddy"]?.status, "ok", "workbuddy source status") + try assertEqual(snapshot.sources["WorkBuddy"]?.records, 1, "workbuddy records") + try assertEqual(snapshot.totals.tokens, 192, "experimental total tokens") try assertEqual(snapshot.daily.first?.tools["ZCode"], 40, "zcode tool tokens") try assertEqual(snapshot.daily.first?.tools["Hermes Agent"], 32, "hermes tool tokens") + try assertEqual(snapshot.daily.first?.tools["WorkBuddy"], 120, "workbuddy tool tokens") try assertEqual(snapshot.totals.cost, 0.42, "hermes actual cost") let work = try unwrap(snapshot.agentWork.first, "experimental agent work") - try assertEqual(work.totalTokens, 72, "experimental agent work tokens") - try assertEqual(work.modelRequestCount, 3, "experimental model requests") - try assertEqual(work.toolCallCount, 10, "experimental tool calls") - try assertEqual(work.sources.count, 2, "experimental source count") + try assertEqual(work.totalTokens, 192, "experimental agent work tokens") + try assertEqual(work.modelRequestCount, 4, "experimental model requests") + try assertEqual(work.toolCallCount, 11, "experimental tool calls") + try assertEqual(work.sources.count, 3, "experimental source count") try assertEqual(work.hourlyBuckets.count, 24, "agent work always exposes 24 hourly buckets") try assertEqual( work.hourlyBuckets.map(\.totalTokens).reduce(0, +) + work.unbucketedTokens, @@ -388,7 +398,7 @@ struct CCSwitchProxyFixtureCheck { ) try assertEqual(work.unbucketedTokens, 0, "timestamped agent rows are fully bucketed") try assertEqual(work.cacheCoverageComplete, true, "experimental cache coverage is complete") - try assertApprox(work.cacheHitRate, 7.0 / 47.0, "daily cache hit uses canonical input denominator") + try assertApprox(work.cacheHitRate, 87.0 / 147.0, "daily cache hit uses canonical input denominator") } private static func runLegacyAgentWorkDecodeCheck() throws { @@ -409,6 +419,81 @@ struct CCSwitchProxyFixtureCheck { try assertNil(work.cacheHitRate, "legacy cache hit is unavailable") } + private static func runAgentWorkRankChecks() throws { + let fetchedAt = Date(timeIntervalSince1970: 1_720_000_000) + let leaderboardData = Data(""" + { + "success": true, + "data": { + "range": "today", + "client": "all", + "usage_mode": "all", + "total_tokens": 999, + "total_ranked_users": 1, + "top_limit": 100, + "rows": [{ + "user": { + "id": 4, + "name": "Agent User", + "email": "ignored@example.com", + "avatar_url": "https://example.com/avatar.png" + }, + "total_tokens": 180, + "call_count": 2, + "session_count": 1, + "clients": {"workbuddy": 120, "codex": 60}, + "models": {"hy3": 120, "gpt-test": 60}, + "rank": 7 + }] + } + } + """.utf8) + let leaderboard = try AgentWorkRankService.decodeLeaderboard( + data: leaderboardData, + fetchedAt: fetchedAt + ) + try assertEqual(leaderboard.fetchedAt, fetchedAt, "rank fetch date") + try assertEqual(leaderboard.totalRankedUsers, 1, "ranked users") + let entry = try unwrap(leaderboard.entry(matching: 4), "rank entry") + try assertEqual(entry.rank, 7, "rank position") + try assertEqual(entry.totalTokens, 180, "rank tokens") + try assertEqual(entry.clients["workbuddy"], 120, "rank workbuddy tokens") + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepRankIdentityFixture-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let stateURL = directory.appendingPathComponent("client-state.json") + try """ + { + "device_token": "must-not-leave-this-file", + "source_id": "private-source", + "last_successful_sync_at": "2026-08-06T08:00:00.661715+00:00", + "user": { + "id": 4, + "name": "Agent User", + "email": "ignored@example.com", + "avatar_url": "https://example.com/avatar.png" + } + } + """.write(to: stateURL, atomically: true, encoding: .utf8) + let identity = try unwrap( + AgentWorkRankService.loadLocalIdentity(clientStateURL: stateURL), + "local rank identity" + ) + try assertEqual(identity.id, 4, "local rank identity id") + try assertEqual(identity.name, "Agent User", "local rank identity name") + try assertEqual(identity.lastSyncedAt == nil, false, "local rank sync date") + + let legacySettings = try JSONDecoder().decode( + TokenStepSettings.self, + from: Data(""" + {"show_token_rank":true,"token_rank_user_id":"168066"} + """.utf8) + ) + try assertEqual(legacySettings.showAgentWorkRank, false, "legacy rank stays disabled") + } + private static func runCodexArchivedSessionChecks() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("TokenStepCodexArchivedFixture-\(UUID().uuidString)", isDirectory: true) diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/AgentWorkRankServiceTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/AgentWorkRankServiceTests.swift new file mode 100644 index 0000000..6adabd6 --- /dev/null +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/AgentWorkRankServiceTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +@testable import TokenStepSwift + +final class AgentWorkRankServiceTests: XCTestCase { + func testPublicLeaderboardDecodesOnlySupportedRankFields() throws { + let fetchedAt = Date(timeIntervalSince1970: 1_720_000_000) + let data = Data(""" + { + "success": true, + "data": { + "range": "today", + "client": "all", + "usage_mode": "all", + "total_tokens": 999, + "total_ranked_users": 1, + "top_limit": 100, + "rows": [{ + "user": { + "id": 4, + "name": "Agent User", + "email": "ignored@example.com", + "avatar_url": "https://example.com/avatar.png", + "is_active_member": true + }, + "total_tokens": 180, + "call_count": 2, + "session_count": 1, + "clients": {"workbuddy": 120, "codex": 60}, + "models": {"hy3": 120, "gpt-test": 60}, + "rank": 7 + }] + } + } + """.utf8) + + let leaderboard = try AgentWorkRankService.decodeLeaderboard( + data: data, + fetchedAt: fetchedAt + ) + + XCTAssertEqual(leaderboard.fetchedAt, fetchedAt) + XCTAssertEqual(leaderboard.totalRankedUsers, 1) + XCTAssertEqual(leaderboard.topLimit, 100) + let entry = try XCTUnwrap(leaderboard.entry(matching: 4)) + XCTAssertEqual(entry.rank, 7) + XCTAssertEqual(entry.name, "Agent User") + XCTAssertEqual(entry.totalTokens, 180) + XCTAssertEqual(entry.clients["workbuddy"], 120) + } + + func testLocalIdentityIgnoresCredentialsAndEmail() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepRankIdentity-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let stateURL = directory.appendingPathComponent("client-state.json") + try """ + { + "device_token": "must-not-leave-this-file", + "source_id": "private-source", + "last_successful_sync_at": "2026-08-06T08:00:00.661715+00:00", + "user": { + "id": 4, + "name": "Agent User", + "email": "ignored@example.com", + "avatar_url": "https://example.com/avatar.png" + } + } + """.write(to: stateURL, atomically: true, encoding: .utf8) + + let identity = try XCTUnwrap( + AgentWorkRankService.loadLocalIdentity(clientStateURL: stateURL) + ) + XCTAssertEqual(identity.id, 4) + XCTAssertEqual(identity.name, "Agent User") + XCTAssertNotNil(identity.lastSyncedAt) + } + + func testLegacyShengcaiSettingsDoNotEnableAgentWorkRank() throws { + let data = Data(""" + { + "show_token_rank": true, + "token_rank_user_id": "168066" + } + """.utf8) + let settings = try JSONDecoder().decode(TokenStepSettings.self, from: data) + XCTAssertFalse(settings.showAgentWorkRank) + } +} diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorExperimentalAgentTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorExperimentalAgentTests.swift index 4f906af..d001533 100644 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorExperimentalAgentTests.swift +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorExperimentalAgentTests.swift @@ -121,6 +121,55 @@ final class UsageCollectorExperimentalAgentTests: XCTestCase { XCTAssertTrue(snapshot.agentWork.isEmpty) } + func testWorkBuddyCollectorReadsUsageWithoutMessageContent() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepWorkBuddy-\(UUID().uuidString)", isDirectory: true) + let project = root.appendingPathComponent("projects/example", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: root) + } + + let lines = [ + """ + {"type":"function_call","timestamp":1717200000000,"sessionId":"wb-session","message":{"usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"cache_read_input_tokens":80}},"providerData":{"requestModelId":"hy3","conversationRequestId":"wb-request-1","toolResult":{"content":"must not be parsed"}}} + """, + """ + {"type":"message","timestamp":1717203600000,"sessionId":"wb-session","providerData":{"requestModelId":"kimi-k3-1","conversationRequestId":"wb-request-2","rawUsage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60,"prompt_cache_hit_tokens":40,"completion_thinking_tokens":3}}} + """, + """ + {"type":"message","timestamp":1717207200000,"sessionId":"wb-session","message":{"content":"no usage row"}} + """ + ] + try lines.joined(separator: "\n").write( + to: project.appendingPathComponent("session.jsonl"), + atomically: true, + encoding: .utf8 + ) + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + workBuddyRootURLs: [root], + includeExperimentalAgentSources: true + ) + + XCTAssertEqual(snapshot.sources["WorkBuddy"]?.status, "ok") + XCTAssertEqual(snapshot.sources["WorkBuddy"]?.files, 1) + XCTAssertEqual(snapshot.sources["WorkBuddy"]?.records, 2) + XCTAssertEqual(snapshot.totals.tokens, 180) + XCTAssertEqual(snapshot.daily.first?.tools["WorkBuddy"], 180) + XCTAssertEqual(snapshot.daily.first?.models["hy3"], 120) + XCTAssertEqual(snapshot.daily.first?.models["kimi-k3-1"], 60) + + let work = try XCTUnwrap(snapshot.agentWork.first) + XCTAssertEqual(work.totalTokens, 180) + XCTAssertEqual(work.inputTokens, 150) + XCTAssertEqual(work.cachedInputTokens, 120) + XCTAssertEqual(work.outputTokens, 30) + XCTAssertEqual(work.modelRequestCount, 2) + XCTAssertEqual(work.toolCallCount, 1) + XCTAssertEqual(work.sources.first?.source, "WorkBuddy") + } + private func makeZCodeDatabase(rowsSQL: String) throws -> URL { let database = try fixtureDatabase(prefix: "TokenStepZCode") try runSQLite(database: database, sql: """ diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index 47eda99..4ebd490 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -21,7 +21,7 @@ RESOURCES="$CONTENTS/Resources" EXECUTABLE="$BUILD_DIR/$PRODUCT_NAME" HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" -VERSION="${TOKENSTEP_VERSION:-0.1.45}" +VERSION="${TOKENSTEP_VERSION:-0.1.46}" LAUNCH=true VERIFY=false diff --git a/script/package_release.sh b/script/package_release.sh index afb2523..da05a7c 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -8,14 +8,14 @@ SWIFT_DIR="$ROOT_DIR/TokenStepSwift" BUILT_APP_BUNDLE="$SWIFT_DIR/dist/$APP_NAME.app" APP_BUNDLE="$BUILT_APP_BUNDLE" RELEASE_DIR="$ROOT_DIR/release" -VERSION="${TOKENSTEP_VERSION:-0.1.45}" +VERSION="${TOKENSTEP_VERSION:-0.1.46}" IDENTITY="${CODE_SIGN_IDENTITY:-}" NOTARIZE=false usage() { cat <<'USAGE' Usage: - TOKENSTEP_VERSION=0.1.45 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] + TOKENSTEP_VERSION=0.1.46 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] Notarization credentials, choose one: TOKENSTEP_NOTARY_PROFILE="notarytool-profile" diff --git a/script/test_ccswitch_proxy_collector.sh b/script/test_ccswitch_proxy_collector.sh index 77cd654..2a0a93c 100755 --- a/script/test_ccswitch_proxy_collector.sh +++ b/script/test_ccswitch_proxy_collector.sh @@ -42,6 +42,7 @@ swiftc \ "$SWIFT_DIR/Sources/TokenStepSwift/Support/Localization.swift" \ "$SWIFT_DIR/Sources/TokenStepSwift/Support/Theme.swift" \ "$SWIFT_DIR/Sources/TokenStepSwift/Models/UsageModels.swift" \ + "$SWIFT_DIR/Sources/TokenStepSwift/Services/TokenRankService.swift" \ "$SWIFT_DIR/Sources/TokenStepSwift/Services/UsageCollector.swift" \ "$SWIFT_DIR/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift" \ -o "$EXECUTABLE"