diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef2e95363..6ffeccf97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,55 @@ business assets, the Parsar product Web, product API and product migrations rema in Parsar. Do not import `server/`, `apps/parsar/`, product CLI/plugin packages or their deployment stack. +`example/parsar/` is an optional, independently started Agent workbench, +authorized to reuse the product UI and maintain a small product-owned SQLite database. +Named Provider groups and their models, anonymous HTTP MCP configurations, runtime profiles and reusable Agents +belong to that database. An Agent contains model, harness, instructions, Skills +and MCP bindings; it excludes the runtime. Starting a Session selects a runtime +and passes an inline Agent/configuration snapshot to the public Core API. One +Agent may have many independent Sessions. Existing Sessions keep their original +configuration and history. Hosted Sessions have independent workspaces; native +Sessions use the selected host directory, so selecting the same path shares files. +There is no task layer or automatic workspace coordination. +Providers are explicit catalog groups without default groups. Each model references +one Provider. The example can fetch an OpenAI-shaped model list from a user-entered +Provider Base URL using that Provider's key, then save checked/custom models and +the Provider in one SQLite transaction. Editing reads the Provider revision and +its models as one snapshot; individual model changes advance the owning Provider +revision. Provider keys stay in the local restricted +SQLite file and are omitted from every browser response. Discovery uses only the +Provider credential, never the Core Project key, and never follows redirects. +Catalog import does not reconfigure deployment routing. Self-hosted Session creation +uses the selected Provider's URL/key in the explicit Session model-provider bundle; +Core freezes and delivers it. Pending requests are never serialized to browsers. +Core owns Skills and all execution/history state. SQLite stores Session references +and freezes pending creation requests with stable idempotency keys for retry; +confirmed requests are removed from local storage. Earlier example templates and +instances migrate to Agent configurations, never to fabricated Sessions. +Hosted HTTP MCP bindings become inline environment Plugins; none uses service-origin +MCP. Unsupported harness/runtime combinations fail before execution. The example +calls only public `/v1` APIs, using `OpenAIAgentsClient` for browser resource calls +and a small server adapter for Session creation. Its Project key stays server-side. +Session output uses the public SSE event stream through a non-buffering proxy; +durable history reads reconcile completion and recover missed events. Browser +disconnection aborts the upstream stream, never the running Session. Send latency +measurements are browser-local observations (request return and first nonempty +text delta), not inferred Core execution timings. +Product resources use `/app/` and never become Core API or database conventions. +The example supports native self-hosted runtime profiles with a platform and existing +absolute workspace/capability paths. Create an empty Session first, issue its credential +in Core Web, then run the displayed native install/start command. The example never +holds a Core key or issues machine credentials; Core's public Environment status gates +message sending. Core-only credential issuance remains in the operator console. +Native Skills come from local capability directories: managed Skill references are +rejected explicitly rather than ignored. Bound service-origin MCP is also rejected +for self-hosted Sessions; use local Plugin capability directories. This example supports Codex and Claude Code on user machines; MiniMax Code's +required token-limit configuration is not exposed here. No scheduler or user permissions +are included. SQLite uses +Node's built-in module (Node 22.13+), lives outside the checkout, and is isolated by +Core origin and Project key fingerprint. The example is excluded from Core +distributions and cannot become a service dependency. + Preserve copied runtime and protocol behavior. Existing Go import paths remain unchanged and do not require fetching the original repository. Installed commands and environment settings use the OpenAgentCore names documented below. The source snapshot and per-file @@ -300,6 +349,10 @@ and unrelated Web changes do not. Manual native validation remains available. Superseded native runs on the same ref are cancelled. Workflow syntax validation and release qualification remain separate checks. +`make check-example` validates the optional application example with TypeScript, +proxy/product-persistence tests, a build and fixture browser acceptance; it also runs in +`make check`. Its synthetic responses are not live model qualification. + Run `make check` before completion. The standalone gate includes all daemon/shared Go tests, Core contract/client/service tests, Core Web and TypeScript client checks (including fixture-only Playwright acceptance), a real dedicated PostgreSQL test diff --git a/Makefile b/Makefile index e54d3c41f..34b6bf41e 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ SWAG_VERSION ?= v1.16.4 help: @printf '%s\n' 'make build-agents-api Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' -check: check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-agents-api check-claude-sdk check-web check-mcode-harness +check: check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-agents-api check-claude-sdk check-web check-example check-mcode-harness @printf 'OpenAgentCore checks passed.\n' .PHONY: check-names @@ -90,6 +90,13 @@ check-web: node-deps build-claude-sdk-runtime: ./scripts/build-claude-sdk-runtime.sh +.PHONY: check-example +check-example: node-deps + pnpm --filter @oac/parsar-example typecheck + pnpm --filter @oac/parsar-example test + pnpm --filter @oac/parsar-example build + pnpm --filter @oac/parsar-example test:e2e + check-mcode-harness: node --test packages/mcode-harness/*.test.mjs @for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done diff --git a/README.md b/README.md index a4cec377d..7dc7da539 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ and native harness differences. ## Documentation +For a complete application example using the Parsar product UI, see +[`example/parsar`](example/parsar/README.md): model, Skill, MCP and runtime management, reusable Agents, and independent +Sessions backed by the public API. + | Start here | Purpose | | --- | --- | | [Documentation index](docs/getting-started/README.md) | Installation, usage and administration reading paths | diff --git a/example/README.md b/example/README.md new file mode 100644 index 000000000..fa984151e --- /dev/null +++ b/example/README.md @@ -0,0 +1,4 @@ +# Application examples + +[Parsar](parsar/README.md) is a small Agent workbench using OpenAgentCore public +resources. Reuse Agent configurations to start independent runtime-bound Sessions. diff --git a/example/parsar/LICENSE b/example/parsar/LICENSE new file mode 100644 index 000000000..60d8db904 --- /dev/null +++ b/example/parsar/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax-AI-Dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/example/parsar/MOTION-PRIMITIVES-LICENSE b/example/parsar/MOTION-PRIMITIVES-LICENSE new file mode 100644 index 000000000..1a911b3bb --- /dev/null +++ b/example/parsar/MOTION-PRIMITIVES-LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 ibelick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +app/page.tsx diff --git a/example/parsar/README.md b/example/parsar/README.md new file mode 100644 index 000000000..08ea3006b --- /dev/null +++ b/example/parsar/README.md @@ -0,0 +1,164 @@ +# Parsar Agent workbench + +A small product application on OpenAgentCore, using Parsar UI. Manage models, +Skills, HTTP MCP services and runtime configurations; compose reusable Agent +configurations and start independent Sessions. Continue the same Session to keep +its history and live workspace. There is no task layer or shared workspace. + +## Run + +Use Node 22.13+ (for built-in SQLite) and pnpm 10.30.3. Configure +[Core](../../docs/getting-started/install.md), a deployment model/provider, and a +Project API key. Store secrets in a private environment file outside the checkout: + +```sh +export OAC_EXAMPLE_CORE_URL='http://127.0.0.1:8091' +export OAC_EXAMPLE_PROJECT_KEY='' +pnpm install --frozen-lockfile +pnpm --filter @oac/parsar-example dev +``` + +Open http://127.0.0.1:18180. The Core URL is an origin without `/v1`; +remote origins require HTTPS. `OAC_EXAMPLE_PORT` changes the local port. +For a built version, run `pnpm --filter @oac/parsar-example build` and then +`pnpm --filter @oac/parsar-example start`. + +## Main flow + +1. **Models:** enter a Provider name, Base URL and API key, then fetch its + `GET /v1/models` list. Select models with checkboxes or add custom model IDs, + then save the Provider and selected models together. The dialog shows discovered + and selected counts. For manual entry, skip discovery. No default group is + created. Keys are stored in the local restricted SQLite file and never returned + to the browser; leaving the key blank while editing preserves it. Discovery + sends only that Provider's key and does not follow redirects. The list expects + the OpenAI-shaped `data: [{id: "..."}]` response. Discovery failures leave manual + entry available. This imports the catalog only: Core still owns execution + connections for hosted/text-only Sessions. Self-hosted Sessions instead pass + this Provider URL/key explicitly to Core, using the selected harness protocol. +2. **Skills:** create a SKILL.md resource or upload a ZIP. Inspect versions, + upload a new version, and choose the default. Core validates and stores bundles. +3. **MCP:** save anonymous HTTPS endpoints and bind them to Agents. Hosted + Sessions install an inline environment Plugin; text-only Sessions use Core's + service-origin MCP. Hosted HTTP MCP supports Claude Code and Codex, not MiniMax + Code. Authentication, Vault management and OAuth setup are not included. +4. **Runtimes:** choose a Core-managed sandbox, text-only environment, or user + machine. User machines select Linux/macOS/Windows and an existing absolute + workspace path, plus an optional local capability directory. Each Session gets + its own daemon installation and credential; choosing the same workspace path + shares the host's files, so use distinct paths for isolated work. + These are placement configurations, not online machine identities. Machine + enrollment, fixed-node routing and self-hosted registration are omitted. +5. **Agents:** combine a model, harness, instructions, Skills and MCP services. + Create, copy and edit configurations without allocating a runtime. +6. **Sessions:** open an Agent, choose a runtime and send the first message. + Read streaming replies and tool activity, continue the conversation, cancel a running turn and reopen + history. Each hosted Session gets its own workspace; text-only has no workspace. + +Agent edits and catalog updates apply to future Sessions. Core freezes existing +Session configuration, including resolved Skill versions. Continuing a Session +retains history and its workspace while the sandbox exists; archiving/replacing a +sandbox is not a persistence guarantee. Saving configuration alone does not verify +model availability or MCP connectivity. Skills require a hosted runtime. + +## Ownership and storage + +The loopback Node server has two small responsibilities: a fixed allowlist proxy +for public Core resources, and `/app/` CRUD for product-owned configuration. One +SQLite table stores typed JSON records. There is no ORM, background scheduler, +execution database or imported Parsar backend. + +SQLite files live in `~/.oac/data/parsar-example/`. Set the absolute +`OAC_EXAMPLE_DATA_DIR` to relocate them. A hash of the Core origin and Project key +selects the file; changing either selects a different local catalog. Back up the +SQLite file with the server stopped. No key is stored in it. Builds and caches +use `${OAC_DEV_HOME:-$HOME/.oac}`. + +Use one server process and a dedicated Project for this local single-user example. +The server rejects cross-origin writes and keeps the Project key out of browser +responses. Session creation freezes a pending request in SQLite before calling Core, and uses +the local Session ID as its idempotency key. An uncertain response can be recovered +from the Agent's Session list, including after a server restart. Successful creation +leaves only the Core Session reference and local title/Agent/runtime relationship; +Core owns messages, turns and execution status. The browser consumes public SSE +text deltas through a non-buffering proxy, reconnects after interruption, and +reconciles with durable history to avoid duplicate final replies. Each send records +request-return latency and first nonempty text-delta latency from browser request +start. These independent observations remain in sessionStorage across reloads; +missing observations are shown as a dash rather than inferred from history. Definite validation rejection +removes the pending request so it can be corrected. Use one server process. +Deleting a local resource is blocked while another local record references it; +Session deletion/archival is outside this example. Skill deletion is a separate +explicit Core operation. Earlier templates and instances become Agent configs on +startup; they are never presented as actual Sessions. + +## Validation + +```sh +make check-example +make check +``` + +The example gate runs TypeScript, proxy and SQLite persistence/binding tests, +a production build and browser acceptance. Install Chrome with +`pnpm exec playwright install chrome` if necessary. Browser fixtures use +ports 18180/18181 and isolated SQLite data under `~/.oac/tests/parsar-example/`. +They verify Provider groups and model selection, live text before durable completion, +resource management, multiple Sessions, continuation, cancellation, lost-response recovery, +Skill versions and mobile/help behavior. Synthetic fixtures are not model execution. + +The opt-in live browser probe requires an already started example backed by an +isolated real Project: + +```sh +OAC_EXAMPLE_LIVE_URL=http://127.0.0.1:18180 \ +OAC_EXAMPLE_LIVE_MODEL='' \ +pnpm --filter @oac/parsar-example test:live +``` + +It leaves sample resources for inspection and executes the configured model. It +checks Skill/Plugin installation, same-Session file reuse, and workspace isolation +between two Sessions. It asks the model to call DeepWiki; inspect the recorded +tool activity to distinguish a successful call from an attempted call. + +## UI provenance + +The copied UI primitives, `src/lib/utils.ts`, `src/style.css` +and `public/*` originate in [Parsar](https://github.com/MiniMax-AI-Dev/parsar) +revision `90fafede`, under [MIT](LICENSE). The responsive body minimum width and +application pages are local adaptations. Navigation animation adapts +[Motion Primitives](https://github.com/ibelick/motion-primitives), with reduced-motion +support and its [MIT notice](MOTION-PRIMITIVES-LICENSE). Multica's resource and +Agent organization informs the product flow; no Multica code is copied. + +## Connect a user machine + +This branch builds on `codex/qualified-release-promotion-20260928` (native daemon +support). Run a matching Core and daemon version; the older preview Core does not +qualify this workflow. The optional unified Harness installer PR is not included. + +Create a user-machine runtime and start a Session with an Agent using Codex or +Claude Code. No initial Turn is sent. Open **Connect user machine**, obtain the +Session's executor credential in Core Web (Session log), save the credential on +the host as instructed, and run the generated platform-specific install/start +command. The daemon binary and native harness must already be installed. Windows +Claude Code also requires Git Bash. The page reads Core's public Environment +status and enables sending after it reports connected. The example backend does +not need or accept the administrator Core key. + +The selected model Provider needs an HTTPS Base URL and API key. Its protocol is +Responses for Codex and Anthropic for Claude Code. Core freezes and delivers the +configuration to the enrolled executor. Local pending creation requests contain +that configuration but browser Session responses omit the entire pending request. + +Self-hosted capability sources are local directories under the current Core API. +Managed Skill bindings fail explicitly; configure local Skills/Plugins in the +runtime's capability directory before connecting. Bound HTTP MCP services also +fail explicitly for self-hosted Sessions; configure them in local Plugins instead. MiniMax Code self-hosted configuration is not +included in this example because its required token limits are not exposed. + +The daemon runs with the starting user's permissions and adds no sandbox. Runtime +home is separate per Session; stopping it preserves local files. Credential +rotation and revocation remain Core console operations. Native install commands +are for fresh installations; restart an existing one with the same Runtime home +and `start`, without rerunning `install`. diff --git a/example/parsar/index.html b/example/parsar/index.html new file mode 100644 index 000000000..3a8a297b5 --- /dev/null +++ b/example/parsar/index.html @@ -0,0 +1,10 @@ + + + + + + + Parsar · OpenAgentCore + +
+ diff --git a/example/parsar/package.json b/example/parsar/package.json new file mode 100644 index 000000000..519245348 --- /dev/null +++ b/example/parsar/package.json @@ -0,0 +1,47 @@ +{ + "name": "@oac/parsar-example", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "node server.mjs --dev", + "build": "vite build", + "start": "node server.mjs", + "typecheck": "tsc --noEmit", + "test": "node --test tests/server.test.mjs tests/product.test.mjs", + "test:e2e": "playwright test --config playwright.config.ts", + "test:live": "node tests/live.mjs" + }, + "dependencies": { + "@agents-core-web/agents-client": "workspace:*", + "@radix-ui/react-dialog": "^1.1.18", + "@radix-ui/react-select": "2.3.2", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tooltip": "^1.2.11", + "@tanstack/react-query": "^5.103.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fflate": "^0.8.3", + "lucide-react": "^1.22.0", + "motion": "^13.4.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.7.0" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.4", + "tailwindcss": "^4.3.3", + "typescript": "^5.8.3", + "vite": "^8.1.4", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=22.13.0" + } +} diff --git a/example/parsar/paths.mjs b/example/parsar/paths.mjs new file mode 100644 index 000000000..c9964c2f9 --- /dev/null +++ b/example/parsar/paths.mjs @@ -0,0 +1,6 @@ +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +const home = process.env.OAC_DEV_HOME || join(homedir(), ".oac"); +if (!isAbsolute(home)) throw new Error("OAC_DEV_HOME must be absolute."); +export const buildDirectory = join(home, "build", "parsar-example"); diff --git a/example/parsar/playwright.config.ts b/example/parsar/playwright.config.ts new file mode 100644 index 000000000..ac8d9d8dd --- /dev/null +++ b/example/parsar/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from "@playwright/test"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export default defineConfig({ + testDir: "tests", + testMatch: "*.spec.ts", + workers: 1, + timeout: 60_000, + outputDir: join( + process.env.OAC_DEV_HOME || join(homedir(), ".oac"), + "tests", + "parsar-example", + ), + reporter: "line", + use: { + baseURL: "http://127.0.0.1:18180", + channel: "chrome", + reducedMotion: "reduce", + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: [ + { + command: "node tests/fixture.mjs", + url: "http://127.0.0.1:18181/health", + reuseExistingServer: false, + }, + { + command: "node server.mjs --dev", + url: "http://127.0.0.1:18180", + env: { + OAC_EXAMPLE_CORE_URL: "http://127.0.0.1:18181", + OAC_EXAMPLE_PROJECT_KEY: "fixture-project-key", + OAC_EXAMPLE_PORT: "18180", + OAC_EXAMPLE_DATA_DIR: join( + homedir(), + ".oac", + "tests", + "parsar-example", + "fixture-store", + ), + }, + reuseExistingServer: false, + }, + ], +}); diff --git a/example/parsar/public/favicon.png b/example/parsar/public/favicon.png new file mode 100644 index 000000000..83ad57715 Binary files /dev/null and b/example/parsar/public/favicon.png differ diff --git a/example/parsar/public/parsar-mark-dark.png b/example/parsar/public/parsar-mark-dark.png new file mode 100644 index 000000000..8a38b4d34 Binary files /dev/null and b/example/parsar/public/parsar-mark-dark.png differ diff --git a/example/parsar/public/parsar-mark-light.png b/example/parsar/public/parsar-mark-light.png new file mode 100644 index 000000000..2d3f2bed9 Binary files /dev/null and b/example/parsar/public/parsar-mark-light.png differ diff --git a/example/parsar/server.mjs b/example/parsar/server.mjs new file mode 100644 index 000000000..a4557c5d9 --- /dev/null +++ b/example/parsar/server.mjs @@ -0,0 +1,330 @@ +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { buildDirectory } from "./paths.mjs"; +import { openStore, dataPath, AppError } from "./server/store.mjs"; +import { productAPI } from "./server/product.mjs"; + +const root = fileURLToPath(new URL(".", import.meta.url)); +const routes = [ + [/^\/v1\/agents\/environments\/[a-f0-9-]{36}$/, ["GET"]], + [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}$/, ["GET"]], + [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/(items|turns)$/, ["GET"]], + [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/events$/, ["GET", "POST"]], + [/^\/v1\/agents$/, ["GET", "POST"]], + [/^\/v1\/agents\/[a-f0-9-]{36}$/, ["GET", "POST"]], +]; +routes.push( + [/^\/v1\/skills$/, ["GET", "POST"]], + [ + /^\/v1\/skills\/skill_[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/, + ["GET", "POST", "DELETE"], + ], + [ + /^\/v1\/skills\/skill_[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/versions$/, + ["GET", "POST"], + ], +); +const mime = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".png": "image/png", + ".svg": "image/svg+xml", +}; + +export function configuration(env = process.env) { + const target = new URL(env.OAC_EXAMPLE_CORE_URL || "http://127.0.0.1:8091"); + if ( + target.username || + target.password || + target.search || + target.hash || + target.pathname !== "/" || + !( + target.protocol === "https:" || + (target.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname)) + ) + ) { + throw new Error( + "OAC_EXAMPLE_CORE_URL must be an HTTPS origin, or a loopback HTTP origin, without /v1.", + ); + } + const key = env.OAC_EXAMPLE_PROJECT_KEY?.trim(); + if (!key || /[\r\n]/.test(key)) + throw new Error("Set OAC_EXAMPLE_PROJECT_KEY to a Project API key."); + const port = Number(env.OAC_EXAMPLE_PORT || 18180); + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("Invalid OAC_EXAMPLE_PORT."); + return { target: target.origin, key, port }; +} + +function fail(res, status, message) { + res.writeHead(status, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify({ error: { message } })); +} + +export function createHandler( + config, + { fetchImpl = fetch, frontend, store } = {}, +) { + const product = + store && + productAPI( + store, + async (path, method, body, key) => { + const response = await fetchImpl(`${config.target}${path}`, { + method, + body: JSON.stringify(body), + redirect: "manual", + signal: AbortSignal.timeout(30_000), + headers: { + ...(key ? { "Idempotency-Key": key } : {}), + Authorization: `Bearer ${config.key}`, + "OpenAI-Beta": "agents=v1", + "Content-Type": "application/json", + }, + }); + if (response.status >= 300 && response.status < 400) + throw new AppError(502, "Core returned a redirect."); + const value = await response.json(); + if (!response.ok) + throw new AppError( + response.status, + value.error?.message || "Core request failed.", + ); + return value; + }, + fetchImpl, + ); + return async (req, res) => { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "no-referrer"); + const hosts = [`127.0.0.1:${config.port}`, `localhost:${config.port}`]; + if ( + !hosts.includes(req.headers.host) || + (req.headers.origin && + req.headers.origin !== `http://${req.headers.host}`) || + req.headers["sec-fetch-site"] === "cross-site" + ) { + return fail( + res, + 403, + "This local example accepts same-origin requests only.", + ); + } + const url = new URL(req.url, `http://${req.headers.host}`); + if (url.pathname.startsWith("/app/")) { + if ( + req.method !== "GET" && + req.headers.origin !== `http://${req.headers.host}` + ) + return fail( + res, + 403, + "A same-origin Origin header is required for writes.", + ); + try { + if (!product) return fail(res, 404, "Product storage unavailable."); + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 1024 * 1024) return fail(res, 413, "Request too large."); + chunks.push(chunk); + } + let body = {}; + try { + if (size) body = JSON.parse(Buffer.concat(chunks)); + } catch { + throw new AppError(400, "Invalid JSON."); + } + if (!body || typeof body !== "object" || Array.isArray(body)) + throw new AppError(400, "Invalid request."); + const value = await product(req.method, url.pathname, body); + res.writeHead(200, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(value)); + } catch (error) { + fail( + res, + error instanceof AppError ? error.status : 502, + error instanceof AppError ? error.message : "无法完成请求,请重试。", + ); + } + return; + } + if ( + url.pathname.startsWith("/v1/") || + url.pathname.startsWith("/core/") || + url.pathname.startsWith("/api/") + ) { + if ( + !routes.some( + ([pattern, methods]) => + pattern.test(url.pathname) && methods.includes(req.method), + ) + ) { + return fail(res, 404, "This example does not expose that route."); + } + if ( + req.method !== "GET" && + req.headers.origin !== `http://${req.headers.host}` + ) { + return fail( + res, + 403, + "A same-origin Origin header is required for writes.", + ); + } + const streaming = + req.method === "GET" && url.pathname.endsWith("/events"); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30_000); + res.on("close", () => controller.abort()); + try { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if ( + size > + (url.pathname.startsWith("/v1/skills") + ? 8 * 1024 * 1024 + : 1024 * 1024) + ) + return fail(res, 413, "Request too large."); + chunks.push(chunk); + } + const headers = { + Authorization: `Bearer ${config.key}`, + "OpenAI-Beta": "agents=v1", + "Content-Type": req.headers["content-type"] || "application/json", + ...(streaming ? { Accept: "text/event-stream" } : {}), + }; + if (url.pathname.startsWith("/v1/skills")) + delete headers["OpenAI-Beta"]; + if (req.headers["idempotency-key"]) + headers["Idempotency-Key"] = req.headers["idempotency-key"]; + const upstream = await fetchImpl( + `${config.target}${url.pathname}${url.search}`, + { + method: req.method, + headers, + redirect: "manual", + signal: controller.signal, + ...(req.method === "POST" ? { body: Buffer.concat(chunks) } : {}), + }, + ); + if (upstream.status >= 300 && upstream.status < 400) + return fail( + res, + 502, + "Core returned a redirect; check its configured origin.", + ); + if (streaming && upstream.ok) { + if ( + !upstream.headers + .get("content-type") + ?.startsWith("text/event-stream") || + !upstream.body + ) + return fail(res, 502, "Core did not return an event stream."); + clearTimeout(timer); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-store", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + await pipeline(Readable.fromWeb(upstream.body), res); + return; + } + const body = await upstream.text(); + res.writeHead(upstream.status, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }); + res.end(body); + } catch { + if (res.headersSent) { + res.destroy(); + return; + } + if (!res.destroyed) + fail( + res, + 502, + "Cannot reach Core. Check the example server configuration and retry the same request.", + ); + } finally { + clearTimeout(timer); + } + return; + } + if (!["GET", "HEAD"].includes(req.method)) + return fail(res, 405, "Method not allowed."); + if (frontend) return frontend(req, res); + try { + const path = resolve( + buildDirectory, + `.${decodeURIComponent(url.pathname)}`, + ); + if ( + !path.startsWith(`${buildDirectory}${sep}`) && + path !== buildDirectory + ) + return fail(res, 404, "Not found."); + const asset = extname(path) + ? path + : resolve(buildDirectory, "index.html"); + const content = await readFile(asset); + res.writeHead(200, { + "Content-Type": mime[extname(asset)] || "application/octet-stream", + "Cache-Control": "no-cache", + }); + res.end(req.method === "HEAD" ? undefined : content); + } catch { + fail(res, 404, "Page not found. Run the example build first."); + } + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const config = configuration(); + const server = createServer(); + const vite = process.argv.includes("--dev") + ? await ( + await import("vite") + ).createServer({ + root, + server: { middlewareMode: true, hmr: { server } }, + appType: "spa", + }) + : undefined; + const store = openStore(dataPath(config)); + server.on( + "request", + createHandler(config, { frontend: vite?.middlewares, store }), + ); + server.listen(config.port, "127.0.0.1", () => { + process.stdout.write(`Parsar: http://127.0.0.1:${config.port}\n`); + }); + for (const signal of ["SIGINT", "SIGTERM"]) + process.on(signal, () => { + server.close(() => store.close()); + void vite?.close(); + }); +} diff --git a/example/parsar/server/product.mjs b/example/parsar/server/product.mjs new file mode 100644 index 000000000..1b3df5f29 --- /dev/null +++ b/example/parsar/server/product.mjs @@ -0,0 +1,188 @@ +import { providerAPI, publicProvider } from "./providers.mjs"; +import { runtimeProfile } from "./runtime-profile.mjs"; +import { sessionAPI } from "./sessions.mjs"; +import { AppError, text, uuid } from "./store.mjs"; + +const kinds = new Set([ + "providers", + "models", + "mcps", + "runtimes", + "agents", + "sessions", +]); +export function productAPI(store, core, fetchImpl = fetch) { + // Preserve earlier example configurations without fabricating execution records. + for (const kind of ["templates", "instances"]) { + for (const row of store.list(kind)) { + if (!store.get("agents", row.id)) { + const { + id, + name, + revision, + model_id, + harness, + instructions, + skill_ids, + mcp_ids, + } = row; + store.put("agents", { + id, + name, + revision, + model_id, + harness, + instructions, + skill_ids, + mcp_ids, + }); + } + store.remove(kind, row.id); + } + } + const sessions = sessionAPI(store, core); + const publicRow = (kind, row) => { + if (kind === "providers") return publicProvider(row); + if (kind === "sessions") { + const { request, ...record } = row; + return record; + } + return row; + }; + const providers = providerAPI(store, fetchImpl); + const touchProviders = (...ids) => { + for (const id of new Set(ids.filter(Boolean))) { + const provider = store.get("providers", id); + if (provider) + store.put("providers", { + ...provider, + revision: provider.revision + 1, + }); + } + }; + const requireReference = (kind, id) => { + if (typeof id !== "string" || !store.get(kind, id)) + throw new AppError(400, "请选择有效的资源。"); + return id; + }; + const references = (kind, ids) => { + if (!Array.isArray(ids) || ids.length > 8) + throw new AppError(400, "最多选择 8 个资源。"); + return [...new Set(ids.map((id) => requireReference(kind, id)))]; + }; + return async (method, path, body) => { + if (path === "/app/providers/discover" && method === "POST") + return providers.discover(body); + const match = path.match(/^\/app\/([a-z]+)(?:\/([a-f0-9-]{36}))?$/); + if (!match || !kinds.has(match[1]) || (match[2] && !uuid.test(match[2]))) + throw new AppError(404, "Not found."); + const [, kind, id] = match; + if (method === "GET") { + if (!id) return store.list(kind).map((row) => publicRow(kind, row)); + const value = store.get(kind, id); + if (!value) throw new AppError(404, "记录不存在。"); + return kind === "providers" + ? { + ...publicProvider(value), + models: store + .list("models") + .filter((model) => model.provider_id === id), + } + : publicRow(kind, value); + } + if (!id) throw new AppError(405, "Method not allowed."); + if (kind === "sessions") { + if (method !== "PUT") throw new AppError(405, "Method not allowed."); + return sessions(id, body); + } + const previous = store.get(kind, id); + if (method === "DELETE") { + const used = + store.list("models").some((row) => row.provider_id === id) || + store + .list("agents") + .some((row) => row.model_id === id || row.mcp_ids.includes(id)) || + store + .list("sessions") + .some((row) => row.agent_id === id || row.runtime_id === id); + if (used) throw new AppError(409, "资源仍被引用,请先调整绑定。"); + store.transaction(() => { + store.remove(kind, id); + if (kind === "models") touchProviders(previous?.provider_id); + }); + return {}; + } + if (method !== "PUT") throw new AppError(405, "Method not allowed."); + if (previous && body.revision !== previous.revision) + throw new AppError(409, "配置已更新,请重新打开后编辑。"); + if (kind === "providers") return providers.save(id, body, previous); + const value = { + id, + name: text(body.name, "名称", 80, true), + revision: (previous?.revision || 0) + 1, + }; + if (kind === "models") { + value.model = text(body.model, "模型 ID", 200, true).trim(); + value.provider_id = requireReference("providers", body.provider_id); + if ( + store + .list("models") + .some( + (row) => + row.id !== id && + row.provider_id === value.provider_id && + row.model === value.model, + ) + ) + throw new AppError(409, "此 Provider 已有相同的模型 ID。"); + } + if (kind === "mcps") { + let url; + try { + url = new URL(body.url); + } catch { + throw new AppError(400, "请填写有效的 MCP URL。"); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.hash || + url.search + ) + throw new AppError(400, "MCP 使用不含凭据的 HTTPS 地址。"); + value.url = url.href; + value.label = text(body.label, "MCP 标识", 64, true); + if (!/^[a-zA-Z0-9_-]+$/.test(value.label)) + throw new AppError(400, "MCP 标识仅支持字母、数字、下划线和连字符。"); + } + if (kind === "runtimes") { + Object.assign(value, runtimeProfile(body)); + } + if (kind === "agents") { + value.model_id = requireReference("models", body.model_id); + if (!["codex", "claude_sdk", "mcode"].includes(body.harness)) + throw new AppError(400, "请选择执行引擎。"); + value.harness = body.harness; + value.instructions = text(body.instructions, "指令", 100000); + value.mcp_ids = references("mcps", body.mcp_ids); + if ( + !Array.isArray(body.skill_ids) || + body.skill_ids.length > 8 || + body.skill_ids.some( + (id) => + typeof id !== "string" || + !/^skill_[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id), + ) + ) + throw new AppError(400, "技能配置无效。"); + value.skill_ids = [...new Set(body.skill_ids)]; + } + store.transaction(() => { + store.put(kind, value); + if (kind === "models") + touchProviders(previous?.provider_id, value.provider_id); + }); + return value; + }; +} diff --git a/example/parsar/server/providers.mjs b/example/parsar/server/providers.mjs new file mode 100644 index 000000000..fcd64477a --- /dev/null +++ b/example/parsar/server/providers.mjs @@ -0,0 +1,149 @@ +import { randomUUID } from "node:crypto"; +import { AppError, text } from "./store.mjs"; + +export function publicProvider({ api_key, ...provider }) { + return { ...provider, has_api_key: Boolean(api_key) }; +} + +function connection(body, previous) { + const base = text( + body.base_url ?? previous?.base_url ?? "", + "Base URL", + 2000, + ).trim(); + let base_url = ""; + if (base) { + let url; + try { + url = new URL(base); + } catch { + throw new AppError(400, "Base URL 无效。"); + } + if ( + url.username || + url.password || + url.search || + url.hash || + !( + url.protocol === "https:" || + (url.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) + ) + ) + throw new AppError( + 400, + "Base URL 使用 HTTPS,或本机 HTTP 地址,不包含凭据和查询参数。", + ); + base_url = url.href.replace(/\/+$/, ""); + } + const api_key = text( + body.api_key ?? previous?.api_key ?? "", + "API Key", + 8192, + ).trim(); + if (/[\r\n]/.test(api_key)) throw new AppError(400, "API Key 无效。"); + return { base_url, api_key }; +} + +export function providerAPI(store, fetchImpl) { + return { + async discover(body) { + const { base_url, api_key } = connection( + body, + body.id ? store.get("providers", body.id) : undefined, + ); + if (!base_url) throw new AppError(400, "请填写 Base URL。"); + const endpoint = base_url.endsWith("/v1") + ? `${base_url}/models` + : `${base_url}/v1/models`; + try { + const response = await fetchImpl(endpoint, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(15000), + headers: { + Accept: "application/json", + ...(api_key ? { Authorization: `Bearer ${api_key}` } : {}), + }, + }); + if (!response.ok) + throw new AppError( + 502, + `获取模型失败(HTTP ${response.status}),请检查地址和密钥。`, + ); + const value = await response.json(); + if (!Array.isArray(value.data) || value.has_more === true) + throw new AppError( + 502, + "模型接口未返回完整的 data 列表,可手动添加模型。", + ); + const models = [ + ...new Set( + value.data + .map((row) => row?.id) + .filter( + (id) => typeof id === "string" && id.trim() && id.length <= 200, + ), + ), + ]; + return { models }; + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError( + 502, + "无法获取模型,请检查地址和密钥,或手动添加模型。", + ); + } + }, + save(id, body, previous) { + const value = { + id, + name: text(body.name, "名称", 80, true), + revision: (previous?.revision || 0) + 1, + ...connection(body, previous), + }; + const existing = store + .list("models") + .filter((model) => model.provider_id === id); + let selected; + if (body.models !== undefined) { + if (!Array.isArray(body.models) || body.models.length > 1000) + throw new AppError(400, "最多选择 1000 个模型。"); + selected = body.models.map((row) => ({ + model: text(row?.model, "模型 ID", 200, true).trim(), + name: text(row?.name, "模型名称", 80, true).trim(), + })); + if (new Set(selected.map((row) => row.model)).size !== selected.length) + throw new AppError(400, "模型 ID 不能重复。"); + } + return store.transaction(() => { + if (selected) { + const removed = existing.filter( + (row) => !selected.some((entry) => entry.model === row.model), + ); + if ( + store + .list("agents") + .some((agent) => removed.some((row) => row.id === agent.model_id)) + ) + throw new AppError( + 409, + "取消的模型仍被 Agent 引用,请先调整 Agent 的模型。", + ); + for (const row of removed) store.remove("models", row.id); + for (const row of selected) { + const old = existing.find((entry) => entry.model === row.model); + store.put("models", { + ...row, + id: old?.id || randomUUID(), + provider_id: id, + revision: (old?.revision || 0) + 1, + }); + } + } + store.put("providers", value); + return publicProvider(value); + }); + }, + }; +} diff --git a/example/parsar/server/runtime-profile.mjs b/example/parsar/server/runtime-profile.mjs new file mode 100644 index 000000000..adefda835 --- /dev/null +++ b/example/parsar/server/runtime-profile.mjs @@ -0,0 +1,46 @@ +import { posix } from "node:path"; +import { AppError, text } from "./store.mjs"; + +export function runtimeProfile(body) { + if (!["openai_hosted", "none", "self_hosted"].includes(body.environment)) + throw new AppError(400, "运行环境无效。"); + if (body.environment !== "self_hosted") + return { environment: body.environment }; + if (!["linux", "macos", "windows"].includes(body.platform)) + throw new AppError(400, "请选择机器平台。"); + const directory = (value) => { + const result = text(value, "工作目录", 4096, true); + const portable = result.replaceAll("\\", "/"); + const clean = (path) => + posix.normalize(path) === path && (path === "/" || !path.endsWith("/")); + const windows = body.platform === "windows"; + const drive = /^[A-Za-z]:\//.test(portable); + const unc = portable.startsWith("//"); + const valid = windows + ? !/[:*?"<>|]/.test(drive ? portable.slice(2) : portable) && + (drive + ? clean(portable.slice(2)) + : unc && + portable.slice(2).split("/").length >= 2 && + portable.slice(2).split("/").every((part) => part && part !== "." && part !== "..")) + : result.startsWith("/") && !unc && !result.includes("\\") && clean(result); + if (!valid || /[\x00-\x1f\x7f]/.test(result) || Buffer.byteLength(result) > 4096) + throw new AppError(400, "请填写规范的绝对目录,不使用重复分隔符、末尾斜杠、. 或 ..。"); + return result; + }; + if ( + body.capability_directories !== undefined && + (!Array.isArray(body.capability_directories) || + body.capability_directories.length > 8) + ) + throw new AppError(400, "能力目录最多 8 个。"); + const directories = (body.capability_directories || []).map(directory); + if (new Set(directories).size !== directories.length) + throw new AppError(400, "能力目录不能重复。"); + return { + environment: "self_hosted", + platform: body.platform, + workspace_directory: directory(body.workspace_directory), + capability_directories: directories, + }; +} diff --git a/example/parsar/server/sessions.mjs b/example/parsar/server/sessions.mjs new file mode 100644 index 000000000..d275e05a8 --- /dev/null +++ b/example/parsar/server/sessions.mjs @@ -0,0 +1,181 @@ +import { zipSync, strToU8 } from "fflate"; +import { AppError, text, uuid } from "./store.mjs"; + +function requestFor(store, id, body) { + const agent = store.get("agents", body.agent_id); + const runtime = store.get("runtimes", body.runtime_id); + if (!agent || !runtime) + throw new AppError(400, "请选择有效的 Agent 和运行时。"); + const model = store.get("models", agent.model_id); + const mcps = agent.mcp_ids.map((id) => store.get("mcps", id)); + if (!model || mcps.some((mcp) => !mcp)) + throw new AppError(400, "Agent 绑定的资源已不可用。"); + if (new Set(mcps.map((mcp) => mcp.label)).size !== mcps.length) + throw new AppError(400, "绑定的 MCP 服务标识不能重复。"); + const environment = { type: runtime.environment }; + const selfHosted = environment.type === "self_hosted"; + let modelProvider; + if (selfHosted) { + if (agent.skill_ids.length) + throw new AppError( + 400, + "用户机器使用本地能力目录,当前 Core 不接受托管 Skill 引用。请使用未绑定托管 Skill 的 Agent,并在运行时填写本地能力目录。", + ); + if (mcps.length) + throw new AppError( + 400, + "用户机器的 MCP 请通过本地 Plugin 能力目录配置。当前 Core 不接受此处的 MCP 服务绑定,请使用未绑定 MCP 的 Agent。", + ); + if (agent.harness === "mcode") + throw new AppError( + 400, + "此示例的用户机器接入先支持 Codex 和 Claude Code,请选择其中一个执行引擎。", + ); + const provider = store.get("providers", model.provider_id); + if (!provider?.base_url?.startsWith("https://") || !provider.api_key) + throw new AppError( + 400, + "用户机器需要模型 Provider 的 HTTPS Base URL 和 API Key,请先在模型页面配置。", + ); + modelProvider = { + protocol: agent.harness === "codex" ? "responses" : "anthropic", + base_url: provider.base_url, + api_key: provider.api_key, + }; + environment.workspace_directory = runtime.workspace_directory; + environment.capability_directories = runtime.capability_directories || []; + } + const tools = [{ type: "web_search", mode: "disabled" }]; + if (environment.type === "none" || selfHosted) { + if (agent.skill_ids.length) + throw new AppError(400, "Skills 需要托管运行环境。"); + tools.push( + ...mcps.map((mcp) => ({ + type: "mcp", + server_label: mcp.label, + transport: { type: "http", server_url: mcp.url }, + connection_origin: "service", + required: true, + })), + ); + } else { + environment.skills = agent.skill_ids.map((skill_id) => ({ + type: "skill_reference", + skill_id, + })); + if (mcps.length) { + if (agent.harness === "mcode") + throw new AppError( + 400, + "MiniMax Code 暂不支持托管环境中的 HTTP MCP,请选择 Claude Code 或 Codex。", + ); + const name = "agent-mcp"; + const description = "Agent HTTP MCP bindings"; + const archive = zipSync({ + "agent/.codex-plugin/plugin.json": strToU8( + JSON.stringify({ name, description, mcpServers: "./.mcp.json" }), + ), + "agent/.mcp.json": strToU8( + JSON.stringify({ + mcpServers: Object.fromEntries( + mcps.map((mcp) => [mcp.label, { type: "http", url: mcp.url }]), + ), + }), + ), + }); + environment.network = { access: "enabled" }; + environment.plugins = [ + { + type: "inline", + name, + description, + source: { + type: "base64", + media_type: "application/zip", + data: Buffer.from(archive).toString("base64"), + }, + }, + ]; + } + } + const name = text(body.name, "会话名称", 80, true); + const input = selfHosted ? undefined : text(body.input, "消息", 100000, true); + return { + id, + name, + agent_id: agent.id, + runtime_id: runtime.id, + created_at: Math.floor(Date.now() / 1000), + ...(selfHosted + ? { + self_hosted: { + platform: runtime.platform, + workspace_directory: runtime.workspace_directory, + }, + } + : {}), + request: { + agent: { + model: model.model, + instructions: agent.instructions, + x_agents_core: { harness: agent.harness }, + multi_agent: { enabled: false }, + text: { verbosity: "medium" }, + tools, + }, + environment, + ...(input ? { input } : {}), + ...(modelProvider + ? { x_agents_core: { model_provider: modelProvider } } + : {}), + metadata: { + application: "parsar-example", + agent_id: agent.id, + title: name, + agent_name: agent.name, + }, + }, + }; +} + +export function sessionAPI(store, core) { + const running = new Map(); + const create = async (id, body) => { + let row = store.get("sessions", id); + if (row?.core_session_id) return row; + if (!row) { + row = requestFor(store, id, body); + store.put("sessions", row); + } + try { + // Freeze the request before sending. Retries, including after restart, reuse the same key and bytes. + const session = await core( + "/v1/agents/sessions", + "POST", + row.request, + id, + ); + if (!uuid.test(session.id)) + throw new AppError(502, "Core 返回了无效的会话。"); + const { request, ...record } = row; + record.core_session_id = session.id; + store.put("sessions", record); + return record; + } catch (error) { + if ( + error instanceof AppError && + [400, 401, 403, 404, 413, 422].includes(error.status) + ) + store.remove("sessions", id); + throw error; + } + }; + return (id, body) => { + if (!running.has(id)) + running.set( + id, + create(id, body).finally(() => running.delete(id)), + ); + return running.get(id); + }; +} diff --git a/example/parsar/server/store.mjs b/example/parsar/server/store.mjs new file mode 100644 index 000000000..dfc8be116 --- /dev/null +++ b/example/parsar/server/store.mjs @@ -0,0 +1,77 @@ +import { DatabaseSync } from "node:sqlite"; +import { mkdirSync, chmodSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + +export class AppError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} +export function dataPath(config, env = process.env) { + const directory = + env.OAC_EXAMPLE_DATA_DIR || + join(homedir(), ".oac", "data", "parsar-example"); + if (!isAbsolute(directory)) + throw new Error("OAC_EXAMPLE_DATA_DIR must be absolute."); + const scope = createHash("sha256") + .update(config.target + "\n" + config.key) + .digest("hex") + .slice(0, 24); + return join(directory, `${scope}.sqlite`); +} +export function openStore(path) { + if (path !== ":memory:") + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const db = new DatabaseSync(path); + if (path !== ":memory:") chmodSync(path, 0o600); + db.exec(`PRAGMA busy_timeout=5000; + CREATE TABLE IF NOT EXISTS records (kind TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(kind,id));`); + return { + close: () => db.close(), + transaction: (write) => { + db.exec("BEGIN IMMEDIATE"); + try { + const result = write(); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + list: (kind) => + db + .prepare("SELECT value FROM records WHERE kind=? ORDER BY rowid DESC") + .all(kind) + .map((r) => JSON.parse(r.value)), + get: (kind, id) => { + const row = db + .prepare("SELECT value FROM records WHERE kind=? AND id=?") + .get(kind, id); + return row ? JSON.parse(row.value) : null; + }, + put: (kind, value) => + db + .prepare( + "INSERT INTO records(kind,id,value) VALUES(?,?,?) ON CONFLICT(kind,id) DO UPDATE SET value=excluded.value", + ) + .run(kind, value.id, JSON.stringify(value)), + remove: (kind, id) => + db.prepare("DELETE FROM records WHERE kind=? AND id=?").run(kind, id), + }; +} +export const uuid = + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +export function text(value, label, max, required = false) { + if ( + typeof value !== "string" || + value.length > max || + value.includes("\0") || + (required && !value.trim()) + ) + throw new AppError(400, `${label}无效或过长。`); + return value; +} diff --git a/example/parsar/src/AgentDetail.tsx b/example/parsar/src/AgentDetail.tsx new file mode 100644 index 000000000..a0a340e5e --- /dev/null +++ b/example/parsar/src/AgentDetail.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowLeft, + Plus, + Settings2, + MessageSquare, + ChevronRight, +} from "lucide-react"; +import { product, useSessions, type AgentProfile } from "./lib/product"; +import { dateTime } from "./lib/api"; +import { AgentEditor } from "./Agents"; +import { NewSession } from "./NewSession"; +import { Button } from "./components/ui/button"; +import { EmptyState } from "./components/ui/empty-state"; +import { ErrorNotice, Help, PageHeader } from "./components/shared"; +export function AgentDetail({ id }: { id: string }) { + const [editing, setEditing] = useState(false); + const [creating, setCreating] = useState(false); + const query = useQuery({ + queryKey: ["agent", id], + queryFn: () => product(`agents/${id}`), + }); + const sessions = useSessions(); + const rows = sessions.data?.filter((row) => row.agent_id === id) || []; + return ( + <> + + + 每个会话独立保存工作区和历史。修改 Agent 配置只影响新会话。 + + + + +
+

会话

+ + {!sessions.isPending && !rows.length && ( + + )} +
+ {editing && query.data && ( + setEditing(false)} /> + )} + {creating && query.data && ( + setCreating(false)} /> + )} + + ); +} diff --git a/example/parsar/src/Agents.tsx b/example/parsar/src/Agents.tsx new file mode 100644 index 000000000..acb8b7d5c --- /dev/null +++ b/example/parsar/src/Agents.tsx @@ -0,0 +1,251 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Plus, Copy, Trash2, Layers } from "lucide-react"; +import { + product, + useAgents, + useModels, + type AgentProfile, +} from "./lib/product"; +import { ConfigurationFields, emptyConfiguration } from "./ConfigurationFields"; + +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { Select, SelectOption } from "./components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "./components/ui/dialog"; +import { ErrorNotice, Field, Help, PageHeader } from "./components/shared"; +import { EmptyState } from "./components/ui/empty-state"; +export function Agents() { + const query = useAgents(); + const models = useModels(); + const cache = useQueryClient(); + const [search, setSearch] = useState(""); + const [editing, setEditing] = useState(null); + + const remove = useMutation({ + mutationFn: (id: string) => product(`agents/${id}`, "DELETE"), + onSuccess: () => cache.invalidateQueries({ queryKey: ["agents"] }), + }); + return ( + <> + + + Agent 保存模型、指令、Skills 和 + MCP。开始会话时选择运行时,每个会话有独立的工作区和历史。 + + + + +
+ setSearch(e.target.value)} + className="max-w-sm" + /> +
+
+
+ {query.data + ?.filter((t) => t.name.toLowerCase().includes(search.toLowerCase())) + .map((agent) => ( +
+
+ +

+ {agent.name} +

+ +
+

+ {agent.instructions || "尚未设置指令"} +

+
+ + {models.data?.find((m) => m.id === agent.model_id)?.name || + "模型不可用"} + + {!!agent.skill_ids.length && ( + + {agent.skill_ids.length} Skills + + )} + {!!agent.mcp_ids.length && ( + + {agent.mcp_ids.length} MCP + + )} +
+
+ + +
+
+ ))} +
+ {!query.isPending && !query.data?.length && ( + + )} +
+ {editing && ( + setEditing(null)} /> + )} + + ); +} +export function AgentEditor({ + value, + close, +}: { + value: AgentProfile; + close: () => void; +}) { + const cache = useQueryClient(); + const [form, setForm] = useState(value); + const [preset, setPreset] = useState(""); + const save = useMutation({ + mutationFn: () => product(`agents/${form.id}`, "PUT", form), + onSuccess: () => { + void cache.invalidateQueries({ queryKey: ["agents"] }); + void cache.invalidateQueries({ queryKey: ["agent", form.id] }); + close(); + }, + }); + return ( + { + if (!open && !save.isPending) close(); + }} + > + + + + {value.revision ? "编辑 Agent" : "新建 Agent"} + + +
{ + e.preventDefault(); + save.mutate(); + }} + > + {!value.revision && ( + + + + )} + + setForm({ ...form, name: e.target.value })} + /> + + setForm({ ...form, ...configuration })} + /> + + + + + +
+
+ ); +} diff --git a/example/parsar/src/App.tsx b/example/parsar/src/App.tsx new file mode 100644 index 000000000..c88138b90 --- /dev/null +++ b/example/parsar/src/App.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { Bot, Cpu, BookOpen, Plug, Server, Moon, Sun } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { Models } from "./Models"; +import { Skills } from "./Skills"; +import { MCPs, Runtimes } from "./Resources"; +import { Agents } from "./Agents"; +import { SessionPage } from "./SessionPage"; +import { AgentDetail } from "./AgentDetail"; +import { Button } from "./components/ui/button"; + +export function App() { + const [route, setRoute] = useState(location.hash.slice(1) || "/agents"); + const [dark, setDark] = useState(() => { + try { + return localStorage.getItem("oac-example-theme") === "dark"; + } catch { + return false; + } + }); + const reduce = useReducedMotion(); + useEffect(() => { + const listener = () => setRoute(location.hash.slice(1) || "/agents"); + window.addEventListener("hashchange", listener); + return () => window.removeEventListener("hashchange", listener); + }, []); + useEffect(() => { + document.documentElement.dataset.theme = dark ? "dark" : "light"; + try { + localStorage.setItem("oac-example-theme", dark ? "dark" : "light"); + } catch { + /* Theme remains usable without storage. */ + } + }, [dark]); + const agentId = route.match(/^\/agents\/([a-f0-9-]{36})$/)?.[1]; + const sessionId = route.match(/^\/sessions\/([a-f0-9-]{36})$/)?.[1]; + const navigation = [ + { href: "/agents", label: "Agents", icon: Bot }, + { href: "/models", label: "模型", icon: Cpu }, + { href: "/skills", label: "Skills", icon: BookOpen }, + { href: "/mcps", label: "MCP", icon: Plug }, + { href: "/runtimes", label: "运行时", icon: Server }, + ]; + return ( +
+ +
+ {sessionId ? ( + + ) : agentId ? ( + + ) : route === "/models" ? ( + + ) : route === "/skills" ? ( + + ) : route === "/mcps" ? ( + + ) : route === "/runtimes" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/example/parsar/src/Composer.tsx b/example/parsar/src/Composer.tsx new file mode 100644 index 000000000..14d1cd9c2 --- /dev/null +++ b/example/parsar/src/Composer.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + AgentCoreError, + createIdempotencyKey, + type AgentSession, +} from "@agents-core-web/agents-client"; +import { ArrowUp, Square } from "lucide-react"; +import { beginTiming, requestReturned } from "./lib/session-timing"; +import { api } from "./lib/api"; +import { ErrorNotice } from "./components/shared"; + +export function Composer({ + session, + turnId, + connectionPending = false, +}: { + session: AgentSession; + turnId?: string; + connectionPending?: boolean; +}) { + const cache = useQueryClient(); + const storageKey = `oac-example-message-${session.id}`; + const [pending, setPending] = useState<{ key: string; text: string } | null>( + () => { + try { + return JSON.parse(sessionStorage.getItem(storageKey) || "null"); + } catch { + return null; + } + }, + ); + const [text, setText] = useState(pending?.text || ""); + const [cancelKey, setCancelKey] = useState(createIdempotencyKey); + const [cancelRequested, setCancelRequested] = useState(false); + useEffect(() => { + setCancelKey(createIdempotencyKey()); + setCancelRequested(false); + }, [turnId]); + const running = + session.status === "in_progress" || session.status === "requires_action"; + const refresh = () => { + void cache.invalidateQueries({ queryKey: ["session", session.id] }); + void cache.invalidateQueries({ queryKey: ["sessions"] }); + }; + const send = useMutation({ + mutationFn: async () => { + const operation = pending || { key: createIdempotencyKey(), text }; + sessionStorage.setItem(storageKey, JSON.stringify(operation)); + setPending(operation); + beginTiming(session.id, operation.key, turnId); + let response: Promise; + try { + response = api.sendMessage(session.id, operation.text, operation.key); + } catch (error) { + // Client validation throws before sending; network failures reject the Promise. + sessionStorage.removeItem(storageKey); + setPending(null); + throw error; + } + await response; + requestReturned(session.id, operation.key); + }, + onSuccess: () => { + setText(""); + setPending(null); + sessionStorage.removeItem(storageKey); + refresh(); + }, + onError: (error) => { + if ( + error instanceof AgentCoreError && + [400, 401, 403, 404, 413, 422].includes(error.status) + ) { + sessionStorage.removeItem(storageKey); + setPending(null); + } + }, + }); + const cancel = useMutation({ + mutationFn: () => api.cancelTurn(session.id, cancelKey), + onSuccess: () => { + setCancelRequested(true); + refresh(); + }, + }); + return ( +
+ + {cancelRequested && running && ( +

+ 已请求取消,等待执行结束。 +

+ )} +
{ + e.preventDefault(); + if (!connectionPending && text.trim() && !running && !send.isPending) + send.mutate(); + }} + > +