macOS webchat: use relay HTTP transport directly
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// Bundled entry point for the macOS WKWebView web chat.
|
// Bundled entry point for the macOS WKWebView web chat.
|
||||||
// This replaces the inline module script in index.html so we can ship a single JS bundle.
|
// This replaces the inline module script in index.html so we can ship a single JS bundle.
|
||||||
|
|
||||||
/* global window, document, crypto */
|
/* global window, document */
|
||||||
|
|
||||||
if (!globalThis.process) {
|
if (!globalThis.process) {
|
||||||
// Some vendor modules peek at process.env; provide a minimal stub for browser.
|
// Some vendor modules peek at process.env; provide a minimal stub for browser.
|
||||||
@@ -11,9 +11,6 @@ if (!globalThis.process) {
|
|||||||
const logStatus = (msg) => {
|
const logStatus = (msg) => {
|
||||||
try {
|
try {
|
||||||
console.log(msg);
|
console.log(msg);
|
||||||
if (typeof window.__clawdisLog === "function") {
|
|
||||||
window.__clawdisLog(msg);
|
|
||||||
}
|
|
||||||
const el = document.getElementById("app");
|
const el = document.getElementById("app");
|
||||||
if (el && !el.dataset.booted) el.textContent = msg;
|
if (el && !el.dataset.booted) el.textContent = msg;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -21,13 +18,21 @@ const logStatus = (msg) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBootstrap = () => {
|
async function fetchBootstrap() {
|
||||||
const bootstrap = window.__clawdisBootstrap || {};
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const sessionKey = params.get("session") || "main";
|
||||||
|
const infoUrl = new URL(`./info?session=${encodeURIComponent(sessionKey)}`, window.location.href);
|
||||||
|
const infoResp = await fetch(infoUrl, { credentials: "omit" });
|
||||||
|
if (!infoResp.ok) {
|
||||||
|
throw new Error(`webchat info failed (${infoResp.status})`);
|
||||||
|
}
|
||||||
|
const info = await infoResp.json();
|
||||||
return {
|
return {
|
||||||
initialMessages: Array.isArray(bootstrap.initialMessages) ? bootstrap.initialMessages : [],
|
sessionKey,
|
||||||
sessionKey: typeof bootstrap.sessionKey === "string" ? bootstrap.sessionKey : "main",
|
basePath: info.basePath || "/webchat/",
|
||||||
|
initialMessages: Array.isArray(info.initialMessages) ? info.initialMessages : [],
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
class NativeTransport {
|
class NativeTransport {
|
||||||
constructor(sessionKey) {
|
constructor(sessionKey) {
|
||||||
@@ -35,10 +40,27 @@ class NativeTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *run(messages, userMessage, cfg, signal) {
|
async *run(messages, userMessage, cfg, signal) {
|
||||||
const result = await window.__clawdisSend({
|
const rpcUrl = new URL("./rpc", window.location.href);
|
||||||
type: "chat",
|
const resultResp = await fetch(rpcUrl, {
|
||||||
payload: { text: userMessage.content?.[0]?.text ?? "", sessionKey: this.sessionKey },
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: userMessage.content?.[0]?.text ?? "",
|
||||||
|
session: this.sessionKey,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!resultResp.ok) {
|
||||||
|
throw new Error(`rpc failed (${resultResp.status})`);
|
||||||
|
}
|
||||||
|
const body = await resultResp.json();
|
||||||
|
if (!body.ok) {
|
||||||
|
throw new Error(body.error || "rpc error");
|
||||||
|
}
|
||||||
|
const first = Array.isArray(body.payloads) ? body.payloads[0] : undefined;
|
||||||
|
const text = (first?.text ?? "").toString();
|
||||||
|
|
||||||
const usage = {
|
const usage = {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
@@ -48,7 +70,7 @@ class NativeTransport {
|
|||||||
};
|
};
|
||||||
const assistant = {
|
const assistant = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text: result.text ?? "" }],
|
content: [{ type: "text", text }],
|
||||||
api: cfg.model.api,
|
api: cfg.model.api,
|
||||||
provider: cfg.model.provider,
|
provider: cfg.model.provider,
|
||||||
model: cfg.model.id,
|
model: cfg.model.id,
|
||||||
@@ -65,7 +87,8 @@ class NativeTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startChat = async () => {
|
const startChat = async () => {
|
||||||
const { initialMessages, sessionKey } = getBootstrap();
|
logStatus("boot: fetching session info");
|
||||||
|
const { initialMessages, sessionKey } = await fetchBootstrap();
|
||||||
|
|
||||||
logStatus("boot: starting imports");
|
logStatus("boot: starting imports");
|
||||||
const { Agent } = await import("./agent/agent.js");
|
const { Agent } = await import("./agent/agent.js");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,24 +4,13 @@ import Network
|
|||||||
import OSLog
|
import OSLog
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
import ClawdisIPC
|
|
||||||
|
|
||||||
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
||||||
|
|
||||||
private struct WebChatCliInfo: Decodable {
|
final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||||
let port: Int
|
|
||||||
let token: String?
|
|
||||||
let host: String?
|
|
||||||
let basePath: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, WKNavigationDelegate {
|
|
||||||
private let webView: WKWebView
|
private let webView: WKWebView
|
||||||
private let sessionKey: String
|
private let sessionKey: String
|
||||||
private var initialMessagesJSON: String = "[]"
|
|
||||||
private var tunnel: WebChatTunnel?
|
private var tunnel: WebChatTunnel?
|
||||||
private var baseEndpoint: URL?
|
private var baseEndpoint: URL?
|
||||||
private var apiToken: String?
|
|
||||||
|
|
||||||
init(sessionKey: String) {
|
init(sessionKey: String) {
|
||||||
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
||||||
@@ -33,34 +22,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
config.preferences.isElementFullscreenEnabled = true
|
config.preferences.isElementFullscreenEnabled = true
|
||||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.__clawdisLog = function(msg) {
|
|
||||||
try {
|
|
||||||
window.webkit?.messageHandlers?.clawdis?.postMessage({ id: 'log', log: String(msg) });
|
|
||||||
} catch (_) {}
|
|
||||||
};
|
|
||||||
"""
|
|
||||||
let userScript = WKUserScript(source: callbackScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
|
||||||
contentController.addUserScript(userScript)
|
|
||||||
|
|
||||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 960, height: 720),
|
contentRect: NSRect(x: 0, y: 0, width: 960, height: 720),
|
||||||
@@ -71,7 +32,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
window.contentView = self.webView
|
window.contentView = self.webView
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
self.webView.navigationDelegate = self
|
self.webView.navigationDelegate = self
|
||||||
contentController.add(self, name: "clawdis")
|
|
||||||
|
|
||||||
self.loadPlaceholder()
|
self.loadPlaceholder()
|
||||||
Task { await self.bootstrap() }
|
Task { await self.bootstrap() }
|
||||||
@@ -88,50 +48,27 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPage(baseURL: URL) {
|
private func loadPage(baseURL: URL) {
|
||||||
let bootstrapScript = """
|
self.webView.load(URLRequest(url: baseURL))
|
||||||
window.__clawdisBootstrap = {
|
webChatLogger.debug("loadPage url=\(baseURL.absoluteString, privacy: .public)")
|
||||||
sessionKey: \(self.sessionKey),
|
|
||||||
initialMessages: \(self.initialMessagesJSON)
|
|
||||||
};
|
|
||||||
"""
|
|
||||||
let userScript = WKUserScript(
|
|
||||||
source: bootstrapScript,
|
|
||||||
injectionTime: .atDocumentStart,
|
|
||||||
forMainFrameOnly: true)
|
|
||||||
self.webView.configuration.userContentController.addUserScript(userScript)
|
|
||||||
|
|
||||||
let url = baseURL.appendingPathComponent("index.html")
|
|
||||||
self.webView.load(URLRequest(url: url))
|
|
||||||
webChatLogger.debug("loadPage url=\(url.absoluteString, privacy: .public)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bootstrap
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
private func bootstrap() async {
|
private func bootstrap() async {
|
||||||
do {
|
do {
|
||||||
let cliInfo = try await self.fetchWebChatCliInfo()
|
|
||||||
guard AppStateStore.webChatEnabled else {
|
guard AppStateStore.webChatEnabled else {
|
||||||
throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
||||||
}
|
}
|
||||||
let endpoint = try await self.prepareEndpoint(remotePort: cliInfo.port)
|
let endpoint = try await self.prepareEndpoint(remotePort: AppStateStore.webChatPort)
|
||||||
self.baseEndpoint = endpoint
|
self.baseEndpoint = endpoint
|
||||||
let infoURL = endpoint.appendingPathComponent("webchat/info")
|
|
||||||
.appending(queryItems: [URLQueryItem(name: "session", value: self.sessionKey)])
|
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(from: infoURL)
|
|
||||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
||||||
let msgs = obj["initialMessages"]
|
|
||||||
{
|
|
||||||
if let json = try? JSONSerialization.data(withJSONObject: msgs, options: []) {
|
|
||||||
self.initialMessagesJSON = String(data: json, encoding: .utf8) ?? "[]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let token = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
|
||||||
let tk = token["token"] as? String, !tk.isEmpty {
|
|
||||||
self.apiToken = tk
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.loadPage(baseURL: endpoint.appendingPathComponent("webchat/"))
|
var comps = URLComponents(url: endpoint.appendingPathComponent("webchat/"), resolvingAgainstBaseURL: false)
|
||||||
|
comps?.queryItems = [URLQueryItem(name: "session", value: self.sessionKey)]
|
||||||
|
if let url = comps?.url {
|
||||||
|
self.loadPage(baseURL: url)
|
||||||
|
} else {
|
||||||
|
self.showError("invalid webchat url")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
let message = error.localizedDescription
|
let message = error.localizedDescription
|
||||||
@@ -140,21 +77,6 @@ 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: args),
|
|
||||||
cwd: CommandResolver.projectRootPath(),
|
|
||||||
env: nil,
|
|
||||||
timeout: 10)
|
|
||||||
guard response.ok, let data = response.payload else {
|
|
||||||
throw NSError(domain: "WebChat", code: 1, userInfo: [NSLocalizedDescriptionKey: response.message ?? "webchat cli failed"])
|
|
||||||
}
|
|
||||||
return try JSONDecoder().decode(WebChatCliInfo.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
||||||
if CommandResolver.connectionModeIsRemote() {
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
let tunnel = try await WebChatTunnel.create(remotePort: remotePort)
|
let tunnel = try await WebChatTunnel.create(remotePort: remotePort)
|
||||||
@@ -178,80 +100,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
webChatLogger.debug("didFinish navigation url=\(webView.url?.absoluteString ?? "nil", privacy: .public)")
|
webChatLogger.debug("didFinish navigation url=\(webView.url?.absoluteString ?? "nil", privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
||||||
guard message.name == "clawdis" else { return }
|
|
||||||
if let body = message.body as? [String: Any], body["id"] as? String == "log" {
|
|
||||||
if let log = body["log"] as? String { webChatLogger.debug("JS: \(log, privacy: .public)") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let body = message.body as? [String: Any],
|
|
||||||
let id = body["id"] as? String
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
guard 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?) {
|
|
||||||
await MainActor.run { AppStateStore.shared.setWorking(true) }
|
|
||||||
defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } }
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Manager
|
// MARK: - Manager
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Web Chat (macOS app)
|
# Web Chat (macOS app)
|
||||||
|
|
||||||
The macOS menu bar app ships a bundled web UI (pi-web-ui) rendered inside WKWebView. It reuses the **primary Clawd session** (`main` by default, configurable via `inbound.reply.session.mainKey`) and never opens a local HTTP port.
|
The macOS menu bar app opens the relay’s loopback web chat server in a WKWebView. It reuses the **primary Clawd session** (`main` by default, configurable via `inbound.reply.session.mainKey`). The server is started by the Node relay (default port 18788, see `webchat.port`).
|
||||||
|
|
||||||
## Launch & debugging
|
## Launch & debugging
|
||||||
- Manual: Lobster menu → “Open Chat”.
|
- Manual: Lobster menu → “Open Chat”.
|
||||||
@@ -9,13 +9,12 @@ The macOS menu bar app ships a bundled web UI (pi-web-ui) rendered inside WKWebV
|
|||||||
- WK logs: navigation lifecycle, readyState, js location, and JS errors/unhandled rejections are mirrored to OSLog for easier diagnosis.
|
- WK logs: navigation lifecycle, readyState, js location, and JS errors/unhandled rejections are mirrored to OSLog for easier diagnosis.
|
||||||
|
|
||||||
## How it’s wired
|
## How it’s wired
|
||||||
- Assets: `apps/macos/Sources/Clawdis/Resources/WebChat/` contains the `pi-web-ui` dist plus a local import map pointing at bundled vendor modules and a tiny `pi-ai` stub. Everything loads from the app bundle (file URLs, no network).
|
- Assets: `apps/macos/Sources/Clawdis/Resources/WebChat/` contains the `pi-web-ui` dist plus a local import map pointing at bundled vendor modules and a tiny `pi-ai` stub. Everything is served from the relay at `/webchat/*`.
|
||||||
- Bridge: `WKScriptMessageHandler` named `clawdis` in `WebChatWindow.swift`. JS posts `{type:"chat", payload:{text, sessionKey}}`; Swift shells `pnpm clawdis agent --to <sessionKey> --message <text> --json`, returns the first payload text, and hydrates the UI with prior messages from `~/.clawdis/sessions/<SessionId>.jsonl`.
|
- Bridge: none. The web UI calls `/webchat/rpc` directly; Swift no longer proxies messages.
|
||||||
- Session: always primary; multiple transports (WhatsApp/Telegram/Desktop) share the same session key so context is unified.
|
- Session: always primary; multiple transports (WhatsApp/Telegram/Desktop) share the same session key so context is unified.
|
||||||
|
|
||||||
## Security / surface area
|
## Security / surface area
|
||||||
- No local server is started; everything is `file://` within the app bundle.
|
- Loopback server only; remote mode uses SSH port-forwarding from the relay host to the Mac. CSP is set to `default-src 'self' 'unsafe-inline' data: blob:`.
|
||||||
- CSP is set to `default-src 'self' 'unsafe-inline' data: blob:` to keep module imports bundle-local.
|
|
||||||
- Web Inspector is opt-in via right-click; otherwise WKWebView stays in the app sandbox.
|
- Web Inspector is opt-in via right-click; otherwise WKWebView stays in the app sandbox.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|||||||
Reference in New Issue
Block a user