mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-25 09:13:05 +00:00
* refactor: remove stale file-backed shims * fix: harden sqlite state ci boundaries * refactor: store matrix idb snapshots in sqlite * fix: satisfy rebased CI guardrails * refactor: store current conversation bindings in sqlite table * refactor: store tui last sessions in sqlite table * refactor: reset sqlite schema history * refactor: drop unshipped sqlite table migration * refactor: remove plugin index file rollback * refactor: drop unshipped sqlite sidecar migrations * refactor: remove runtime commitments kv migration * refactor: preserve kysely sync result types * refactor: drop unshipped sqlite schema migration table * test: keep session usage coverage sqlite-backed * refactor: keep sqlite migration doctor-only * refactor: isolate device legacy imports * refactor: isolate push voicewake legacy imports * refactor: isolate remaining runtime legacy imports * refactor: tighten sqlite migration guardrails * test: cover sqlite persisted enum parsing * refactor: isolate legacy update and tui imports * refactor: tighten sqlite state ownership * refactor: move legacy imports behind doctor * refactor: remove legacy session row lookup * refactor: canonicalize memory transcript locators * refactor: drop transcript path scope fallbacks * refactor: drop runtime legacy session delivery pruning * refactor: store tts prefs only in sqlite * refactor: remove cron store path runtime * refactor: use cron sqlite store keys * refactor: rename telegram message cache scope * refactor: read memory dreaming status from sqlite * refactor: rename cron status store key * refactor: stop remembering transcript file paths * test: use sqlite locators in agent fixtures * refactor: remove file-shaped commitments and cron store surfaces * refactor: keep compaction transcript handles out of session rows * refactor: derive transcript handles from session identity * refactor: derive runtime transcript handles * refactor: remove gateway session locator reads * refactor: remove transcript locator from session rows * refactor: store raw stream diagnostics in sqlite * refactor: remove file-shaped transcript rotation * refactor: hide legacy trajectory paths from runtime * refactor: remove runtime transcript file bridges * refactor: repair database-first rebase fallout * refactor: align tests with database-first state * refactor: remove transcript file handoffs * refactor: sync post-compaction memory by transcript scope * refactor: run codex app-server sessions by id * refactor: bind codex runtime state by session id * refactor: pass memory transcripts by sqlite scope * refactor: remove transcript locator cleanup leftovers * test: remove stale transcript file fixtures * refactor: remove transcript locator test helper * test: make cron sqlite keys explicit * test: remove cron runtime store paths * test: remove stale session file fixtures * test: use sqlite cron keys in diagnostics * refactor: remove runtime delivery queue backfill * test: drop fake export session file mocks * refactor: rename acp session read failure flag * refactor: rename acp row session key * refactor: remove session store test seams * refactor: move legacy session parser tests to doctor * refactor: reindex managed memory in place * refactor: drop stale session store wording * refactor: rename session row helpers * refactor: rename sqlite session entry modules * refactor: remove transcript locator leftovers * refactor: trim file-era audit wording * refactor: clean managed media through sqlite * fix: prefer explicit agent for exports * fix: use prepared agent for session resets * fix: canonicalize legacy codex binding import * test: rename state cleanup helper * docs: align backup docs with sqlite state * refactor: drop legacy Pi usage auth fallback * refactor: move legacy auth profile imports to doctor * refactor: keep Pi model discovery auth in memory * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime * refactor: remove model json compatibility aliases * refactor: store auth profiles in sqlite * refactor: seed copied auth profiles in sqlite * refactor: make auth profile runtime sqlite-addressed * refactor: migrate hermes secrets into sqlite auth store * refactor: move plugin install config migration to doctor * refactor: rename plugin index audit checks * test: drop auth file assumptions * test: remove legacy transcript file assertions * refactor: drop legacy cli session aliases * refactor: store skill uploads in sqlite * refactor: keep subagent attachments in sqlite vfs * refactor: drop subagent attachment cleanup state * refactor: move legacy session aliases to doctor * refactor: require node 24 for sqlite state runtime * refactor: move provider caches into sqlite state * fix: harden virtual agent filesystem * refactor: enforce database-first runtime state * refactor: rename compaction transcript rotation setting * test: clean sqlite refactor test types * refactor: consolidate sqlite runtime state * refactor: model session conversations in sqlite * refactor: stop deriving cron delivery from session keys * refactor: stop classifying sessions from key shape * refactor: hydrate announce targets from typed delivery * refactor: route heartbeat delivery from typed sqlite context * refactor: tighten typed sqlite session routing * refactor: remove session origin routing shadow * refactor: drop session origin shadow fixtures * perf: query sqlite vfs paths by prefix * refactor: use typed conversation metadata for sessions * refactor: prefer typed session routing metadata * refactor: require typed session routing metadata * refactor: resolve group tool policy from typed sessions * refactor: delete dead session thread info bridge * Show Codex subscription reset times in channel errors (#80456) * feat(plugin-sdk): consolidate session workflow APIs * fix(agents): allow read-only agent mount reads * [codex] refresh plugin regression fixtures * fix(agents): restore compaction gateway logs * test: tighten gateway startup assertions * Redact persisted secret-shaped payloads [AI] (#79006) * test: tighten device pair notify assertions * test: tighten hermes secret assertions * test: assert matrix client error shapes * test: assert config compat warnings * fix(heartbeat): remap cron-run exec events to session keys (#80214) * fix(codex): route btw through native side threads * fix(auth): accept friendly OpenAI order for Codex profiles * fix(codex): rotate auth profiles inside harness * fix: keep browser status page probe within timeout * test: assert agents add outputs * test: pin cron read status * fix(agents): avoid Pi resource discovery stalls Co-authored-by: dataCenter430 <titan032000@gmail.com> * fix: retire timed-out codex app-server clients * test: tighten qa lab runtime assertions * test: check security fix outputs * test: verify extension runtime messages * feat(wake): expose typed sessionKey on wake protocol + system event CLI * fix(gateway): await session_end during shutdown drain and track channel + compaction lifecycle paths (#57790) * test: guard talk consult call helper * fix(codex): scale context engine projection (#80761) * fix(codex): scale context engine projection * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * chore: align Codex projection changelog * chore: realign Codex projection changelog * fix: isolate Codex projection patch --------- Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> * refactor: move agent runtime state toward piless * refactor: remove cron session reaper * refactor: move session management to sqlite * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: remove stale file-backed shims * test: harden kysely type coverage # Conflicts: # .agents/skills/kysely-database-access/SKILL.md # src/infra/kysely-sync.types.test.ts # src/proxy-capture/store.sqlite.test.ts # src/state/openclaw-agent-db.test.ts # src/state/openclaw-state-db.test.ts * refactor: remove cron store path runtime * refactor: keep compaction transcript handles out of session rows * refactor: derive embedded transcripts from sqlite identity * refactor: remove embedded transcript locator handoff * refactor: remove runtime transcript file bridges * refactor: remove transcript file handoffs * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime # Conflicts: # docs/cli/secrets.md # docs/gateway/authentication.md # docs/gateway/secrets.md * fix: keep oauth sibling sync sqlite-local # Conflicts: # src/commands/onboard-auth.test.ts * refactor: remove task session store maintenance # Conflicts: # src/commands/tasks.ts * refactor: keep diagnostics in state sqlite * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * Show Codex subscription reset times in channel errors (#80456) * fix(codex): refresh subscription limit resets * fix(codex): format reset times for channels * Update CHANGELOG with latest changes and fixes Updated CHANGELOG with recent fixes and improvements. * fix(codex): keep command load failures on codex surface * fix(codex): format account rate limits as rows * fix(codex): summarize account limits as usage status * fix(codex): simplify account limit status * test: tighten subagent announce queue assertion * test: tighten session delete lifecycle assertions * test: tighten cron ops assertions * fix: track cron execution milestones * test: tighten hermes secret assertions * test: assert matrix sync store payloads * test: assert config compat warnings * fix(codex): align btw side thread semantics * fix(codex): honor codex fallback blocking * fix(agents): avoid Pi resource discovery stalls * test: tighten codex event assertions * test: tighten cron assertions * Fix Codex app-server OAuth harness auth * refactor: move agent runtime state toward piless * refactor: move device and push state to sqlite * refactor: move runtime json state imports to doctor * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: clarify cron sqlite store keys * refactor: remove stale file-backed shims * refactor: bind codex runtime state by session id * test: expect sqlite trajectory branch export * refactor: rename session row helpers * fix: keep legacy device identity import in doctor * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * build: align pi contract wrappers * chore: repair database-first rebase * refactor: remove session file test contracts * test: update gateway session expectations * refactor: stop routing from session compatibility shadows * refactor: stop persisting session route shadows * refactor: use typed delivery context in clients * refactor: stop echoing session route shadows * refactor: repair embedded runner rebase imports # Conflicts: # src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts * refactor: align pi contract imports * refactor: satisfy kysely sync helper guard * refactor: remove file transcript bridge remnants * refactor: remove session locator compatibility * refactor: remove session file test contracts * refactor: keep rebase database-first clean * refactor: remove session file assumptions from e2e * docs: clarify database-first goal state * test: remove legacy store markers from sqlite runtime tests * refactor: remove legacy store assumptions from runtime seams * refactor: align sqlite runtime helper seams * test: update memory recall sqlite audit mock * refactor: align database-first runtime type seams * test: clarify doctor cron legacy store names * fix: preserve sqlite session route projections * test: fix copilot token cache test syntax * docs: update database-first proof status * test: align database-first test fixtures * docs: update database-first proof status * refactor: clean extension database-first drift * test: align agent session route proof * test: clarify doctor legacy path fixtures * chore: clean database-first changed checks * chore: repair database-first rebase markers * build: allow baileys git subdependency * chore: repair exp-vfs rebase drift * chore: finish exp-vfs rebase cleanup * chore: satisfy rebase lint drift * chore: fix qqbot rebase type seam * chore: fix rebase drift leftovers * fix: keep auth profile oauth secrets out of sqlite * fix: repair rebase drift tests * test: stabilize pairing request ordering * test: use source manifests in plugin contract checks * fix: restore gateway session metadata after rebase * fix: repair database-first rebase drift * fix: clean up database-first rebase fallout * test: stabilize line quick reply receipt time * fix: repair extension rebase drift * test: keep transcript redaction tests sqlite-backed * fix: carry injected transcript redaction through sqlite * chore: clean database branch rebase residue * fix: repair database branch CI drift * fix: repair database branch CI guard drift * fix: stabilize oauth tls preflight test * test: align database branch fast guards * test: repair build artifact boundary guards * chore: clean changelog rebase markers --------- Co-authored-by: pashpashpash <nik@vault77.ai> Co-authored-by: Eva <eva@100yen.org> Co-authored-by: stainlu <stainlu@newtype-ai.org> Co-authored-by: Jason Zhou <jason.zhou.design@gmail.com> Co-authored-by: Ruben Cuevas <hi@rubencu.com> Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> Co-authored-by: Shakker <shakkerdroid@gmail.com> Co-authored-by: Kaspre <36520309+Kaspre@users.noreply.github.com> Co-authored-by: dataCenter430 <titan032000@gmail.com> Co-authored-by: Kaspre <kaspre@gmail.com> Co-authored-by: pandadev66 <nova.full.stack@outlook.com> Co-authored-by: Eva <admin@100yen.org> Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> Co-authored-by: jeffjhunter <support@aipersonamethod.com>
489 lines
18 KiB
Swift
489 lines
18 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import OpenClawChatUI
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import OSLog
|
|
import QuartzCore
|
|
import SwiftUI
|
|
|
|
private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI")
|
|
private let webChatThinkingLevelDefaultsKey = "openclaw.webchat.thinkingLevel"
|
|
|
|
private enum WebChatSwiftUILayout {
|
|
static let windowSize = NSSize(width: 500, height: 840)
|
|
static let panelSize = NSSize(width: 480, height: 640)
|
|
static let windowMinSize = NSSize(width: 480, height: 360)
|
|
static let anchorPadding: CGFloat = 8
|
|
}
|
|
|
|
struct MacGatewayChatTransport: OpenClawChatTransport {
|
|
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
|
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
|
|
}
|
|
|
|
func listModels() async throws -> [OpenClawChatModelChoice] {
|
|
do {
|
|
let data = try await GatewayConnection.shared.request(
|
|
method: "models.list",
|
|
params: [:],
|
|
timeoutMs: 15000)
|
|
let result = try JSONDecoder().decode(ModelsListResult.self, from: data)
|
|
return result.models.map(Self.mapModelChoice)
|
|
} catch {
|
|
webChatSwiftLogger.warning(
|
|
"models.list failed; hiding model picker: \(error.localizedDescription, privacy: .public)")
|
|
return []
|
|
}
|
|
}
|
|
|
|
func abortRun(sessionKey: String, runId: String) async throws {
|
|
_ = try await GatewayConnection.shared.request(
|
|
method: "chat.abort",
|
|
params: [
|
|
"sessionKey": AnyCodable(sessionKey),
|
|
"runId": AnyCodable(runId),
|
|
],
|
|
timeoutMs: 10000)
|
|
}
|
|
|
|
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
|
var params: [String: AnyCodable] = [
|
|
"includeGlobal": AnyCodable(true),
|
|
"includeUnknown": AnyCodable(false),
|
|
]
|
|
if let limit {
|
|
params["limit"] = AnyCodable(limit)
|
|
}
|
|
let data = try await GatewayConnection.shared.request(
|
|
method: "sessions.list",
|
|
params: params,
|
|
timeoutMs: 15000)
|
|
let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
|
|
let mainSessionKey = await GatewayConnection.shared.cachedMainSessionKey()
|
|
let defaults = decoded.defaults.map {
|
|
OpenClawChatSessionsDefaults(
|
|
modelProvider: $0.modelProvider,
|
|
model: $0.model,
|
|
contextTokens: $0.contextTokens,
|
|
thinkingLevels: $0.thinkingLevels,
|
|
thinkingOptions: $0.thinkingOptions,
|
|
thinkingDefault: $0.thinkingDefault,
|
|
mainSessionKey: mainSessionKey)
|
|
} ?? OpenClawChatSessionsDefaults(
|
|
model: nil,
|
|
contextTokens: nil,
|
|
mainSessionKey: mainSessionKey)
|
|
return OpenClawChatSessionsListResponse(
|
|
ts: decoded.ts,
|
|
databasePath: decoded.databasePath,
|
|
count: decoded.count,
|
|
defaults: defaults,
|
|
sessions: decoded.sessions)
|
|
}
|
|
|
|
func setSessionModel(sessionKey: String, model: String?) async throws {
|
|
var params: [String: AnyCodable] = [
|
|
"key": AnyCodable(sessionKey),
|
|
]
|
|
params["model"] = model.map(AnyCodable.init) ?? AnyCodable(NSNull())
|
|
_ = try await GatewayConnection.shared.request(
|
|
method: "sessions.patch",
|
|
params: params,
|
|
timeoutMs: 15000)
|
|
}
|
|
|
|
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
|
|
let params: [String: AnyCodable] = [
|
|
"key": AnyCodable(sessionKey),
|
|
"thinkingLevel": AnyCodable(thinkingLevel),
|
|
]
|
|
_ = try await GatewayConnection.shared.request(
|
|
method: "sessions.patch",
|
|
params: params,
|
|
timeoutMs: 15000)
|
|
}
|
|
|
|
func sendMessage(
|
|
sessionKey: String,
|
|
message: String,
|
|
thinking: String,
|
|
idempotencyKey: String,
|
|
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
|
{
|
|
try await GatewayConnection.shared.chatSend(
|
|
sessionKey: sessionKey,
|
|
message: message,
|
|
thinking: thinking,
|
|
idempotencyKey: idempotencyKey,
|
|
attachments: attachments)
|
|
}
|
|
|
|
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
|
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
|
}
|
|
|
|
func resetSession(sessionKey: String) async throws {
|
|
_ = try await GatewayConnection.shared.request(
|
|
method: "sessions.reset",
|
|
params: ["key": AnyCodable(sessionKey)],
|
|
timeoutMs: 10000)
|
|
}
|
|
|
|
func compactSession(sessionKey: String) async throws {
|
|
_ = try await GatewayConnection.shared.request(
|
|
method: "sessions.compact",
|
|
params: ["key": AnyCodable(sessionKey)],
|
|
timeoutMs: 10000)
|
|
}
|
|
|
|
func setActiveSessionKey(_ sessionKey: String) async throws {
|
|
await MainActor.run {
|
|
WebChatManager.shared.recordActiveSessionKey(sessionKey)
|
|
}
|
|
_ = try await GatewayConnection.shared.request(
|
|
method: "sessions.messages.subscribe",
|
|
params: ["key": AnyCodable(sessionKey)],
|
|
timeoutMs: 10000)
|
|
}
|
|
|
|
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
|
AsyncStream { continuation in
|
|
let task = Task {
|
|
do {
|
|
try await GatewayConnection.shared.refresh()
|
|
} catch {
|
|
webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
|
|
let stream = await GatewayConnection.shared.subscribe()
|
|
for await push in stream {
|
|
if Task.isCancelled { return }
|
|
if let evt = Self.mapPushToTransportEvent(push) {
|
|
continuation.yield(evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
continuation.onTermination = { @Sendable _ in
|
|
task.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
static func mapPushToTransportEvent(_ push: GatewayPush) -> OpenClawChatTransportEvent? {
|
|
switch push {
|
|
case let .snapshot(hello):
|
|
let ok = (try? JSONDecoder().decode(
|
|
OpenClawGatewayHealthOK.self,
|
|
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
|
|
return .health(ok: ok)
|
|
|
|
case let .event(evt):
|
|
switch evt.event {
|
|
case "health":
|
|
guard let payload = evt.payload else { return nil }
|
|
let ok = (try? JSONDecoder().decode(
|
|
OpenClawGatewayHealthOK.self,
|
|
from: JSONEncoder().encode(payload)))?.ok ?? true
|
|
return .health(ok: ok)
|
|
case "tick":
|
|
return .tick
|
|
case "chat":
|
|
guard let payload = evt.payload else { return nil }
|
|
guard let chat = try? JSONDecoder().decode(
|
|
OpenClawChatEventPayload.self,
|
|
from: JSONEncoder().encode(payload))
|
|
else {
|
|
return nil
|
|
}
|
|
return .chat(chat)
|
|
case "session.message":
|
|
guard let payload = evt.payload else { return nil }
|
|
guard let message = try? JSONDecoder().decode(
|
|
OpenClawSessionMessageEventPayload.self,
|
|
from: JSONEncoder().encode(payload))
|
|
else {
|
|
return nil
|
|
}
|
|
return .sessionMessage(message)
|
|
case "agent":
|
|
guard let payload = evt.payload else { return nil }
|
|
guard let agent = try? JSONDecoder().decode(
|
|
OpenClawAgentEventPayload.self,
|
|
from: JSONEncoder().encode(payload))
|
|
else {
|
|
return nil
|
|
}
|
|
return .agent(agent)
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
case .seqGap:
|
|
return .seqGap
|
|
}
|
|
}
|
|
|
|
private static func mapModelChoice(_ model: OpenClawProtocol.ModelChoice) -> OpenClawChatModelChoice {
|
|
OpenClawChatModelChoice(
|
|
modelID: model.id,
|
|
name: model.name,
|
|
provider: model.provider,
|
|
contextWindow: model.contextwindow)
|
|
}
|
|
}
|
|
|
|
// MARK: - Window controller
|
|
|
|
@MainActor
|
|
final class WebChatSwiftUIWindowController {
|
|
private let presentation: WebChatPresentation
|
|
private let sessionKey: String
|
|
private let hosting: NSHostingController<OpenClawChatView>
|
|
private let contentController: NSViewController
|
|
private var window: NSWindow?
|
|
private var dismissMonitor: Any?
|
|
var onClosed: (() -> Void)?
|
|
var onVisibilityChanged: ((Bool) -> Void)?
|
|
|
|
convenience init(sessionKey: String, presentation: WebChatPresentation) {
|
|
self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport())
|
|
}
|
|
|
|
init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) {
|
|
self.sessionKey = sessionKey
|
|
self.presentation = presentation
|
|
let vm = OpenClawChatViewModel(
|
|
sessionKey: sessionKey,
|
|
transport: transport,
|
|
initialThinkingLevel: Self.persistedThinkingLevel(),
|
|
onThinkingLevelChanged: { level in
|
|
UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
|
|
})
|
|
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
|
self.hosting = NSHostingController(rootView: OpenClawChatView(
|
|
viewModel: vm,
|
|
showsSessionSwitcher: true,
|
|
userAccent: accent))
|
|
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
|
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
|
}
|
|
|
|
deinit {}
|
|
|
|
var isVisible: Bool {
|
|
self.window?.isVisible ?? false
|
|
}
|
|
|
|
func show() {
|
|
guard let window else { return }
|
|
self.ensureWindowSize()
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
self.onVisibilityChanged?(true)
|
|
}
|
|
|
|
func presentAnchored(anchorProvider: () -> NSRect?) {
|
|
guard case .panel = self.presentation, let window else { return }
|
|
self.installDismissMonitor()
|
|
let target = self.reposition(using: anchorProvider)
|
|
|
|
if !self.isVisible {
|
|
let start = target.offsetBy(dx: 0, dy: 8)
|
|
window.setFrame(start, display: true)
|
|
window.alphaValue = 0
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.18
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
window.animator().setFrame(target, display: true)
|
|
window.animator().alphaValue = 1
|
|
}
|
|
} else {
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
self.onVisibilityChanged?(true)
|
|
}
|
|
|
|
func close() {
|
|
self.window?.orderOut(nil)
|
|
self.onVisibilityChanged?(false)
|
|
self.onClosed?()
|
|
self.removeDismissMonitor()
|
|
}
|
|
|
|
@discardableResult
|
|
private func reposition(using anchorProvider: () -> NSRect?) -> NSRect {
|
|
guard let window else { return .zero }
|
|
guard let anchor = anchorProvider() else {
|
|
let frame = WindowPlacement.topRightFrame(
|
|
size: WebChatSwiftUILayout.panelSize,
|
|
padding: WebChatSwiftUILayout.anchorPadding)
|
|
window.setFrame(frame, display: false)
|
|
return frame
|
|
}
|
|
let screen = NSScreen.screens.first { screen in
|
|
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
|
} ?? NSScreen.main
|
|
let bounds = (screen?.visibleFrame ?? .zero).insetBy(
|
|
dx: WebChatSwiftUILayout.anchorPadding,
|
|
dy: WebChatSwiftUILayout.anchorPadding)
|
|
let frame = WindowPlacement.anchoredBelowFrame(
|
|
size: WebChatSwiftUILayout.panelSize,
|
|
anchor: anchor,
|
|
padding: WebChatSwiftUILayout.anchorPadding,
|
|
in: bounds)
|
|
window.setFrame(frame, display: false)
|
|
return frame
|
|
}
|
|
|
|
private func installDismissMonitor() {
|
|
if ProcessInfo.processInfo.isRunningTests { return }
|
|
guard self.dismissMonitor == nil, self.window != nil else { return }
|
|
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown])
|
|
{ [weak self] _ in
|
|
guard let self, let win = self.window else { return }
|
|
let pt = NSEvent.mouseLocation
|
|
if !win.frame.contains(pt) {
|
|
self.close()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeDismissMonitor() {
|
|
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
|
|
}
|
|
|
|
private static func persistedThinkingLevel() -> String? {
|
|
let stored = UserDefaults.standard.string(forKey: webChatThinkingLevelDefaultsKey)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
guard let stored, ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"].contains(stored) else {
|
|
return nil
|
|
}
|
|
return stored
|
|
}
|
|
|
|
private static func makeWindow(
|
|
for presentation: WebChatPresentation,
|
|
contentViewController: NSViewController) -> NSWindow
|
|
{
|
|
switch presentation {
|
|
case .window:
|
|
let window = NSWindow(
|
|
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize),
|
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
|
backing: .buffered,
|
|
defer: false)
|
|
window.title = "OpenClaw Chat"
|
|
window.contentViewController = contentViewController
|
|
window.isReleasedWhenClosed = false
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.backgroundColor = .clear
|
|
window.isOpaque = false
|
|
window.center()
|
|
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
|
|
window.minSize = WebChatSwiftUILayout.windowMinSize
|
|
window.contentView?.wantsLayer = true
|
|
window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
|
return window
|
|
case .panel:
|
|
let panel = WebChatPanel(
|
|
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize),
|
|
styleMask: [.borderless],
|
|
backing: .buffered,
|
|
defer: false)
|
|
panel.level = .statusBar
|
|
panel.hidesOnDeactivate = true
|
|
panel.hasShadow = true
|
|
panel.isMovable = false
|
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
panel.titleVisibility = .hidden
|
|
panel.titlebarAppearsTransparent = true
|
|
panel.backgroundColor = .clear
|
|
panel.isOpaque = false
|
|
panel.contentViewController = contentViewController
|
|
panel.becomesKeyOnlyIfNeeded = true
|
|
panel.contentView?.wantsLayer = true
|
|
panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
|
panel.setFrame(
|
|
WindowPlacement.topRightFrame(
|
|
size: WebChatSwiftUILayout.panelSize,
|
|
padding: WebChatSwiftUILayout.anchorPadding),
|
|
display: false)
|
|
return panel
|
|
}
|
|
}
|
|
|
|
private static func makeContentController(
|
|
for presentation: WebChatPresentation,
|
|
hosting: NSHostingController<OpenClawChatView>) -> NSViewController
|
|
{
|
|
let controller = NSViewController()
|
|
let effectView = NSVisualEffectView()
|
|
effectView.material = .sidebar
|
|
effectView.blendingMode = switch presentation {
|
|
case .panel:
|
|
.withinWindow
|
|
case .window:
|
|
.behindWindow
|
|
}
|
|
effectView.state = .active
|
|
effectView.wantsLayer = true
|
|
effectView.layer?.cornerCurve = .continuous
|
|
let cornerRadius: CGFloat = switch presentation {
|
|
case .panel:
|
|
16
|
|
case .window:
|
|
0
|
|
}
|
|
effectView.layer?.cornerRadius = cornerRadius
|
|
effectView.layer?.masksToBounds = true
|
|
effectView.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
effectView.translatesAutoresizingMaskIntoConstraints = true
|
|
effectView.autoresizingMask = [.width, .height]
|
|
let rootView = effectView
|
|
|
|
hosting.view.translatesAutoresizingMaskIntoConstraints = false
|
|
hosting.view.wantsLayer = true
|
|
hosting.view.layer?.cornerCurve = .continuous
|
|
hosting.view.layer?.cornerRadius = cornerRadius
|
|
hosting.view.layer?.masksToBounds = true
|
|
hosting.view.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
controller.addChild(hosting)
|
|
effectView.addSubview(hosting.view)
|
|
controller.view = rootView
|
|
|
|
NSLayoutConstraint.activate([
|
|
hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
|
|
hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
|
|
hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor),
|
|
hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
|
|
])
|
|
|
|
return controller
|
|
}
|
|
|
|
private func ensureWindowSize() {
|
|
guard case .window = self.presentation, let window else { return }
|
|
let current = window.frame.size
|
|
let min = WebChatSwiftUILayout.windowMinSize
|
|
if current.width < min.width || current.height < min.height {
|
|
let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize)
|
|
window.setFrame(frame, display: false)
|
|
}
|
|
}
|
|
|
|
private static func color(fromHex raw: String?) -> Color? {
|
|
ColorHexSupport.color(fromHex: raw)
|
|
}
|
|
}
|