mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:24:46 +00:00
fix(mac): speed up channels settings
This commit is contained in:
@@ -12,6 +12,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
|
||||
- Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion.
|
||||
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
|
||||
- Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.
|
||||
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
|
||||
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)
|
||||
- Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style `reasoning.enabled`/`max_tokens` controls for reasoning-capable OpenAI-completions models.
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ConfigSchemaFormMode {
|
||||
case full
|
||||
case channelQuick
|
||||
}
|
||||
|
||||
struct ConfigSchemaForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let schema: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
let mode: ConfigSchemaFormMode
|
||||
|
||||
init(
|
||||
store: ChannelsStore,
|
||||
schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
mode: ConfigSchemaFormMode = .full)
|
||||
{
|
||||
self.store = store
|
||||
self.schema = schema
|
||||
self.path = path
|
||||
self.mode = mode
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(self.schema, path: self.path)
|
||||
@@ -12,7 +30,7 @@ struct ConfigSchemaForm: View {
|
||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||
let storedValue = self.store.configValue(at: path)
|
||||
let value = storedValue ?? schema.explicitDefault
|
||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||
let label = self.fieldLabel(for: schema, path: path)
|
||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
||||
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
|
||||
|
||||
@@ -62,18 +80,16 @@ struct ConfigSchemaForm: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
let properties = schema.properties
|
||||
let sortedKeys = properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
|
||||
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
|
||||
ForEach(sortedKeys, id: \ .self) { key in
|
||||
if let child = properties[key] {
|
||||
self.renderNode(child, path: path + [.key(key)])
|
||||
}
|
||||
}
|
||||
if schema.allowsAdditionalProperties {
|
||||
if sortedKeys.isEmpty, self.mode == .channelQuick, self.isChannelRoot(path) {
|
||||
self.renderChannelQuickEmptyState()
|
||||
}
|
||||
if self.shouldRenderAdditionalProperties(schema, path: path, value: value) {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
})
|
||||
@@ -100,6 +116,117 @@ struct ConfigSchemaForm: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldLabel(for schema: ConfigSchemaNode, path: ConfigPath) -> String? {
|
||||
hintForPath(path, hints: self.store.configUiHints)?.label
|
||||
?? schema.title
|
||||
?? labelForConfigPath(path)
|
||||
}
|
||||
|
||||
private func visibleObjectKeys(
|
||||
properties: [String: ConfigSchemaNode],
|
||||
path: ConfigPath) -> [String]
|
||||
{
|
||||
let sortedKeys = properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
|
||||
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
guard self.mode == .channelQuick, self.isChannelRoot(path) else {
|
||||
return sortedKeys
|
||||
}
|
||||
|
||||
return sortedKeys.filter { key in
|
||||
guard let child = properties[key] else { return false }
|
||||
return self.shouldRenderChannelQuickField(key: key, schema: child, path: path + [.key(key)])
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRenderChannelQuickField(
|
||||
key: String,
|
||||
schema: ConfigSchemaNode,
|
||||
path: ConfigPath) -> Bool
|
||||
{
|
||||
if hintForPath(path, hints: self.store.configUiHints)?.advanced == true {
|
||||
return false
|
||||
}
|
||||
if Self.channelQuickKeys.contains(key) {
|
||||
return self.isSimpleField(schema)
|
||||
}
|
||||
return self.store.configValue(at: path) != nil && self.isSimpleField(schema)
|
||||
}
|
||||
|
||||
private func isSimpleField(_ schema: ConfigSchemaNode) -> Bool {
|
||||
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
|
||||
let nonNullVariants = variants.filter { !$0.isNullSchema }
|
||||
if !nonNullVariants.isEmpty {
|
||||
return nonNullVariants.allSatisfy(self.isSimpleField)
|
||||
}
|
||||
if let enumValues = schema.enumValues {
|
||||
return !enumValues.isEmpty
|
||||
}
|
||||
switch schema.schemaType {
|
||||
case "boolean", "integer", "number", "string":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRenderAdditionalProperties(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
value: Any?) -> Bool
|
||||
{
|
||||
guard schema.allowsAdditionalProperties else { return false }
|
||||
if self.mode != .channelQuick { return true }
|
||||
guard let dict = value as? [String: Any] else { return false }
|
||||
let reserved = Set(schema.properties.keys)
|
||||
return dict.keys.contains { !reserved.contains($0) }
|
||||
}
|
||||
|
||||
private func isChannelRoot(_ path: ConfigPath) -> Bool {
|
||||
guard path.count == 2 else { return false }
|
||||
guard case .key("channels") = path[0] else { return false }
|
||||
guard case .key = path[1] else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderChannelQuickEmptyState() -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("No quick settings for this channel.")
|
||||
.font(.callout.weight(.semibold))
|
||||
Text("Use Config for account, guild, action, and policy details.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private static let channelQuickKeys: Set<String> = [
|
||||
"apiHash",
|
||||
"apiId",
|
||||
"appToken",
|
||||
"baseUrl",
|
||||
"botToken",
|
||||
"configWrites",
|
||||
"deviceName",
|
||||
"dmPolicy",
|
||||
"enabled",
|
||||
"groupPolicy",
|
||||
"historyLimit",
|
||||
"mode",
|
||||
"nativeCommands",
|
||||
"nativeSkillCommands",
|
||||
"phoneNumber",
|
||||
"signingSecret",
|
||||
"token",
|
||||
"url",
|
||||
"username",
|
||||
"webhookUrl",
|
||||
]
|
||||
|
||||
@ViewBuilder
|
||||
private func renderStringField(
|
||||
_ schema: ConfigSchemaNode,
|
||||
@@ -353,7 +480,11 @@ struct ChannelConfigForm: View {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
|
||||
ConfigSchemaForm(
|
||||
store: self.store,
|
||||
schema: schema,
|
||||
path: [.key("channels"), .key(self.channelId)],
|
||||
mode: .channelQuick)
|
||||
} else {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -6,7 +6,7 @@ extension ChannelsSettings {
|
||||
_ id: String,
|
||||
as type: T.Type) -> T?
|
||||
{
|
||||
self.store.snapshot?.decodeChannel(id, as: type)
|
||||
self.store.decodedChannel(id, as: type)
|
||||
}
|
||||
|
||||
private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color {
|
||||
@@ -358,12 +358,16 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
func ensureSelection() {
|
||||
self.ensureSelection(in: self.orderedChannels)
|
||||
}
|
||||
|
||||
func ensureSelection(in orderedChannels: [ChannelItem]) {
|
||||
guard let selected = self.selectedChannel else {
|
||||
self.selectedChannel = self.orderedChannels.first
|
||||
self.selectedChannel = orderedChannels.first
|
||||
return
|
||||
}
|
||||
if !self.orderedChannels.contains(selected) {
|
||||
self.selectedChannel = self.orderedChannels.first
|
||||
if !orderedChannels.contains(selected) {
|
||||
self.selectedChannel = orderedChannels.first
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,34 +2,38 @@ import SwiftUI
|
||||
|
||||
extension ChannelsSettings {
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
self.sidebar
|
||||
let channels = self.orderedChannels
|
||||
return HStack(spacing: 0) {
|
||||
self.sidebar(channels: channels)
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear {
|
||||
self.store.start()
|
||||
self.ensureSelection()
|
||||
self.ensureSelection(in: channels)
|
||||
}
|
||||
.onChange(of: self.orderedChannels) { _, _ in
|
||||
self.ensureSelection()
|
||||
.onChange(of: channels) { _, newValue in
|
||||
self.ensureSelection(in: newValue)
|
||||
}
|
||||
.onDisappear { self.store.stop() }
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
SettingsSidebarScroll {
|
||||
private func sidebar(channels: [ChannelItem]) -> some View {
|
||||
let enabled = channels.filter { self.channelEnabled($0) }
|
||||
let available = channels.filter { !self.channelEnabled($0) }
|
||||
|
||||
return SettingsSidebarScroll {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if !self.enabledChannels.isEmpty {
|
||||
if !enabled.isEmpty {
|
||||
self.sidebarSectionHeader("Configured")
|
||||
ForEach(self.enabledChannels) { channel in
|
||||
ForEach(enabled) { channel in
|
||||
self.sidebarRow(channel)
|
||||
}
|
||||
}
|
||||
|
||||
if !self.availableChannels.isEmpty {
|
||||
if !available.isEmpty {
|
||||
self.sidebarSectionHeader("Available")
|
||||
ForEach(self.availableChannels) { channel in
|
||||
ForEach(available) { channel in
|
||||
self.sidebarRow(channel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ extension ChannelsStore {
|
||||
guard self.pollTask == nil else { return }
|
||||
self.pollTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh(probe: false)
|
||||
async let schemaLoad: Void = self.loadConfigSchema()
|
||||
async let configLoad: Void = self.loadConfig(force: false)
|
||||
async let statusRefresh: Void = self.refresh(probe: true)
|
||||
_ = await (schemaLoad, configLoad, statusRefresh)
|
||||
_ = await (schemaLoad, configLoad)
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
await self.refresh(probe: false)
|
||||
@@ -47,14 +47,15 @@ extension ChannelsStore {
|
||||
defer { self.isRefreshing = false }
|
||||
|
||||
do {
|
||||
let statusTimeoutMs = probe ? 8000 : 2500
|
||||
let params: [String: AnyCodable] = [
|
||||
"probe": AnyCodable(probe),
|
||||
"timeoutMs": AnyCodable(8000),
|
||||
"timeoutMs": AnyCodable(statusTimeoutMs),
|
||||
]
|
||||
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .channelsStatus,
|
||||
params: params,
|
||||
timeoutMs: 12000)
|
||||
timeoutMs: probe ? 12000 : 5000)
|
||||
self.snapshot = snap
|
||||
self.lastSuccess = Date()
|
||||
self.lastError = nil
|
||||
|
||||
@@ -224,7 +224,11 @@ struct ConfigSnapshot: Codable {
|
||||
final class ChannelsStore {
|
||||
static let shared = ChannelsStore()
|
||||
|
||||
var snapshot: ChannelsStatusSnapshot?
|
||||
var snapshot: ChannelsStatusSnapshot? {
|
||||
didSet {
|
||||
self.decodedChannelCache.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
var lastError: String?
|
||||
var lastSuccess: Date?
|
||||
var isRefreshing = false
|
||||
@@ -255,6 +259,7 @@ final class ChannelsStore {
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
var configSourceKey: String?
|
||||
@ObservationIgnored private var decodedChannelCache: [String: Any] = [:]
|
||||
|
||||
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
|
||||
self.snapshot?.channelMeta?.first(where: { $0.id == id })
|
||||
@@ -297,6 +302,18 @@ final class ChannelsStore {
|
||||
return self.snapshot?.channelOrder ?? []
|
||||
}
|
||||
|
||||
func decodedChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
|
||||
let key = "\(id)#\(ObjectIdentifier(type))"
|
||||
if let cached = self.decodedChannelCache[key] as? T {
|
||||
return cached
|
||||
}
|
||||
guard let decoded = self.snapshot?.decodeChannel(id, as: type) else {
|
||||
return nil
|
||||
}
|
||||
self.decodedChannelCache[key] = decoded
|
||||
return decoded
|
||||
}
|
||||
|
||||
func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) {
|
||||
self.whatsappLoginMessage = result.message
|
||||
self.whatsappLoginConnected = result.connected
|
||||
|
||||
@@ -208,6 +208,25 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
|
||||
|| key.hasSuffix("key")
|
||||
}
|
||||
|
||||
func labelForConfigPath(_ path: ConfigPath) -> String? {
|
||||
for segment in path.reversed() {
|
||||
if case let .key(key) = segment {
|
||||
return humanizeConfigKey(key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func humanizeConfigKey(_ key: String) -> String {
|
||||
key.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(
|
||||
of: "([a-z0-9])([A-Z])",
|
||||
with: "$1 $2",
|
||||
options: .regularExpression)
|
||||
.capitalized
|
||||
}
|
||||
|
||||
func pathKey(_ path: ConfigPath) -> String {
|
||||
path.compactMap { segment -> String? in
|
||||
switch segment {
|
||||
|
||||
@@ -45,6 +45,7 @@ struct SettingsRootView: View {
|
||||
}
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(SettingsWindowChromeConfigurator())
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in
|
||||
if let tab = note.object as? SettingsTab {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) {
|
||||
@@ -75,12 +76,6 @@ struct SettingsRootView: View {
|
||||
guard !self.isPreview else { return }
|
||||
await self.refreshPerms()
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
async let schemaLoad: Void = ChannelsStore.shared.loadConfigSchema()
|
||||
async let configLoad: Void = ChannelsStore.shared.loadConfig(force: false)
|
||||
_ = await (schemaLoad, configLoad)
|
||||
}
|
||||
.task(id: self.state.connectionMode) {
|
||||
guard !self.isPreview else { return }
|
||||
await self.refreshSnapshotPaths()
|
||||
@@ -255,6 +250,27 @@ enum SettingsTab: CaseIterable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsWindowChromeConfigurator: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView(frame: .zero)
|
||||
self.configureWindow(for: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
self.configureWindow(for: nsView)
|
||||
}
|
||||
|
||||
private func configureWindow(for view: NSView) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
window.styleMask.remove(.fullSizeContentView)
|
||||
window.titleVisibility = .visible
|
||||
window.titlebarAppearsTransparent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum SettingsTabRouter {
|
||||
private static var pending: SettingsTab?
|
||||
|
||||
Reference in New Issue
Block a user