diff --git a/.changeset/calm-agents-monitor.md b/.changeset/calm-agents-monitor.md new file mode 100644 index 00000000..424f8233 --- /dev/null +++ b/.changeset/calm-agents-monitor.md @@ -0,0 +1,6 @@ +--- +"@workspace/web": patch +"@workspace/lib": patch +--- + +Operate local website onboarding, one-shot browser collection, shared drafts and monitoring through MCP with durable operation history. diff --git a/.changeset/honest-early-dashboard.md b/.changeset/honest-early-dashboard.md new file mode 100644 index 00000000..0dbaf518 --- /dev/null +++ b/.changeset/honest-early-dashboard.md @@ -0,0 +1,5 @@ +--- +"@workspace/web": patch +--- + +Show early results as a neutral baseline, stop trends from drawing days before the first collection, and reorganise Actions & retests. diff --git a/.changeset/near-black-workspace.md b/.changeset/near-black-workspace.md new file mode 100644 index 00000000..2bc36a94 --- /dev/null +++ b/.changeset/near-black-workspace.md @@ -0,0 +1,5 @@ +--- +"@workspace/web": patch +--- + +Restyle the dashboard with a near-black analytics palette, inline date-range tabs, larger page titles and BeatAPI's faint code texture. diff --git a/.changeset/real-workflow-case.md b/.changeset/real-workflow-case.md new file mode 100644 index 00000000..58bb4984 --- /dev/null +++ b/.changeset/real-workflow-case.md @@ -0,0 +1,5 @@ +--- +"@workspace/web": patch +--- + +Show a bilingual BeatAPI workflow case with verified results, a downloadable summary and recording material. diff --git a/README.de.md b/README.de.md index 5a11f4e4..8516a9a9 100644 --- a/README.de.md +++ b/README.de.md @@ -10,6 +10,10 @@ Ein quelloffener GEO-/AEO-Arbeitsbereich von [BeatAPI](https://beatapi.io). Verf [Website](https://beatapi.io/geo) · [Schnellstart](#schnellstart) · [Ablauf](#ablauf) · [Funktionsstand](#funktionsstand) · [Roadmap](docs/roadmap.md) · [MIT-Lizenz](LICENSE.md) +[![44-Sekunden-Demo von BeatGEO ansehen](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4) + +Echte Bildschirme aus einem aufgezeichneten BeatAPI-Test vom 2026-10-22, als Rückblick und nicht als Live-Lauf. [Fallnotizen auf Chinesisch](docs/beat-geo/cases/beatapi-agent/README.md) + > **Lokales MVP / Quellcode-Vorschau.** Die Kette ChatGPT-Erfassung → Datenbank → Worker wurde in Live-Smoke-Tests geprüft. Andere Engines, kostenpflichtige Recherche und unbeaufsichtigter Dauerbetrieb haben eigene Prüfgrenzen. [Prüfprotokoll auf Englisch](docs/beat-geo/validation.md) ## Von der Website zu wiederholbaren Fragen diff --git a/README.ja.md b/README.ja.md index bc5d1afe..3f3c1ba8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -10,6 +10,10 @@ [公式サイト](https://beatapi.io/geo) · [クイックスタート](#クイックスタート) · [ワークフロー](#ワークフロー) · [対応状況](#対応状況) · [ロードマップ](docs/roadmap.md) · [MIT ライセンス](LICENSE.md) +[![44秒の BeatGEO デモを見る](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4) + +2026-10-22 に記録した BeatAPI 実測の実画面をまとめた振り返りで、ライブ実行ではありません。[ケース記録(中国語)](docs/beat-geo/cases/beatapi-agent/README.md) + > **ローカル MVP / ソースプレビュー版。** ChatGPT の収集 → データベース → worker は実環境のスモークテストを通過しています。他のエンジン、有料リサーチ、長期間の無人運用は別途検証が必要です。[検証記録(英語)](docs/beat-geo/validation.md) ## Web サイトから、継続して測定できる質問へ diff --git a/README.ko.md b/README.ko.md index 1ef22029..75521ce5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,6 +10,10 @@ [공식 웹사이트](https://beatapi.io/geo) · [빠른 시작](#빠른-시작) · [제품 흐름](#제품-흐름) · [지원 현황](#지원-현황) · [로드맵](docs/roadmap.md) · [MIT 라이선스](LICENSE.md) +[![44초 BeatGEO 데모 보기](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4) + +2026-10-22에 기록한 BeatAPI 실측의 실제 화면을 정리한 회고이며, 실시간 실행이 아닙니다. [사례 기록 — 중국어](docs/beat-geo/cases/beatapi-agent/README.md) + > **로컬 MVP / 소스 미리보기 버전.** ChatGPT 수집 → 데이터베이스 → worker 경로는 실제 스모크 테스트를 통과했습니다. 다른 엔진, 유료 리서치, 장기 무인 운영은 별도로 검증해야 합니다. [검증 기록 — 영어](docs/beat-geo/validation.md) ## 웹사이트 하나에서 반복 가능한 질문 세트로 diff --git a/README.md b/README.md index e9eb9409..545cb073 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ An open-source GEO / AEO workspace by [BeatAPI](https://beatapi.io). Track brand [Website](https://beatapi.io/geo) · [Quick start](#quick-start) · [Product workflow](#the-product-workflow) · [Current support](#current-support) · [Roadmap](docs/roadmap.md) · [MIT license](LICENSE.md) +[![Watch the 44-second BeatGEO demo](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4) + +Real screens from a recorded BeatAPI test on 2026-10-22, replayed as a summary rather than a live run. [Case notes](docs/beat-geo/cases/beatapi-agent/README.md) + > **Local MVP / source preview.** The ChatGPT collection → database → worker path has passed live smoke tests. Other engines, paid research and unattended operation have separate acceptance boundaries. [Validation log](docs/beat-geo/validation.md) ## From a website to a repeatable question set @@ -45,6 +49,10 @@ The installer prepares a dedicated local PostgreSQL database and the included ex [Detailed onboarding — Chinese](docs/beat-geo/getting-started.md) · [Manual PostgreSQL setup — Chinese](docs/self-hosting.md) +## Work from your Agent + +Give your MCP-capable Agent a website. The local MCP can create a paused brand, save reviewed questions, queue bounded BeatBrowser collection, inspect evidence, edit shared content drafts and control monitoring. The manual dashboard shows the same project and operation history. [Agent workflow and connection guide](docs/beat-geo/agent-workflow.md). + ## The product workflow ![The product workflow](assets/readme/workflow.en.svg) diff --git a/README.zh-CN.md b/README.zh-CN.md index 0fc10373..afbdd278 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,6 +10,10 @@ [项目官网](https://beatapi.io/geo) · [快速开始](#快速开始) · [产品闭环](#产品闭环) · [支持范围](#支持范围) · [路线图](docs/roadmap.md) · [MIT 许可](LICENSE.md) +[![观看 44 秒 BeatGEO 演示](assets/readme/demo-poster.jpg)](assets/readme/beatgeo-demo.mp4) + +画面来自 2026-10-22 的 BeatAPI 实测记录,是结果回顾,不是实时运行。[案例说明](docs/beat-geo/cases/beatapi-agent/README.md) + > **本地 MVP / 源码预览版。** ChatGPT 采集 → 数据库 → worker 已通过真实冒烟测试。其他平台、付费研究与长期无人值守分别验收。[验收记录](docs/beat-geo/validation.md) ## 从一个网站,开始持续观察 diff --git a/apps/browser-lab/async-collection.test.mjs b/apps/browser-lab/async-collection.test.mjs new file mode 100644 index 00000000..291f1caa --- /dev/null +++ b/apps/browser-lab/async-collection.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; + +test("async collection acknowledges durable evidence and can be polled without another submission", { + timeout: 20000, +}, async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "beatgeo-async-")); + const fake = path.join(directory, "fake.mjs"); + await writeFile( + fake, + ` +import {existsSync,writeFileSync} from 'node:fs'; +const tool=process.argv[3], p=JSON.parse(process.argv[4]), sent=${JSON.stringify(path.join(directory, "sent"))}; +if(tool==='spaces') console.log(JSON.stringify({spaceId:'fixture-space',tabId:42,state:'running'})); +else if(tool==='snapshot') console.log('# ChatGPT'); +else if(tool==='type') {if(existsSync(sent))throw Error('Duplicate submission');writeFileSync(sent,'1');console.log('Typed');} +else if(p.selector.includes('contenteditable')) console.log('[{"label":"Ask"}]'); +else if(p.selector.includes('aria-busy')||p.selector.includes('a[href]'))console.log('[]'); +else console.log(existsSync(sent)?'[{"text":"A completed fixture answer."}]':'[]'); +`, + ); + const probe = net.createServer().listen(0, "127.0.0.1"); + await once(probe, "listening"); + const port = probe.address().port; + await new Promise((resolve) => probe.close(resolve)); + const child = spawn(process.execPath, ["apps/browser-lab/server.mjs"], { + env: { + ...process.env, + BROWSER_LAB_PORT: String(port), + BROWSER_RUNNER_TOKEN: "fixture-token", + BROWSER_RUNS_DIR: path.join(directory, "runs"), + BEAT_BROWSER_COMMAND: process.execPath, + BEAT_BROWSER_ARGS_JSON: JSON.stringify([fake]), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + try { + await Promise.race([ + once(child.stdout, "data"), + once(child, "exit").then(() => { + throw Error("Runner exited"); + }), + ]); + const base = `http://127.0.0.1:${port}/api/collect`; + const submit = () => + fetch(base, { + method: "POST", + headers: { Authorization: "Bearer fixture-token", Prefer: "respond-async", "Content-Type": "application/json" }, + body: JSON.stringify({ engine: "chatgpt", prompt: "Which providers?" }), + }); + const response = await submit(); + assert.equal(response.status, 202); + const { runId } = await response.json(); + assert.equal(JSON.parse(await readFile(path.join(directory, "runs", `${runId}.json`), "utf8")).id, runId); + assert.equal((await fetch(`${base}/${runId}`)).status, 403); + assert.equal((await submit()).status, 409); + let run; + do { + await delay(500); + const progress = await fetch(`${base}/${runId}`, { headers: { Authorization: "Bearer fixture-token" } }); + assert.equal(progress.status, 200); + run = await progress.json(); + } while (["opening", "submitting", "waiting"].includes(run.status)); + assert.equal(run.status, "completed"); + assert.equal(run.answer, "A completed fixture answer."); + assert.equal(await readFile(path.join(directory, "sent"), "utf8"), "1"); + } finally { + if (child.exitCode === null) { + const stopped = once(child, "exit"); + child.kill("SIGTERM"); + await stopped; + } + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/browser-lab/core.mjs b/apps/browser-lab/core.mjs index cc41ce45..ac1bdf8f 100644 --- a/apps/browser-lab/core.mjs +++ b/apps/browser-lab/core.mjs @@ -1,7 +1,8 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; @@ -47,6 +48,12 @@ export function createBrowser(env = process.env) { throw new Error("BEAT_BROWSER_ARGS_JSON must be a JSON array of command arguments."); } return async (tool, params) => { + if (useBundle) + assertCompatibleBridge( + JSON.parse( + readFileSync(new URL("../../node_modules/@beatapi/beat-browser/package.json", import.meta.url), "utf8"), + ).version, + ); const { stdout } = await execute(command, [...prefix, "call", tool, JSON.stringify(params)], { timeout: 45_000, maxBuffer: 4 * 1024 * 1024, @@ -56,6 +63,19 @@ export function createBrowser(env = process.env) { }; } +export function assertCompatibleBridge(expected, metadataPath = path.join(os.homedir(), ".beat-browser/bridge.json")) { + let info; + try { + info = JSON.parse(readFileSync(metadataPath, "utf8")); + } catch { + return; + } + if (info.version && info.version !== expected) + throw new Error( + `[BROWSER_VERSION_MISMATCH] Existing BeatBrowser bridge ${info.version} differs from bundled ${expected}. Reuse the matching installed CLI with BEAT_BROWSER_COMMAND and BEAT_BROWSER_ARGS_JSON, or update the connected extension and Agent clients together. The shared bridge was not restarted.`, + ); +} + export function validateInput(input) { if (!input || typeof input !== "object") throw new Error("Collection input is required."); if (!Object.hasOwn(ENGINES, input.engine)) throw new Error("Choose chatgpt or gemini."); @@ -192,6 +212,8 @@ async function openRunPage(config, run, transport, useSpaces) { action: "open", url: ENGINES[config.engine].url, label: `BeatGEO · ${ENGINES[config.engine].name}`, + // Keep control through composer attachment, answer waiting and evidence reads. + leaseMs: Math.min(3_600_000, run.timeoutMs + 90_000), profileId: config.profileId || process.env.BEAT_BROWSER_PROFILE_ID || undefined, }), ); diff --git a/apps/browser-lab/core.test.mjs b/apps/browser-lab/core.test.mjs index 9b341482..5e7c370c 100644 --- a/apps/browser-lab/core.test.mjs +++ b/apps/browser-lab/core.test.mjs @@ -163,12 +163,13 @@ test("an interrupted TaskSpace never falls back to an ordinary browser tab", asy assert(calls.every((call) => call.tool === "spaces")); }); -test("default collection uses the chosen profile and preserves evidence pages", async () => { +test("collection retains the chosen profile through long answer waits and preserves evidence pages", async () => { const calls = []; let submitted = false; const run = await collect( { engine: "chatgpt", prompt: "best providers", brand: "Example", profileId: "chosen-profile" }, { + timeoutMs: 1_800_000, delay: async () => {}, browser: async (tool, params) => { calls.push({ tool, params }); @@ -188,6 +189,7 @@ test("default collection uses the chosen profile and preserves evidence pages", assert.equal(run.status, "completed"); assert.equal(run.brandMentioned, true); assert.equal(calls[0].params.profileId, "chosen-profile"); + assert(calls[0].params.leaseMs > run.timeoutMs + 60_000); assert.equal( calls.some((call) => call.tool === "tabs"), false, @@ -250,3 +252,18 @@ for (const readiness of ["transient", "persistent", "human-pause"]) { if (readiness === "human-pause") assert.equal(run.errorCode, "needs_attention"); }); } + +test("a different shared bridge is left untouched and returns an actionable compatibility error", async () => { + const { writeFile } = await import("node:fs/promises"); + const { assertCompatibleBridge } = await import("./core.mjs"); + const root = await mkdtemp(path.join(os.tmpdir(), "beatgeo-bridge-")); + const file = path.join(root, "bridge.json"); + try { + await writeFile(file, JSON.stringify({ version: "1.3.2", pid: 123, token: "private-fixture" })); + assert.throws(() => assertCompatibleBridge("1.4.1", file), /shared bridge was not restarted/); + assert.doesNotThrow(() => assertCompatibleBridge("1.3.2", file)); + assert.doesNotThrow(() => assertCompatibleBridge("1.4.1", path.join(root, "absent.json"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/browser-lab/server.mjs b/apps/browser-lab/server.mjs index c8101ff3..5f1c5ce4 100644 --- a/apps/browser-lab/server.mjs +++ b/apps/browser-lab/server.mjs @@ -57,13 +57,14 @@ function allowedRequest(request) { return true; } -async function executeRun(input) { +async function executeRun(input, started) { active = { status: "opening" }; try { const result = await collect(input, { publish: async (run) => { active = { ...run }; await saveRun(directory, run); + started?.(run.id); }, }); await saveRun(directory, result); @@ -131,15 +132,25 @@ async function selectProfile(request, response) { async function handle(request, response) { if (!allowedRequest(request)) return json(response, 403, { error: "Loopback same-origin requests only." }); const url = new URL(request.url, `http://127.0.0.1:${port}`); - if (request.method === "GET") return handleRead(url, response); + if (request.method === "GET") return handleRead(url, request, response); if (request.method === "POST" && url.pathname === "/api/browser/profile") return selectProfile(request, response); if (request.method !== "POST" || !["/api/run", "/api/collect"].includes(url.pathname)) return json(response, 404, { error: "Not found" }); return handleCollection(request, response, url); } -async function handleRead(url, response) { +async function handleRead(url, request, response) { if (await staticAsset(url, response)) return; + const collection = url.pathname.match(/^\/api\/collect\/([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/i); + if (collection) { + if (!authorized(request)) return json(response, 403, { error: "Invalid local runner token" }); + try { + return json(response, 200, JSON.parse(await readFile(path.join(directory, `${collection[1]}.json`), "utf8"))); + } catch (error) { + if (error.code !== "ENOENT") throw error; + return json(response, 404, { error: "Collection not found" }); + } + } if (url.pathname === "/") { response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", @@ -166,7 +177,16 @@ async function handleCollection(request, response, url) { if (active) return json(response, 409, { error: "A browser run is already active. Wait before submitting another." }); const input = validateInput(await bodyOf(request)); if (active) return json(response, 409, { error: "A browser run started while this request was being read." }); - const operation = executeRun(input); + const started = Promise.withResolvers(); + const operation = executeRun(input, started.resolve); + if (url.pathname === "/api/collect" && request.headers.prefer === "respond-async") { + operation.catch((error) => { + started.reject(error); + console.error("Run failed:", error.message); + }); + // A receipt is returned only after its initial private run record is durable. + return json(response, 202, { accepted: true, runId: await started.promise }); + } if (url.pathname === "/api/collect") return json(response, 200, await operation); operation.catch((error) => console.error("Run failed:", error.message)); return json(response, 202, { accepted: true }); diff --git a/apps/web/public/code-texture.svg b/apps/web/public/code-texture.svg deleted file mode 100644 index 26d36032..00000000 --- a/apps/web/public/code-texture.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - -client = OpenAI( - base_url="https://api.beatapi.io/v1", - api_key=os.environ["BEATAPI_API_KEY"] -) - - -{ - "brand": "your website", - "questions": [], - "citations": [] -} - - -// original answers, visible sources -observe → compare → improve -// repeat the same questions - - -"mcpServers": { - "beatapi": { - "url": "https://beatapi.io/mcp" - } -} - - -const browser = createBrowser(); -await browser.profiles(); -// evidence from your browser - - diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 1a6fc3a0..0c600626 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -1,5 +1,6 @@ import { SidebarInset, SidebarProvider } from "@workspace/ui/components/sidebar"; import type { CSSProperties, ReactNode } from "react"; +import { CodeTexture } from "@/components/code-texture"; export function AppShell({ sidebar, @@ -15,6 +16,7 @@ export function AppShell({ {sidebar} {/* Clip horizontal overflow without changing the sticky header's scroll parent. */} + {header} {children} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 56a176ce..88a97c1f 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -69,12 +69,24 @@ function organizationGroups( { title: "MCP", link: { to: "/app/org/$org/settings/mcp", params }, icon: IconPlugConnected }, ]; + if (mode === "local") + docs.push({ title: "Real workflow case", link: { to: "/app/org/$org/settings/cases", params }, icon: IconSchool }); + // The skill is BeatGEO's, and whitelabel deployments never point users at BeatGEO. if (mode !== "whitelabel") { docs.push({ title: "Skill", link: { to: "/app/org/$org/settings/skill", params }, icon: IconSchool }); } + // Org pages replace the brand menu, so keep a way straight back to each brand's dashboard. + const brands: NavItem[] = organization.brands.map((brand) => ({ + title: brand.name, + link: { to: "/app/org/$org/brand/$brand", params: brandLinkParams(organization, brand) }, + icon: IconDashboard, + exact: true, + })); + return [ + ...(brands.length > 0 ? [{ label: "Brand dashboards", items: brands }] : []), { label: "Organization Settings", items }, { label: "Docs", items: docs }, ]; @@ -149,6 +161,18 @@ export function AppSidebar({ section, organization, brand }: AppSidebarProps) { ...(section === "organization" && organization ? organizationGroups(organization, features, mode) : []), ...(section === "admin" && adminItems.length > 0 ? [{ label: "Admin", items: adminItems }] : []), ]; + if (mode === "local" && section === "brand" && organization) { + groups.push({ + label: "Learn", + items: [ + { + title: "Real workflow case", + link: { to: "/app/org/$org/settings/cases", params: orgLinkParams(organization) }, + icon: IconSchool, + }, + ], + }); + } const brandmark = ( <> diff --git a/apps/web/src/components/auth/auth-split-layout.tsx b/apps/web/src/components/auth/auth-split-layout.tsx index 68a356ba..5aac6a64 100644 --- a/apps/web/src/components/auth/auth-split-layout.tsx +++ b/apps/web/src/components/auth/auth-split-layout.tsx @@ -14,6 +14,7 @@ */ import type { ReactNode } from "react"; +import { CodeTexture } from "@/components/code-texture"; import { LanguageSwitch } from "@/components/language-switch"; import { Logo } from "@/components/logo"; @@ -56,7 +57,8 @@ export function AuthSplitLayout({ title, subtitle, children, pitch, footer }: Au -