mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
266 lines
8.6 KiB
JavaScript
266 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
readdir,
|
|
readFile,
|
|
readlink,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
unlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
|
const desktopRoot = path.resolve(__dirname, "..");
|
|
const engineDest = path.resolve(
|
|
process.env.NANOBOT_ENGINE_DEST ?? path.join(desktopRoot, "resources", "nanobot-engine"),
|
|
);
|
|
const pythonVersion = process.env.NANOBOT_DESKTOP_PYTHON_VERSION ?? "3.12";
|
|
const githubBase = "https://github.com/astral-sh/python-build-standalone";
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
stdio: "inherit",
|
|
...options,
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command} ${args.join(" ")} failed`);
|
|
}
|
|
}
|
|
|
|
async function download(url, destination) {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"User-Agent": "nanobot/desktop-build",
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`failed to download ${url}: HTTP ${response.status}`);
|
|
}
|
|
await writeFile(destination, Buffer.from(await response.arrayBuffer()));
|
|
}
|
|
|
|
async function fetchText(url) {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"User-Agent": "nanobot/desktop-build",
|
|
"Accept": "text/html",
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
|
|
}
|
|
return await response.text();
|
|
}
|
|
|
|
function targetTriple() {
|
|
const requested = process.env.NANOBOT_DESKTOP_ARCH ?? process.arch;
|
|
if (requested === "arm64" || requested === "aarch64") return "aarch64-apple-darwin";
|
|
if (requested === "x64" || requested === "x86_64") return "x86_64-apple-darwin";
|
|
throw new Error(`unsupported desktop engine arch: ${requested}`);
|
|
}
|
|
|
|
function latestReleaseTag(html) {
|
|
const match = html.match(/\/astral-sh\/python-build-standalone\/releases\/tag\/(\d{8})/);
|
|
if (!match) {
|
|
throw new Error("could not find latest python-build-standalone release tag");
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
async function defaultStandaloneUrl() {
|
|
const release =
|
|
process.env.PYTHON_STANDALONE_RELEASE
|
|
?? latestReleaseTag(await fetchText(`${githubBase}/releases`));
|
|
const triple = targetTriple();
|
|
const assetHtml = await fetchText(`${githubBase}/releases/expanded_assets/${release}`);
|
|
const escapedVersion = pythonVersion.replace(".", "\\.");
|
|
const assetPattern = new RegExp(
|
|
`cpython-${escapedVersion}\\.\\d+\\+${release}-${triple}-install_only\\.tar\\.gz`,
|
|
);
|
|
const asset = assetHtml.match(assetPattern)?.[0];
|
|
if (!asset) {
|
|
throw new Error(
|
|
`could not find a CPython ${pythonVersion} install_only asset for ${triple} in ${release}`,
|
|
);
|
|
}
|
|
return `${githubBase}/releases/download/${release}/${asset}`;
|
|
}
|
|
|
|
async function walk(dir, matches = []) {
|
|
for (const entry of await readdir(dir)) {
|
|
const fullPath = path.join(dir, entry);
|
|
const info = await stat(fullPath);
|
|
if (info.isDirectory()) {
|
|
await walk(fullPath, matches);
|
|
} else if (entry === "python3" || entry === "python") {
|
|
matches.push(fullPath);
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
async function findStandaloneRoot(extractDir) {
|
|
const candidates = await walk(extractDir);
|
|
for (const candidate of candidates) {
|
|
const normalized = candidate.split(path.sep).join("/");
|
|
if (normalized.endsWith("/install/bin/python3")) {
|
|
return path.dirname(path.dirname(candidate));
|
|
}
|
|
}
|
|
for (const candidate of candidates) {
|
|
const parent = path.dirname(candidate);
|
|
if (path.basename(parent) === "bin") {
|
|
return path.dirname(parent);
|
|
}
|
|
}
|
|
throw new Error("could not find python-build-standalone bin/python3 in extracted archive");
|
|
}
|
|
|
|
function isInside(parent, child) {
|
|
const relative = path.relative(parent, child);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
async function rewriteInternalSymlinks(root, sourceRoot) {
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(root, entry.name);
|
|
const info = await lstat(fullPath);
|
|
if (info.isSymbolicLink()) {
|
|
const target = await readlink(fullPath);
|
|
if (!path.isAbsolute(target) || !isInside(sourceRoot, target)) {
|
|
continue;
|
|
}
|
|
const targetInBundle = path.join(engineDest, path.relative(sourceRoot, target));
|
|
const relativeTarget = path.relative(path.dirname(fullPath), targetInBundle);
|
|
await unlink(fullPath);
|
|
await symlink(relativeTarget, fullPath);
|
|
} else if (entry.isDirectory()) {
|
|
await rewriteInternalSymlinks(fullPath, sourceRoot);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function assertNoExternalSymlinks(root) {
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(root, entry.name);
|
|
const info = await lstat(fullPath);
|
|
if (info.isSymbolicLink()) {
|
|
const target = await readlink(fullPath);
|
|
if (path.isAbsolute(target) && !isInside(engineDest, target)) {
|
|
throw new Error(`external symlink left in engine bundle: ${fullPath} -> ${target}`);
|
|
}
|
|
} else if (entry.isDirectory()) {
|
|
await assertNoExternalSymlinks(fullPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function tarSupportsZstd() {
|
|
const result = spawnSync("tar", ["--help"], { encoding: "utf8" });
|
|
return `${result.stdout}\n${result.stderr}`.includes("zstd");
|
|
}
|
|
|
|
async function extractArchive(archive, destination) {
|
|
await mkdir(destination, { recursive: true });
|
|
if (archive.endsWith(".tar.zst") && !(await tarSupportsZstd())) {
|
|
throw new Error("tar.zst archives require a tar build with zstd support");
|
|
}
|
|
run("tar", ["-xf", archive, "-C", destination]);
|
|
}
|
|
|
|
async function resolveArchive() {
|
|
const localArchive = process.env.PYTHON_STANDALONE_TARBALL;
|
|
if (localArchive) {
|
|
return { archive: path.resolve(localArchive), cleanupArchive: false };
|
|
}
|
|
|
|
const url = process.env.PYTHON_STANDALONE_URL ?? await defaultStandaloneUrl();
|
|
const suffix = url.endsWith(".tar.gz") ? ".tar.gz" : path.extname(url);
|
|
const downloadPath = path.join(tmpdir(), `nanobot-python-${Date.now()}${suffix}`);
|
|
console.log(`Downloading Python runtime from ${url}`);
|
|
await download(url, downloadPath);
|
|
return { archive: downloadPath, cleanupArchive: true };
|
|
}
|
|
|
|
async function installNanobot(pythonPath) {
|
|
run(pythonPath, ["-m", "ensurepip", "--upgrade"]);
|
|
run(pythonPath, ["-m", "pip", "install", "--upgrade", "pip"]);
|
|
|
|
const installArgs = ["-m", "pip", "install", "--upgrade"];
|
|
const wheelhouse = process.env.NANOBOT_WHEELHOUSE;
|
|
if (wheelhouse) {
|
|
installArgs.push("--no-index", "--find-links", path.resolve(wheelhouse));
|
|
}
|
|
installArgs.push(`${repoRoot}[api]`);
|
|
run(pythonPath, installArgs);
|
|
}
|
|
|
|
async function writeManifest(pythonPath) {
|
|
const version = spawnSync(pythonPath, ["--version"], { encoding: "utf8" });
|
|
const pyproject = await readFile(path.join(repoRoot, "pyproject.toml"), "utf8");
|
|
const match = pyproject.match(/^version\s*=\s*"([^"]+)"/m);
|
|
await writeFile(
|
|
path.join(engineDest, "nanobot-engine.json"),
|
|
JSON.stringify(
|
|
{
|
|
python: version.stdout.trim() || version.stderr.trim(),
|
|
nanobot_version: match?.[1] ?? "unknown",
|
|
prepared_at: new Date().toISOString(),
|
|
source: "python-build-standalone",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
if (process.argv.includes("--print-runtime-url")) {
|
|
console.log(await defaultStandaloneUrl());
|
|
return;
|
|
}
|
|
|
|
const { archive, cleanupArchive } = await resolveArchive();
|
|
const extractDir = path.join(tmpdir(), `nanobot-engine-${Date.now()}`);
|
|
try {
|
|
await rm(extractDir, { recursive: true, force: true });
|
|
await extractArchive(archive, extractDir);
|
|
|
|
const standaloneRoot = await findStandaloneRoot(extractDir);
|
|
await rm(engineDest, { recursive: true, force: true });
|
|
await mkdir(path.dirname(engineDest), { recursive: true });
|
|
await cp(standaloneRoot, engineDest, { recursive: true });
|
|
await rewriteInternalSymlinks(engineDest, standaloneRoot);
|
|
await assertNoExternalSymlinks(engineDest);
|
|
|
|
const pythonPath = path.join(engineDest, "bin", "python3");
|
|
await installNanobot(pythonPath);
|
|
await writeManifest(pythonPath);
|
|
await writeFile(path.join(engineDest, ".gitkeep"), "", "utf8");
|
|
console.log(`Prepared nanobot desktop engine at ${engineDest}`);
|
|
} finally {
|
|
await rm(extractDir, { recursive: true, force: true });
|
|
if (cleanupArchive) {
|
|
await rm(archive, { force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|