mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ios): preserve localized runtime semantics
This commit is contained in:
@@ -82,6 +82,8 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
private static func tokenCountText(_ count: Int) -> String {
|
||||
String(AttributedString(localized: "^[\(count) token](inflect: true)").characters)
|
||||
String(
|
||||
format: String(localized: "%@ tokens"),
|
||||
self.compactNumber(count))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1287,13 +1287,13 @@ extension GatewayConnectionController {
|
||||
Verify Tailscale Serve is enabled and publishes this Gateway.
|
||||
"""),
|
||||
host,
|
||||
port.formatted())
|
||||
String(port))
|
||||
} else {
|
||||
String(
|
||||
format: String(
|
||||
localized: "Can't reach gateway at %1$@:%2$@. Check Tailscale or LAN."),
|
||||
host,
|
||||
port.formatted())
|
||||
String(port))
|
||||
}
|
||||
case .tlsHandshakeTimeout:
|
||||
String(
|
||||
@@ -1302,7 +1302,7 @@ extension GatewayConnectionController {
|
||||
Secure endpoint was reached, but TLS did not finish in time.
|
||||
"""),
|
||||
host,
|
||||
port.formatted())
|
||||
String(port))
|
||||
case .tlsUnavailable:
|
||||
String(
|
||||
format: String(localized: """
|
||||
@@ -1311,13 +1311,13 @@ extension GatewayConnectionController {
|
||||
with Unencrypted selected.
|
||||
"""),
|
||||
host,
|
||||
port.formatted())
|
||||
String(port))
|
||||
case .certificateUnavailable:
|
||||
String(
|
||||
format: String(
|
||||
localized: "Could not read the TLS certificate from %1$@:%2$@."),
|
||||
host,
|
||||
port.formatted())
|
||||
String(port))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6037,6 +6037,12 @@ extension NodeAppModel {
|
||||
if let problem = self.lastGatewayProblem {
|
||||
return Self.makeWatchGatewayProblemStatus(problem)
|
||||
}
|
||||
let statusText = self.gatewayStatusText == "Connected"
|
||||
? self.operatorStatusText
|
||||
: self.gatewayStatusText
|
||||
if statusText != "Offline" {
|
||||
return OpenClawWatchAppStatus(code: .legacy, verbatim: statusText)
|
||||
}
|
||||
return OpenClawWatchAppStatus(code: .gatewayOffline)
|
||||
}
|
||||
|
||||
@@ -6100,6 +6106,14 @@ extension NodeAppModel {
|
||||
case .apiKeyMissing:
|
||||
return OpenClawWatchAppStatus(code: .talkAPIKeyMissing)
|
||||
}
|
||||
switch self.talkMode.watchPresentation {
|
||||
case let .localized(key):
|
||||
return OpenClawWatchAppStatus(code: .talkFailure, localizationKey: key)
|
||||
case .phase:
|
||||
break
|
||||
case let .verbatim(value):
|
||||
return OpenClawWatchAppStatus(code: .talkFailure, verbatim: value)
|
||||
}
|
||||
return switch self.talkMode.phase {
|
||||
case .connecting:
|
||||
OpenClawWatchAppStatus(code: .talkConnecting)
|
||||
|
||||
@@ -4,6 +4,37 @@ import Photos
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private enum PrivacyPermissionStatus {
|
||||
case addOnly
|
||||
case allowed
|
||||
case limited
|
||||
case notAllowed
|
||||
case notSet
|
||||
case unknown
|
||||
|
||||
var resource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .addOnly: LocalizedStringResource("Add-Only")
|
||||
case .allowed: LocalizedStringResource("Allowed")
|
||||
case .limited: LocalizedStringResource("Limited")
|
||||
case .notAllowed: LocalizedStringResource("Not Allowed")
|
||||
case .notSet: LocalizedStringResource("Not Set")
|
||||
case .unknown: LocalizedStringResource("Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
var tone: OpenClawStatusTone {
|
||||
switch self {
|
||||
case .allowed, .limited:
|
||||
.ok
|
||||
case .addOnly, .notSet:
|
||||
.warn
|
||||
case .notAllowed, .unknown:
|
||||
.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyAccessSectionView: View {
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
@@ -16,41 +47,46 @@ struct PrivacyAccessSectionView: View {
|
||||
var body: some View {
|
||||
DisclosureGroup {
|
||||
self.permissionRow(
|
||||
identifier: "contacts",
|
||||
title: "Contacts",
|
||||
icon: "person.crop.circle",
|
||||
status: self.statusText(for: self.contactsStatus),
|
||||
status: self.permissionStatus(for: self.contactsStatus),
|
||||
detail: "Search and add contacts from the assistant.",
|
||||
actionTitle: self.actionTitle(for: self.contactsStatus),
|
||||
action: self.handleContactsAction)
|
||||
|
||||
self.permissionRow(
|
||||
identifier: "photos",
|
||||
title: "Photos",
|
||||
icon: "photo.on.rectangle",
|
||||
status: self.photosStatusText,
|
||||
status: self.photosPermissionStatus,
|
||||
detail: self.photosDetail,
|
||||
actionTitle: self.photosActionTitle,
|
||||
action: self.handlePhotosAction)
|
||||
|
||||
self.permissionRow(
|
||||
identifier: "calendar-add",
|
||||
title: "Calendar (Add Events)",
|
||||
icon: "calendar.badge.plus",
|
||||
status: self.calendarWriteStatusText,
|
||||
status: self.calendarWritePermissionStatus,
|
||||
detail: "Add events with least privilege.",
|
||||
actionTitle: self.calendarWriteActionTitle,
|
||||
action: self.handleCalendarWriteAction)
|
||||
|
||||
self.permissionRow(
|
||||
identifier: "calendar-view",
|
||||
title: "Calendar (View Events)",
|
||||
icon: "calendar",
|
||||
status: self.calendarReadStatusText,
|
||||
status: self.calendarReadPermissionStatus,
|
||||
detail: "List and read calendar events.",
|
||||
actionTitle: self.calendarReadActionTitle,
|
||||
action: self.handleCalendarReadAction)
|
||||
|
||||
self.permissionRow(
|
||||
identifier: "reminders",
|
||||
title: "Reminders",
|
||||
icon: "checklist",
|
||||
status: self.remindersStatusText,
|
||||
status: self.remindersPermissionStatus,
|
||||
detail: "List, add, and complete reminders.",
|
||||
actionTitle: self.remindersActionTitle,
|
||||
action: self.handleRemindersAction)
|
||||
@@ -68,102 +104,96 @@ struct PrivacyAccessSectionView: View {
|
||||
}
|
||||
|
||||
private func permissionRow(
|
||||
title: String,
|
||||
identifier: String,
|
||||
title: LocalizedStringResource,
|
||||
icon: String,
|
||||
status: String,
|
||||
detail: String,
|
||||
actionTitle: String?,
|
||||
status: PrivacyPermissionStatus,
|
||||
detail: LocalizedStringResource,
|
||||
actionTitle: LocalizedStringResource?,
|
||||
action: (() -> Void)?) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Label(LocalizedStringKey(title), systemImage: icon)
|
||||
Label {
|
||||
Text(title)
|
||||
} icon: {
|
||||
Image(systemName: icon)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Spacer()
|
||||
OpenClawStatusBadge(label: .localized(status), tone: self.statusTone(for: status))
|
||||
.accessibilityIdentifier("privacy-access-\(title)-status")
|
||||
OpenClawStatusBadge(
|
||||
label: .verbatim(String(localized: status.resource)),
|
||||
tone: status.tone)
|
||||
.accessibilityIdentifier("privacy-access-\(identifier)-status")
|
||||
}
|
||||
Text(LocalizedStringKey(detail))
|
||||
Text(detail)
|
||||
.font(OpenClawType.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let actionTitle, let action {
|
||||
Button(action: action) {
|
||||
Text(LocalizedStringKey(actionTitle))
|
||||
Text(actionTitle)
|
||||
.font(OpenClawType.footnoteSemiBold)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("privacy-access-\(title)-action")
|
||||
.accessibilityIdentifier("privacy-access-\(identifier)-action")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func statusTone(for status: String) -> OpenClawStatusTone {
|
||||
switch status {
|
||||
case "Allowed", "Limited":
|
||||
.ok
|
||||
case "Not Set":
|
||||
.warn
|
||||
case "Add-Only":
|
||||
.warn
|
||||
default:
|
||||
.danger
|
||||
}
|
||||
}
|
||||
|
||||
private func statusText(for cnStatus: CNAuthorizationStatus) -> String {
|
||||
private func permissionStatus(for cnStatus: CNAuthorizationStatus) -> PrivacyPermissionStatus {
|
||||
switch cnStatus {
|
||||
case .authorized, .limited:
|
||||
"Allowed"
|
||||
.allowed
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
.notSet
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
.notAllowed
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
.unknown
|
||||
}
|
||||
}
|
||||
|
||||
private func actionTitle(for cnStatus: CNAuthorizationStatus) -> String? {
|
||||
private func actionTitle(for cnStatus: CNAuthorizationStatus) -> LocalizedStringResource? {
|
||||
switch cnStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
LocalizedStringResource("Request Access")
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
LocalizedStringResource("Open Settings")
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private var photosStatusText: String {
|
||||
private var photosPermissionStatus: PrivacyPermissionStatus {
|
||||
switch self.photosStatus {
|
||||
case .authorized:
|
||||
"Allowed"
|
||||
.allowed
|
||||
case .limited:
|
||||
"Limited"
|
||||
.limited
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
.notSet
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
.notAllowed
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
.unknown
|
||||
}
|
||||
}
|
||||
|
||||
private var photosDetail: String {
|
||||
private var photosDetail: LocalizedStringResource {
|
||||
self.photosStatus == .limited
|
||||
? "Read photos you select for the assistant."
|
||||
: "Read recent photos for the assistant."
|
||||
? LocalizedStringResource("Read photos you select for the assistant.")
|
||||
: LocalizedStringResource("Read recent photos for the assistant.")
|
||||
}
|
||||
|
||||
private var photosActionTitle: String? {
|
||||
private var photosActionTitle: LocalizedStringResource? {
|
||||
switch self.photosStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
LocalizedStringResource("Request Access")
|
||||
case .limited:
|
||||
"Manage Access"
|
||||
LocalizedStringResource("Manage Access")
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
LocalizedStringResource("Open Settings")
|
||||
default:
|
||||
nil
|
||||
}
|
||||
@@ -207,25 +237,25 @@ struct PrivacyAccessSectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarWriteStatusText: String {
|
||||
private var calendarWritePermissionStatus: PrivacyPermissionStatus {
|
||||
switch self.calendarStatus {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
"Allowed"
|
||||
.allowed
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
.notSet
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
.notAllowed
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
.unknown
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarWriteActionTitle: String? {
|
||||
private var calendarWriteActionTitle: LocalizedStringResource? {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
LocalizedStringResource("Request Access")
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
LocalizedStringResource("Open Settings")
|
||||
default:
|
||||
nil
|
||||
}
|
||||
@@ -250,29 +280,29 @@ struct PrivacyAccessSectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarReadStatusText: String {
|
||||
private var calendarReadPermissionStatus: PrivacyPermissionStatus {
|
||||
switch self.calendarStatus {
|
||||
case .authorized, .fullAccess:
|
||||
"Allowed"
|
||||
.allowed
|
||||
case .writeOnly:
|
||||
"Add-Only"
|
||||
.addOnly
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
.notSet
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
.notAllowed
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
.unknown
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarReadActionTitle: String? {
|
||||
private var calendarReadActionTitle: LocalizedStringResource? {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
"Request Full Access"
|
||||
LocalizedStringResource("Request Full Access")
|
||||
case .writeOnly:
|
||||
"Upgrade to Full Access"
|
||||
LocalizedStringResource("Upgrade to Full Access")
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
LocalizedStringResource("Open Settings")
|
||||
default:
|
||||
nil
|
||||
}
|
||||
@@ -297,29 +327,29 @@ struct PrivacyAccessSectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var remindersStatusText: String {
|
||||
private var remindersPermissionStatus: PrivacyPermissionStatus {
|
||||
switch self.remindersStatus {
|
||||
case .authorized, .fullAccess:
|
||||
"Allowed"
|
||||
.allowed
|
||||
case .writeOnly:
|
||||
"Add-Only"
|
||||
.addOnly
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
.notSet
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
.notAllowed
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
.unknown
|
||||
}
|
||||
}
|
||||
|
||||
private var remindersActionTitle: String? {
|
||||
private var remindersActionTitle: LocalizedStringResource? {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
LocalizedStringResource("Request Access")
|
||||
case .writeOnly:
|
||||
"Upgrade to Full Access"
|
||||
LocalizedStringResource("Upgrade to Full Access")
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
LocalizedStringResource("Open Settings")
|
||||
default:
|
||||
nil
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ enum TalkPhase: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkWatchPresentation: Equatable {
|
||||
case localized(String)
|
||||
case phase
|
||||
case verbatim(String)
|
||||
}
|
||||
|
||||
private struct FinishingPushToTalk {
|
||||
let captureId: String
|
||||
let generation: UInt64
|
||||
@@ -68,7 +74,11 @@ private struct ChatCompletionResult {
|
||||
private final class TranscriptStreamingOwner {
|
||||
var task: Task<Void, Never>?
|
||||
var speechGeneration: Int?
|
||||
var terminalStatus: (text: String, phase: TalkPhase)?
|
||||
var terminalStatus: (
|
||||
text: String,
|
||||
phase: TalkPhase,
|
||||
watchPresentation: TalkWatchPresentation
|
||||
)?
|
||||
/// Subscribed before chat.send so a fast terminal cannot outrun its owner.
|
||||
var completionEvents: AsyncStream<EventFrame>?
|
||||
}
|
||||
@@ -129,6 +139,7 @@ final class TalkModeManager: NSObject {
|
||||
var isUserSpeechDetected: Bool = false
|
||||
var isPushToTalkActive: Bool = false
|
||||
private(set) var phase: TalkPhase = .idle
|
||||
private(set) var watchPresentation: TalkWatchPresentation = .phase
|
||||
var statusText: String = "Off" {
|
||||
didSet {
|
||||
self.statusRevision &+= 1
|
||||
@@ -328,8 +339,13 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func setStatus(_ text: String, phase: TalkPhase) -> UInt64 {
|
||||
private func setStatus(
|
||||
_ text: String,
|
||||
phase: TalkPhase,
|
||||
watchPresentation: TalkWatchPresentation = .phase) -> UInt64
|
||||
{
|
||||
self.phase = phase
|
||||
self.watchPresentation = watchPresentation
|
||||
self.statusText = text
|
||||
return self.statusRevision
|
||||
}
|
||||
@@ -639,7 +655,10 @@ final class TalkModeManager: NSObject {
|
||||
+ "elapsedMs=\(Self.elapsedMs(since: permissionStartedAt))")
|
||||
guard micOk else {
|
||||
self.logger.warning("start blocked: microphone permission denied")
|
||||
self.setStatus(String(localized: "Microphone permission denied"), phase: .idle)
|
||||
self.setStatus(
|
||||
String(localized: "Microphone permission denied"),
|
||||
phase: .idle,
|
||||
watchPresentation: .localized("Microphone permission denied"))
|
||||
return
|
||||
}
|
||||
guard self.isCurrentStartAttempt(attemptID) else { return }
|
||||
@@ -714,7 +733,10 @@ final class TalkModeManager: NSObject {
|
||||
guard self.captureMode != .pushToTalk else { return false }
|
||||
guard self.finishingPushToTalk == nil else { return false }
|
||||
guard self.foregroundAudioCaptureAllowed else {
|
||||
self.setStatus(String(localized: "Paused"), phase: .idle)
|
||||
self.setStatus(
|
||||
String(localized: "Paused"),
|
||||
phase: .idle,
|
||||
watchPresentation: .localized("Paused"))
|
||||
GatewayDiagnostics.log("talk start ignored: app backgrounded")
|
||||
return false
|
||||
}
|
||||
@@ -844,7 +866,10 @@ final class TalkModeManager: NSObject {
|
||||
self.isListening = false
|
||||
self.isPushToTalkActive = false
|
||||
self.captureMode = .idle
|
||||
self.setStatus(String(localized: "Paused"), phase: .idle)
|
||||
self.setStatus(
|
||||
String(localized: "Paused"),
|
||||
phase: .idle,
|
||||
watchPresentation: .localized("Paused"))
|
||||
self.gatewayTalkActiveModeTitle = "Paused"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.lastTranscript = ""
|
||||
@@ -974,7 +999,10 @@ final class TalkModeManager: NSObject {
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture)
|
||||
guard micOk else {
|
||||
self.setStatus(String(localized: "Microphone permission denied"), phase: .idle)
|
||||
self.setStatus(
|
||||
String(localized: "Microphone permission denied"),
|
||||
phase: .idle,
|
||||
watchPresentation: .localized("Microphone permission denied"))
|
||||
throw NSError(domain: "TalkMode", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Microphone permission denied",
|
||||
])
|
||||
@@ -1016,11 +1044,13 @@ final class TalkModeManager: NSObject {
|
||||
if isCancelled {
|
||||
self.setStatus(String(localized: "Ready"), phase: .idle)
|
||||
} else if !isPermissionError {
|
||||
let status = String(
|
||||
format: String(localized: "Start failed: %@"),
|
||||
error.localizedDescription)
|
||||
self.setStatus(
|
||||
String(
|
||||
format: String(localized: "Start failed: %@"),
|
||||
error.localizedDescription),
|
||||
phase: .idle)
|
||||
status,
|
||||
phase: .idle,
|
||||
watchPresentation: .verbatim(status))
|
||||
}
|
||||
}
|
||||
let shouldResume = self.isEnabled
|
||||
@@ -1524,7 +1554,8 @@ final class TalkModeManager: NSObject {
|
||||
msg)
|
||||
self.speechErrorStatusRevisionPendingRestart = self.setStatus(
|
||||
errorStatus,
|
||||
phase: .idle)
|
||||
phase: .idle,
|
||||
watchPresentation: .verbatim(errorStatus))
|
||||
}
|
||||
} else {
|
||||
let errorStatus = String(
|
||||
@@ -1532,7 +1563,8 @@ final class TalkModeManager: NSObject {
|
||||
msg)
|
||||
self.speechErrorStatusRevisionPendingRestart = self.setStatus(
|
||||
errorStatus,
|
||||
phase: .idle)
|
||||
phase: .idle,
|
||||
watchPresentation: .verbatim(errorStatus))
|
||||
}
|
||||
}
|
||||
self.logger.debug("speech recognition error: \(msg, privacy: .public)")
|
||||
@@ -1797,7 +1829,10 @@ final class TalkModeManager: NSObject {
|
||||
if self.isCurrentTranscriptProcessing(generation),
|
||||
let terminalStatus = streamingOwner.terminalStatus
|
||||
{
|
||||
self.setStatus(terminalStatus.text, phase: terminalStatus.phase)
|
||||
self.setStatus(
|
||||
terminalStatus.text,
|
||||
phase: terminalStatus.phase,
|
||||
watchPresentation: terminalStatus.watchPresentation)
|
||||
}
|
||||
let shouldResume = restartAfter &&
|
||||
self.isEnabled &&
|
||||
@@ -1876,7 +1911,8 @@ final class TalkModeManager: NSObject {
|
||||
normalizedStatus == "error"
|
||||
? String(localized: "Chat error")
|
||||
: String(localized: "Aborted"),
|
||||
.idle)
|
||||
.idle,
|
||||
.localized(normalizedStatus == "error" ? "Chat error" : "Aborted"))
|
||||
self.logger.warning(
|
||||
"""
|
||||
chat.send terminal ack runId=\(runId, privacy: .public) \
|
||||
@@ -1897,17 +1933,16 @@ final class TalkModeManager: NSObject {
|
||||
else { return }
|
||||
guard self.isCurrentTranscriptProcessing(generation) else { return }
|
||||
if completedSuccessfully, !self.isEnabled {
|
||||
streamingOwner.terminalStatus = (String(localized: "Ready"), .idle)
|
||||
streamingOwner.terminalStatus = (String(localized: "Ready"), .idle, .phase)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard self.isCurrentTranscriptProcessing(generation) else { return }
|
||||
streamingOwner.terminalStatus = (
|
||||
String(
|
||||
format: String(localized: "Talk failed: %@"),
|
||||
error.localizedDescription),
|
||||
.idle)
|
||||
let status = String(
|
||||
format: String(localized: "Talk failed: %@"),
|
||||
error.localizedDescription)
|
||||
streamingOwner.terminalStatus = (status, .idle, .verbatim(status))
|
||||
self.logger.error("finalize failed: \(error.localizedDescription, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: failed error=\(error.localizedDescription)")
|
||||
}
|
||||
@@ -1964,7 +1999,10 @@ final class TalkModeManager: NSObject {
|
||||
streamingOwner.task?.cancel()
|
||||
await self.finishIncrementalSpeech()
|
||||
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
|
||||
streamingOwner.terminalStatus = (String(localized: "Aborted"), .idle)
|
||||
streamingOwner.terminalStatus = (
|
||||
String(localized: "Aborted"),
|
||||
.idle,
|
||||
.localized("Aborted"))
|
||||
return nil
|
||||
} else if completion.state == .error {
|
||||
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
|
||||
@@ -1972,7 +2010,10 @@ final class TalkModeManager: NSObject {
|
||||
streamingOwner.task?.cancel()
|
||||
await self.finishIncrementalSpeech()
|
||||
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
|
||||
streamingOwner.terminalStatus = (String(localized: "Chat error"), .idle)
|
||||
streamingOwner.terminalStatus = (
|
||||
String(localized: "Chat error"),
|
||||
.idle,
|
||||
.localized("Chat error"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -2000,7 +2041,10 @@ final class TalkModeManager: NSObject {
|
||||
streamingOwner.task?.cancel()
|
||||
await self.finishIncrementalSpeech()
|
||||
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
|
||||
streamingOwner.terminalStatus = (String(localized: "No reply"), .idle)
|
||||
streamingOwner.terminalStatus = (
|
||||
String(localized: "No reply"),
|
||||
.idle,
|
||||
.localized("No reply"))
|
||||
return nil
|
||||
}
|
||||
self.logger.info("assistant text ok chars=\(assistantText.count, privacy: .public)")
|
||||
@@ -2089,7 +2133,10 @@ final class TalkModeManager: NSObject {
|
||||
return .unavailable(realtimeIssue(message: "Gateway not connected", phase: "start"))
|
||||
}
|
||||
guard self.foregroundAudioCaptureAllowed else {
|
||||
self.setStatus(String(localized: "Paused"), phase: .idle)
|
||||
self.setStatus(
|
||||
String(localized: "Paused"),
|
||||
phase: .idle,
|
||||
watchPresentation: .localized("Paused"))
|
||||
GatewayDiagnostics.log("talk realtime ignored: app backgrounded")
|
||||
return .ignored
|
||||
}
|
||||
@@ -2539,11 +2586,13 @@ final class TalkModeManager: NSObject {
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let status = String(
|
||||
format: String(localized: "Speak failed: %@"),
|
||||
error.localizedDescription)
|
||||
self.setStatus(
|
||||
String(
|
||||
format: String(localized: "Speak failed: %@"),
|
||||
error.localizedDescription),
|
||||
phase: .idle)
|
||||
status,
|
||||
phase: .idle,
|
||||
watchPresentation: .verbatim(status))
|
||||
self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -2642,11 +2691,13 @@ final class TalkModeManager: NSObject {
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let status = String(
|
||||
format: String(localized: "Speak failed: %@"),
|
||||
error.localizedDescription)
|
||||
self.setStatus(
|
||||
String(
|
||||
format: String(localized: "Speak failed: %@"),
|
||||
error.localizedDescription),
|
||||
phase: .idle)
|
||||
status,
|
||||
phase: .idle,
|
||||
watchPresentation: .verbatim(status))
|
||||
self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -4538,7 +4589,12 @@ extension TalkModeManager {
|
||||
}
|
||||
|
||||
func _test_markSpeechErrorStatusPendingRestart(_ text: String) {
|
||||
self.speechErrorStatusRevisionPendingRestart = self.setStatus(text, phase: .idle)
|
||||
self.isEnabled = true
|
||||
self.gatewayConnected = true
|
||||
self.speechErrorStatusRevisionPendingRestart = self.setStatus(
|
||||
text,
|
||||
phase: .idle,
|
||||
watchPresentation: .verbatim(text))
|
||||
}
|
||||
|
||||
func _test_restoreListeningStatusAfterSpeechErrorRestart() {
|
||||
|
||||
@@ -78,12 +78,19 @@ struct WatchChatStatusLocalizationTests {
|
||||
let backendOverride = OpenClawWatchAppStatus(
|
||||
code: .gatewayProblem,
|
||||
verbatim: "Gateway says update channel beta")
|
||||
let localizedTalkFailure = OpenClawWatchAppStatus(
|
||||
code: .talkFailure,
|
||||
localizationKey: "Paused")
|
||||
|
||||
#expect(localized.localizedText(
|
||||
localizePresentation: { key, _ in
|
||||
key == "Gateway update required" ? "Mise à jour requise" : key
|
||||
}) == "Mise à jour requise")
|
||||
#expect(backendOverride.localizedText() == "Gateway says update channel beta")
|
||||
#expect(localizedTalkFailure.localizedText(
|
||||
localizePresentation: { key, _ in
|
||||
key == "Paused" ? "En pause" : key
|
||||
}) == "En pause")
|
||||
}
|
||||
|
||||
private static func semanticPayload() -> [String: Any] {
|
||||
|
||||
@@ -4712,6 +4712,50 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||
#expect(watchService.lastSentAppSnapshot?.gatewayStatus.code == .gatewayOffline)
|
||||
}
|
||||
|
||||
@Test @MainActor func `watch app snapshot preserves gateway connection progress`() async throws {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
|
||||
watchService.emitAppSnapshotRequest(
|
||||
WatchAppSnapshotRequestEvent(
|
||||
requestId: "app-snapshot-connecting",
|
||||
sentAtMs: 123,
|
||||
transport: "sendMessage"))
|
||||
for _ in 0..<20 {
|
||||
if watchService.lastSentAppSnapshot != nil {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
|
||||
let status = try #require(watchService.lastSentAppSnapshot?.gatewayStatus)
|
||||
#expect(status.code == .legacy)
|
||||
#expect(status.verbatim == "Connecting…")
|
||||
}
|
||||
|
||||
@Test @MainActor func `watch app snapshot preserves talk failures`() async throws {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
appModel.talkMode._test_markSpeechErrorStatusPendingRestart("Speech error: denied")
|
||||
|
||||
watchService.emitAppSnapshotRequest(
|
||||
WatchAppSnapshotRequestEvent(
|
||||
requestId: "app-snapshot-talk-failure",
|
||||
sentAtMs: 123,
|
||||
transport: "sendMessage"))
|
||||
for _ in 0..<20 {
|
||||
if watchService.lastSentAppSnapshot != nil {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
|
||||
let status = try #require(watchService.lastSentAppSnapshot?.talkStatus)
|
||||
#expect(status.code == .talkFailure)
|
||||
#expect(status.verbatim == "Speech error: denied")
|
||||
}
|
||||
|
||||
@Test @MainActor func `watch app snapshot publishes online when operator reconnects`() async {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
|
||||
@@ -857,7 +857,7 @@ extension OpenClawWatchAppStatus {
|
||||
case .talkAPIKeyMissing:
|
||||
localize(.apiKeyMissing)
|
||||
case .talkFailure:
|
||||
self.verbatim ?? localize(.unavailable)
|
||||
self.localizedPresentation(localize: localizePresentation)
|
||||
case .chatConnectIPhone:
|
||||
localize(.connectIPhoneChat)
|
||||
case .chatNoMessages:
|
||||
|
||||
Reference in New Issue
Block a user