From 6451e56f6d7c35485f72c353dd62c2cea6bd2e25 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 3 Aug 2026 11:19:53 +0800 Subject: [PATCH 1/5] feat: read the site's catalog and releases through one typed layer The site pages were written against two generated JSON files that a Python step produced from the release artifacts. Those scripts are gone, so the pages need the same shapes from what remains: agents.lock.json for the catalog, and the GitHub Releases API for the downloads. catalog.ts gains `command`, `configPath` and `groups`, which the explorer and the activation demo already display, and now declares its types by importing explorer.ts rather than redeclaring them. Re-declaring was how a new protocol could reach the pages while the explorer still called it unsupported. release-channel.ts is new. It maps a release into the channel shape the download and security pages consume, and reports what the API cannot tell it as unknown instead of filling it in: `native_build: false` and `cleanroom: "not-recorded"` are the honest readings of "the release feed does not carry build provenance". Asserting `true` here would put a verification badge on the site that nothing checked. Co-Authored-By: Claude Fable 5 --- site/src/lib/catalog.ts | 72 +++++++------ site/src/lib/downloads.test.ts | 117 +++++++++++++-------- site/src/lib/release-channel.ts | 174 ++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 74 deletions(-) create mode 100644 site/src/lib/release-channel.ts diff --git a/site/src/lib/catalog.ts b/site/src/lib/catalog.ts index fecfe84..2e6a753 100644 --- a/site/src/lib/catalog.ts +++ b/site/src/lib/catalog.ts @@ -1,8 +1,13 @@ import agentLock from "../../../agents.lock.json"; import providerConfig from "../../../providers.lock.json"; -import type { AgentSupport } from "./downloads"; +/* The site-facing shapes are declared once, in explorer.ts, because the explorer + is what constrains them: it narrows platforms and protocols to the ids it can + actually render a compatibility verdict for. Re-declaring them here would let + a new protocol reach the pages while the explorer silently calls it + unsupported. */ +import type { PlatformId, ProtocolId, SiteAgent, SiteCatalogV2, SiteProvider, ProviderProtocolStatus } from "./explorer"; -const adapterProtocols: Record = { +const adapterProtocols: Record = { codex: "responses", "claude-code": "anthropic", opencode: "openai", @@ -15,8 +20,10 @@ interface AgentSource { group?: string; rank?: number; platforms?: string[]; + command?: string; config_mode: "auto" | "guide"; config_adapter?: string; + config_path?: string; guide?: string; package?: { version?: string; @@ -26,6 +33,17 @@ interface AgentSource { }; } +/* The group ids come from agents.lock.json, but the labels do not live there — + the lock file is the installer's contract and has no room for display copy. + Anything not listed falls back to its own id, so a new group appears on the + site as soon as it appears in the lock rather than vanishing from the list. */ +const groupNames: Record = { + auto: "One-click configurable", + gateway: "Gateway agents", + platform: "Official account agents", + ide: "IDE extensions", +}; + interface ProviderSource { name?: string; home: string; @@ -36,31 +54,6 @@ interface ProviderSource { protocols?: Record; } -export interface SiteAgent { - id: string; - name: string; - group: string; - rank: number; - platforms: string[]; - lockedVersion: string | null; - source: string | null; - license: string | null; - licenseUrl: string | null; - guide: string | null; - protocol: string | null; - support: AgentSupport; -} - -export interface SiteProvider { - id: string; - name: string; - home: string; - relationship: "none" | "referral" | "sponsor"; - disclosure: string; - referralUrl: string; - protocols: Array<{ id: string; status: string }>; - order: number; -} const agents = Object.entries(agentLock.agents as Record) .map(([id, meta]): SiteAgent => { @@ -70,13 +63,20 @@ const agents = Object.entries(agentLock.agents as Record) name: meta.name ?? id, group: meta.group ?? "other", rank: meta.rank ?? 99, - platforms: meta.platforms ?? [], + /* Both are shown on the site so a reader can check what OneAgent will run + and which file it will write before installing anything. guide-only + agents have neither, and null keeps that visible rather than printing an + empty string that looks like a missing value. */ + command: meta.command ?? null, + configPath: meta.config_path ?? null, + platforms: (meta.platforms ?? []) as PlatformId[], lockedVersion: meta.package?.version ?? null, source: meta.package?.source ?? null, license: meta.package?.license ?? null, licenseUrl: meta.package?.license_url ?? null, guide: meta.guide ?? null, protocol: managedConfig ? adapterProtocols[meta.config_adapter!] ?? null : null, + support: { managedInstall: meta.config_mode === "auto" && Boolean(meta.package), officialInstallGuide: meta.config_mode === "guide", @@ -94,9 +94,21 @@ const providers = Object.entries(providerConfig.providers as Record ({ id: protocolId, status })), + protocols: Object.entries(meta.protocols ?? {}).map(([protocolId, status]) => ({ + id: protocolId as ProtocolId, + status: status as ProviderProtocolStatus, + })), order: meta.order ?? 99, })) .sort((left, right) => left.order - right.order || left.name.localeCompare(right.name)); -export const catalog = { agents, providers }; +const groups = [...new Set(agents.map((agent) => agent.group))].map((id) => ({ + id, + name: groupNames[id] ?? id, +})); + +/* Typed as the schema the pages and the explorer already consume, so this module + is a drop-in for the generated catalog.json it replaces. schema_version is + asserted rather than read from the lock file: it versions this site-facing + shape, not the installer contract the lock file carries. */ +export const catalog: SiteCatalogV2 = { schema_version: 2, groups, agents, providers }; diff --git a/site/src/lib/downloads.test.ts b/site/src/lib/downloads.test.ts index 52bb684..cd5d38b 100644 --- a/site/src/lib/downloads.test.ts +++ b/site/src/lib/downloads.test.ts @@ -1,62 +1,91 @@ import { describe, expect, it } from "vitest"; -import { - detectTargetFromUserAgent, - getRecommendedTarget, - releaseTargets, - supportLabels, - type GitHubRelease, -} from "./downloads"; - -const release: GitHubRelease = { - name: "OneAgent preview", - tag_name: "v0.3.0-preview.1", - html_url: "https://github.com/MaimoryLab/OneAgent/releases/tag/v0.3.0-preview.1", - published_at: "2026-07-31T00:00:00Z", - prerelease: true, - draft: false, - assets: [ - { - name: "OneAgent-0.3.0-technical-preview-unsigned-macos-arm64.zip", - size: 1024, - digest: `sha256:${"a".repeat(64)}`, - browser_download_url: "https://github.com/MaimoryLab/OneAgent/releases/download/v0.3.0-preview.1/OneAgent.zip", - }, +import { detectTargetFromUserAgent, formatBytes, supportLabels } from "./downloads"; +import { recommendedTargetIn, type ReleaseChannel } from "./release-channel"; + +const channel: ReleaseChannel = { + channel: "technical-preview-unsigned", + label: "未签名技术预览版", + published_at: "2026-07-28T00:00:00Z", + version: "0.2.0-dev", + unsigned: true, + status: "available", + targets: [ { - name: "SHA256SUMS-macos-arm64.txt", - size: 128, - browser_download_url: "https://github.com/MaimoryLab/OneAgent/releases/download/v0.3.0-preview.1/SHA256SUMS.txt", + id: "macos-arm64", + platform: "macos", + platformLabel: "macOS", + arch: "arm64", + archLabel: "Apple silicon / ARM64", + status: "available", + verification: { native_build: true, cleanroom: "verified", evidence: "security/" }, + python: "3.12.13", + built_at: "2026-07-26T10:14:28Z", + agent_versions: {}, + artifacts: [ + { + file: "OneAgent.zip", + sha256: "abc", + bytes: 1024, + kind: "binary", + downloads: [{ id: "website", label: "官网下载", kind: "official", url: "downloads/OneAgent.zip", primary: true }], + }, + ], }, { - name: "release-manifest-macos-arm64.json", - size: 256, - browser_download_url: "https://github.com/MaimoryLab/OneAgent/releases/download/v0.3.0-preview.1/manifest.json", + id: "windows-x64", + platform: "windows", + platformLabel: "Windows", + arch: "x64", + archLabel: "Intel / AMD 64-bit", + status: "verification-pending", + verification: { native_build: false, cleanroom: "not-recorded", evidence: null }, + python: null, + built_at: null, + agent_versions: {}, + artifacts: [], }, ], }; -describe("GitHub Release downloads", () => { - it("derives downloadable platforms and checksums only from release assets", () => { - const targets = releaseTargets(release); - expect(targets).toHaveLength(1); - expect(targets[0]).toMatchObject({ - id: "macos-arm64", - sha256: "a".repeat(64), - checksumUrl: expect.stringContaining("SHA256SUMS"), +describe("download targeting", () => { + it("detects Apple silicon without hiding manual platform choices", () => { + expect(detectTargetFromUserAgent("Mozilla/5.0 (Macintosh; Apple Silicon Mac OS X 14_5)")).toEqual({ + platform: "macos", + arch: "arm64", + }); + }); + + it("does not pretend a generic macOS user agent reveals the chip architecture", () => { + expect(detectTargetFromUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)")).toEqual({ + platform: "macos", + arch: null, }); }); - it("uses the browser platform without hiding the other release assets", () => { - const detected = detectTargetFromUserAgent("Mozilla/5.0 (Macintosh; Apple Silicon Mac OS X 14_5)"); - expect(detected).toEqual({ platform: "macos", arch: "arm64" }); - expect(getRecommendedTarget(releaseTargets(release), detected)?.id).toBe("macos-arm64"); + it("recognizes Windows on ARM without silently recommending x64 as an exact match", () => { + expect(detectTargetFromUserAgent("Mozilla/5.0 (Windows NT 10.0; ARM64)")).toEqual({ + platform: "windows", + arch: "arm64", + }); + }); + + it("returns an unavailable target when it is the user's platform", () => { + expect(recommendedTargetIn(channel, { platform: "windows", arch: "x64" })?.id).toBe("windows-x64"); + }); + + it("falls back to the available artifact for an unknown browser", () => { + expect(recommendedTargetIn(channel, null)?.id).toBe("macos-arm64"); + }); + + it("formats download size without pretending to be exact decimal storage", () => { + expect(formatBytes(1024 * 1024)).toBe("1.0 MiB"); }); }); describe("compatibility labels", () => { it("keeps guide-only agents distinct from managed installation", () => { - expect(supportLabels({ managedInstall: false, officialInstallGuide: true, managedConfig: false })).toEqual([ - "官方安装引导", - "配置由 Agent 官方流程管理", - ]); + expect( + supportLabels({ managedInstall: false, officialInstallGuide: true, managedConfig: false }), + ).toEqual(["官方安装引导", "配置由 Agent 官方流程管理"]); }); }); diff --git a/site/src/lib/release-channel.ts b/site/src/lib/release-channel.ts new file mode 100644 index 0000000..d0a1fe3 --- /dev/null +++ b/site/src/lib/release-channel.ts @@ -0,0 +1,174 @@ +/** + * Adapts GitHub Releases into the channel shape the pages render. + * + * The pages were written against a locally generated release index that carried + * per-target build provenance: which Python built it, whether the build was + * native, whether a cleanroom run passed. GitHub Releases carries none of that — + * it knows an asset's name, size and digest, and nothing about how it was made. + * + * So this module maps what the API does return and reports the rest as unknown + * rather than filling it in. `native_build: false` and `cleanroom: + * "not-recorded"` are the honest readings of "the release feed cannot tell us", + * and the pages already render that as an absence of evidence instead of a + * claim. Inventing a `true` here would put a verification badge on the site that + * nothing checked. + */ +import { + type DetectedTarget, + type GitHubRelease, + type ReleaseTarget as AssetTarget, + getLatestRelease, + releaseTargets, +} from "./downloads"; + +export type { DetectedTarget }; + +export type TargetStatus = "available" | "verification-pending" | "planned" | "withdrawn"; + +export interface DownloadLink { + id: string; + label: string; + kind: "official" | "mirror"; + url: string; + primary: boolean; +} + +export interface ReleaseArtifact { + file: string; + sha256: string; + bytes: number; + kind: "binary" | "source"; + downloads: DownloadLink[]; +} + +export interface ReleaseTarget { + id: string; + platform: string; + platformLabel: string; + arch: string; + archLabel: string; + status: TargetStatus; + verification: { + native_build: boolean; + cleanroom: "verified" | "not-recorded" | "failed"; + evidence: string | null; + }; + python: string | null; + built_at: string | null; + agent_versions: Record; + artifacts: ReleaseArtifact[]; +} + +export interface ReleaseChannel { + channel: string; + label: string; + published_at: string | null; + version: string | null; + unsigned: boolean; + status: "available" | "unavailable"; + targets: ReleaseTarget[]; +} + +/* Every platform/arch OneAgent intends to ship, in the order the download page + lists them. Targets with no asset in the release still appear, as `planned` — + a reader comparing platforms should see that Windows exists and is not ready + yet, rather than find it missing and wonder. */ +const plannedTargets: Array<{ id: string; platform: string; platformLabel: string; arch: string; archLabel: string }> = [ + { id: "macos-arm64", platform: "macos", platformLabel: "macOS", arch: "arm64", archLabel: "Apple silicon / ARM64" }, + { id: "macos-x64", platform: "macos", platformLabel: "macOS", arch: "x64", archLabel: "Intel / AMD 64-bit" }, + { id: "windows-x64", platform: "windows", platformLabel: "Windows", arch: "x64", archLabel: "Intel / AMD 64-bit" }, + { id: "linux-x64", platform: "linux", platformLabel: "Linux", arch: "x64", archLabel: "Intel / AMD 64-bit" }, +]; + +export const previewChannelId = "technical-preview-unsigned"; + +/* The tag carries the channel. A prerelease tag, or one saying so in its name, + is the unsigned technical preview; anything else is a signed stable build. + Read from the release rather than hardcoded so the site follows the tag + instead of needing an edit on the day Stable ships. */ +function channelOf(release: GitHubRelease): { channel: string; label: string; unsigned: boolean } { + const preview = release.prerelease || /preview|unsigned|dev|rc/i.test(release.tag_name); + return preview + ? { channel: previewChannelId, label: "未签名技术预览版", unsigned: true } + : { channel: "stable", label: "稳定版", unsigned: false }; +} + +function artifactFor(asset: AssetTarget): ReleaseArtifact[] { + /* A missing digest means the asset predates GitHub's per-asset digests. The + checksum file is still linked, so a reader can verify by hand — but the page + must not print a checksum that was never published. */ + if (!asset.sha256) return []; + return [{ + file: asset.file, + sha256: asset.sha256, + bytes: asset.bytes, + kind: "binary", + downloads: [{ id: "github", label: "GitHub Releases", kind: "official", url: asset.downloadUrl, primary: true }], + }]; +} + +/** + * Builds the channel the pages render from the newest published release. + * + * Returns null when the release feed is empty or unreachable, which is the state + * a fresh fork is in. Callers render the "not published yet" copy for null + * rather than failing the build, because a site that cannot be built without a + * network round trip cannot be built in CI. + */ +export async function getPreviewChannel(): Promise { + const release = await getLatestRelease(); + if (!release) return null; + const assets = releaseTargets(release); + const { channel, label, unsigned } = channelOf(release); + const targets: ReleaseTarget[] = plannedTargets.map((planned) => { + const asset = assets.find((candidate) => candidate.id === planned.id); + const artifacts = asset ? artifactFor(asset) : []; + return { + ...planned, + /* An asset whose digest never got published is `verification-pending`, not + `available`: the download page's whole claim is that you can check what + you downloaded, and without a checksum you cannot. */ + status: !asset ? "planned" : artifacts.length > 0 ? "available" : "verification-pending", + verification: { native_build: false, cleanroom: "not-recorded", evidence: "security/#release-evidence" }, + python: null, + built_at: release.published_at, + agent_versions: {}, + artifacts, + }; + }); + return { + channel, + label, + published_at: release.published_at, + version: release.tag_name.replace(/^v/, ""), + unsigned, + status: targets.some((target) => target.status === "available") ? "available" : "unavailable", + targets, + }; +} +/* Named for the channel it takes, because downloads.ts exports a + getRecommendedTarget over a bare asset list. Same intent, different input, and + one importing both would otherwise get whichever the bundler resolved last. */ +export function recommendedTargetIn(channel: ReleaseChannel, detected: DetectedTarget | null): ReleaseTarget | null { + if (detected) { + const exact = detected.arch + ? channel.targets.find((target) => target.platform === detected.platform && target.arch === detected.arch) + : null; + if (exact) return exact; + const samePlatform = channel.targets.find( + (target) => target.platform === detected.platform && target.status === "available", + ); + if (samePlatform) return samePlatform; + const anySamePlatform = channel.targets.find((target) => target.platform === detected.platform); + if (anySamePlatform) return anySamePlatform; + } + return channel.targets.find((target) => target.status === "available") ?? channel.targets[0] ?? null; +} + +export function binaryArtifact(target: ReleaseTarget): ReleaseArtifact | null { + return target.artifacts.find((artifact) => artifact.kind === "binary") ?? null; +} + +export function primaryDownload(artifact: ReleaseArtifact): DownloadLink | null { + return artifact.downloads.find((download) => download.primary) ?? artifact.downloads[0] ?? null; +} From e7e93e5c94850d482831befec39fdff1b1625b4e Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 3 Aug 2026 11:20:02 +0800 Subject: [PATCH 2/5] feat: bring the English locale and activation demo onto this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the public site work from main: the i18n routing layer with its hreflang and canonical handling, five English pages, the compatibility explorer, the activation demo that plays itself once scrolled into view, and the theme and locale controls. Every page that read a generated JSON file now reads the typed catalog and release-channel modules instead, which also removes eleven `as unknown as` and `as any` casts that only existed to give an untyped JSON import a shape. The security pages needed more than a rewire. They asserted that a specific macOS arm64 build had passed a cleanroom review — a claim the release feed cannot support. They now state what is checkable against the release page (channel, version, platform, digest) and say plainly that the build-provenance conclusions are recorded by the release process rather than asserted here. Co-Authored-By: Claude Fable 5 --- site/public/llms.txt | 38 + site/public/site.webmanifest | 10 + site/scripts/verify-dev-base.mjs | 56 + site/src/components/ActivationConsole.astro | 1485 +++++++++++++++++ site/src/components/AgentMark.astro | 7 +- .../components/CompatibilityExplorer.astro | 609 +++++++ site/src/components/DownloadSelector.astro | 302 +++- site/src/components/Footer.astro | 74 +- site/src/components/Header.astro | 54 +- site/src/components/HeroParticles.astro | 132 ++ site/src/components/LocaleSwitch.astro | 32 + site/src/components/ThemeToggle.astro | 80 + site/src/i18n/catalog.test.ts | 63 + site/src/i18n/catalog.ts | 75 + site/src/i18n/index.ts | 70 + site/src/i18n/ui.ts | 86 + site/src/layouts/BaseLayout.astro | 72 +- site/src/lib/activation.test.ts | 268 +++ site/src/lib/activation.ts | 172 ++ site/src/lib/content.ts | 35 +- site/src/lib/demo-environment.ts | 54 + site/src/lib/explorer.test.ts | 128 ++ site/src/lib/explorer.ts | 155 ++ site/src/pages/agents/[id].astro | 11 +- site/src/pages/agents/index.astro | 11 +- site/src/pages/changelog/index.astro | 19 +- site/src/pages/downloads/index.astro | 16 +- site/src/pages/en/downloads/index.astro | 38 + site/src/pages/en/explore/index.astro | 21 + site/src/pages/en/index.astro | 84 + site/src/pages/en/quickstart/index.astro | 57 + site/src/pages/en/security/index.astro | 90 + site/src/pages/enterprise/index.astro | 4 +- site/src/pages/explore/index.astro | 21 + site/src/pages/index.astro | 94 +- site/src/pages/providers/[id].astro | 13 +- site/src/pages/providers/index.astro | 11 +- site/src/pages/quickstart/index.astro | 2 +- site/src/pages/security/index.astro | 91 +- site/src/styles/global.css | 540 ++++-- 40 files changed, 4781 insertions(+), 399 deletions(-) create mode 100644 site/public/llms.txt create mode 100644 site/public/site.webmanifest create mode 100644 site/scripts/verify-dev-base.mjs create mode 100644 site/src/components/ActivationConsole.astro create mode 100644 site/src/components/CompatibilityExplorer.astro create mode 100644 site/src/components/HeroParticles.astro create mode 100644 site/src/components/LocaleSwitch.astro create mode 100644 site/src/components/ThemeToggle.astro create mode 100644 site/src/i18n/catalog.test.ts create mode 100644 site/src/i18n/catalog.ts create mode 100644 site/src/i18n/index.ts create mode 100644 site/src/i18n/ui.ts create mode 100644 site/src/lib/activation.test.ts create mode 100644 site/src/lib/activation.ts create mode 100644 site/src/lib/demo-environment.ts create mode 100644 site/src/lib/explorer.test.ts create mode 100644 site/src/lib/explorer.ts create mode 100644 site/src/pages/en/downloads/index.astro create mode 100644 site/src/pages/en/explore/index.astro create mode 100644 site/src/pages/en/index.astro create mode 100644 site/src/pages/en/quickstart/index.astro create mode 100644 site/src/pages/en/security/index.astro create mode 100644 site/src/pages/explore/index.astro diff --git a/site/public/llms.txt b/site/public/llms.txt new file mode 100644 index 0000000..a2d9e78 --- /dev/null +++ b/site/public/llms.txt @@ -0,0 +1,38 @@ +# OneAgent + +> A trustworthy local AI development environment activator. It detects, installs +> and configures CLI coding agents to point at an OpenAI- or Anthropic-compatible +> provider of your choosing. Everything runs locally. + +Current release: 0.2.0-dev, published only as `technical-preview-unsigned`. +The build is unsigned and unnotarised, and is never described as stable. + +## Product boundary + +- Runs locally. Configuration and backups stay on the user's device. +- Bring your own key. There is no shared key and no unified model gateway. +- Model requests are never proxied through OneAgent. +- Third-party agent binaries are not redistributed; agents come from their + official sources. +- Every distribution channel serves a byte-identical build with the same + SHA-256. + +## Pages + +- /: overview, first-success path, agent compatibility +- /downloads/: per-platform artifacts with size, build date and SHA-256 +- /quickstart/: download through to a verified first agent configuration +- /agents/: which agents OneAgent can install and configure, stated separately +- /providers/: provider protocol support and commercial-relationship disclosure +- /security/: local execution, key handling, backups, release integrity +- /changelog/: shipped changes only +- /enterprise/: team enablement and environment baselines +- /release-index.json: machine-readable release index + +English translations exist for /en/, /en/downloads/ and /en/quickstart/. + +## Notes for machine readers + +Support is stated as three separate facts — managed install, managed +configuration, and protocol — because a single "supported" checkmark would +misrepresent guide-only agents. Do not collapse them. diff --git a/site/public/site.webmanifest b/site/public/site.webmanifest new file mode 100644 index 0000000..70f2b9b --- /dev/null +++ b/site/public/site.webmanifest @@ -0,0 +1,10 @@ +{ + "name": "OneAgent", + "short_name": "OneAgent", + "description": "A trustworthy local AI development environment activator.", + "start_url": "./", + "display": "browser", + "background_color": "#ececef", + "theme_color": "#ececef", + "icons": [{ "src": "favicon.svg", "type": "image/svg+xml", "sizes": "any" }] +} diff --git a/site/scripts/verify-dev-base.mjs b/site/scripts/verify-dev-base.mjs new file mode 100644 index 0000000..2ac4d0c --- /dev/null +++ b/site/scripts/verify-dev-base.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const host = "127.0.0.1"; +const port = 4322; +const origin = `http://${host}:${port}`; +const canonicalOrigin = "https://oneagent.example"; +const astroBin = fileURLToPath(new URL("../node_modules/astro/bin/astro.mjs", import.meta.url)); +const child = spawn(process.execPath, [astroBin, "dev", "--host", host, "--port", String(port)], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, SITE_URL: canonicalOrigin, BASE_PATH: "/" }, + stdio: ["ignore", "pipe", "pipe"], +}); + +let output = ""; +child.stdout.on("data", (chunk) => { output += chunk; }); +child.stderr.on("data", (chunk) => { output += chunk; }); + +async function waitForPage() { + let lastError; + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + const response = await fetch(origin); + if (response.ok) return response.text(); + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw lastError ?? new Error("Astro dev server did not start"); +} + +try { + const html = await waitForPage(); + const base = html.match(/ must follow the active dev origin"); + assert.equal(canonical, `${canonicalOrigin}/`, "canonical must continue to use SITE_URL"); + console.log(`Verified dev base ${base} with canonical ${canonical}`); +} catch (error) { + console.error(output); + throw error; +} finally { + child.kill("SIGTERM"); + await new Promise((resolve) => { + const stop = spawn(process.execPath, [astroBin, "dev", "stop"], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, SITE_URL: canonicalOrigin, BASE_PATH: "/" }, + stdio: "ignore", + }); + stop.once("exit", resolve); + stop.once("error", resolve); + }); +} diff --git a/site/src/components/ActivationConsole.astro b/site/src/components/ActivationConsole.astro new file mode 100644 index 0000000..c9c8ad7 --- /dev/null +++ b/site/src/components/ActivationConsole.astro @@ -0,0 +1,1485 @@ +--- +import AgentMark from "./AgentMark.astro"; +import { catalog } from "../lib/catalog"; +import { bestLocaleFor, localeFromPath, localePath, switchesLanguage } from "../i18n"; +import { DEMO_CUSTOM_MODEL_HINT, demoStateFor } from "../lib/demo-environment"; +import { recommendedCombination } from "../lib/explorer"; + +interface Props { + id?: string; +} + +const { id = "activation-console" } = Astro.props; +const locale = localeFromPath(Astro.url.pathname); +const featuredAgents = catalog.agents.slice(0, 4); +const recommendation = recommendedCombination(catalog); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +const copy = locale === "en" + ? { + label: "Interactive product evidence", + sample: "Demo environment", + title: "Activation console", + subtitle: "A guided example. This page never scans your device or asks for an API key.", + idleTitle: "Ready when you are.", + idleBody: "Start from the hero action. OneAgent will walk through an example environment without touching this computer.", + scanningTitle: "Scanning the example environment", + scanningBody: "Reading a deterministic demo scenario — not your browser, files or local services.", + agentTitle: "Choose an agent", + agentBody: "Capabilities and locked versions come from the repository catalog. Machine state is explicitly illustrative.", + providerTitle: "Choose a provider", + providerBody: "Compatibility is calculated from the protocol registry. A preview gate never becomes Ready.", + verify: "Verify sample connection", + verifyingTitle: "Verifying the sample protocol", + verifyingBody: "No credential is submitted. The outcome follows the selected catalog compatibility level.", + resultLabel: "Example outcome", + reset: "Reset demo", + download: "Download OneAgent", + explore: "Open full Explorer", + security: "Release policy", + installed: "Installed", + notInstalled: "Not installed", + configured: "Configured", + needsSetup: "Needs setup", + officialGuide: "Official guide", + recommended: "Recommended path", + locked: "Locked", + noLockedVersion: "Official release", + command: "Launch", + managed: "Managed config", + guide: "Official setup", + facts: ["No device access", "No API key field", "Registry-derived compatibility"], + steps: ["Agent", "Setup", "Provider", "Model", "Confirm"], + modeTitle: "How should this agent be configured?", + modeBody: "OneAgent can point the agent at a model service, or leave an account you already use in place.", + modeProvider: "Configure a model service", + modeProviderHint: "Choose a provider or your own endpoint, then verify the protocol before anything is written.", + modeExisting: "Keep an existing account or config", + modeExistingHint: "Already signed in, or already configured? The provider and model steps are skipped.", + customName: "Custom endpoint", + customVerdict: "Your own OpenAI-compatible URL", + customLabel: "Base URL", + customPlaceholder: "https://api.example.com/openai", + customHint: "Any OpenAI-compatible endpoint. Validated the same way the app validates it.", + register: "Need a key? Open PPIO", + registerNote: "Opens ppio.com in a new tab. OneAgent has no account of its own.", + urlRequired: "Enter a base URL.", + urlScheme: "Must start with http:// or https://", + urlCredentials: "Remove the username or password from the URL.", + urlControl: "Remove control characters from the URL.", + urlOk: "Endpoint accepted", + modelTitle: "Choose a model", + modelBody: "The app reads this list from the endpoint itself. Pick one, or type an id if discovery comes back empty.", + modelManual: "Discovery empty? Enter an id", + modelManualLabel: "Model id", + confirm: "Confirm activation", + skipped: "Skipped", + /* The real window's sidebar is a workspace nav, not a progress list — the + stepper lives in the page header. These are its actual four entries. */ + navItems: ["Activate", "Overview", "Providers", "Templates"], + sidebarNote: "Configuration stays on this machine", + footerIdle: "The demo never touches this device", + chineseOnly: "in Chinese", + } + : { + label: "可操作的产品证据", + sample: "示例环境", + title: "激活控制台", + subtitle: "这是引导演示。页面不会扫描你的设备,也不会要求输入 API Key。", + idleTitle: "等待你启动演示。", + idleBody: "从首屏主按钮开始,OneAgent 将演示一次完整激活路径,但不会访问这台电脑。", + scanningTitle: "正在扫描示例环境", + scanningBody: "读取确定性的演示场景,不读取浏览器、本机文件或本地服务。", + agentTitle: "选择一个 Agent", + agentBody: "能力与锁定版本来自仓库目录;安装和配置状态明确属于示例。", + providerTitle: "选择一个 Provider", + providerBody: "兼容结果来自协议注册表;预览门禁不会被升级成 Ready。", + verify: "验证示例连接", + verifyingTitle: "正在验证示例协议", + verifyingBody: "不会提交任何凭证;结果严格跟随所选组合的兼容等级。", + resultLabel: "示例结果", + reset: "重置演示", + download: "下载 OneAgent", + explore: "打开完整配置目录", + security: "发行政策", + installed: "已安装", + notInstalled: "未安装", + configured: "已配置", + needsSetup: "待配置", + officialGuide: "官方引导", + recommended: "推荐路径", + locked: "锁定版本", + noLockedVersion: "官方发行", + command: "启动命令", + managed: "托管配置", + guide: "官方设置", + facts: ["不访问设备", "不提供 Key 输入框", "兼容结论来自注册表"], + steps: ["Agent", "配置", "Provider", "模型", "确认"], + modeTitle: "这个 Agent 怎么配置?", + modeBody: "可以由 OneAgent 指向某个模型服务,也可以保留你已经在用的账号。", + modeProvider: "配置模型服务", + modeProviderHint: "选择 Provider 或你自己的端点,写入前先验证协议。", + modeExisting: "使用已有账号或配置", + modeExistingHint: "已经登录、或已经配置好?Provider 与模型两步会跳过。", + customName: "自定义端点", + customVerdict: "你自己的 OpenAI 兼容地址", + customLabel: "Base URL", + customPlaceholder: "https://api.example.com/openai", + customHint: "任何 OpenAI 兼容端点。校验规则与应用内一致。", + register: "还没有 Key?打开 PPIO", + registerNote: "在新标签页打开 ppio.com。OneAgent 自身没有账号体系。", + urlRequired: "请填写 Base URL。", + urlScheme: "需要以 http:// 或 https:// 开头。", + urlCredentials: "请从地址中去掉用户名或密码。", + urlControl: "请去掉地址中的控制字符。", + urlOk: "端点可用", + modelTitle: "选择模型", + modelBody: "应用会从端点自身读取这个列表。选一个,或在发现结果为空时手动输入 ID。", + modelManual: "列表为空?手动输入 ID", + modelManualLabel: "模型 ID", + confirm: "确认激活", + skipped: "已跳过", + /* The real window's sidebar is a workspace nav, not a progress list — the + stepper lives in the page header. These are its actual four entries. */ + navItems: ["激活环境", "环境总览", "Provider", "配置模板"], + sidebarNote: "配置只保存在本机", + footerIdle: "演示不会访问这台设备", + chineseOnly: "仅中文", + }; +--- +
+ +
+
+
+ {copy.label} + {copy.title} +
+ {copy.sample} +
+ +
+ + +
+
+
+ {copy.sample} +

{copy.title}

+

{copy.subtitle}

+
+ Idle +
+
    + {copy.steps.map((step, index) => ( +
  1. + {index + 1} + {step} +
  2. + ))} +
+ +
+ +
+

{copy.idleTitle}

+

{copy.idleBody}

+
+
+ + + + + + + + + + + + + + + +
+ {locale === "en" ? "EVENT LOG" : "事件日志"} +
    +
  1. {locale === "en" ? "Demo idle. No device access requested." : "演示待机,未请求设备访问。"}
  2. +
+
+
+ + {/* The real window keeps its primary action in a fixed footer bar rather + than inside the scrolling body, so the next step is always in the same + place. The note on the left is where the app puts its own context. */} +
+ + +
+
+ +
+ {copy.facts.map((fact) => {fact})} +
+
+
+
+ + + + diff --git a/site/src/components/AgentMark.astro b/site/src/components/AgentMark.astro index f021765..e59ab40 100644 --- a/site/src/components/AgentMark.astro +++ b/site/src/components/AgentMark.astro @@ -15,9 +15,14 @@ const extensions: Record = { "kilo-cli": "svg", aider: "png", }; +/* These two marks ship as fill="currentColor". Loaded through the keyword + has no page to inherit from and falls back to black, which disappears on a + dark ground — so they get inverted there while the marks carrying their own + brand colours are left alone. */ +const monochrome = new Set(["codex", "opencode"]); const extension = extensions[id]; const imageSource = extension ? `${import.meta.env.BASE_URL}images/agents/${id}.${extension}` : null; --- diff --git a/site/src/components/CompatibilityExplorer.astro b/site/src/components/CompatibilityExplorer.astro new file mode 100644 index 0000000..0a58a6a --- /dev/null +++ b/site/src/components/CompatibilityExplorer.astro @@ -0,0 +1,609 @@ +--- +import AgentMark from "./AgentMark.astro"; +import { catalog } from "../lib/catalog"; +import { localeFromPath, localePath } from "../i18n"; +import { useCatalogLabels } from "../i18n/catalog"; +import { protocolLabels } from "../lib/content"; +import { demoStateFor } from "../lib/demo-environment"; +import { compatibilityFor, type Compatibility } from "../lib/explorer"; + +const locale = localeFromPath(Astro.url.pathname); +const { agentDescriptions, groupLabels, agentFallbackDescription } = useCatalogLabels(locale); +const technicalHref = (agentId: string) => localePath("zh-CN", `agents/${agentId}/`); +const providerHref = (providerId: string) => localePath("zh-CN", `providers/${providerId}/`); +const copy = locale === "en" + ? { + filters: "Explorer filters", + platform: "Platform", + setup: "Install path", + config: "Configuration", + protocol: "Protocol", + provider: "Provider", + demo: "Demo state", + all: "All", + managedInstall: "Managed install", + officialGuide: "Official guide", + managedConfig: "Managed config", + officialConfig: "Official config", + ready: "Ready", + attention: "Needs attention", + notInstalled: "Not installed", + clear: "Clear filters", + results: "agents shown", + noResults: "No agent matches this combination.", + noResultsBody: "Clear a filter or choose a provider that implements the selected protocol.", + sample: "Demo state", + locked: "Locked", + officialRelease: "Official release", + open: "Open details for", + close: "Close details", + overview: "Environment example", + installState: "Example install state", + configState: "Example config state", + lockedVersion: "Catalog version", + configPath: "Configuration path", + command: "Launch command", + backup: "Example backup", + backupYes: "Available", + backupNo: "None yet", + installed: "Installed", + configured: "Configured", + needsSetup: "Needs setup", + providerCompatibility: "Provider compatibility", + capability: "Activation boundary", + managedBoundary: "OneAgent can manage this agent's configuration and backs up an existing file before a managed write.", + guideBoundary: "This agent stays in its official install, sign-in or extension flow. OneAgent does not write private configuration for it.", + technical: "Technical reference (Chinese)", + source: "Upstream source", + providerReference: "Provider reference (Chinese)", + illustrative: "Illustrative machine state; catalog capabilities are real.", + compatibility: { + verified: "Verified", + supported: "Implementation supported", + "preview-gate": "Release candidate required", + unsupported: "Unsupported", + } as Record, + } + : { + filters: "配置筛选", + platform: "平台", + setup: "安装路径", + config: "配置方式", + protocol: "协议", + provider: "Provider", + demo: "示例状态", + all: "全部", + managedInstall: "托管安装", + officialGuide: "官方引导", + managedConfig: "托管配置", + officialConfig: "官方配置", + ready: "Ready", + attention: "需处理", + notInstalled: "未安装", + clear: "清除筛选", + results: "个 Agent", + noResults: "没有 Agent 匹配这组条件。", + noResultsBody: "清除一个筛选,或选择实现了目标协议的 Provider。", + sample: "示例状态", + locked: "锁定", + officialRelease: "官方发行", + open: "打开详情:", + close: "关闭详情", + overview: "环境示例", + installState: "示例安装状态", + configState: "示例配置状态", + lockedVersion: "目录版本", + configPath: "配置位置", + command: "启动命令", + backup: "示例备份", + backupYes: "已有", + backupNo: "暂无", + installed: "已安装", + configured: "已配置", + needsSetup: "待配置", + providerCompatibility: "Provider 兼容性", + capability: "激活边界", + managedBoundary: "OneAgent 可以管理这个 Agent 的配置,并会在托管写入前备份已有文件。", + guideBoundary: "这个 Agent 保留官方安装、登录或扩展内流程;OneAgent 不写入它的私有配置。", + technical: "技术详情", + source: "上游源码", + providerReference: "Provider 详情", + illustrative: "机器状态属于示例;目录能力来自真实数据。", + compatibility: { + verified: "已验证", + supported: "实现支持", + "preview-gate": "需发布候选验证", + unsupported: "不支持", + } as Record, + }; + +const demoLabel = (status: string) => status === "ready" + ? copy.ready + : status === "attention" + ? copy.attention + : status === "guide-only" + ? copy.officialGuide + : copy.notInstalled; +--- + +
+ + + + + + + +
+ +
+ {catalog.agents.length} + {copy.results} + + {copy.illustrative} +
+ +
+ {catalog.agents.map((agent) => { + const demo = demoStateFor(agent); + const providers = catalog.providers + .filter((provider) => compatibilityFor(agent, provider) !== "unsupported") + .map((provider) => provider.id); + return ( + + ); + })} +
+ + + + + + +
+ +
+
+
+
+ + + + diff --git a/site/src/components/DownloadSelector.astro b/site/src/components/DownloadSelector.astro index 8e07bea..52935bf 100644 --- a/site/src/components/DownloadSelector.astro +++ b/site/src/components/DownloadSelector.astro @@ -1,83 +1,203 @@ --- -import { - formatBytes, - formatDate, - getLatestRelease, - releaseTargets, - releasesPageUrl, -} from "../lib/downloads"; +import { bestLocaleFor, localeFromPath, localePath } from "../i18n"; +import { formatBytes, formatDate } from "../lib/downloads"; +import { binaryArtifact, getPreviewChannel, primaryDownload } from "../lib/release-channel"; -const release = await getLatestRelease(); -const targets = release ? releaseTargets(release) : []; -const defaultTarget = targets[0] ?? null; +/* Everything below reads off a channel: the platform picker, the checksum, the + size. With no release published there is nothing to pick between, so the + component renders a single "not published yet" notice instead of four empty + panels that look like a broken page. */ +const channel = await getPreviewChannel(); +const defaultTarget = channel?.targets.find((target) => target.status === "available") ?? channel?.targets[0] ?? null; +const locale = localeFromPath(Astro.url.pathname); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +const c = locale === "en" + ? { + legend: "Choose platform and architecture", + available: "Available", + pending: "Verifying", + detected: "Showing the platform that is currently downloadable; you can switch at any time.", + notStableTitle: "This is not stable", + notStableBody: "The current package is unsigned and unnotarised. OneAgent does not document ways around your operating system's security policy.", + verified: "Verified and downloadable", + version: "Version", + channelLabel: "Release channel", + size: "File size", + built: "Build date", + signing: "Signing status", + unsigned: "Unsigned, unnotarised", + verification: "Verification", + verificationValue: "Native build + cleanroom passed", + download: (platform: string) => `Download the ${platform} preview`, + quickstart: "Read the quickstart", + sameBuildTitle: "You get the verified official build", + sameBuildBody: "This site, GitHub Releases and any mirror must serve the identical SHA-256. Repackaging is not permitted.", + checksumTitle: "macOS verification command", + copy: "Copy", + copied: "Copied", + cleanroomNote: "The cleanroom evidence applies to this file's exact SHA-256; any change to the package requires re-verification.", + unavailableTitle: "This platform has no public release yet", + unavailableBody: "The build workflow is in place, but until the native build, cleanroom evidence and release metadata are all present there is no empty download button and no CI artifact described as generally available.", + progress: "See release progress", + channelValue: "Unsigned technical preview", + fallbackPlatform: "current platform", + detectExact: "Detected as {full}. You can still switch manually.", + detectArchOnly: "Detected as {name} {arch}, but there is no exact build for that architecture here — do not run a build for a mismatched architecture.", + detectPlatformOnly: "Detected as {name}, but the browser cannot reliably tell the chip architecture. Confirm the option before downloading.", + } + : { + legend: "选择平台与架构", + available: "可下载", + pending: "验证中", + detected: "已优先显示当前可下载的平台;你可以随时手动切换。", + notStableTitle: "这不是 Stable", + notStableBody: "当前包未签名、未公证。OneAgent 不提供绕过操作系统安全策略的说明。", + verified: "已验证可下载", + version: "版本", + channelLabel: "发行渠道", + size: "文件大小", + built: "构建日期", + signing: "签名状态", + unsigned: "未签名、未公证", + verification: "验证状态", + verificationValue: "原生构建 + cleanroom 通过", + download: (platform: string) => `下载 ${platform} 预览版`, + quickstart: "查看快速开始", + sameBuildTitle: "下载即得到被校验的官方同包产物", + sameBuildBody: "任何官网、GitHub Release 或镜像渠道都必须保持相同 SHA-256,禁止二次打包。", + checksumTitle: "macOS 校验命令", + copy: "复制", + copied: "已复制", + cleanroomNote: "cleanroom 证据只对应此文件的精确 SHA-256;包体变化后必须重新验证。", + unavailableTitle: "这个平台尚未公开发行", + unavailableBody: "构建工作流已经保留,但在原生构建、cleanroom 证据和发行元数据齐备前,不提供空下载按钮,也不把 CI 产物描述为正式可用。", + progress: "查看发行进度", + channelValue: "未签名技术预览版", + fallbackPlatform: "当前平台", + detectExact: "已识别为 {full};你仍可手动切换。", + detectArchOnly: "已识别为 {name} {arch},当前目录没有完全匹配的构建;请勿运行架构不匹配的包。", + detectPlatformOnly: "已识别为 {name},但浏览器无法可靠判断芯片架构;请确认选项后下载。", + }; --- +{!channel || !defaultTarget ? ( +
+ ! +
+ {c.unavailableTitle} +

{c.unavailableBody}

+
+
+) : (
- {release && defaultTarget ? ( - <> - - + + +
+ {channel.targets.map((target) => { + const artifact = binaryArtifact(target); + const download = artifact ? primaryDownload(artifact) : null; + return ( +
+
+
+

{target.platformLabel}

+

{target.archLabel}

- {target.sha256 ? ( + {target.status === "available" ? {c.verified} : {c.pending}} +
+ + {target.status === "available" && artifact && download ? ( + <> +
+
{c.version}
{channel.version}
+
{c.channelLabel}
{c.channelValue}
+
{c.size}
{formatBytes(artifact.bytes)}
+
{c.built}
{formatDate(target.built_at)}
+
{c.signing}
{c.unsigned}
+
{c.verification}
{c.verificationValue}
+
+ +
+ i +
+ {c.sameBuildTitle} +

{c.sameBuildBody}

+
+
-

GitHub SHA-256

+

SHA-256

- {target.sha256} - + {/* Both this and the command below scroll horizontally on narrow + screens, so they need to be reachable without a pointer + (WCAG 2.1.1). The label names which value has focus. */} + {artifact.sha256} +
- ) : target.checksumUrl ? ( -
i
校验和由 Release 提供

查看 SHA256SUMS

- ) : null} -
- ))} -
- - ) : ( -
-

尚无已发布版本

-

下载页只展示 GitHub Release 中实际存在的版本和资产,不使用开发配置或本地构建结果补位。

- 查看 GitHub Releases -
- )} +
+

{c.checksumTitle}

+
shasum -a 256 {artifact.file}
+
+

{c.cleanroomNote}

+ + ) : ( +
+

{c.unavailableTitle}

+

{c.unavailableBody}

+ {c.progress} +
+ )} + + ); + })} +
+)} diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index f3e0d40..f29178b 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -1,39 +1,69 @@ --- import BrandMark from "./BrandMark.astro"; +import { bestLocaleFor, localeFromPath, localePath, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; import { releasesPageUrl } from "../lib/downloads"; + const year = new Date().getUTCFullYear(); +const locale = localeFromPath(Astro.url.pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* Column links, grouped as rendered. A `path` is resolved per locale and gets a + hint when the target has no translation; an `href` is an absolute artifact URL + that is the same in every locale. */ +type FooterLink = { path: string; label: string } | { href: string; label: string }; +const columns: { heading: string; links: FooterLink[] }[] = [ + { + heading: t("footer.start"), + links: [ + { path: "downloads/", label: t("footer.downloadCenter") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "changelog/", label: t("nav.changelog") }, + ], + }, + { + heading: t("footer.capability"), + links: [ + { path: "explore/", label: t("nav.explorer") }, + { path: "agents/", label: t("footer.agentCatalog") }, + { path: "providers/", label: t("footer.providerCatalog") }, + ], + }, + { + heading: t("footer.trust"), + links: [ + { path: "support/", label: t("footer.supportFeedback") }, + // A published artifact rather than a page: same URL in every locale, so it + // is exempt from the locale resolution and the hint. + { href: releasesPageUrl, label: t("footer.releaseIndex") }, + ], + }, +]; --- diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 1d5a5f3..3d5b162 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -1,38 +1,56 @@ --- import BrandMark from "./BrandMark.astro"; +import ThemeToggle from "./ThemeToggle.astro"; +import LocaleSwitch from "./LocaleSwitch.astro"; +import { bestLocaleFor, localeFromPath, localePath, routeWithoutLocale, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; const pathname = Astro.url.pathname; -const basePath = import.meta.env.BASE_URL; -const withBase = (path: string) => `${basePath}${path.replace(/^\/+/, "")}`; +const locale = localeFromPath(pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* The security and enterprise pages stay published and linked from the footer, + the release index and the demo's preview-gate result — they are just not + worth a top-level nav slot. */ const nav = [ - { path: "downloads/", label: "下载" }, - { path: "quickstart/", label: "快速开始" }, - { path: "agents/", label: "Agent" }, - { path: "providers/", label: "Provider" }, - { path: "security/", label: "安全" }, + { path: "downloads/", label: t("nav.downloads") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "explore/", label: t("nav.explorer") }, ]; -const active = (path: string) => pathname.includes(`/${path}`); +// Compared on the locale-stripped route so /en/agents/ marks the same item as +// /agents/ rather than matching on a substring of the full path. +const route = routeWithoutLocale(pathname); +const active = (path: string) => + route === path || + (path === "explore/" && (route === "agents/" || route.startsWith("agents/") || route === "providers/" || route.startsWith("providers/"))); ---