Remote web chat tunnel and onboarding polish
This commit is contained in:
@@ -110,6 +110,14 @@ final class AppState: ObservableObject {
|
||||
didSet { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||
}
|
||||
|
||||
@Published var webChatEnabled: Bool {
|
||||
didSet { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) }
|
||||
}
|
||||
|
||||
@Published var webChatPort: Int {
|
||||
didSet { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) }
|
||||
}
|
||||
|
||||
@Published var remoteTarget: String {
|
||||
didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||
}
|
||||
@@ -170,6 +178,9 @@ final class AppState: ObservableObject {
|
||||
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.webChatEnabled = UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true
|
||||
let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey)
|
||||
self.webChatPort = storedPort > 0 ? storedPort : 18788
|
||||
|
||||
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
||||
self.swabbleEnabled = false
|
||||
@@ -232,6 +243,15 @@ enum AppStateStore {
|
||||
static func updateLaunchAtLogin(enabled: Bool) {
|
||||
LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath)
|
||||
}
|
||||
|
||||
static var webChatEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
static var webChatPort: Int {
|
||||
let stored = UserDefaults.standard.integer(forKey: webChatPortKey)
|
||||
return stored > 0 ? stored : 18788
|
||||
}
|
||||
}
|
||||
|
||||
extension AppState {
|
||||
|
||||
@@ -14,6 +14,8 @@ struct ConfigSettings: View {
|
||||
@State private var allowAutosave = false
|
||||
@State private var heartbeatMinutes: Int?
|
||||
@State private var heartbeatBody: String = "HEARTBEAT"
|
||||
@AppStorage(webChatEnabledKey) private var webChatEnabled: Bool = true
|
||||
@AppStorage(webChatPortKey) private var webChatPort: Int = 18788
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
@@ -92,6 +94,27 @@ struct ConfigSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
LabeledContent("Web chat") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Enable embedded web chat (loopback only)", isOn: self.$webChatEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.frame(width: 320, alignment: .leading)
|
||||
HStack(spacing: 8) {
|
||||
Text("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
Text("Mac app connects to the relay’s loopback web chat on this port. Remote mode uses SSH -L to forward it.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -24,6 +24,8 @@ let connectionModeKey = "clawdis.connectionMode"
|
||||
let remoteTargetKey = "clawdis.remoteTarget"
|
||||
let remoteIdentityKey = "clawdis.remoteIdentity"
|
||||
let remoteProjectRootKey = "clawdis.remoteProjectRoot"
|
||||
let webChatEnabledKey = "clawdis.webChatEnabled"
|
||||
let webChatPortKey = "clawdis.webChatPort"
|
||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
||||
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
||||
|
||||
@@ -71,19 +71,23 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("Clawdis runs", selection: self.$state.connectionMode) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdis runs")
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Picker("", selection: self.$state.connectionMode) {
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 360)
|
||||
.frame(width: 380, alignment: .leading)
|
||||
|
||||
self.healthRow
|
||||
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
}
|
||||
|
||||
self.healthRow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,10 +96,10 @@ struct GeneralSettings: View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 44, alignment: .leading)
|
||||
.frame(width: 48, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
@@ -103,12 +107,12 @@ struct GeneralSettings: View {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
@@ -148,14 +152,11 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: use Tailscale so your remote Clawdis stays reachable.")
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
@@ -304,6 +305,8 @@ extension GeneralSettings {
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,10 +317,21 @@ extension GeneralSettings {
|
||||
let response = await ShellRunner.run(command: command, cwd: nil, env: nil, timeout: 10)
|
||||
if response.ok {
|
||||
self.remoteStatus = .ok
|
||||
} else {
|
||||
let msg = response.message ?? "test failed"
|
||||
self.remoteStatus = .failed(msg)
|
||||
return
|
||||
}
|
||||
|
||||
let msg: String
|
||||
if let payload = response.payload,
|
||||
let text = String(data: payload, encoding: .utf8),
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
msg = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else if let message = response.message, !message.isEmpty {
|
||||
msg = message
|
||||
} else {
|
||||
msg = "Remote status failed (is clawdis on PATH on the remote host?)"
|
||||
}
|
||||
self.remoteStatus = .failed(msg)
|
||||
}
|
||||
|
||||
private func revealLogs() {
|
||||
|
||||
@@ -163,12 +163,11 @@ final class HealthStore: ObservableObject {
|
||||
return "Not linked — run clawdis login"
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
if let err = connect.error, err.contains("timeout") {
|
||||
let elapsed = connect.elapsedMs.map { " after \(Int($0))ms" } ?? ""
|
||||
return "Web connect timed out\(elapsed)"
|
||||
let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
|
||||
if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil {
|
||||
return "Health check timed out (\(elapsed))"
|
||||
}
|
||||
let code = connect.status.map { "status \($0)" } ?? "status unknown"
|
||||
let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return "\(reason) (\(code), \(elapsed))"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
@@ -56,6 +57,8 @@ private struct MenuContent: View {
|
||||
@ObservedObject private var relayManager = RelayProcessManager.shared
|
||||
@ObservedObject private var healthStore = HealthStore.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -68,7 +71,12 @@ private struct MenuContent: View {
|
||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) }
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
if AppStateStore.webChatEnabled {
|
||||
Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) }
|
||||
}
|
||||
Divider()
|
||||
Button("Settings…") { self.open(tab: .general) }
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
@@ -79,6 +87,11 @@ private struct MenuContent: View {
|
||||
Divider()
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
.task(id: self.state.swabbleEnabled) {
|
||||
if self.state.swabbleEnabled {
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func open(tab: SettingsTab) {
|
||||
@@ -166,6 +179,77 @@ private struct MenuContent: View {
|
||||
})
|
||||
}
|
||||
|
||||
private var showVoiceWakeMicPicker: Bool {
|
||||
voiceWakeSupported && self.state.swabbleEnabled
|
||||
}
|
||||
|
||||
private var voiceWakeMicMenu: some View {
|
||||
Menu {
|
||||
Picker("Microphone", selection: self.$state.voiceWakeMicID) {
|
||||
Text(self.defaultMicLabel).tag("")
|
||||
ForEach(self.availableMics) { mic in
|
||||
Text(mic.name).tag(mic.uid)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
if self.loadingMics {
|
||||
Divider()
|
||||
Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath")
|
||||
.labelStyle(.titleOnly)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(true)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Microphone")
|
||||
Spacer()
|
||||
Text(self.selectedMicLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task { await self.loadMicrophones() }
|
||||
}
|
||||
|
||||
private var selectedMicLabel: String {
|
||||
if self.state.voiceWakeMicID.isEmpty { return self.defaultMicLabel }
|
||||
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
|
||||
return match.name
|
||||
}
|
||||
return "Unavailable"
|
||||
}
|
||||
|
||||
private var defaultMicLabel: String {
|
||||
if let host = Host.current().localizedName, !host.isEmpty {
|
||||
return "Auto-detect (\(host))"
|
||||
}
|
||||
return "System default"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicrophones(force: Bool = false) async {
|
||||
guard self.showVoiceWakeMicPicker else {
|
||||
self.availableMics = []
|
||||
self.loadingMics = false
|
||||
return
|
||||
}
|
||||
if !force, !self.availableMics.isEmpty { return }
|
||||
self.loadingMics = true
|
||||
let discovery = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .microphone],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
self.availableMics = discovery.devices
|
||||
.sorted { lhs, rhs in
|
||||
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
|
||||
}
|
||||
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
self.loadingMics = false
|
||||
}
|
||||
|
||||
private func primarySessionKey() -> String {
|
||||
// Prefer canonical main session; fall back to most recent.
|
||||
let storePath = SessionLoader.defaultStorePath
|
||||
@@ -183,6 +267,12 @@ private struct MenuContent: View {
|
||||
}
|
||||
return "+1003"
|
||||
}
|
||||
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
var id: String { self.uid }
|
||||
}
|
||||
}
|
||||
|
||||
private struct CritterStatusLabel: View {
|
||||
|
||||
@@ -49,8 +49,8 @@ struct OnboardingView: View {
|
||||
@ObservedObject private var state = AppStateStore.shared
|
||||
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
|
||||
|
||||
private let pageWidth: CGFloat = 640
|
||||
private let contentHeight: CGFloat = 340
|
||||
private let pageWidth: CGFloat = 680
|
||||
private let contentHeight: CGFloat = 520
|
||||
private let permissionsPageIndex = 2
|
||||
private var pageCount: Int { 6 }
|
||||
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
|
||||
@@ -59,9 +59,9 @@ struct OnboardingView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
GlowingClawdisIcon(size: 156)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
.frame(height: 200)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 2)
|
||||
.frame(height: 176)
|
||||
|
||||
GeometryReader { _ in
|
||||
HStack(spacing: 0) {
|
||||
@@ -79,11 +79,11 @@ struct OnboardingView: View {
|
||||
.frame(height: self.contentHeight, alignment: .top)
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: 260)
|
||||
.frame(height: self.contentHeight)
|
||||
|
||||
self.navigationBar
|
||||
}
|
||||
.frame(width: self.pageWidth, height: 560)
|
||||
.frame(width: self.pageWidth, height: 720)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.onAppear {
|
||||
self.currentPage = 0
|
||||
@@ -129,20 +129,20 @@ struct OnboardingView: View {
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10, padding: 12) {
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
self.onboardingCard(spacing: 12, padding: 14) {
|
||||
Picker("Clawdis runs", selection: self.$state.connectionMode) {
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 320)
|
||||
.frame(width: 360)
|
||||
|
||||
if self.state.connectionMode == .remote {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("SSH target") {
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
.frame(width: 300)
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
@@ -150,20 +150,21 @@ struct OnboardingView: View {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
.frame(width: 300)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
.frame(width: 300)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
Text("Tip: keep a Tailscale IP here so the agent stays reachable off-LAN.")
|
||||
Text("Tip: enable Tailscale so your remote Clawdis stays reachable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
@@ -309,8 +309,7 @@ enum CommandResolver {
|
||||
|
||||
private static func sshCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
|
||||
guard !settings.target.isEmpty else { return nil }
|
||||
let parsed = VoiceWakeForwarder.parse(target: settings.target)
|
||||
guard let parsed else { return nil }
|
||||
guard let parsed = VoiceWakeForwarder.parse(target: settings.target) else { return nil }
|
||||
|
||||
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
@@ -320,11 +319,21 @@ enum CommandResolver {
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
let quotedArgs = (["clawdis", subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
||||
// Prefer the Node CLI ("clawdis") on the remote host; fall back to pnpm or the mac helper if present.
|
||||
let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm:$PATH"
|
||||
let cdPrefix = settings.projectRoot.isEmpty ? "" : "cd \(self.shellQuote(settings.projectRoot)) && "
|
||||
let scriptBody = "\(cdPrefix)\(quotedArgs)"
|
||||
let wrapped = VoiceWakeForwarder.commandWithCliPath(scriptBody, target: settings.target)
|
||||
args.append(contentsOf: ["/bin/sh", "-c", wrapped])
|
||||
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
||||
let scriptBody = """
|
||||
PATH=\(exportedPath);
|
||||
CLI="";
|
||||
if command -v clawdis >/dev/null 2>&1; then CLI="clawdis";
|
||||
elif command -v pnpm >/dev/null 2>&1; then CLI="pnpm --silent clawdis";
|
||||
elif command -v clawdis-mac >/dev/null 2>&1; then CLI="clawdis-mac";
|
||||
fi;
|
||||
if [ -z "$CLI" ]; then echo "clawdis missing on remote host"; exit 127; fi;
|
||||
\(cdPrefix)$CLI \(quotedArgs)
|
||||
"""
|
||||
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
|
||||
return ["/usr/bin/ssh"] + args
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ actor VoiceWakeRuntime {
|
||||
private func monitorCapture(config: RuntimeConfig) async {
|
||||
let start = self.captureStartedAt ?? Date()
|
||||
let hardStop = start.addingTimeInterval(self.captureHardStop)
|
||||
var silentStrikes = 0
|
||||
|
||||
while self.isCapturing {
|
||||
let now = Date()
|
||||
@@ -191,8 +192,13 @@ actor VoiceWakeRuntime {
|
||||
}
|
||||
|
||||
if let last = self.lastHeard, now.timeIntervalSince(last) >= self.silenceWindow {
|
||||
await self.finalizeCapture(config: config)
|
||||
return
|
||||
silentStrikes += 1
|
||||
if silentStrikes >= 2 {
|
||||
await self.finalizeCapture(config: config)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
silentStrikes = 0
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
@@ -110,6 +110,9 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
||||
private func bootstrap() async {
|
||||
do {
|
||||
let cliInfo = try await self.fetchWebChatCliInfo()
|
||||
guard AppStateStore.webChatEnabled else {
|
||||
throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
||||
}
|
||||
let endpoint = try await self.prepareEndpoint(remotePort: cliInfo.port)
|
||||
self.baseEndpoint = endpoint
|
||||
let infoURL = endpoint.appendingPathComponent("webchat/info")
|
||||
@@ -138,8 +141,11 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
||||
}
|
||||
|
||||
private func fetchWebChatCliInfo() async throws -> WebChatCliInfo {
|
||||
var args = ["--json"]
|
||||
let port = AppStateStore.webChatPort
|
||||
if port > 0 { args += ["--port", String(port)] }
|
||||
let response = await ShellRunner.run(
|
||||
command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: ["--json"]),
|
||||
command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: args),
|
||||
cwd: CommandResolver.projectRootPath(),
|
||||
env: nil,
|
||||
timeout: 10)
|
||||
@@ -209,50 +215,42 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
||||
private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) {
|
||||
await MainActor.run { AppStateStore.shared.setWorking(true) }
|
||||
defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } }
|
||||
if let base = self.baseEndpoint {
|
||||
do {
|
||||
var req = URLRequest(url: base.appendingPathComponent("webchat/rpc"))
|
||||
req.httpMethod = "POST"
|
||||
var headers: [String: String] = ["Content-Type": "application/json"]
|
||||
if let apiToken, !apiToken.isEmpty { headers["Authorization"] = "Bearer \(apiToken)" }
|
||||
req.allHTTPHeaderFields = headers
|
||||
let body: [String: Any] = [
|
||||
"text": text,
|
||||
"session": sessionKey,
|
||||
"thinking": "default",
|
||||
"deliver": false,
|
||||
"to": sessionKey,
|
||||
]
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ok = obj["ok"] as? Bool,
|
||||
ok == true
|
||||
{
|
||||
if let payloads = obj["payloads"] as? [[String: Any]],
|
||||
let first = payloads.first,
|
||||
let txt = first["text"] as? String
|
||||
{
|
||||
return (txt, nil)
|
||||
}
|
||||
return (nil, nil)
|
||||
}
|
||||
let errObj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let err = (errObj?["error"] as? String) ?? "rpc failed"
|
||||
return (nil, err)
|
||||
} catch {
|
||||
return (nil, error.localizedDescription)
|
||||
}
|
||||
guard let base = self.baseEndpoint else {
|
||||
return (nil, "web chat endpoint missing")
|
||||
}
|
||||
do {
|
||||
var req = URLRequest(url: base.appendingPathComponent("webchat/rpc"))
|
||||
req.httpMethod = "POST"
|
||||
var headers: [String: String] = ["Content-Type": "application/json"]
|
||||
if let apiToken, !apiToken.isEmpty { headers["Authorization"] = "Bearer \(apiToken)" }
|
||||
req.allHTTPHeaderFields = headers
|
||||
let body: [String: Any] = [
|
||||
"text": text,
|
||||
"session": sessionKey,
|
||||
"thinking": "default",
|
||||
"deliver": false,
|
||||
"to": sessionKey,
|
||||
]
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ok = obj["ok"] as? Bool,
|
||||
ok == true
|
||||
{
|
||||
if let payloads = obj["payloads"] as? [[String: Any]],
|
||||
let first = payloads.first,
|
||||
let txt = first["text"] as? String
|
||||
{
|
||||
return (txt, nil)
|
||||
}
|
||||
return (nil, nil)
|
||||
}
|
||||
let errObj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let err = (errObj?["error"] as? String) ?? "rpc failed"
|
||||
return (nil, err)
|
||||
} catch {
|
||||
return (nil, error.localizedDescription)
|
||||
}
|
||||
|
||||
// Fallback to AgentRPC when no base endpoint is known (should not happen after bootstrap).
|
||||
let result = await AgentRPC.shared.send(
|
||||
text: text,
|
||||
thinking: "default",
|
||||
session: sessionKey,
|
||||
deliver: false,
|
||||
to: sessionKey)
|
||||
return (result.text, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user