feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* 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>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user