diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift index 9b46a85ad..7abb680a0 100644 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -1466,6 +1466,7 @@ actor MicLevelMonitor { } } +@MainActor final class VoiceWakeTester { private let recognizer: SFSpeechRecognizer? private let audioEngine = AVAudioEngine() @@ -2146,14 +2147,14 @@ struct VoiceWakeSettings: View { let cleanedID = self.normalizedLocaleIdentifier(locale.identifier) let cleanLocale = Locale(identifier: cleanedID) - if let langCode = cleanLocale.languageCode, + if let langCode = cleanLocale.language.languageCode?.identifier, let lang = cleanLocale.localizedString(forLanguageCode: langCode), - let regionCode = cleanLocale.regionCode, + let regionCode = cleanLocale.region?.identifier, let region = cleanLocale.localizedString(forRegionCode: regionCode) { return "\(lang) (\(region))" } - if let langCode = cleanLocale.languageCode, + if let langCode = cleanLocale.language.languageCode?.identifier, let lang = cleanLocale.localizedString(forLanguageCode: langCode) { return lang diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift new file mode 100644 index 000000000..75f90083e --- /dev/null +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -0,0 +1,243 @@ +import AppKit +import Foundation +import WebKit + +final class WebChatWindowController: NSWindowController, WKScriptMessageHandler { + private let webView: WKWebView + private let sessionKey: String + + init(sessionKey: String) { + self.sessionKey = sessionKey + + let config = WKWebViewConfiguration() + let contentController = WKUserContentController() + config.userContentController = contentController + config.preferences.isElementFullscreenEnabled = true + + // Inject callback receiver stub + let callbackScript = """ + window.__clawdisCallbacks = new Map(); + window.__clawdisReceive = function(resp) { + const entry = window.__clawdisCallbacks.get(resp.id); + if (!entry) return; + window.__clawdisCallbacks.delete(resp.id); + if (resp.ok) { + entry.resolve(resp.result); + } else { + entry.reject(resp.error || 'unknown error'); + } + }; + window.__clawdisSend = function(payload) { + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + window.__clawdisCallbacks.set(id, { resolve, reject }); + window.webkit?.messageHandlers?.clawdis?.postMessage({ id, ...payload }); + }); + }; + """ + let userScript = WKUserScript(source: callbackScript, injectionTime: .atDocumentStart, forMainFrameOnly: true) + contentController.addUserScript(userScript) + + self.webView = WKWebView(frame: .zero, configuration: config) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 960, height: 720), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false) + window.title = "Clawd Web Chat" + window.contentView = self.webView + super.init(window: window) + contentController.add(self, name: "clawdis") + self.loadPage() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + private func loadPage() { + let monoRoot = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Projects/pi-mono") + let webUiDist = monoRoot.appendingPathComponent("packages/web-ui/dist") + let piAiDist = monoRoot.appendingPathComponent("packages/ai/dist/index.js") + let miniLitDist = monoRoot.appendingPathComponent("packages/web-ui/node_modules/@mariozechner/mini-lit/dist/index.js") + let litDist = monoRoot.appendingPathComponent("packages/web-ui/node_modules/lit/index.js") + let lucideDist = monoRoot.appendingPathComponent("node_modules/lucide/dist/esm/lucide.js") + + let distPath = webUiDist.path(percentEncoded: false) + let cssPath = webUiDist.appendingPathComponent("app.css").path(percentEncoded: false) + + let importMap = [ + "imports": [ + "@mariozechner/pi-web-ui": "file://\(distPath)/index.js", + "@mariozechner/pi-ai": "file://\(piAiDist.path(percentEncoded: false))", + "@mariozechner/mini-lit": "file://\(miniLitDist.path(percentEncoded: false))", + "lit": "file://\(litDist.path(percentEncoded: false))", + "lucide": "file://\(lucideDist.path(percentEncoded: false))" + ] + ] + + let importMapJSON: String + if let data = try? JSONSerialization.data(withJSONObject: importMap, options: [.prettyPrinted]), + let json = String(data: data, encoding: .utf8) { + importMapJSON = json + } else { + importMapJSON = "{}" + } + + let html = """ + + + + + Clawd Web Chat + + + + + +
+ + + + """ + self.webView.loadHTMLString(html, baseURL: URL(string: distPath)) + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any], + let id = body["id"] as? String, + let type = body["type"] as? String, + type == "chat", + let payload = body["payload"] as? [String: Any], + let text = payload["text"] as? String + else { return } + + Task { @MainActor in + let reply = await runAgent(text: text, sessionKey: sessionKey) + let json: [String: Any] = [ + "id": id, + "ok": reply.error == nil, + "result": ["text": reply.text ?? ""], + "error": reply.error ?? NSNull(), + ] + if let data = try? JSONSerialization.data(withJSONObject: json), + let js = String(data: data, encoding: .utf8) + { + _ = try? await self.webView.evaluateJavaScript("window.__clawdisReceive(" + js + ")") + } + } + } + + private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) { + let data: Data + do { + data = try await Task.detached(priority: .utility) { () -> Data in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["pnpm", "clawdis", "agent", "--to", sessionKey, "--message", text, "--json"] + process.currentDirectoryURL = URL(fileURLWithPath: "/Users/steipete/Projects/clawdis") + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + let out = pipe.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + throw NSError( + domain: "ClawdisAgent", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: String(data: out, encoding: .utf8) ?? "unknown error"]) + } + return out + }.value + } catch { + return (nil, error.localizedDescription) + } + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let payloads = obj["payloads"] as? [[String: Any]], + let first = payloads.first, + let text = first["text"] as? String + { + return (text, nil) + } + return (String(data: data, encoding: .utf8), nil) + } +} + +@MainActor +final class WebChatManager { + static let shared = WebChatManager() + private var window: WebChatWindowController? + + func show(sessionKey: String) { + if self.window == nil { + self.window = WebChatWindowController(sessionKey: sessionKey) + } + self.window?.showWindow(nil) + self.window?.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} diff --git a/docs/webchat.md b/docs/webchat.md new file mode 100644 index 000000000..21582fc61 --- /dev/null +++ b/docs/webchat.md @@ -0,0 +1,27 @@ +# Web Chat (macOS menu bar) + +The macOS Clawdis app ships a built-in web chat window that reuses your primary Clawd session instead of creating a new one. This is meant for quick desktop access without exposing any local HTTP ports. + +## How it works + +- UI: `pi-mono/packages/web-ui` bundle loaded in a `WKWebView`. +- Bridge: `WKScriptMessageHandler` named `clawdis` (see `apps/macos/Sources/Clawdis/WebChatWindow.swift`). The page posts `sessionKey` + message; Swift shells `pnpm clawdis agent --to --message --json` and returns the first payload text to the page. No sockets are opened. +- Session selection: picks the most recently updated entry in `~/.clawdis/sessions/sessions.json`; falls back to `+1003` if none exist. This keeps the web chat on the same primary conversation as the relay/CLI. +- Assets: currently loads `pi-web-ui` directly from `../pi-mono/packages/web-ui/dist` on disk. (We should copy it into the app bundle in a future step.) + +## Requirements + +- `pnpm` on PATH. +- `pnpm install` already run in the repo so `pnpm clawdis agent ...` works. +- `pi-mono` checked out at `../pi-mono` with `packages/web-ui/dist` built. + +## Limitations / TODO + +- Single-turn (no streaming), text-only; attachments/tools not wired yet. +- Absolute dist path; bundle should be copied into app resources and versioned. +- Errors from the agent subprocess are minimally surfaced. + +## Usage + +- Launch the macOS Clawdis menu bar app, click the lobster icon → “Open Web Chat”. +- Type and send; replies continue the primary Clawd session.