初始化:独立 oclaw 仓库首提交

- 在 oclaw/ 下重新初始化 Git 仓库
- 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等)
- 提交当前工程代码与配置

Made-with: Cursor
This commit is contained in:
oliver 2026-04-24 22:31:22 +08:00
commit ba3836f00f
579 changed files with 83112 additions and 0 deletions

49
desktop/README.md Normal file
View file

@ -0,0 +1,49 @@
# Desktop Shell
Electron wrapper for existing `admin/chat` frontend with an embedded local backend process.
## Prerequisites
- Node.js 20+
- Python 3.10+ (available in `PATH` as `python`)
- Python deps installed in repo root:
```powershell
python -m pip install -r requirements.txt
```
## Development
From this `oclaw/desktop` directory:
```powershell
npm install
npm run dev
```
The app will:
1. Pick an available local port (default prefers `8787`).
2. Start backend using `python -m oclaw.ops gateway start --host 127.0.0.1 --port <port>`.
3. Open `http://127.0.0.1:<port>/chat` in the desktop window.
Logs are written under:
- `%APPDATA%/oclaw/logs/backend.log` (Windows)
## Environment knobs
- `PYTHON_EXECUTABLE`: absolute path to python executable.
- `AIA_DESKTOP_BACKEND_PORT`: preferred backend port.
## Packaging (Windows)
```powershell
npm run pack:win
```
Output goes to `oclaw/desktop/dist/`, e.g.:
- `oclaw-setup-<version>.exe`
- `oclaw-setup-<version>.exe.blockmap`
- `win-unpacked/oclaw.exe`

BIN
desktop/assets/oclaw.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

503
desktop/main.js Normal file
View file

