mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-01 21:35:53 +00:00
Summary: - Replace the legacy iOS shell with Pro Command, Chat, Agents, and Settings tabs. - Wire iOS chat/session/settings/diagnostics and realtime Talk flows through gateway-backed APIs. - Add gateway/session and shared chat coverage for the new iOS flow. Verification: - git diff --check - node scripts/run-vitest.mjs src/gateway/server.sessions.create.test.ts src/gateway/talk-realtime-relay.test.ts - swift test --filter ChatViewModelTests (apps/shared/OpenClawKit) - xcodebuild build for Nimrod's iPhone succeeded; install succeeded; launch was blocked because the phone was locked Known follow-up: - Preserve traceLevel in sessions.create parent runtime inheritance and keep the changelog credit in the follow-up patch.
252 lines
9.0 KiB
Swift
252 lines
9.0 KiB
Swift
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import SwiftUI
|
|
|
|
extension AgentProTab {
|
|
func agentName(for agent: AgentSummary) -> String {
|
|
self.normalized(agent.name) ?? agent.id
|
|
}
|
|
|
|
func agentBadge(for agent: AgentSummary) -> String {
|
|
if let identity = agent.identity,
|
|
let emoji = identity["emoji"]?.value as? String,
|
|
let normalizedEmoji = self.normalized(emoji)
|
|
{
|
|
return normalizedEmoji
|
|
}
|
|
|
|
let words = self.agentName(for: agent)
|
|
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
|
.prefix(2)
|
|
let initials = words.compactMap(\.first).map(String.init).joined()
|
|
return initials.isEmpty ? "OC" : initials.uppercased()
|
|
}
|
|
|
|
func agentTint(for agent: AgentSummary, state: AgentRosterState) -> Color {
|
|
if agent.id == self.activeAgentID { return OpenClawBrand.accent }
|
|
return state.color.opacity(0.62)
|
|
}
|
|
|
|
func agentDetail(for agent: AgentSummary) -> String {
|
|
let parts = [
|
|
self.normalized(agent.workspace),
|
|
self.modelLabel(for: agent),
|
|
agent.id == self.appModel.gatewayDefaultAgentId ? "default" : nil,
|
|
].compactMap(\.self)
|
|
return parts.isEmpty ? agent.id : parts.joined(separator: " • ")
|
|
}
|
|
|
|
func agentSessionSummary(_ agent: AgentSummary) -> String {
|
|
guard self.gatewayConnected else { return "0" }
|
|
if agent.id == self.activeAgentID {
|
|
return self.appModel.isOperatorGatewayConnected ? "1 running" : "0"
|
|
}
|
|
return "0"
|
|
}
|
|
|
|
func agentRuntimeSummary(_ agent: AgentSummary) -> String {
|
|
if let runtime = agent.agentruntime,
|
|
let id = runtime["id"]?.value as? String,
|
|
let normalized = self.normalized(id)
|
|
{
|
|
return normalized
|
|
}
|
|
if let model = self.modelLabel(for: agent) {
|
|
return Self.shortModelLabel(model)
|
|
}
|
|
return "default"
|
|
}
|
|
|
|
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
|
|
guard self.gatewayConnected else { return .idle }
|
|
if agent.id == self.activeAgentID { return .online }
|
|
if self.cronJobsContain(agentID: agent.id) { return .busy }
|
|
return .idle
|
|
}
|
|
|
|
func cronJobsContain(agentID: String) -> Bool {
|
|
self.recentCronJobs.contains { job in
|
|
self.normalized(job.agentid) == agentID && job.enabled
|
|
}
|
|
}
|
|
|
|
func modelLabel(for agent: AgentSummary) -> String? {
|
|
guard let model = agent.model else { return nil }
|
|
for key in ["primary", "name", "id", "model"] {
|
|
if let value = model[key]?.value as? String,
|
|
let normalized = self.normalized(value)
|
|
{
|
|
return normalized
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func shortModelLabel(_ model: String) -> String {
|
|
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "default" }
|
|
let leaf = trimmed.split(separator: "/").last.map(String.init) ?? trimmed
|
|
return leaf
|
|
.replacingOccurrences(of: "claude-", with: "")
|
|
.replacingOccurrences(of: "gpt-", with: "")
|
|
}
|
|
|
|
func presenceLabel(_ entry: PresenceEntry) -> String? {
|
|
self.normalized(entry.host)
|
|
?? self.normalized(entry.devicefamily)
|
|
?? self.normalized(entry.platform)
|
|
?? self.normalized(entry.mode)
|
|
}
|
|
|
|
func cronJobDetail(_ job: CronJob) -> String {
|
|
if let nextRunAtMs = AgentProValueReader.intValue(job.state["nextRunAtMs"]) {
|
|
return "Next \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
|
}
|
|
if let description = self.normalized(job.description) {
|
|
return description
|
|
}
|
|
if let agentId = self.normalized(job.agentid) {
|
|
return agentId
|
|
}
|
|
return job.id
|
|
}
|
|
|
|
func cronJobState(_ job: CronJob) -> String {
|
|
if !job.enabled {
|
|
return "paused"
|
|
}
|
|
if let status = Self.stringValue(job.state["lastStatus"]) ?? Self.stringValue(job.state["lastRunStatus"]) {
|
|
return status
|
|
}
|
|
return "enabled"
|
|
}
|
|
|
|
@MainActor
|
|
func refreshOverview(force: Bool) async {
|
|
guard self.scenePhase == .active else { return }
|
|
guard self.appModel.isOperatorGatewayConnected else {
|
|
self.overview = nil
|
|
self.overviewErrorText = nil
|
|
self.overviewLoading = false
|
|
return
|
|
}
|
|
if self.overviewLoading, force == false {
|
|
return
|
|
}
|
|
|
|
self.overviewLoading = true
|
|
self.overviewErrorText = nil
|
|
defer { self.overviewLoading = false }
|
|
|
|
let activeAgentID = self.activeAgentID
|
|
let skillsParams = Self.agentScopedParams(agentId: activeAgentID)
|
|
async let skills = self.requestOptional(
|
|
SkillStatusReportLite.self,
|
|
method: "skills.status",
|
|
paramsJSON: skillsParams)
|
|
async let config = self.requestOptional(ConfigSnapshotLite.self, method: "config.get")
|
|
async let presence = self.requestOptional([PresenceEntry].self, method: "system-presence")
|
|
async let cronStatus = self.requestOptional(CronStatusLite.self, method: "cron.status")
|
|
async let cronJobs = self.requestOptional(
|
|
CronJobsListLite.self,
|
|
method: "cron.list",
|
|
paramsJSON: "{\"includeDisabled\":true,\"limit\":8,\"sortBy\":\"nextRunAtMs\",\"sortDir\":\"asc\"}",
|
|
timeoutSeconds: 12)
|
|
async let dreaming = self.requestOptional(DreamingStatusEnvelope.self, method: "doctor.memory.status")
|
|
async let dreamDiary = self.requestOptional(DreamDiaryLite.self, method: "doctor.memory.dreamDiary")
|
|
async let usage = self.requestOptional(
|
|
CostUsageSummaryLite.self,
|
|
method: "usage.cost",
|
|
paramsJSON: "{\"days\":31}",
|
|
timeoutSeconds: 12)
|
|
|
|
let loadedSkills = await skills
|
|
let loadedConfig = await config
|
|
let loadedPresence = await presence
|
|
let loadedCronStatus = await cronStatus
|
|
let loadedCronJobs = await cronJobs
|
|
let loadedDreaming = await dreaming
|
|
let loadedDreamDiary = await dreamDiary
|
|
let loadedUsage = await usage
|
|
let snapshot = AgentOverviewSnapshot(
|
|
skills: loadedSkills,
|
|
presence: loadedPresence ?? [],
|
|
cronStatus: loadedCronStatus,
|
|
cronJobs: loadedCronJobs?.jobs ?? [],
|
|
dreaming: loadedDreaming?.dreaming,
|
|
dreamDiary: loadedDreamDiary,
|
|
usage: loadedUsage,
|
|
activeAgentId: activeAgentID,
|
|
agentSkillFilter: loadedSkills?.agentSkillFilter
|
|
?? loadedConfig?.effectiveSkillFilter(agentId: activeAgentID),
|
|
loadedAt: Date())
|
|
|
|
if snapshot.hasAnyLiveData {
|
|
self.overview = snapshot
|
|
} else {
|
|
self.overview = snapshot
|
|
self.overviewErrorText = "Live overview could not load yet."
|
|
}
|
|
}
|
|
|
|
func requestOptional<T: Decodable>(
|
|
_ type: T.Type,
|
|
method: String,
|
|
paramsJSON: String = "{}",
|
|
timeoutSeconds: Int = 8) async -> T?
|
|
{
|
|
do {
|
|
let data = try await self.appModel.operatorSession.request(
|
|
method: method,
|
|
paramsJSON: paramsJSON,
|
|
timeoutSeconds: timeoutSeconds)
|
|
return try JSONDecoder().decode(T.self, from: data)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func normalized(_ value: String?) -> String? {
|
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
static func stringValue(_ value: AnyCodable?) -> String? {
|
|
guard let string = value?.value as? String else { return nil }
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
|
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
|
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
|
}
|
|
|
|
static func compactNumber(_ value: Int) -> String {
|
|
value.formatted(.number.notation(.compactName))
|
|
}
|
|
|
|
static func currency(_ value: Double) -> String {
|
|
value.formatted(.currency(code: "USD").precision(.fractionLength(0...2)))
|
|
}
|
|
|
|
static func duration(milliseconds: Int) -> String {
|
|
let seconds = max(0, milliseconds / 1000)
|
|
if seconds < 60 { return "\(seconds)s" }
|
|
let minutes = seconds / 60
|
|
if minutes < 60 { return "\(minutes)m" }
|
|
let hours = minutes / 60
|
|
if hours < 24 { return "\(hours)h" }
|
|
return "\(hours / 24)d"
|
|
}
|
|
|
|
static func agentScopedParams(agentId: String) -> String {
|
|
guard let data = try? JSONEncoder().encode(["agentId": agentId]),
|
|
let json = String(data: data, encoding: .utf8)
|
|
else {
|
|
return "{}"
|
|
}
|
|
return json
|
|
}
|
|
}
|