重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,107 @@
import Foundation
import Network
import OpenClawKit
enum A2UIReadyState {
case ready(String)
case hostNotConfigured
case hostUnavailable
}
extension NodeAppModel {
func resolveCanvasHostURL() async -> String? {
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
if let host = base.host, LoopbackHost.isLoopback(host) {
return nil
}
return base.appendingPathComponent("__openclaw__/canvas/").absoluteString
}
func _test_resolveA2UIHostURL() async -> String? {
await self.resolveA2UIHostURL()
}
func resolveA2UIHostURL() async -> String? {
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
if let host = base.host, LoopbackHost.isLoopback(host) {
return nil
}
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
}
/// Normalize a URL string for trust comparison: lowercase scheme/host and strip fragment.
/// This matches the normalization applied by ScreenController.isTrustedCanvasUIURL so that
/// SPA hash-routing fragments and scheme/host casing do not silently prevent trust being set.
static func normalizeURLForTrustComparison(_ raw: String) -> String {
guard let url = URL(string: raw),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return raw }
components.fragment = nil
components.scheme = components.scheme?.lowercased()
components.host = components.host?.lowercased()
return components.url?.absoluteString ?? raw
}
func showA2UIOnConnectIfNeeded() async {
await MainActor.run {
// Keep the bundled home canvas as the default connected view.
// Agents can still explicitly present a remote or local canvas later.
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
}
func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState {
guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else {
return .hostNotConfigured
}
self.screen.navigate(to: initialUrl, trustA2UIActions: true)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(initialUrl)
}
// First render can fail when scoped capability rotates between reconnects.
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable }
guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable }
self.screen.navigate(to: refreshedUrl, trustA2UIActions: true)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(refreshedUrl)
}
return .hostUnavailable
}
func showLocalCanvasOnDisconnect() {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? {
if let url = await self.resolveA2UIHostURL() {
return url
}
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
return await self.resolveA2UIHostURL()
}
private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? {
if let url = await self.resolveCanvasHostURL() {
return url
}
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
return await self.resolveCanvasHostURL()
}
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
guard let host = url.host, !host.isEmpty else { return false }
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
return await TCPProbe.probe(
host: host,
port: portInt,
timeoutSeconds: timeoutSeconds,
queueLabel: "a2ui.preflight")
}
}

View file

@ -0,0 +1,103 @@
import Foundation
import OpenClawKit
extension NodeAppModel {
static func normalizeWatchNotifyParams(_ params: OpenClawWatchNotifyParams) -> OpenClawWatchNotifyParams {
var normalized = params
normalized.title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
normalized.promptId = self.trimmedOrNil(params.promptId)
normalized.sessionKey = self.trimmedOrNil(params.sessionKey)
normalized.kind = self.trimmedOrNil(params.kind)
normalized.details = self.trimmedOrNil(params.details)
normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk)
normalized.risk = self.normalizedWatchRisk(params.risk, priority: normalized.priority)
let normalizedActions = self.normalizeWatchActions(
params.actions,
kind: normalized.kind,
promptId: normalized.promptId)
normalized.actions = normalizedActions.isEmpty ? nil : normalizedActions
return normalized
}
static func normalizeWatchActions(
_ actions: [OpenClawWatchAction]?,
kind: String?,
promptId: String?) -> [OpenClawWatchAction]
{
let provided = (actions ?? []).compactMap { action -> OpenClawWatchAction? in
let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines)
let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !id.isEmpty, !label.isEmpty else { return nil }
return OpenClawWatchAction(
id: id,
label: label,
style: self.trimmedOrNil(action.style))
}
if !provided.isEmpty {
return Array(provided.prefix(4))
}
// Only auto-insert quick actions when this is a prompt/decision flow.
guard promptId?.isEmpty == false else {
return []
}
let normalizedKind = kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
if normalizedKind.contains("approval") || normalizedKind.contains("approve") {
return [
OpenClawWatchAction(id: "approve", label: "Approve"),
OpenClawWatchAction(id: "decline", label: "Decline", style: "destructive"),
OpenClawWatchAction(id: "open_phone", label: "Open iPhone"),
OpenClawWatchAction(id: "escalate", label: "Escalate"),
]
}
return [
OpenClawWatchAction(id: "done", label: "Done"),
OpenClawWatchAction(id: "snooze_10m", label: "Snooze 10m"),
OpenClawWatchAction(id: "open_phone", label: "Open iPhone"),
OpenClawWatchAction(id: "escalate", label: "Escalate"),
]
}
static func normalizedWatchRisk(
_ risk: OpenClawWatchRisk?,
priority: OpenClawNotificationPriority?) -> OpenClawWatchRisk?
{
if let risk { return risk }
switch priority {
case .passive:
return .low
case .active:
return .medium
case .timeSensitive:
return .high
case nil:
return nil
}
}
static func normalizedWatchPriority(
_ priority: OpenClawNotificationPriority?,
risk: OpenClawWatchRisk?) -> OpenClawNotificationPriority?
{
if let priority { return priority }
switch risk {
case .low:
return .passive
case .medium:
return .active
case .high:
return .timeSensitive
case nil:
return nil
}
}
static func trimmedOrNil(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
import Foundation
@MainActor
final class WatchReplyCoordinator {
enum Decision {
case dropMissingFields
case deduped(replyId: String)
case queue(replyId: String, actionId: String)
case forward
}
private var queuedReplies: [WatchQuickReplyEvent] = []
private var seenReplyIds = Set<String>()
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
if replyId.isEmpty || actionId.isEmpty {
return .dropMissingFields
}
if self.seenReplyIds.contains(replyId) {
return .deduped(replyId: replyId)
}
self.seenReplyIds.insert(replyId)
if !isGatewayConnected {
self.queuedReplies.append(event)
return .queue(replyId: replyId, actionId: actionId)
}
return .forward
}
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
let pending = self.queuedReplies
self.queuedReplies.removeAll()
return pending
}
func requeueFront(_ event: WatchQuickReplyEvent) {
self.queuedReplies.insert(event, at: 0)
}
var queuedCount: Int {
self.queuedReplies.count
}
}