@ -0,0 +1,503 @@
const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
const path = require("node:path");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const http = require("node:http");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 8787;
const APP_DISPLAY_NAME = "oclaw";
const APP_ROOT = path.resolve(__dirname, "..", "..");
const APP_ICON_PATH = path.join(APP_ROOT, "src", "admin", "static", "oliver.svg");
const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data");
const LOG_ROOT = path.join(app.getPath("userData"), "logs");
const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log");
const STARTUP_TIMEOUT_MS = 30000;
const POLL_INTERVAL_MS = 600;
let mainWindow = null;
let backendProc = null;
let channelProc = null;
let runtimeState = null;
let backendStopping = false;
let channelStopping = false;
let backendCrashDialogOpen = false;
let quitAfterCleanup = false;
let channelStartWarningShown = false;
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function resolvePythonBin() {
const fromEnv = String(process.env.PYTHON_EXECUTABLE || "").trim();
if (fromEnv) return fromEnv;
if (process.platform === "win32") return "python";
return "python3";
}
function checkPythonAvailable(pythonBin) {
return new Promise((resolve) => {
const probe = spawn(pythonBin, ["--version"], {
cwd: APP_ROOT,
windowsHide: true,
stdio: "ignore",
});
probe.once("error", () => resolve(false));
probe.once("close", (code) => resolve(code === 0));
});
}
function pickPort(preferredPort) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(preferredPort, DEFAULT_HOST, () => {
const addr = server.address();
const chosenPort = typeof addr === "object" && addr ? addr.port : preferredPort;
server.close(() => resolve(chosenPort));
});
});
}
function waitForBackend(url, deadlineAtMs) {
return new Promise((resolve, reject) => {
const attempt = () => {
const req = http.get(url, (res) => {
res.resume();
if (res.statusCode && res.statusCode < 500) {
resolve();
return;
}
if (Date.now() >= deadlineAtMs) {
reject(new Error(`backend not ready: HTTP ${res.statusCode || "unknown"}`));
return;
}
setTimeout(attempt, POLL_INTERVAL_MS);
});
req.on("error", () => {
if (Date.now() >= deadlineAtMs) {
reject(new Error("backend did not become reachable in time"));
return;
}
setTimeout(attempt, POLL_INTERVAL_MS);
});
req.setTimeout(2500, () => {
req.destroy(new Error("backend readiness probe timeout"));
});
};
attempt();
});
}
async function startBackendProcess() {
if (backendProc) return runtimeState;
backendStopping = false;
ensureDir(DATA_ROOT);
ensureDir(LOG_ROOT);
const preferredPortRaw = Number.parseInt(String(process.env.AIA_DESKTOP_BACKEND_PORT || ""), 10);
const preferredPort = Number.isFinite(preferredPortRaw) ? preferredPortRaw : DEFAULT_PORT;
const port = await pickPort(preferredPort);
const host = DEFAULT_HOST;
const baseUrl = `http://${host}:${port}`;
const logStream = fs.createWriteStream(BACKEND_LOG_FILE, { flags: "a" });
const pythonBin = resolvePythonBin();
const pythonOk = await checkPythonAvailable(pythonBin);
if (!pythonOk) {
logStream.end();
throw new Error(
`Python not found: ${pythonBin}. 请先安装 Python 3.10+,或设置环境变量 PYTHON_EXECUTABLE 指向可用解释器。`
);
}
const env = {
...process.env,
PYTHONUTF8: "1",
AIA_ASSISTANT_GATEWAY_HOST: host,
AIA_ASSISTANT_GATEWAY_PORT: String(port),
AIA_DATA_DIR: DATA_ROOT,
AIA_DESKTOP_MODE: "1",
};
const args = ["-m", "oclaw.ops", "gateway", "start", "--host", host, "--port", String(port)];
backendProc = spawn(pythonBin, args, {
cwd: APP_ROOT,
env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
runtimeState = { host, port, baseUrl, pythonBin };
backendProc.stdout.on("data", (chunk) => {
logStream.write(chunk);
});
backendProc.stderr.on("data", (chunk) => {
logStream.write(chunk);
});
backendProc.on("close", async (code, signal) => {
const msg = `[backend-exit] code=${code} signal=${signal || "none"}\n`;
logStream.write(msg);
logStream.end();
const crashed = !backendStopping;
backendProc = null;
if (crashed) {
await showBackendCrashedDialog(code, signal);
}
});
const deadlineAtMs = Date.now() + STARTUP_TIMEOUT_MS;
await waitForBackend(`${baseUrl}/health`, deadlineAtMs);
return runtimeState;
}
function waitForProcessHealthy(proc, deadlineAtMs) {
return new Promise((resolve, reject) => {
const check = () => {
if (!proc) {
resolve(false);
return;
}
if (proc.exitCode !== null) {
resolve(false);
return;
}
if (Date.now() >= deadlineAtMs) {
resolve(true);
return;
}
setTimeout(check, 250);
};
check();
});
}
function runPythonInline(pythonBin, code, extraEnv = {}) {
return new Promise((resolve) => {
const proc = spawn(pythonBin, ["-c", code], {
cwd: APP_ROOT,
env: { ...process.env, ...extraEnv },
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
let err = "";
proc.stdout.on("data", (c) => {
out += String(c || "");
});
proc.stderr.on("data", (c) => {
err += String(c || "");
});
proc.once("error", (e) => {
resolve({ ok: false, code: -1, out, err: `${err}\n${String(e && e.message ? e.message : e)}` });
});
proc.once("close", (code0) => {
resolve({ ok: code0 === 0, code: Number(code0 ?? -1), out, err });
});
});
}
async function startChannelProcess() {
if (channelProc) return true;
ensureDir(LOG_ROOT);
if (!runtimeState || !runtimeState.host || !runtimeState.port) {
throw new Error("runtime_state_missing");
}
const channelLogFile = path.join(LOG_ROOT, "channel-wecom.log");
const logStream = fs.createWriteStream(channelLogFile, { flags: "a" });
const pythonBin = resolvePythonBin();
// Kill stale/orphan WeCom workers first to avoid single-instance lock conflicts.
try {
const cleanupRes = await runPythonInline(
pythonBin,
"from oclaw.ops.runtime import cleanup_orphan_service_processes; k=cleanup_orphan_service_processes('channel:wecom'); print('killed=' + ','.join(str(x) for x in k))",
{ AIA_DATA_DIR: DATA_ROOT },
);
if (!cleanupRes.ok) {
logStream.write(`[channel-cleanup-warn] code=${cleanupRes.code} err=${String(cleanupRes.err || "").trim()}\n`);
} else {
const line = String(cleanupRes.out || "").trim();
if (line) logStream.write(`[channel-cleanup] ${line}\n`);
}
} catch (_) {}
const env = {
...process.env,
PYTHONUTF8: "1",
AIA_ASSISTANT_GATEWAY_HOST: String(runtimeState.host),
AIA_ASSISTANT_GATEWAY_PORT: String(runtimeState.port),
AIA_DATA_DIR: DATA_ROOT,
AIA_DESKTOP_MODE: "1",
};
const args = ["-m", "oclaw.ops", "channel", "wecom", "start", "--mode", "ws", "--interval", "3.0", "--deliver-outbound"];
channelStopping = false;
channelProc = spawn(pythonBin, args, {
cwd: APP_ROOT,
env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
channelProc.stdout.on("data", (chunk) => {
logStream.write(chunk);
});
channelProc.stderr.on("data", (chunk) => {
logStream.write(chunk);
});
channelProc.on("close", (code, signal) => {
logStream.write(`[channel-exit] code=${code} signal=${signal || "none"}\n`);
logStream.end();
const crashed = !channelStopping;
channelProc = null;
if (crashed) {
setTimeout(() => {
if (!quitAfterCleanup) {
startChannelProcess().catch(() => {});
}
}, 1200);
}
});
const healthy = await waitForProcessHealthy(channelProc, Date.now() + 2000);
if (!healthy) {
const msg = `[channel-start-warn] process_exit_${channelProc && channelProc.exitCode !== null ? channelProc.exitCode : "unknown"}\n`;
try {
fs.appendFileSync(BACKEND_LOG_FILE, msg, { encoding: "utf-8" });
} catch (_) {}
return false;
}
return true;
}
function waitProcessClose(proc, timeoutMs) {
return new Promise((resolve) => {
if (!proc || proc.exitCode !== null || proc.killed) {
resolve();
return;
}
let done = false;
const finish = () => {
if (done) return;
done = true;
resolve();
};
const timer = setTimeout(finish, Math.max(200, Number(timeoutMs) || 6000));
proc.once("close", () => {
clearTimeout(timer);
finish();
});
proc.once("exit", () => {
clearTimeout(timer);
finish();
});
});
}
function taskkillTree(pid) {
return new Promise((resolve) => {
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
windowsHide: true,
stdio: "ignore",
});
killer.once("error", () => resolve(false));
killer.once("close", (code) => resolve(code === 0));
});
}
async function stopBackendProcess() {
if (!backendProc) return;
const proc = backendProc;
backendStopping = true;
if (process.platform === "win32") {
const ok = await taskkillTree(proc.pid);
if (!ok) {
try {
proc.kill("SIGTERM");
} catch (_) {}
}
await waitProcessClose(proc, 8000);
return;
}
try {
proc.kill("SIGTERM");
} catch (_) {}
await waitProcessClose(proc, 5000);
if (proc.exitCode === null && !proc.killed) {
try {
proc.kill("SIGKILL");
} catch (_) {}
await waitProcessClose(proc, 3000);
}
}
async function stopChannelProcess() {
if (!channelProc) return;
const proc = channelProc;
channelStopping = true;
if (process.platform === "win32") {
const ok = await taskkillTree(proc.pid);
if (!ok) {
try {
proc.kill("SIGTERM");
} catch (_) {}
}
await waitProcessClose(proc, 6000);
return;
}
try {
proc.kill("SIGTERM");
} catch (_) {}
await waitProcessClose(proc, 4000);
}
async function showBackendCrashedDialog(code, signal) {
if (backendCrashDialogOpen) return;
backendCrashDialogOpen = true;
try {
const result = await dialog.showMessageBox({
type: "error",
title: "Backend stopped unexpectedly",
message: "本地后端进程已退出",
detail: `exit_code=${code ?? "unknown"}, signal=${signal || "none"}\n\n日志文件:${BACKEND_LOG_FILE}`,
buttons: ["重启后端", "打开日志目录", "退出应用"],
defaultId: 0,
cancelId: 2,
});
if (result.response === 0) {
await startBackendProcess();
if (mainWindow && !mainWindow.isDestroyed()) {
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat`);
}
return;
}
if (result.response === 1) {
shell.showItemInFolder(BACKEND_LOG_FILE);
await showBackendCrashedDialog(code, signal);
return;
}
app.quit();
} finally {
backendCrashDialogOpen = false;
}
}
function createMainWindow() {
mainWindow = new BrowserWindow({
title: APP_DISPLAY_NAME,
width: 1400,
height: 900,
minWidth: 1100,
minHeight: 700,
icon: APP_ICON_PATH,
show: false,
backgroundColor: "#0d0d0d",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
devTools: true,
},
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (String(url || "").startsWith(runtimeState?.baseUrl || "")) {
return { action: "allow" };
}
shell.openExternal(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const base = runtimeState?.baseUrl || "";
if (!base || !String(url).startsWith(base)) {
event.preventDefault();
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
}
async function showStartupError(error) {
const message = String(error && error.message ? error.message : error || "unknown startup failure");
await dialog.showMessageBox({
type: "error",
title: "Desktop startup failed",
message: "无法启动本地后端服务",
detail: `${message}\n\n日志文件:${BACKEND_LOG_FILE}`,
});
}
async function boot() {
try {
app.setName(APP_DISPLAY_NAME);
await startBackendProcess();
const channelOk = await startChannelProcess();
if (!channelOk && !channelStartWarningShown) {
channelStartWarningShown = true;
await dialog.showMessageBox({
type: "warning",
title: "Channel startup warning",
message: "企微通道启动失败(不影响桌面端启动)",
detail: "你可以在 Admin -> 运行时中查看并重试服务。",
});
}
createMainWindow();
try {
// Desktop policy: every restart requires explicit login.
// Clear persisted web storage before first page load.
await mainWindow.webContents.session.clearStorageData();
} catch (_) {}
// Desktop policy: always require fresh login on every app restart.
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`);
mainWindow.show();
} catch (error) {
await showStartupError(error);
app.quit();
}
}
ipcMain.handle("desktop:getRuntimeInfo", async () => {
const state = runtimeState || {};
return {
host: state.host || DEFAULT_HOST,
port: state.port || DEFAULT_PORT,
baseUrl: state.baseUrl || "",
logFile: BACKEND_LOG_FILE,
};
});
ipcMain.handle("desktop:restartBackend", async () => {
await stopChannelProcess();
await stopBackendProcess();
await startBackendProcess();
await startChannelProcess();
if (mainWindow && !mainWindow.isDestroyed()) {
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`);
}
return true;
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", (event) => {
if (quitAfterCleanup) return;
event.preventDefault();
quitAfterCleanup = true;
// Turn quit into graceful async shutdown so backend child tree is fully reaped.
(async () => {
await stopChannelProcess();
await stopBackendProcess();
app.quit();
})().catch(() => {
app.quit();
});
});
app.whenReady().then(boot);

5066
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

58
desktop/package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "oclaw",
"version": "1.0.0",
"description": "oclaw desktop shell for admin/chat UI",
"main": "main.js",
"scripts": {
"dev": "electron .",
"start": "electron .",
"prepare:icon": "node ./tools/generate-icon.cjs",
"pack:dir": "npm run prepare:icon && electron-builder --dir -c.win.signAndEditExecutable=false",
"pack:win": "npm run prepare:icon && electron-builder --win nsis -c.win.signAndEditExecutable=false"
},
"keywords": [
"electron",
"desktop",
"oclaw"
],
"author": "",
"license": "MIT",
"type": "commonjs",
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"electron": "^41.2.1",
"electron-builder": "^26.8.1",
"png-to-ico": "^3.0.1"
},
"build": {
"appId": "com.oclaw.desktop",
"productName": "oclaw",
"artifactName": "oclaw-${version}-${arch}.${ext}",
"directories": {
"output": "dist"
},
"files": [
"**/*",
"!dist/**",
"!node_modules/.cache/**"
],
"win": {
"icon": "assets/oclaw.ico",
"executableName": "oclaw",
"signAndEditExecutable": false,
"target": [
"nsis"
]
},
"nsis": {
"artifactName": "oclaw-setup-${version}.${ext}",
"menuCategory": "oclaw",
"shortcutName": "oclaw",
"createDesktopShortcut": "always",
"createStartMenuShortcut": true,
"uninstallDisplayName": "oclaw",
"installerIcon": "assets/oclaw.ico",
"uninstallerIcon": "assets/oclaw.ico"
}
}
}

6
desktop/preload.js Normal file
View file

@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("desktopBridge", {
getRuntimeInfo: () => ipcRenderer.invoke("desktop:getRuntimeInfo"),
restartBackend: () => ipcRenderer.invoke("desktop:restartBackend"),
});

View file

@ -0,0 +1,38 @@
const fs = require("node:fs");
const path = require("node:path");
const { Resvg } = require("@resvg/resvg-js");
const pngToIcoModule = require("png-to-ico");
const pngToIco = typeof pngToIcoModule === "function" ? pngToIcoModule : pngToIcoModule.default;
async function main() {
const desktopRoot = path.resolve(__dirname, "..");
const svgPath = path.resolve(desktopRoot, "..", "src", "admin", "static", "oliver.svg");
const assetsDir = path.join(desktopRoot, "assets");
const icoPath = path.join(assetsDir, "oclaw.ico");
if (!fs.existsSync(svgPath)) {
throw new Error(`logo not found: ${svgPath}`);
}
fs.mkdirSync(assetsDir, { recursive: true });
const svg = fs.readFileSync(svgPath, "utf8");
const sizes = [16, 24, 32, 48, 64, 128, 256];
const pngBuffers = [];
for (const size of sizes) {
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: size },
background: "rgba(0,0,0,0)",
});
const pngData = resvg.render().asPng();
pngBuffers.push(Buffer.from(pngData));
}
const ico = await pngToIco(pngBuffers);
fs.writeFileSync(icoPath, ico);
process.stdout.write(`Generated ${icoPath}\n`);
}
main().catch((err) => {
process.stderr.write(`${String(err && err.message ? err.message : err)}\n`);
process.exit(1);
});