diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6901af58d..9d0b73d19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,12 +38,12 @@ Core builds, deploys and runs independently of product services, frontends and d ### Optional application example -`example/parsar/` is an optional Agent workbench in this repository. Its [README](example/parsar/README.md) owns its product behavior. +`example/parsar/` is an optional conversation workspace in this repository. Its [README](example/parsar/README.md) owns its product behavior. -- It calls only public `/v1` APIs. Its Project key stays server-side; it never holds a Core key or issues machine credentials. Self-hosted connection displays the public Session installation command unchanged; Core owns bootstrap authorization and machine credential issuance. +- It calls only public `/v1` APIs. Its Project key stays server-side; it never holds a Core key or issues machine credentials. The example uses only Core-managed hosted environments; its environment templates prepare packages, initialization commands, variables and initial files through the public Session contract. - It may keep a small product-owned SQLite database (Node's built-in module, Node 22.13+), outside the checkout and isolated by Core origin and Project key fingerprint. Provider keys never reach the browser. -- Core owns Skills and all execution and history state. The example stores only Session references and pending creation requests with stable idempotency keys. -- Product resources use `/app/` and never become Core API or database conventions. +- Core owns Skills and authoritative execution/history state. The example owns resource configurations, projects, tasks, comments, interval/Cron plans and Feishu delivery receipts. Session records retain Core references and pending creation requests with stable idempotency keys; product workers use the same public API as interactive flows. +- Product resources use `/app/` and never become Core API or database conventions. Execution uses bypass mode. The local Web operator, Agent-scoped local MCP tokens and configured Feishu routes are the example’s access boundary; it does not implement product accounts or fine-grained permissions. - It is excluded from Core distributions and cannot become a service dependency. ## Workflow and review diff --git a/docs/examples.md b/docs/examples.md index 1e61981fb..cb3c32a73 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,7 +6,7 @@ Complete applications built on the [Agents API guide](./api/public-agent-api.md) ## Parsar Agent workbench -A small single-user product built on Core. You save model providers, Skills and MCP servers, combine them into Agents, then start Sessions in a managed sandbox, with no workspace, or on your own machine. +A conversation workspace built on Core. Assemble Agents from a resource library and an environment template, then talk to them in a local Web chat or through Feishu bots that act as the Agent's identity. Both entries create ordinary public Sessions. Source: [`example/parsar`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/README.md). @@ -20,12 +20,12 @@ Each feature maps to one part of the API. Read the code next to the guide sectio | --- | --- | --- | --- | | Server-side key and headers | Bearer key, `OpenAI-Beta`, route allowlist | [Before you start](./api/public-agent-api.md#before-you-start) | [`server.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server.mjs) | | Agents with a Harness and model | `x_agents_core.harness`, `model_provider` | [Core extensions](./api/public-agent-api.md#core-extensions) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs) | -| Sessions on three runtimes | `openai_hosted`, `none`, `self_hosted` with `workspace_directory` | [Create a Session](./api/public-agent-api.md#create-a-session) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs) | +| Web and Feishu conversations | Public Session creation, input events, Turns and Items | [Send input](./api/public-agent-api.md#send-input) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs), [`server/feishu.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/feishu.mjs) | +| Hosted Environments from templates | `openai_hosted` with packages, files, `env` and `setup_commands` | [Create a Session](./api/public-agent-api.md#create-a-session) | [`server/runtime-profile.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/runtime-profile.mjs) | | Crash-safe Session creation | `Idempotency-Key`, recovery from a lost response | [Idempotency](./api/public-agent-api.md#idempotency) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs) | | Live replies | Event stream, text deltas, reconnect and reconcile | [Stream events](./api/public-agent-api.md#stream-events) | [`src/lib/live-session.ts`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/lib/live-session.ts) | | Follow-ups and cancel | Input events with idempotency keys | [Send input](./api/public-agent-api.md#send-input) | [`src/Composer.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/Composer.tsx) | | History | Paging Turns and Items | [Pagination](./api/public-agent-api.md#pagination) | [`src/lib/api.ts`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/lib/api.ts) | | Skills and versions | `/skills`, default version | [Skills](./api/public-agent-api.md#skills) | [`src/Skills.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/Skills.tsx) | -| Connect your own machine | `x_agents_core.installation`, Environment status | [Run self-hosted Sessions](./getting-started/self-hosted.md) | [`src/ConnectMachine.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/ConnectMachine.tsx) | To contribute an example, follow the [application example boundary](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#optional-application-example). diff --git a/docs/zh/examples.md b/docs/zh/examples.md index 80b9fe13f..a10876b8b 100644 --- a/docs/zh/examples.md +++ b/docs/zh/examples.md @@ -1,14 +1,14 @@ --- title: "示例" source: docs/examples.md -source_hash: 9678dd4dd68c8b78151a441360adb3384542708ad981eb451d98732c4a4a7c58 +source_hash: 99d9e955b20c81fea35ba28915e0bd96619afa94b6fc224b8b7a2a1047cce4a4 --- 基于 [Agents API 指南](api/public-agent-api.md)构建的完整应用,每个都使用项目 API 密钥连接真实的 Core 安装。 ## Parsar Agent 工作台 {#parsar-agent-workbench} -基于 Core 构建的小型单用户产品。保存模型提供商、Skills 和 MCP 服务器,将它们组合成 Agent,然后在托管沙箱、无工作区环境或自己的机器上启动 Session。 +基于 Core 构建的对话工作空间。从资源库和环境模板组装 Agent,然后在本地网页聊天中对话,或通过以 Agent 身份工作的飞书机器人对话。两个入口都创建普通的公开 Session。 源码:[`example/parsar`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/README.md)。 @@ -22,12 +22,12 @@ source_hash: 9678dd4dd68c8b78151a441360adb3384542708ad981eb451d98732c4a4a7c58 | --- | --- | --- | --- | | 服务端密钥和请求头 | Bearer 密钥、`OpenAI-Beta`、路由允许列表 | [开始前](api/public-agent-api.md#before-you-start) | [`server.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server.mjs) | | 带 Harness 和模型的 Agent | `x_agents_core.harness`、`model_provider` | [Core 扩展](api/public-agent-api.md#core-extensions) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs) | -| 三种 Runtime 上的 Session | `openai_hosted`、`none`、带 `workspace_directory` 的 `self_hosted` | [创建 Session](api/public-agent-api.md#create-a-session) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs) | +| 网页与飞书对话 | 公开 Session 创建、输入事件、Turns 和 Items | [发送输入](api/public-agent-api.md#send-input) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs)、[`server/feishu.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/feishu.mjs) | +| 基于模板的托管 Environment | 带软件包、文件、`env` 和 `setup_commands` 的 `openai_hosted` | [创建 Session](api/public-agent-api.md#create-a-session) | [`server/runtime-profile.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/runtime-profile.mjs) | | 可在崩溃后恢复的 Session 创建 | `Idempotency-Key`、响应丢失后的恢复 | [幂等性](api/public-agent-api.md#idempotency) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/sessions.mjs) | | 实时回复 | 事件流、文本增量、重连与状态协调 | [流式事件](api/public-agent-api.md#stream-events) | [`src/lib/live-session.ts`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/lib/live-session.ts) | | 后续输入与取消 | 带幂等键的输入事件 | [发送输入](api/public-agent-api.md#send-input) | [`src/Composer.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/Composer.tsx) | | 历史 | 分页读取 Turns 和 Items | [分页](api/public-agent-api.md#pagination) | [`src/lib/api.ts`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/lib/api.ts) | | Skills 和版本 | `/skills`、默认版本 | [Skills](api/public-agent-api.md#skills) | [`src/Skills.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/Skills.tsx) | -| 连接自己的机器 | `x_agents_core.installation`、Environment 状态 | [运行自托管 Session](getting-started/self-hosted.md) | [`src/ConnectMachine.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/src/ConnectMachine.tsx) | 要贡献示例,请遵循[应用示例边界](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#optional-application-example)。 diff --git a/example/parsar/README.md b/example/parsar/README.md index 309cdf499..b9a1623ae 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -1,56 +1,168 @@ -# Parsar Agent workbench +# Parsar workspace -An independently started small application on OpenAgentCore. 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. +[简体中文](README.zh.md) + +Parsar is an optional, independently started conversation workspace built on OpenAgentCore’s public `/v1` Agents API. People talk to Agents in two places: the local Web chat and Feishu. Core owns execution; Parsar owns Agents, environment templates, the resource library and Feishu delivery. Execution always uses bypass mode. Accounts and fine-grained permissions are deferred. ## 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: +Use Node 22.13+ and pnpm 10.30.3. Configure [Core](../../docs/getting-started/install.md), a model connection and a dedicated Project key. Keep credentials 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='' +export OPENAI_BASE_URL='http://127.0.0.1:8091/v1' +export OPENAI_API_KEY='' pnpm --filter @oac/parsar-example... 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`. +Open http://127.0.0.1:18180. `OPENAI_BASE_URL` ends in `/v1`; remote endpoints require HTTPS. An SSH tunnel can expose a remote Core on loopback. `OAC_EXAMPLE_PORT` changes the local port. For production assets, run `pnpm --filter @oac/parsar-example build`, then `pnpm --filter @oac/parsar-example start`. The server binds to `OAC_EXAMPLE_HOST`, default `127.0.0.1`. Set it to one IPv4 address of the machine, such as its LAN address, to serve that network; Parsar has no sign-in, so everyone who can reach the address can use the workspace, its models and its Feishu bots. Links from other sites may open the homepage as a top-level browser navigation; API requests retain same-origin checks. The browser Host must be that address (`localhost` is also accepted on the loopback default), a local tunnel may use a different port, and write Origins must exactly match the Host, including its port. Use one server process per data directory. + +## Product model + +| Object | What it is | +| --- | --- | +| **资源库** | Building blocks, grouped by what uses them: models for Agent engines; Skills, MCP servers, Plugins, knowledge and instruction fragments for Agents; credentials for MCP. Resources are shared: editing one affects everything that uses it, and an item in use cannot be removed. | +| **环境模板** | The prepared workspace: npm and Python packages, a Bash setup script, environment variables, Feishu bot variables and initial files. | +| **Agent** | What people talk to: name, description, instructions, how it runs, one required environment template, capabilities and behavior switches. How it runs mirrors a local Codex configuration: Harness, model, reasoning effort, web search and, under **高级**, reasoning summary, verbosity and service tier. **复制** creates an independent copy. | +| **飞书机器人** | One bot is bound to at most one Agent. It routes its chats to that Agent and is the Agent’s Feishu identity in every conversation, including Web chats. | +| **对话** | One public Core Session with its own Environment. It freezes the Agent and environment configuration when it starts. | + +Configuration lives only in templates and Agents; a conversation never overrides it. Edits affect only new conversations. When the Agent or its environment template changes, an existing conversation says so and offers **用新配置开始新对话**. + +## CLI and management Agent + +[cli.mjs](cli.mjs) is a dependency-free Node 22.13+ client of the same `/app` and exposed `/v1` routes as the UI. It adds no product server endpoint or execution path. Set `PARSAR_URL` to the Parsar Web origin, not the Core URL. It inherits the deployment's trusted-network access: anyone who can reach Parsar can manage its resources. No Core key is passed to the CLI or Agent; do not expose this workspace publicly or grant the management environment to an untrusted Agent. + +```sh +export PARSAR_URL=http://127.0.0.1:18180 +node example/parsar/cli.mjs --help +node example/parsar/cli.mjs GET /app/agents +node example/parsar/cli.mjs uuid +node example/parsar/cli.mjs PUT /app/runtimes/REPLACE_WITH_UUID --file environment.json +``` + +The CLI emits JSON; `--file -` reads a JSON body from stdin. It never follows redirects or retries writes automatically. Generate and retain each resource ID before creating it. After an uncertain write, GET that ID before retrying. Editing uses GET followed by PUT of the complete object, preserving its `revision` and untouched fields; a 409 requires rereading and reconciling the change. Environment variable values returned as `null` retain their saved values when sent back. The client handles JSON APIs; binary downloads, multipart uploads and SSE remain available through the existing Web UI/client. + +The same file is a JavaScript SDK: + +```js +import { createParsarClient } from "./cli.mjs"; +const parsar = createParsarClient(process.env.PARSAR_URL, { token: process.env.PARSAR_TOKEN }); +const agents = await parsar("GET", "/app/agents"); +``` + +The [management example](examples/manager/configure.mjs) creates **parsar他爹** as an ordinary Agent plus an ordinary environment template containing the CLI and its [operation guide](examples/manager/GUIDE.md). It uses an existing model and the Node runtime included with hosted Codex or Claude Code; it does not require MCP or a special Agent type. Start Parsar with `OAC_EXAMPLE_HOST` set to a local LAN IPv4 reachable by both the configuring process and the sandbox (see [Run](#run)); the default loopback listener cannot serve a hosted sandbox: + +```sh +node example/parsar/examples/manager/configure.mjs \ + --url http://PARSAR_LAN_IP:18180 \ + --model MODEL_ID --harness codex +``` + +`--url` is the address reached by the configuring process; `--agent-url` is the same server's address reachable from the sandbox, defaulting to `--url`. Use different addresses only when your deployment explicitly provides a proxy between them. The script prints resource IDs before writes and refuses to overwrite existing resources. Use `--agent-id` and `--environment-id` to choose IDs explicitly. If a later step fails, inspect the reported IDs; partial resources are retained. Open the returned Agent link and start a conversation, for example “创建一个能分析 Excel 的 Agent,复用现有模型,配置好环境后给我链接。” Created resources appear in the ordinary lists and remain editable there. Trials verify environment preparation and a first reply, not the complete business task. Shared templates may be trialled with another bound Agent. + +You can also ask “帮我给这个 Agent 开通飞书机器人。” The manager follows its operation guide to reuse a bot or start registration, returns the authorization link for you to confirm, then binds the Agent and checks connection and delivery status. Enter existing application secrets only on **飞书接入**, never in chat. New setups default to private chat; group chat requires an explicit request. Registration and connection alone do not prove that a test message was received and answered. + +### Hosted sandboxes and an authenticated gateway + +The managed microsandbox deployment blocks private networks except its Core connection ([node defaults](../../docs/getting-started/nodes.md)). A LAN Web address can therefore work on the host and still return `ECONNREFUSED` inside the Agent. Do not weaken Core isolation or substitute its administration endpoint. Provide a public HTTPS gateway to Parsar with its own credential. + +[The Caddy example](examples/manager/Caddyfile) binds only to `127.0.0.1:18181`, requires a nonempty `PARSAR_TOKEN` on every forwarded request, exposes only `/app/*` and `/v1/*`, and removes the gateway credential before forwarding to Parsar. It rewrites Host and Origin to the exact `PARSAR_UPSTREAM` address expected by the existing server. It adds no Core access. The token grants the same workspace access as the internal Web UI; use a cryptographically random token, keep it in a private environment file, and give the management environment only to trusted Agents. + +With [Caddy](https://caddyserver.com/docs/install) and [cloudflared](https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/) installed on the Parsar host, set `PARSAR_UPSTREAM` to its existing IPv4 address and port (for example `10.0.0.2:18180`) and load `PARSAR_TOKEN` from private storage. Start Caddy, then the tunnel in another process: + +```sh +caddy run --config example/parsar/examples/manager/Caddyfile --adapter caddyfile +cloudflared tunnel --url http://127.0.0.1:18181 +``` -## Main flow +Point the tunnel only at the authenticated gateway, never at the unauthenticated Parsar listener. Verify the public URL returns 401 without a token and with an incorrect token before use. The CLI reads `PARSAR_TOKEN` from its environment and sends it as a Bearer header; tokens require HTTPS (loopback HTTP is allowed for local tests), and redirects remain disabled. Generate a token, for example, with `node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))'` redirected to a mode-0600 file; do not paste it into a conversation or URL. -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. Workspace Sessions pass the selected Provider URL/key explicitly to Core, using the harness protocol. These Sessions require an HTTPS Base URL and API key. Text-only Sessions use Core's deployment connection; the creation dialog states that their Provider is only a catalog group. -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. 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. Unsupported Skill/MCP/runtime combinations are explained before creation. The Session's **Files** dialog uploads one file at a time (up to 5 MiB) through Core's public Environment Files API while idle. Each upload gets a unique path under `/workspace/inputs`; a path relative to the working directory is inserted into the message draft, not sent automatically. Unsent drafts, including uploaded file paths, survive reloads and navigation in the same browser tab and remain scoped to the Session. This keeps the API's `/workspace` alias distinct from the physical user-machine directory. Ask the Agent to save generated files under `./outputs`; published Artifacts can be paged and downloaded from the same dialog, even when the live workspace is unavailable. Downloads remain binary; errors stay in the dialog. Text-only Sessions have no file actions. Each hosted Session gets its own workspace; text-only has no workspace. +Run the management example with `--url https:// --web-url http://:18180` and `PARSAR_TOKEN` loaded in the configuring process. The script stores the token as an environment value, which product reads redact, and sets `PARSAR_WEB_URL` for browser links. For an existing manager, update its environment's `PARSAR_URL`, `PARSAR_TOKEN`, `PARSAR_WEB_URL` and initial CLI/guide files through the ordinary GET/PUT flow, preserving the revision and other fields. Start a **new conversation** to use the updated environment; old conversations retain the previous address and files. -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. +Quick Tunnel URLs last only for that tunnel process and may change after restart. Keep the gateway and tunnel running, update the manager environment after a URL change, and use a new conversation. This preview gateway serves API calls, not public browser sessions or SSE. Stop the tunnel to revoke public access, or replace its token and restart Caddy to rotate the credential. Gateway checks run with `PARSAR_TEST_CADDY=/path/to/caddy node --test example/parsar/tests/gateway.test.mjs` while port 18181 is free. -## Ownership and storage +## Web chat -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. +**新对话** opens the home page: choose an Agent, describe the work and send. The sidebar lists recent conversations; **全部对话** opens the full list with search, Agent, source and date filters, plus **运行**, **用量** and **审计日志**. **运行** also sits directly under **新对话** with the number of running conversations. It lists every running turn from Web and Feishu with its stage, waiting time and the Agent's usual first-reply time, and the recently finished ones with their duration, automatic recoveries and plain-language failures; it also shows how many conversations are running, waiting for an answer or failed in the last five minutes. Running turns can be stopped and the latest failed turn of a conversation retried there. Date filters use the browser’s local dates. Usage totals cover all matching records; missing usage remains unknown. Optional model prices are USD per million input/output tokens, frozen when a conversation starts; costs are estimates, not provider bills. -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. The Core Project key is not stored in it. Provider keys are stored there. Builds and caches use `${OAC_DEV_HOME:-$HOME/.oac}`. +A conversation streams replies and tool activity, groups work steps, navigates user turns with the turn rail, supports follow-ups and cancellation and reopens durable history. Messages sent while the Agent is working reach the running task; answer business questions on their card. Enter sends; Shift+Enter adds a line. Drafts survive reloads in the same browser tab. The folder button uploads inputs up to 5 MiB while idle, inserts their path into the draft and downloads Artifacts after the live workspace is gone; ask the Agent to save deliverables under `./outputs`. The info button shows the Agent, model, Harness, environment template, Feishu identity, source and the configuration recorded at creation, without variable values, script contents or credentials. When a creation response is lost, the conversation page offers **恢复创建**, which reuses the frozen request and idempotency key. -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. +To show thinking, set the Agent’s **高级 → 推理摘要** to `auto` and start a new conversation. When the Harness emits a native summary, **思考过程** appears directly in the conversation, separate from tool steps. While thinking is in progress it shows the latest text; after completion it defaults to a single row. Click to read the full summary, which is restored from history after reconnection. Existing conversations keep the configuration recorded at creation; enabling summaries does not add thinking to old history. -A few more implementation details: +Parsar watches every running turn, from Web and Feishu alike. Before the first output it shows the stage (**正在准备工作环境**, **正在启动**) with the waiting time and the Agent's usual first-reply time from the last seven days. Recovery is automatic and happens only while nothing has run: an Environment that fails to initialize, or a claimed prepared conversation whose first turn has not started within 45 seconds, is replaced once with the identical frozen request; a turn without any output 45 seconds after it started is cancelled and its input resent once; a turn that fails before any output is resent once. Replaced turns are hidden from the conversation and never reported in Feishu. A final failure is explained in plain language with **重试**. **产物** lists the files recent conversations produced, linked to their conversation. -- **Providers and models** are saved together in one SQLite transaction. Editing reads a Provider and its models as one snapshot; changing a model advances the Provider's revision. -- **Browser calls** use `OpenAIAgentsClient`, with a small public HTTP reader for Artifacts (the client has no Artifact method); only Session creation goes through a small server adapter. Closing the browser aborts the upstream stream, never the running Session. -- **Workspaces:** hosted Sessions each get their own. User-machine Sessions use the selected host directory, so the same path means shared files. -- **Skills on a user machine** come only from local capability directories in this example; it rejects Agents bound to managed Skills instead of ignoring them. Core itself can deliver managed Skills to user machines through `x_agents_core.environment`. +Optional business questions use the declared `ask_user` function. They appear in the conversation and return through public function-result events with stable request IDs. This is information gathering, not execution approval. + +**设置** changes the workspace name, the Chinese/English language and the light, dark or system theme. + +## Agents and resources + +**Agent** lists Agents as cards with their engine, model, environment template and bound Feishu bot. The Agent page groups **基本信息**, **执行与环境**, **指令**, **能力** and **行为**. **主指令** is the Agent’s own instruction text; optional **指令片段** are appended in selection order to produce one system prompt. Selection limits follow Core's [Environment limits](../../contracts/agents-api/environments.md#plugins-and-capability-directories): up to 50 Skills and 50 Plugins, and up to 50 knowledge items and environment-template files together; instruction fragments and MCP servers have no count limit. Bind only what the Agent needs, since every Skill and tool adds to what the model reads. In **执行与环境**, the Harness narrows every option to its declared values ([Harness capabilities](../../contracts/agents-api/harness-capabilities.md)); switching Harness resets values the new one does not accept and says which. Empty options keep the Harness default; web search defaults to **关闭**. Web conversations and Feishu step records show searches as **搜索网页**. Creating or editing a model, Skill, MCP, environment template or capability from the Agent page keeps unsaved fields, and a newly created resource is selected automatically. Unsaved Agent edits are restored when you return to the page in the same browser tab, and **保存** points to any missing required field. An existing Agent's page shows the last seven days: success rate, median first reply and automatic recoveries. + +**模型** groups model IDs under Providers. Fetch a Provider’s OpenAI-shaped `GET /v1/models` catalog or enter IDs manually. Keys remain server-side; blank edits preserve the saved key. **测试** sends one tiny generation with the saved connection and shows the latency or the provider's error, never the key. Discovery sends only that Provider’s key and never follows redirects. Every Harness uses the selected HTTPS Provider: Responses for Codex, Anthropic for Claude Code and MiniMax Code. Unsupported combinations fail explicitly; consult the [Harness coverage ledger](../../contracts/agents-api/index.md). + +- **Skills** supports SKILL.md creation, ZIP upload, versions, default selection, per-Agent version pins and bounded file previews. **浏览目录** searches the skills.sh index; previews use the pinned `skills@1.5.23` CLI in a temporary directory and the normal public Skill upload. This requires `npx` and network access on the Parsar host. Imported archives are bounded to 32 MiB and 1,000 files. +- **MCP** provides a static service directory and HTTPS endpoint configuration. Optional Vault credentials use official public MCP tools with environment origin. +- **凭据** creates Vaults and write-only bearer credentials. **OAuth 授权** uses the maintained MCP SDK for discovery, dynamic client registration, issuer binding and PKCE, then stores the token through public Vault APIs. OAuth tokens never return to the browser. +- Instruction fragments and knowledge have immutable numbered versions, defaults, pins and deprecation. Knowledge versions become initial workspace files. Standard Claude/Codex Plugin ZIPs become public Environment Plugins; declared `.mcp.json` servers are listed. Executables required by stdio servers must exist in the environment. + +**环境模板** runs only in Core-managed workspaces. Each new conversation installs files and packages and completes setup before starting the Agent; a setup failure stops execution. Initial files use canonical `/workspace/` paths; scripts run from the workspace. Variable values are write-only in product responses: `null` preserves an existing value, a string replaces it and removing the name deletes it. **飞书机器人变量** map variable names to the bound bot’s **App ID**, **App Secret** or region; a conversation with such a template requires the Agent to have a bound bot. Parsar sends templates inline through the public Session Environment contract and does not create a Core Template resource. Core may reuse an identical earlier preparation for up to 24 hours ([preparation reuse](../../contracts/agents-api/environments.md#preparation-reuse)), and a long-idle conversation can move to a new sandbox; in both cases only `/environment` (including `/workspace`) is kept and setup does not run again. Setup must therefore write everything it needs later under `/workspace` or the package directories, never under HOME. The Feishu workspace keeps lark-cli's configuration and encrypted credentials in `/workspace/.lark-cli` through `LARKSUITE_CLI_CONFIG_DIR` and `LARKSUITE_CLI_DATA_DIR`. + +**提前准备,新对话无需等待安装** keeps one prepared Session for each Agent that uses the template. Parsar creates it without input, so Core prepares its Environment ahead of time, and the next Web or Feishu conversation takes it over with its first message; the conversation keeps its own title. A prepared Session is replaced when the Agent, its environment template, bot or model provider changes, after 12 hours, or once taken; turning the switch off discards it. A first message with images or files starts an ordinary Session. Each prepared Session holds a workspace while it waits. + +**试运行** creates one real conversation with an Agent that uses the template, waits for its first reply, shows success with the time or the plain-language failure, and deletes the conversation. + +## Feishu + +**飞书接入** shows one card per bot. **添加机器人** offers **扫码创建**, which provisions an application through Feishu’s device flow, or **已有应用** with an App ID and App Secret. A new bot stays disabled until configured. Secrets stay in the restricted local database. The official SDK owns authentication, normalization and long connections; no inbound public callback is needed. + +1. On the card, choose the **Agent**, turn on **私聊** and/or **群聊**, then **保存并启用**. Enabling requires credentials, an Agent with an environment template and at least one of the two. With **群聊** on, the bot answers mentions in every group it has joined; adding the bot to a group is all that is needed. Who can add the bot is controlled by the app’s availability in the developer console. +2. In the developer console, enable the bot, long-connection event delivery and `im.message.receive_v1`, publish the app and add it to the target groups. Grant mention receipt and send-as-bot permissions; direct messages, message resources and reactions need their own scopes. A missing reaction permission does not discard a message. +3. Private chat keeps one conversation per sender until `/new` or `/reset`. In groups, mentioning the bot starts a topic and each topic is one conversation shared by everyone who replies in it. Follow-ups without a mention arrive only when the app has the sensitive “read all group messages” scope (`im:message.group_msg`); otherwise mention the bot again in the topic. A topic reply that mentions only other people is addressed to them and is ignored. The bot serves its own tenant only: messages from external contacts are ignored. Other bots and `@all` are ignored, as are groups while **群聊** is off and private chats while **私聊** is off. Changing the bound Agent affects new topics and new private conversations. +4. Text, rich posts, images, files, audio and video, quoted messages and merged forwards are normalized before dispatch. Attachment limits are eight resources and 5 MiB total. Follow-up files wait until the conversation is idle. Quoted messages are read up to four levels; earlier group messages are not read. +5. `/cancel`, `/answer `, `/new` and `/help` work in the same thread. A plain reply answers a single pending business question. Cancellation takes priority over queued follow-ups and recovers an uncertain creation before cancelling it. + +Accepted messages are committed to SQLite before the WebSocket ACK; request IDs and frozen payloads survive retries and restarts. The worker edits one compact progress card in place, includes any pending business question in that card, and finishes it with the result. Each reply shows only the current exchange, including answers that resume the same Core Turn. The answer stays visible; while awaiting a business decision, the question takes priority and its supporting context is collapsed, except QR authorization instructions. Available reasoning summaries, progress commentary and tool calls use separate collapsed sections. Tool calls pair with their results and show parameters, output, duration and failure details. The card previews the latest six calls with explicitly marked, byte-bounded excerpts; it counts each call once and does not count failed or incomplete calls as completed. Progress uses five-second polling. Complete history and Artifacts remain in the Web conversation. Settled threads stop polling after five minutes and wake on new messages. **消息记录** lists the latest 100 deliveries plus unresolved ones; **重试** keeps the original identity. A failed inbound message blocks later messages in that thread. Uncertain outbound sends are not replayed beyond 55 minutes. Disabling a bot pauses delivery without cancelling Core work. + +### Bot identity + +The Agent acts as its bound bot. With **飞书机器人变量**, Parsar puts the bot’s App ID and App Secret into the conversation’s Environment variables. The Feishu case maps them to `FEISHU_*` names and its setup script runs `lark-cli config init --app-secret-stdin`, so the CLI mints and refreshes tenant tokens itself and long conversations keep working. Do not map them to `LARKSUITE_CLI_APP_ID` or `LARKSUITE_CLI_APP_SECRET`: those switch lark-cli to environment credentials, which require a ready-made tenant token. Anyone who can talk to the bot can use the bot’s permissions, and tools in the Environment can read the secret. Grant the bot only the scopes its Agent needs and prefer one bot per Agent. A rotated secret applies to new conversations only. + +### Feishu workspace case + +[The Feishu workspace](examples/feishu/) is assembled from ordinary templates. [environment.json](examples/feishu/environment.json) declares packages, bot variables and files, [setup.sh](examples/feishu/setup.sh) installs the checksum-verified official CLI binary and configures it with the bot credentials, [agent.json](examples/feishu/agent.json) defines a Codex Agent. Configure a model, import the official `lark-shared` Skill and the business Skills you need, add a bot, then run: + +```bash +node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 --model MODEL_ID --bot BOT_ID --skills SHARED_SKILL_ID,DOMAIN_SKILL_ID +``` + +`--bot` is the Parsar bot ID, not the Feishu App ID. The script creates the environment template and Agent through the same `/app/runtimes` and `/app/agents` operations as the UI, binds the bot and prints the Agent link. The first message can ask for `lark-cli api GET /open-apis/bot/v3/info --as bot --json`. The node must resolve and reach the Feishu open platform; templates cannot repair node DNS or network policy. + +## Ownership and local API + +Core’s Project key stays server-side. Successful conversation creation leaves a Core reference and product metadata; pending creation retains the frozen public request until confirmed. Core remains authoritative for execution, Items, Turns, Skills, Vaults and Artifacts. SQLite stores product configuration, capability versions, Feishu delivery state and temporary OAuth/registration state. Files live under `~/.oac/data/parsar-example/`; `OAC_EXAMPLE_DATA_DIR` overrides this with an absolute path. Core origin and Project-key fingerprint select the file. Back it up with the server stopped. Provider and bot secrets reside in it; the Core Project key does not. Builds and caches use `${OAC_DEV_HOME:-$HOME/.oac}`. + +| Product route family | Caller and credential | +| --- | --- | +| `/app/providers`, `/app/models`, `/app/agents`, `/app/runtimes`, `/app/mcps`, `/app/sessions`, `/app/artifacts` | Same-origin local UI; the Feishu worker reuses Session creation in-process | +| `/app/feishu/bots`, `/app/feishu/provision`, `/app/feishu/deliveries` | Same-origin local UI; credentials never returned | +| `/app/capabilities`, `/app/skills/registry/preview` | Same-origin local UI | +| `/app/mcp-oauth/`, `/oauth/callback` | Local OAuth flow; random state, PKCE and same-origin completion POST | +| `/app/settings`, `/app/audit` | Same-origin local UI; audit contains mutation metadata, never request bodies | +| Allowlisted `/v1` proxy | Same-origin local UI; server injects its Project key | + +Core builds and distributes independently of this example. No `/core/v1` or machine API is available through the product proxy. ## 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 --filter @oac/parsar-example 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 gate runs TypeScript, Node proxy/product/Feishu tests, a production build and browser acceptance. Browser fixtures use ports 18180/18181 and isolated data under `~/.oac/tests/parsar-example/`; set `OAC_EXAMPLE_PORT` when 18180 is taken. Install Chrome with `pnpm --filter @oac/parsar-example exec playwright install chrome` when needed. Tests exercise durable retries, duplicate ingress, Feishu routing, shared topics, bot identity variables, QR state, OAuth PKCE/Vault persistence and UI workflows. Fixture tests do not establish real model or Feishu service availability. -The opt-in live browser probe requires an already started example backed by an isolated real Project: +The opt-in live browser probe requires an already running example backed by an isolated real Project: ```sh OAC_EXAMPLE_LIVE_URL=http://127.0.0.1:18180 \ @@ -58,14 +170,10 @@ 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. - -## Connect a user machine - -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** and run the Core-provided command on the target host. The installer downloads the matching native distribution, installs the selected harness and starts the connection. 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. Commands carry temporary Environment-scoped authorization; do not share them. Session refresh renews them. +It executes a real model and leaves resources for inspection. Live Feishu acceptance requires a designated test bot with its scopes and an authorized chat; configuration alone does not verify delivery. -The selected model Provider needs an HTTPS Base URL and API key. Its protocol is Responses for Codex and Anthropic for Claude Code and MiniMax 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. +### Chat media -Self-hosted capability sources are local directories under the 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. +Web conversations preview PNG, JPEG, GIF and WebP Session Artifacts beside the reply and provide file downloads. Save outputs under `./outputs`; Markdown image/file links to their `/workspace/outputs/` paths resolve to the persisted artifacts. Other formats, including SVG, remain downloads; external image URLs are not fetched. Feishu sends each artifact as an image or file message, with a 5 MiB limit per nonempty attachment. Upload keys and message identities persist across retries; failed deliveries remain in **消息记录**. Audio and video are downloadable files, without inline playback. -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. Reuse the original installation directory when reconnecting. +Bot registration returns `verification_url` and a relative `qr_url`. Show the authorization link and `![飞书授权二维码]()` with the returned path substituted. The PNG is generated inside Parsar, and `GET /app/feishu/provision//qrcode` rejects expired or completed registrations. Web and Feishu use the same image; neither sends authorization URLs to a third-party QR service. diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md new file mode 100644 index 000000000..8c7a64046 --- /dev/null +++ b/example/parsar/README.zh.md @@ -0,0 +1,184 @@ +--- +source: example/parsar/README.md +source_hash: 012de696eb00f1550dfa2651d517821f7831e01ad3e6039b5038555a68ef75b7 +--- + +# Parsar 工作空间 + +[English](README.md) + +Parsar 是可独立启动的可选对话工作空间,只通过 OpenAgentCore 公开 `/v1` Agents API 执行。用户在两个地方与 Agent 对话:本地网页聊天和飞书。Core 管执行;Parsar 管 Agent、环境模板、资源库和飞书投递。执行始终使用 bypass。账号和细粒度权限暂缓。 + +## 启动 {#run} + +使用 Node 22.13+ 和 pnpm 10.30.3,准备好 [Core](../../docs/zh/getting-started/install.md)、模型连接和专用 Project key。密钥配置放在仓库外的私有环境文件中: + +```sh +export OPENAI_BASE_URL='http://127.0.0.1:8091/v1' +export OPENAI_API_KEY='' +pnpm --filter @oac/parsar-example... install --frozen-lockfile +pnpm --filter @oac/parsar-example dev +``` + +打开 http://127.0.0.1:18180。`OPENAI_BASE_URL` 以 `/v1` 结尾;远程地址必须使用 HTTPS,也可通过 SSH 隧道将远程 Core 映射至本机。`OAC_EXAMPLE_PORT` 调整端口。构建运行使用 `pnpm --filter @oac/parsar-example build`,再运行 `pnpm --filter @oac/parsar-example start`。服务监听 `OAC_EXAMPLE_HOST`,默认 `127.0.0.1`。设为本机的某个 IPv4 地址(例如局域网地址)即可向该网络提供服务;Parsar 没有登录,能访问该地址的人都可以使用工作空间、其中的模型和飞书机器人。允许从其他站点的链接顶层打开首页,API 请求仍检查同源。浏览器 Host 必须是该地址(默认回环地址时也接受 `localhost`),本地隧道可使用不同端口,写请求的 Origin 必须与 Host 完全一致,包括端口;每个数据目录只运行一个服务进程。 + +## 产品模型 {#product-model} + +| 对象 | 含义 | +| --- | --- | +| **资源库** | 组装用的零件,按使用方分组:模型供 Agent 的执行引擎使用;Skills、MCP 服务、Plugins、知识和指令片段供 Agent 使用;凭据供 MCP 使用。资源是共享的:修改一项会影响所有使用它的对象,正在使用的条目不能移除。 | +| **环境模板** | 准备好的工作区:npm 与 Python 软件包、Bash 初始化脚本、环境变量、飞书机器人变量和初始文件。 | +| **Agent** | 用户对话的对象:名称、描述、指令、运行方式、一个必选的环境模板、能力和行为开关。运行方式对应本地 Codex 的配置:Harness、模型、推理强度、网页搜索,以及 **高级** 中的推理摘要、输出详细度和服务层级。**复制**会生成一份独立副本。 | +| **飞书机器人** | 一个机器人最多绑定一个 Agent。它把自己的聊天路由给该 Agent,并在所有对话中(包括网页对话)作为该 Agent 的飞书身份。 | +| **对话** | 一个公开 Core Session,拥有独立的 Environment,在开始时冻结 Agent 和环境配置。 | + +配置只在模板和 Agent 中设定,对话不会覆盖。修改只影响新对话。Agent 或其环境模板变更后,已有对话会给出提示,并提供 **用新配置开始新对话**。 + +## CLI 与管理 Agent {#cli-and-management-agent} + +[cli.mjs](cli.mjs) 使用 Node 22.13+,无额外依赖,直接调用网页使用的 `/app` 和已开放的 `/v1` 接口,不新增产品服务端接口或执行路径。`PARSAR_URL` 填 Parsar 网页地址,不是 Core 地址。权限沿用当前部署的可信网络边界:能访问 Parsar 的调用方可以管理资源。CLI 和 Agent 不持有 Core key;不要把工作空间直接开放到公网,也不要把管理环境交给不可信的 Agent。 + +```sh +export PARSAR_URL=http://127.0.0.1:18180 +node example/parsar/cli.mjs --help +node example/parsar/cli.mjs GET /app/agents +node example/parsar/cli.mjs uuid +node example/parsar/cli.mjs PUT /app/runtimes/REPLACE_WITH_UUID --file environment.json +``` + +CLI 输出 JSON,`--file -` 从标准输入读取 JSON 请求体。不跟随重定向,不自动重试写入。创建前先生成并保留资源 ID;写入结果不确定时,先 GET 同一个 ID。编辑时先 GET,修改后 PUT 完整对象,保留 `revision` 和未修改字段;遇到 409 需重新读取并核对修改。环境变量返回的 `null` 原样传回即可保留原值。客户端支持 JSON 接口;二进制下载、multipart 上传和 SSE 继续使用现有网页或客户端。 + +同一个文件也可作为 JavaScript SDK 使用: + +```js +import { createParsarClient } from "./cli.mjs"; +const parsar = createParsarClient(process.env.PARSAR_URL, { token: process.env.PARSAR_TOKEN }); +const agents = await parsar("GET", "/app/agents"); +``` + +[管理案例](examples/manager/configure.mjs) 用普通 Agent 和普通环境模板创建 **parsar他爹**。环境中包含 CLI 和[操作说明](examples/manager/GUIDE.md),使用已有模型和托管 Codex、Claude Code 自带的 Node,无需 MCP 或特殊 Agent 类型。先将 `OAC_EXAMPLE_HOST` 设为配置脚本和沙箱都能访问的本机内网 IPv4,再启动 Parsar(见[运行](#run));默认的 loopback 监听无法服务托管沙箱: + +```sh +node example/parsar/examples/manager/configure.mjs \ + --url http://PARSAR_LAN_IP:18180 \ + --model MODEL_ID --harness codex +``` + +`--url` 是配置脚本访问的地址;`--agent-url` 是沙箱能访问的同一台 Parsar 地址,默认等于 `--url`。只有部署确实提供地址间代理时,才使用不同地址。脚本在写入前输出资源 ID,拒绝覆盖已有资源;也可通过 `--agent-id` 和 `--environment-id` 指定 ID。若后续步骤失败,按输出的 ID 检查,已创建的部分会保留。打开返回的 Agent 链接发起对话,例如“创建一个能分析 Excel 的 Agent,复用现有模型,配置好环境后给我链接。”创建的资源直接出现在原有列表,仍可通过网页编辑。试运行只证明环境准备和首次回复正常,不代表完整业务任务通过;共享模板可能使用另一个绑定的 Agent 试运行。 + +也可以说“帮我给这个 Agent 开通飞书机器人。”管理 Agent 会按操作指南复用机器人或发起注册,给你授权链接,等你确认后绑定 Agent,再检查连接和投递状态。已有应用的密钥只在 **飞书接入** 页面填写,不发到聊天里。新接入默认仅开私聊,群聊需明确要求。注册成功、连接成功都不等于测试消息已经收到并回复。 + +### 托管沙箱与认证入口 {#hosted-sandboxes-and-an-authenticated-gateway} + +托管 microsandbox 部署默认禁止访问私网,仅放行 Core 连接(见[节点默认设置](../../docs/zh/getting-started/nodes.md))。因此主机能访问的内网页面地址,在 Agent 内仍可能报 `ECONNREFUSED`。不要放宽 Core 隔离规则或改用管理接口;应为 Parsar 提供带独立凭据的公网 HTTPS 入口。 + +[Caddy 配置示例](examples/manager/Caddyfile) 只监听 `127.0.0.1:18181`,转发前必须验证非空的 `PARSAR_TOKEN`,仅开放 `/app/*` 和 `/v1/*`,并在转发到 Parsar 前删除入口凭据。Host 和 Origin 会改为现有服务要求的 `PARSAR_UPSTREAM` 地址,不增加 Core 权限。令牌拥有与内网页面相同的工作空间访问权限;使用密码学随机令牌,保存在私有环境文件中,只将管理环境分配给可信 Agent。 + +在 Parsar 主机安装 [Caddy](https://caddyserver.com/docs/install) 和 [cloudflared](https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/),将 `PARSAR_UPSTREAM` 设为现有 Parsar IPv4 地址和端口(例如 `10.0.0.2:18180`),从私有存储加载 `PARSAR_TOKEN`。先运行 Caddy,再用另一个进程运行隧道: + +```sh +caddy run --config example/parsar/examples/manager/Caddyfile --adapter caddyfile +cloudflared tunnel --url http://127.0.0.1:18181 +``` + +隧道只能指向认证入口,不可直接指向无认证的 Parsar 监听端口。使用前确认公网地址在没有令牌或令牌错误时均返回 401。CLI 自动从环境读取 `PARSAR_TOKEN` 并作为 Bearer 请求头发送;使用令牌时必须为 HTTPS(本地测试允许 loopback HTTP),仍不跟随重定向。可用 `node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))'` 生成令牌并重定向到权限为 0600 的文件,不要将令牌贴入对话或 URL。 + +运行管理案例时传入 `--url https://<隧道域名> --web-url http://:18180`,并在配置进程中加载 `PARSAR_TOKEN`。脚本将令牌写入环境变量,产品读取时会隐藏其值,同时设置 `PARSAR_WEB_URL` 用于网页链接。已有管理 Agent 通过普通 GET/PUT 流程更新环境的 `PARSAR_URL`、`PARSAR_TOKEN`、`PARSAR_WEB_URL` 和初始 CLI、操作说明文件,保留 revision 与其他字段。更新后必须**新建对话**,已有对话仍使用旧地址和文件。 + +Quick Tunnel 地址只在该隧道进程运行期间有效,重启可能换地址。保持入口和隧道运行,地址变化后更新管理环境并新建对话。该预览入口用于 API,不提供公网网页会话或 SSE。停止隧道即可关闭公网访问;替换令牌并重启 Caddy 可轮换凭据。端口 18181 空闲时,用 `PARSAR_TEST_CADDY=/path/to/caddy node --test example/parsar/tests/gateway.test.mjs` 验证入口。 + +## 网页聊天 {#web-chat} + +**新对话** 打开首页:选择 Agent、描述工作并发送。侧栏列出最近对话;**全部对话** 打开完整列表,支持搜索以及按 Agent、来源和日期筛选,并提供 **运行**、**用量** 和 **审计日志**。**运行** 也直接放在 **新对话** 下方,并显示正在进行的对话数。它列出网页和飞书中每一轮正在执行的对话,显示所处阶段、已等待时间和这个 Agent 通常的首次回复时间;也列出最近完成的轮次,显示用时、自动恢复次数和通俗的失败原因,并统计正在进行、等你回复和近 5 分钟失败的数量。可以在这里停止正在执行的轮次,或重试对话中最近失败的一轮。日期筛选按浏览器本地日期计算;用量汇总覆盖所有匹配记录,缺失用量保持未知。模型可配置每百万输入/输出 Token 的美元价格,在对话开始时冻结;费用为估算,不是服务商账单。 + +对话会流式展示回复和工具活动,归组工作步骤,可用对话导航条跳转用户轮次,支持追问和取消,并可重新打开持久历史。Agent 执行中发送的消息会进入正在运行的任务;业务提问请在提问卡片上回答。Enter 发送,Shift+Enter 换行。草稿在同一浏览器标签页中刷新后保留。文件夹按钮可在空闲时上传最大 5 MiB 的输入文件,并把路径插入草稿;实时工作区回收后仍可下载 Artifacts,请让 Agent 将交付物保存到 `./outputs`。信息按钮展示 Agent、模型、Harness、环境模板、飞书身份、来源以及创建时记录的配置,不含变量值、脚本内容或凭据。创建响应丢失时,对话页提供 **恢复创建**,复用冻结的请求和幂等键。 + +如需展示 thinking,将 Agent 的 **高级 → 推理摘要** 设为 `auto`,再新建对话。Harness 返回原生摘要时,**思考过程** 会直接出现在对话中,与工具步骤分开。生成时显示最新片段,结束后默认收成一行;点击可阅读完整摘要,断线后从历史恢复。已有对话保留创建时的配置;开启摘要不会为旧历史补出思考内容。 + +Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出现第一条输出之前,会显示所处阶段(**正在准备工作环境**、**正在启动**)、已等待的时间,以及这个 Agent 近 7 天通常的首次回复时间。自动恢复只在还没有执行任何内容时进行:环境初始化失败,或接管的提前准备会话 45 秒内仍未开始第一轮时,用同一份冻结的请求重建一次;一轮对话开始 45 秒后仍没有任何输出,会取消并重新发送一次输入;在任何输出之前就失败的一轮,会重新发送一次。被替换的轮次不会出现在对话中,也不会发到飞书。最终失败会用通俗的话说明原因,并提供 **重试**。**产物** 汇总最近对话生成的文件,并链接到对应的对话。 + +可选的业务提问使用声明的 `ask_user` 函数。问题出现在对话中,并通过带稳定请求 ID 的公开 function-result 事件返回。这是收集信息,不是执行审批。 + +**设置** 可修改工作空间名称、中英文界面以及浅色、深色或跟随系统的主题。 + +## Agent 与资源 {#agents-and-resources} + +**Agent** 以卡片列出 Agent,展示执行引擎、模型、环境模板和绑定的飞书机器人。Agent 页面分为 **基本信息**、**执行与环境**、**指令**、**能力** 和 **行为**。**主指令** 是 Agent 自己的指令文本;可选的 **指令片段** 按选择顺序追加,合并为一份系统提示词。选择上限与 Core 的 [Environment 限制](../../contracts/agents-api/zh/environments.md#plugins-and-capability-directories)一致:Skills 和 Plugins 各最多 50 个,知识与环境模板文件合计最多 50 个;指令片段和 MCP 服务没有数量上限。只绑定 Agent 需要的资源,因为每个 Skill 和工具都会增加模型要读的内容。在 **执行与环境** 中,Harness 会把每个选项收窄到它声明支持的值([Harness 能力](../../contracts/agents-api/zh/harness-capabilities.md));切换 Harness 时,新 Harness 不接受的值会恢复为默认,并说明是哪几项。留空的选项保留 Harness 默认值;网页搜索默认 **关闭**。网页对话和飞书执行记录中的搜索显示为 **搜索网页**。在 Agent 页面中新建或编辑模型、Skill、MCP、环境模板或能力时,未保存的字段会保留,新建的资源会自动选中。在同一浏览器标签页中回到 Agent 页面时,会恢复未保存的修改;点击 **保存** 时会指出缺少的必填项。已有 Agent 的页面会显示近 7 天的成功率、首次回复中位数和自动恢复次数。 + +**模型** 按 Provider 分组管理模型 ID。可获取 Provider 的 OpenAI 格式 `GET /v1/models` 目录,也可手动填写。密钥只保存在服务端,留空编辑会保留已保存的密钥。**测试** 会用已保存的连接发起一次极小的生成,显示耗时或服务商返回的错误,不会显示密钥。获取目录时只发送该 Provider 的密钥,且不跟随重定向。所有 Harness 都使用所选的 HTTPS Provider:Codex 使用 Responses 协议,Claude Code 和 MiniMax Code 使用 Anthropic 协议。不支持的组合会明确失败,请参阅 [Harness 覆盖记录](../../contracts/agents-api/zh/index.md)。 + +- **Skills** 支持创建 SKILL.md、上传 ZIP、版本管理、默认版本选择、按 Agent 固定版本以及有限的文件预览。**浏览目录** 搜索 skills.sh 索引;预览在临时目录中使用固定版本的 `skills@1.5.23` CLI,再走常规公开 Skill 上传。这需要 Parsar 主机具备 `npx` 和网络访问。导入的压缩包限制为 32 MiB 和 1,000 个文件。 +- **MCP** 提供静态服务目录和 HTTPS 端点配置。可选的 Vault 凭据使用官方公开 MCP 工具,连接来源为 environment。 +- **凭据** 创建 Vault 和只写的 bearer 凭据。**OAuth 授权** 使用维护中的 MCP SDK 完成发现、动态客户端注册、签发方绑定和 PKCE,再通过公开 Vault API 保存令牌。OAuth 令牌不会返回浏览器。 +- 指令片段和知识具有不可变的编号版本、默认版本、固定版本和停用状态。知识版本会成为初始工作区文件。标准 Claude/Codex Plugin ZIP 会成为公开 Environment Plugin,并列出其中声明的 `.mcp.json` 服务。stdio 服务所需的可执行文件必须存在于环境中。 + +**环境模板** 只在 Core 托管的工作区中运行。每个新对话会先写入文件、安装软件包并完成初始化,再启动 Agent;初始化失败会停止执行。初始文件使用规范的 `/workspace/` 路径,脚本在工作区中运行。变量值在产品响应中只写:`null` 保留已有值,字符串替换,删除名称即删除变量。**飞书机器人变量** 把变量名映射到所绑定机器人的 **App ID**、**App Secret** 或地区;使用这类模板的对话要求 Agent 已绑定机器人。Parsar 通过公开 Session Environment 合约内联发送模板,不创建 Core Template 资源。Core 可能在 24 小时内复用一次完全相同的准备结果([准备结果复用](../../contracts/agents-api/zh/environments.md#preparation-reuse)),长时间空闲的对话也可能换到新沙箱;这两种情况下只保留 `/environment`(包括 `/workspace`),初始化脚本不会再次执行。因此初始化脚本必须把之后要用的内容写到 `/workspace` 或软件包目录下,不能写在 HOME 中。飞书工作环境通过 `LARKSUITE_CLI_CONFIG_DIR` 和 `LARKSUITE_CLI_DATA_DIR` 把 lark-cli 的配置和加密凭证放在 `/workspace/.lark-cli`。 + +**提前准备,新对话无需等待安装** 会为使用该模板的每个 Agent 保留一个准备好的 Session。Parsar 在没有输入的情况下创建它,Core 会提前准备好 Environment;下一个网页或飞书对话发送第一条消息时直接接管它,并保留自己的标题。Agent、其环境模板、机器人或模型服务商变更后,或超过 12 小时、或被接管后,准备好的 Session 会被替换;关闭开关会丢弃它。第一条消息带图片或文件时,会创建普通的 Session。每个准备好的 Session 在等待期间会占用一个工作区。 + +**试运行** 会用使用该模板的 Agent 发起一次真实对话,等待首次回复,显示成功及耗时或通俗的失败原因,然后删除这次对话。 + +## 飞书 {#feishu} + +**飞书接入** 为每个机器人展示一张卡片。**添加机器人** 提供 **扫码创建**(通过飞书设备流创建应用)和 **已有应用**(填写 App ID 与 App Secret)。新机器人在配置完成前保持停用。密钥保存在受限的本地数据库中。官方 SDK 负责鉴权、消息规范化和长连接,不需要公网回调。 + +1. 在卡片上选择 **Agent**,打开 **私聊** 和/或 **群聊**,然后 **保存并启用**。启用需要应用凭证、已配置环境模板的 Agent,以及两者至少开启一项。打开 **群聊** 后,机器人会回复它所在的任何群里的 @ 消息,把机器人拉进群即可使用。谁能添加机器人由开发者后台中应用的可用范围控制。 +2. 在开发者后台启用机器人、长连接事件推送和 `im.message.receive_v1`,发布应用并把它加入目标群。授予接收 @ 消息和以机器人身份发送消息的权限;私聊、消息资源和表情回复需要各自的权限。缺少表情权限不会丢弃消息。 +3. 私聊为每个发送者保持一个对话,直到发送 `/new` 或 `/reset`。群聊中 @ 机器人会开启一个话题,每个话题是一个对话,所有在其中回复的人共享。只有应用开通了敏感权限“获取群组中所有消息”(`im:message.group_msg`),话题中不带 @ 的后续消息才会推送给机器人;否则需要在话题中再次 @ 机器人。话题中只 @ 了其他人的回复是发给他们的,会被忽略。其他机器人和 机器人只服务自己所在的企业,外部联系人发来的消息会被忽略。`@所有人` 会被忽略;关闭 **群聊** 时忽略群消息,关闭 **私聊** 时忽略私聊。更换绑定的 Agent 只影响新话题和新的私聊对话。 +4. 文本、富文本、图片、文件、音视频、引用消息和合并转发会在投递前规范化。附件上限为 8 个、合计 5 MiB。后续消息中的文件会等到对话空闲后写入。引用消息最多读取四层;不读取更早的群消息。 +5. `/cancel`、`/answer <回答>`、`/new` 和 `/help` 在同一话题中使用。只有一个待回答的业务问题时,直接回复即可作答。取消优先于排队中的追问,并会先确认不确定的创建再取消。 + +收到的消息在 WebSocket ACK 之前写入 SQLite;请求 ID 和冻结的负载在重试与重启后保持不变。工作器原地更新一张简洁进度卡片,将待回答的问题合并到卡片中,结束后改成结果。每次回复只展示当前交流的内容,包括回答提问后继续同一 Core Turn 的情况。正文突出当前回复;等待业务选择时优先展示问题,将补充说明折叠,二维码授权说明保持可见。已有的思考摘要、过程说明和工具调用分别默认折叠。工具调用与返回结果合并,展开可查看参数、输出、耗时和失败详情。卡片预览最近六次调用,长内容按字节限制截取并明确标注;一次调用只计数一次,失败或未完成不算完成。进度每五秒轮询一次。完整历史和 Artifacts 保留在网页对话中。已结束的话题在五分钟后停止轮询,收到新消息时恢复。**消息记录** 列出最近 100 条投递以及未解决的记录;**重试** 保留原有标识。入站消息失败会阻塞该话题后续消息。不确定的出站消息超过 55 分钟后不再重放。停用机器人会暂停投递,但不会取消 Core 中的执行。 + +### 机器人身份 {#bot-identity} + +Agent 以绑定的机器人身份工作。使用 **飞书机器人变量** 时,Parsar 会把机器人的 App ID 和 App Secret 写入对话 Environment 的变量。飞书案例把它们映射为 `FEISHU_*` 变量,初始化脚本运行 `lark-cli config init --app-secret-stdin`,由 CLI 自行获取并刷新租户令牌,长对话也能持续工作。不要映射为 `LARKSUITE_CLI_APP_ID` 或 `LARKSUITE_CLI_APP_SECRET`:这两个变量会让 lark-cli 进入环境凭据模式,该模式要求直接提供租户令牌。任何能与机器人对话的人都能使用机器人的权限,Environment 中的工具也能读取该密钥。只为机器人授予其 Agent 需要的权限,尽量一个 Agent 使用一个机器人。轮换后的密钥只对新对话生效。 + +### 飞书工作环境案例 {#feishu-workspace-case} + +[飞书工作环境](examples/feishu/)由普通模板组装而成。[environment.json](examples/feishu/environment.json) 声明软件包、机器人变量和文件,[setup.sh](examples/feishu/setup.sh) 安装经过校验和验证的官方 CLI 并用机器人凭证完成配置,[agent.json](examples/feishu/agent.json) 定义一个 Codex Agent。配置好模型,导入官方 `lark-shared` Skill 和需要的业务 Skill,添加机器人后运行: + +```bash +node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 --model MODEL_ID --bot BOT_ID --skills SHARED_SKILL_ID,DOMAIN_SKILL_ID +``` + +`--bot` 是 Parsar 中的机器人 ID,不是飞书 App ID。脚本通过与界面相同的 `/app/runtimes` 和 `/app/agents` 操作创建环境模板和 Agent,绑定机器人并输出 Agent 链接。第一条消息可以要求执行 `lark-cli api GET /open-apis/bot/v3/info --as bot --json`。运行节点必须能解析并访问飞书开放平台;模板无法修复节点 DNS 或网络策略。 + +## 数据归属与本地 API {#ownership-and-local-api} + +Core 的 Project key 只保存在服务端。对话创建成功后,只保留 Core 引用和产品元数据;创建未确认时保留冻结的公开请求直到确认。Core 仍是执行、Items、Turns、Skills、Vaults 和 Artifacts 的权威来源。SQLite 保存产品配置、能力版本、飞书投递状态以及临时 OAuth/注册状态。文件位于 `~/.oac/data/parsar-example/`,可用绝对路径 `OAC_EXAMPLE_DATA_DIR` 覆盖;由 Core 地址和 Project key 指纹选择具体文件。请在服务停止时备份。Provider 和机器人密钥保存在其中,Core Project key 不在其中。构建和缓存使用 `${OAC_DEV_HOME:-$HOME/.oac}`。 + +| 产品路由组 | 调用方与凭据 | +| --- | --- | +| `/app/providers`、`/app/models`、`/app/agents`、`/app/runtimes`、`/app/mcps`、`/app/sessions`、`/app/artifacts` | 同源本地 UI;飞书工作器在进程内复用对话创建 | +| `/app/feishu/bots`、`/app/feishu/provision`、`/app/feishu/deliveries` | 同源本地 UI;从不返回凭据 | +| `/app/capabilities`、`/app/skills/registry/preview` | 同源本地 UI | +| `/app/mcp-oauth/`、`/oauth/callback` | 本地 OAuth 流程;随机 state、PKCE 和同源完成 POST | +| `/app/settings`、`/app/audit` | 同源本地 UI;审计只含变更元数据,不含请求体 | +| 允许列表内的 `/v1` 代理 | 同源本地 UI;服务端注入 Project key | + +Core 独立于本示例构建和分发。产品代理不提供 `/core/v1` 或机器 API。 + +## 验证 {#validation} + +```sh +make check-example +``` + +该检查运行 TypeScript、Node 代理/产品/飞书测试、生产构建和浏览器验收。浏览器夹具使用 18180/18181 端口和 `~/.oac/tests/parsar-example/` 下的隔离数据;18180 被占用时设置 `OAC_EXAMPLE_PORT`。需要时用 `pnpm --filter @oac/parsar-example exec playwright install chrome` 安装 Chrome。测试覆盖持久重试、重复入站、飞书路由、共享话题、机器人身份变量、扫码状态、OAuth PKCE/Vault 持久化和界面流程。夹具测试不代表真实模型或飞书服务可用。 + +可选的真实浏览器探针需要一个已运行、并连接独立真实 Project 的示例: + +```sh +OAC_EXAMPLE_LIVE_URL=http://127.0.0.1:18180 \ +OAC_EXAMPLE_LIVE_MODEL='' \ +pnpm --filter @oac/parsar-example test:live +``` + +它会调用真实模型,并保留资源供检查。真实飞书验收需要一个具备相应权限的专用测试机器人和已授权的会话;仅完成配置并不能证明投递可用。 + +### 聊天中的图片与文件 + +网页会话在回复旁预览 PNG、JPEG、GIF 和 WebP 产物,并提供文件下载。将输出保存到 `./outputs`;Markdown 中指向 `/workspace/outputs/` 的图片和文件链接会对应到已保存的产物。SVG 等其他格式只提供下载,不加载外部图片地址。飞书将每个产物发送为图片或文件消息,单个附件不能为空且不能超过 5 MiB。上传标识和消息标识会保留以供重试,失败记录显示在**消息记录**中。音视频作为文件下载,暂不在聊天中播放。 + +机器人注册返回 `verification_url` 和相对地址 `qr_url`。同时展示授权链接和 `![飞书授权二维码]()`,将占位符替换为返回的路径。PNG 由 Parsar 本地生成;注册过期或完成后,`GET /app/feishu/provision//qrcode` 返回失效提示。网页和飞书使用同一张二维码,不会把授权地址发送给第三方二维码服务。 diff --git a/example/parsar/cli.mjs b/example/parsar/cli.mjs new file mode 100644 index 000000000..e4104d842 --- /dev/null +++ b/example/parsar/cli.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; + +const help = `Parsar CLI (Node 22.13+, no dependencies) + + PARSAR_URL=http://127.0.0.1:18180 node cli.mjs METHOD /path [--file FILE|-] + node cli.mjs uuid + +METHOD: GET, PUT, POST, PATCH, DELETE. Bodies are JSON; --file - reads stdin. +Responses are JSON on stdout; failures are JSON on stderr with exit code 1. +Use /app for product configuration and /v1 for exposed Core resources. + + GET /app/agents List Agent configurations + GET /app/runtimes List environment templates + GET /app/models List configured models + GET /app/mcps List MCP connections + GET /app/feishu/bots List bots and connection health + GET /app/feishu/bots/UUID Read a bot before editing + PUT /app/feishu/bots/UUID Bind/enable a bot; preserve revision + POST /app/feishu/provision/UUID Register: action begin or poll + GET /app/feishu/deliveries Check message delivery records + GET /v1/skills List Skills (follow has_more/after) + PUT /app/agents/UUID Create or replace an Agent + PUT /app/runtimes/UUID Create or replace an environment template + POST /app/runtimes/UUID/trial Start an environment trial + GET /app/runtimes/UUID/trial Read its result + +Generate a UUID once before creating a resource. Save it for recovery. +For edits, GET first, keep all fields and the revision, then PUT the full object. +On an uncertain write, GET the same ID before retrying; writes are not retried. +Uses the same trusted-network access as the Web UI. No Core key is needed. +For an authenticated HTTPS gateway, set PARSAR_TOKEN to its access token. +See examples/manager/GUIDE.md for complete creation examples. +`; + +// The UI and this client share the server's validation, revisions and redaction. +export function createParsarClient(baseURL, { token } = {}) { + const base = new URL(baseURL); + if ( + !["http:", "https:"].includes(base.protocol) || + base.username || base.password || base.pathname !== "/" || base.search || base.hash + ) throw new Error("PARSAR_URL must be an HTTP(S) origin without credentials or a path."); + if (token !== undefined && ( + typeof token !== "string" || !/^[A-Za-z0-9._~+/-]+=*$/.test(token) || + (base.protocol !== "https:" && !["127.0.0.1", "localhost", "[::1]"].includes(base.hostname)) + )) throw new Error("PARSAR_TOKEN requires a valid bearer token and HTTPS (or loopback HTTP for local testing)."); + return async (method, path, body) => { + if (!["GET", "PUT", "POST", "PATCH", "DELETE"].includes(method)) + throw new Error("Unsupported method."); + const url = new URL(path, base); + if ( + !/^\/(app|v1)\//.test(path) || url.origin !== base.origin || + !/^\/(app|v1)\//.test(url.pathname) || url.hash + ) throw new Error("Use a relative /app/ or /v1/ path on this Parsar server."); + if (method === "GET" && body !== undefined) + throw new Error("GET does not accept a JSON body."); + const response = await fetch(url, { + method, + redirect: "error", + signal: AbortSignal.timeout(60_000), + headers: { Origin: base.origin, "Content-Type": "application/json", ...(token === undefined ? {} : { Authorization: `Bearer ${token}` }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const text = await response.text(); + let value; + try { value = text ? JSON.parse(text) : null; } + catch { throw new Error(`Parsar returned a non-JSON response (HTTP ${response.status}).`); } + if (!response.ok) { + const error = new Error(value?.error?.message || `Parsar returned HTTP ${response.status}.`); + error.status = response.status; + throw error; + } + return value; + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const args = process.argv.slice(2); + if (!args.length || (args.length === 1 && ["--help", "-h"].includes(args[0]))) { + process.stdout.write(help); + } else if (args.length === 1 && args[0] === "uuid") { + console.log(randomUUID()); + } else { + const [method, path, flag, file] = args; + if (!(args.length === 2 || (args.length === 4 && flag === "--file"))) + throw new Error("Usage: node cli.mjs METHOD /path [--file FILE|-]"); + if (!process.env.PARSAR_URL) throw new Error("Set PARSAR_URL to the Parsar Web origin."); + let body; + if (file !== undefined) { + const input = readFileSync(file === "-" ? 0 : file, "utf8"); + try { body = JSON.parse(input); } + catch { throw new Error("The input file must contain valid JSON."); } + } + const result = await createParsarClient(process.env.PARSAR_URL, { token: process.env.PARSAR_TOKEN })(method, path, body); + console.log(JSON.stringify(result, null, 2)); + } + } catch (error) { + console.error(JSON.stringify({ error: { message: error.message, ...(error.status ? { status: error.status } : {}) } })); + process.exitCode = 1; + } +} diff --git a/example/parsar/examples/feishu/agent.json b/example/parsar/examples/feishu/agent.json new file mode 100644 index 000000000..425b52c7a --- /dev/null +++ b/example/parsar/examples/feishu/agent.json @@ -0,0 +1,14 @@ +{ + "name": "飞书工作助手", + "description": "以机器人身份查询和整理飞书里的消息、文档、日程与多维表格。", + "harness": "codex", + "reasoning_effort": "", + "reasoning_summary": "", + "web_search": "disabled", + "verbosity": "", + "service_tier": "", + "instructions": "你是飞书工作助手。使用环境中已安装的官方 lark-cli 完成用户明确委托的工作。先读取 lark-shared 和相应业务 Skill,并根据 CLI --help 确认命令;不得自行安装或修复工具。\n\n你以 Agent 绑定的飞书机器人身份运行,使用 --as bot。机器人只能访问已授权给它的资源;不要运行 config init、auth login 或尝试切换为用户身份。身份核验使用 lark-cli api GET /open-apis/bot/v3/info --as bot --json,只报告机器人名称及验证结果,不打印 App Secret、令牌或环境变量。\n\n任务直接执行,不请求引擎审批。缺少目标或业务信息时再询问;不得发送无关消息或修改无关资源。遵守工具明确的高风险确认要求。缺少权限时报告具体 scope,提示在飞书开发者后台为机器人开通后再试。DNS、网络或环境准备错误应报告实际故障,不伪装成缺少授权,不修改 hosts 或绕过运行环境网络策略。交付文件放在 ./outputs。", + "mcp_ids": [], + "ask_user": true, + "enabled": true +} diff --git a/example/parsar/examples/feishu/configure.mjs b/example/parsar/examples/feishu/configure.mjs new file mode 100644 index 000000000..793c5535c --- /dev/null +++ b/example/parsar/examples/feishu/configure.mjs @@ -0,0 +1,71 @@ +import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +// This case is a client of the ordinary Parsar resource API, not a server feature. +const args = new Map(); +for (let i = 2; i < process.argv.length; i += 2) { + const name = process.argv[i], + value = process.argv[i + 1]; + if ( + !["--url", "--model", "--bot", "--skills"].includes(name) || + !value || + args.has(name) + ) + throw new Error( + "Usage: node configure.mjs --url URL --model MODEL_ID --bot BOT_ID --skills SKILL_ID[,SKILL_ID]", + ); + args.set(name, value); +} +for (const name of ["--url", "--model", "--bot", "--skills"]) + if (!args.has(name)) throw new Error(`Missing ${name}`); +const base = new URL(args.get("--url")); +if ( + !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) || + base.username || + base.password || + !["http:", "https:"].includes(base.protocol) +) + throw new Error("Use the forwarded loopback Parsar URL."); +async function api(path, method, body) { + const response = await fetch(new URL(path, base), { + method, + redirect: "error", + signal: AbortSignal.timeout(30000), + headers: { Origin: base.origin, "Content-Type": "application/json" }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!response.ok) + throw new Error(`Parsar returned ${response.status} for ${path}`); + return response.json(); +} +const read = async (name) => + JSON.parse(await readFile(new URL(name, import.meta.url), "utf8")); +const environment = await read("./environment.json"), + agent = await read("./agent.json"); +environment.setup_script = await readFile( + new URL("./setup.sh", import.meta.url), + "utf8", +); +const environmentId = randomUUID(), + agentId = randomUUID(); +agent.model_id = args.get("--model"); +agent.skill_ids = args.get("--skills").split(","); +agent.runtime_id = environmentId; +await api(`/app/runtimes/${environmentId}`, "PUT", environment); +try { + await api(`/app/agents/${agentId}`, "PUT", agent); +} catch (error) { + await api(`/app/runtimes/${environmentId}`, "DELETE"); + throw error; +} +// The Agent runs as the bot it is bound to; binding also routes the bot's chats to it. +const botPath = `/app/feishu/bots/${args.get("--bot")}`; +const bot = await api(botPath, "GET"); +await api(botPath, "PUT", { ...bot, agent_id: agentId }); +console.log( + JSON.stringify({ + agent_id: agentId, + environment_id: environmentId, + agent_url: `${base.origin}/#/agents/${agentId}`, + }), +); diff --git a/example/parsar/examples/feishu/environment.json b/example/parsar/examples/feishu/environment.json new file mode 100644 index 000000000..4f25681b6 --- /dev/null +++ b/example/parsar/examples/feishu/environment.json @@ -0,0 +1,23 @@ +{ + "name": "飞书工作环境", + "description": "安装官方 lark-cli,并以 Agent 绑定的飞书机器人身份调用开放平台。需要运行节点能够访问飞书开放平台。", + "environment": "openai_hosted", + "packages": { "npm": ["@larksuite/cli@1.0.98"], "python": [] }, + "env": { + "npm_config_ignore_scripts": "true", + "LARKSUITE_CLI_DEFAULT_AS": "bot", + "LARKSUITE_CLI_STRICT_MODE": "bot", + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", + "LARKSUITE_CLI_CONFIG_DIR": "/workspace/.lark-cli", + "LARKSUITE_CLI_DATA_DIR": "/workspace/.lark-cli/data" + }, + "bot_env": { + "FEISHU_APP_ID": "app_id", + "FEISHU_APP_SECRET": "app_secret", + "FEISHU_BRAND": "brand" + }, + "files": [ + { "path": "/workspace/WORKSPACE.md", "content": "# 飞书工作环境\n\n官方 lark-cli 已在 Agent 启动前安装,以 Agent 绑定的飞书机器人身份运行。禁止输出 App Secret、令牌或环境变量。读取 Skill 后使用具体业务命令。网络错误与权限不足分别报告。产物写到 ./outputs。\n" } + ] +} diff --git a/example/parsar/examples/feishu/setup.sh b/example/parsar/examples/feishu/setup.sh new file mode 100644 index 000000000..2d6124881 --- /dev/null +++ b/example/parsar/examples/feishu/setup.sh @@ -0,0 +1,49 @@ +set -eu +export LARK_LAUNCHER="$(command -v lark-cli)" +node <<'JS' +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +(async () => { + const root = path.dirname(path.dirname(fs.realpathSync(process.env.LARK_LAUNCHER))); + const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + const platform = {linux:'linux', darwin:'darwin'}[process.platform]; + const arch = {x64:'amd64', arm64:'arm64'}[process.arch]; + if (!platform || !arch) throw new Error('Unsupported lark-cli release platform'); + const asset = `lark-cli-${version}-${platform}-${arch}.tar.gz`; + const line = fs.readFileSync(path.join(root, 'checksums.txt'), 'utf8').split('\n').find(line => line.trim().split(/\s+/)[1] === asset); + if (!line) throw new Error('Release checksum unavailable'); + const expected = line.trim().split(/\s+/)[0]; + const urls = [ + `https://registry.npmmirror.com/-/binary/lark-cli/v${version}/${asset}`, + `https://github.com/larksuite/cli/releases/download/v${version}/${asset}`, + ]; + let bytes; + for (const url of urls) { + try { + const response = await fetch(url, {signal: AbortSignal.timeout(10000)}); + if (!response.ok) continue; + const candidate = Buffer.from(await response.arrayBuffer()); + if (crypto.createHash('sha256').update(candidate).digest('hex') !== expected) throw new Error('Release checksum mismatch'); + bytes = candidate; + break; + } catch (error) { + if (error.message === 'Release checksum mismatch') throw error; + } + } + if (!bytes) throw new Error('Could not download verified lark-cli release'); + const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'lark-download-')); + try { + const archive = path.join(directory, asset); + fs.writeFileSync(archive, bytes); + execFileSync('tar', ['-xzf', archive, '-C', directory, 'lark-cli']); + fs.mkdirSync(path.join(root, 'bin'), {recursive: true}); + fs.copyFileSync(path.join(directory, 'lark-cli'), path.join(root, 'bin/lark-cli')); + fs.chmodSync(path.join(root, 'bin/lark-cli'), 0o755); + } finally { fs.rmSync(directory, {recursive: true, force: true}); } + console.log('Verified lark-cli release installed'); +})().catch(error => { console.error(error.message); process.exitCode = 1; }); +JS +lark-cli --version +printf '%s' "$FEISHU_APP_SECRET" | lark-cli config init --app-id "$FEISHU_APP_ID" --app-secret-stdin --brand "${FEISHU_BRAND:-feishu}" diff --git a/example/parsar/examples/manager/Caddyfile b/example/parsar/examples/manager/Caddyfile new file mode 100644 index 000000000..5aee57368 --- /dev/null +++ b/example/parsar/examples/manager/Caddyfile @@ -0,0 +1,28 @@ +{ + admin off + auto_https off + persist_config off +} + +# TLS terminates at the tunnel; this gateway is reachable only on loopback. +:18181 { + bind 127.0.0.1 + header Cache-Control no-store + @authorized { + expression `{env.PARSAR_TOKEN} != ""` + header Authorization "Bearer {$PARSAR_TOKEN}" + path /app/* /v1/* + } + handle @authorized { + reverse_proxy http://{$PARSAR_UPSTREAM} { + header_up Host {$PARSAR_UPSTREAM} + header_up Origin http://{$PARSAR_UPSTREAM} + header_up -Authorization + header_up -Cookie + } + } + handle { + header Content-Type application/json + respond `{"error":{"message":"A valid Parsar gateway token and API path are required."}}` 401 + } +} diff --git a/example/parsar/examples/manager/GUIDE.md b/example/parsar/examples/manager/GUIDE.md new file mode 100644 index 000000000..16b7b1d0d --- /dev/null +++ b/example/parsar/examples/manager/GUIDE.md @@ -0,0 +1,128 @@ +# Manage Parsar + +Use `node /workspace/parsar.mjs --help`. `PARSAR_URL` is already set to this workspace's Parsar API. The CLI uses Node 22.13+ without dependencies and prints JSON. If the deployment needs authentication, the CLI automatically reads `PARSAR_TOKEN`; never print, inspect, copy or include that token in commands, files, links or created resources. It is a Parsar gateway credential, not a Core key. All configuration changes go through the same API and validation as the Web UI. Do not edit databases or use Core administration endpoints. + +If that server is unreachable or denies access, stop and report the configured address and error. Do not scan ports, try other services, bypass authentication, or inspect Core hosts, credentials or internal APIs. Connectivity must be fixed by the deployment operator. + +## Conversation + +Reply in the user's language and keep the visible answer short. A greeting needs a greeting and one question about the user's goal, not a workspace inventory or a connectivity test. Read this guide when a management task needs it; do not narrate reading instructions or repeat setup checks on every reply. Query only the resources relevant to the current request, and reuse facts already established in the conversation unless a change requires a fresh read. Explain the next useful action instead of listing internal IDs, environment variables or configuration tables. Use `ask_user` only for missing information that blocks the user's task; ask once, with concise options, without repeating the question in the preceding message. + +## Discover and reuse + +```sh +node /workspace/parsar.mjs GET /app/models +node /workspace/parsar.mjs GET /app/agents +node /workspace/parsar.mjs GET /app/runtimes +node /workspace/parsar.mjs GET /app/mcps +node /workspace/parsar.mjs GET /app/feishu/bots +node /workspace/parsar.mjs GET /app/capabilities +node /workspace/parsar.mjs GET '/v1/skills?limit=100' +``` + +Reuse suitable existing models, Skills and templates. Never invent resource IDs. For paginated Core lists, continue with `after=` while `has_more` is true. Model entries contain `provider_id`; `/app/providers` exposes provider names and URLs, not their keys. `codex` uses Responses; `claude_sdk` and `mcode` use Anthropic. Choose an existing working combination or ask when its compatibility is unknown. Do not change provider credentials to make an example work. + +## Create an environment template + +Generate one ID and keep it in your notes before sending the write: + +```sh +node /workspace/parsar.mjs uuid +``` + +Write a JSON file, then call `PUT /app/runtimes/ --file environment.json`: + +```json +{ + "name": "Excel 分析环境", + "description": "读取 Excel,生成分析报告。", + "environment": "openai_hosted", + "packages": { "npm": [], "python": ["openpyxl"] }, + "setup_script": "mkdir -p /workspace/outputs", + "env": {}, + "bot_env": {}, + "files": [], + "prepare": false +} +``` + +Use only needed packages and files. Initial files are `{ "path": "/workspace/name", "content": "text" }`. Setup runs in the hosted workspace before the Agent starts; it is executable code, so review it as part of the requested configuration. Keep persistent files under `/workspace`; HOME is not persistent. Environment preparation may be reused. Never put the management CLI or its address in ordinary business templates unless the user explicitly asks for management access. + +Environment variable values are write-only: GET returns `null`, and PUT with `null` keeps the saved value. Retain untouched names. Removing a name removes its value. Do not ask users to paste secrets into chat; direct them to the existing configuration UI. A template with `bot_env` needs an Agent bound to a Feishu bot before it can run. + +## Create an Agent + +Generate another UUID. Use real `model_id` and `runtime_id` values in an `agent.json` file, then call `PUT /app/agents/ --file agent.json`: + +```json +{ + "name": "Excel 分析助手", + "description": "分析上传的 Excel,生成可下载的报告。", + "harness": "codex", + "model_id": "", + "runtime_id": "", + "instructions": "根据用户目标分析上传的 Excel。缺少关键业务信息时提问。报告注明数据来源和计算方法,交付物写入 ./outputs。", + "skill_ids": [], + "mcp_ids": [], + "capability_ids": [], + "ask_user": true, + "enabled": true +} +``` + +An Agent is a reusable configuration, not a live session. Its environment template is required. `skill_ids` reference `/v1/skills`; `mcp_ids` and `capability_ids` reference the product lists. Omit optional execution settings to keep native defaults. Creation does not prove a model or tool works. + +## Guide Feishu bot onboarding + +When a user asks to open or connect a Feishu bot, guide the whole setup rather than merely linking to settings. Use only Parsar's `/app/feishu/*` API through this CLI. The user performs Feishu login, scanning, authorization, application publication and any administrator approval. Never request their password or App Secret in chat, read credentials from the environment, or call Feishu's registration service directly. + +### Choose the Agent and reuse a bot + +Read `/app/agents`, `/app/runtimes` and `/app/feishu/bots`. Identify the intended business Agent from the user's goal; if it does not exist, create it using the workflow above. Do not bind the management Agent unless the user explicitly wants management access through that bot. Each bot binds one Agent and each Agent binds at most one bot. Reuse the intended Agent's existing bot; do not replace another binding, rotate credentials or create a duplicate to resolve a conflict. Ask only for unresolved choices such as which Agent or whether to use an existing Feishu application. + +For a new setup, default to private chat only and tell the user. Enable group chat only when requested: it permits replies to mentions in any group the bot joins, not a list of selected group IDs. Preserve the chat choices of an existing bot unless the user asks to change them. Binding a bot supplies that bot's identity to new Agent conversations, including Web conversations when the environment maps `bot_env`; do not copy credentials into files or ordinary Agent instructions. + +### Register a new application + +Generate and retain one UUID as `bot_id` before starting. Start registration with `POST /app/feishu/provision/` and JSON `{"action":"begin","name":""}`. The response has `status`, `verification_url`, `qr_url`, `expires_at` (Unix milliseconds) and `interval` (seconds). Present the returned `verification_url` unchanged as a clickable authorization link, followed by `![飞书授权二维码]()` using the exact relative `qr_url` returned (replace the placeholder, without angle brackets). Both Web and Feishu display this as an image. Explain when it expires and ask the user to scan/confirm. Never invent an image URL, encode the link yourself or substitute an ASCII QR. Show it only to the requesting user; do not send the link to a third-party QR image service. Do not substitute the general **扫码创建** button: that starts another registration with a different ID. + +Keep the same `bot_id` while waiting. End the reply with the link and QR image, then resume when the user returns; do not spin in a shell polling loop while waiting for a person. Once the user confirms, send `POST /app/feishu/provision/` with `{"action":"poll"}`. A user's confirmation is not proof of success: continue only when the returned `status` is `success`. While `pending`, wait at least the returned `interval` before another poll, respect any increased interval, and tell the user what is still pending. The server saves the registration across restarts. A successful poll creates the bot with the same ID and leaves it disabled; it returns neither the device code nor the App Secret. + +For a registration 409 (busy), wait the polling interval and poll the same ID; there is no GET registration-state endpoint. If a response is lost, poll the retained ID first. If polling reports that the registration does not exist, repeat `begin` with that same ID. Do not create a different ID for an uncertain request. When `expires_at` has passed, `begin` with the same ID can obtain a new authorization link. For `access_denied` or another terminal error, explain the result and stop; if the user requests a fresh attempt before expiration, use a new ID only after confirming the previous registration failed and no bot was created. Never repeatedly retry denied authorization or claim that opening a link completed it. + +### Connect an existing application + +Send the user to **飞书接入 → 添加机器人 → 已有应用** at `PARSAR_WEB_URL/#/feishu` (use the browser-link rule below). They enter the App ID and App Secret there. After they return, read `/app/feishu/bots` again and identify the saved bot by its name or App ID; do not ask for the secret. If it already exists, reuse its ID and saved credentials without asking the user to enter them again. + +### Bind and enable + +After registration succeeds or the existing application is saved, GET `/app/feishu/bots/` immediately before editing. PUT the full returned object to the same path, retaining its current `revision`, `app_id`, `brand`, name and other settings. Change only the intended `agent_id`, agreed `direct`/`groups` settings and `enabled: true`. Omit `app_secret`; the server retains it. Require `has_app_secret: true`, an existing Agent with an environment template, and at least one chat channel before enabling. On 409, read again and reconcile; never overwrite a concurrent edit. A GET for an unknown bot ID returns an empty draft with revision 0, so it does not prove registration succeeded. + +### Check connection and verify delivery + +Read `/app/feishu/bots` for the selected bot's `health.state` (the single-bot GET does not include health). `connected` confirms the connection, not message delivery. If it is anything other than `connected` (including `reconnecting` or `core_unavailable`), explain that setup is not ready and direct the user to **飞书接入**. For application-side setup, guide the user to check the Feishu developer console for bot capability, long-connection event subscription, `im.message.receive_v1`, required messaging permissions, application publication and availability. Do not assume a scan granted every business permission; requests to access documents or other resources require the relevant Skills and permissions separately. + +Ask the user to send a harmless test message in private chat, or mention the bot in the intended group if group chat was requested. Do not send unsolicited messages to other people. Before that test, record the current delivery IDs; afterwards read `/app/feishu/deliveries`, match the bot's `app_id` and the new inbound record's `thread_id`, and inspect the new inbound and outbound records. An existing private chat may reuse its thread ID. Claim end-to-end success only after the new outbound record is `delivered` and its conversation has a completed reply to this test message, and the user confirms seeing the reply (follow its product `session_id` to `/app/sessions/` and the referenced public Core Session/Turns/Items). Old successful deliveries, a saved configuration, a successful environment trial or a connected WebSocket are not proof of this test. If no matching test arrives, report “configured/connected, waiting for your test message.” Link to `/#/feishu`, the Agent and the verified conversation when available; report failures without exposing secrets. + +## Edit without losing settings + +GET `/app/agents/` or `/app/runtimes/` first. Modify the returned object and PUT the full object back, including its current `revision`. PUT is replacement, not a partial update. Do not reconstruct only the fields you want to change. A 409 means someone else edited it: GET again and reconcile the user's intended change. Shared template edits affect all bound Agents in new conversations; list those Agents and explain the impact. Existing conversations retain their original configuration. + +Writes are not automatically retried. If the connection fails, GET the same ID to find out whether the write succeeded; never create another UUID just because a response was lost. Keep partially created resources discoverable and report them if a later step fails. Do not delete or overwrite unrelated resources. + +## Verify and return links + +After saving an Agent, `POST /app/runtimes//trial` starts a real environment trial. Poll `GET /app/runtimes//trial` until `state` becomes `succeeded` or `failed`. This checks environment preparation and a first reply; it does not prove business tools or the Agent's whole task work. The trial uses an enabled Agent bound to that template, which may differ when the template is shared. To test the specific Agent, open a new conversation with it and run a small representative task when requested. + +Return browser links using `PARSAR_WEB_URL` when set, otherwise `PARSAR_URL`. The authenticated API gateway may not serve Web pages; never put a token in a URL: + +- Agent: `/#/agents/` +- New conversation: `/#/new?agent=` +- Environment templates: `/#/runtimes` +- Feishu setup: `/#/feishu` + +Report saved resources, reused resources and the exact validation performed. Never claim that a successful PUT means the Agent was tested successfully. + +## Share images and files + +Save generated files under `/workspace/outputs/` so Core publishes them as Session Artifacts. Use Markdown `![description](/workspace/outputs/picture.png)` for images and `[filename](/workspace/outputs/report.pdf)` for files. Web previews PNG, JPEG, GIF and WebP and offers downloads for other formats. Feishu delivers each artifact as an image or file message; each attachment must be nonempty and at most 5 MiB. Other formats, including SVG, are files, not inline previews. Do not embed arbitrary remote image URLs or data URIs: save the image as an artifact first. Authorization QR images use the returned `qr_url` instead and never require copying tokens into files. A failed attachment delivery is visible in **飞书接入 → 消息记录**; do not claim the user received it without confirmation. diff --git a/example/parsar/examples/manager/configure.mjs b/example/parsar/examples/manager/configure.mjs new file mode 100644 index 000000000..6387e17d9 --- /dev/null +++ b/example/parsar/examples/manager/configure.mjs @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { createParsarClient } from "../../cli.mjs"; + +// An ordinary API client assembles this Agent; the server has no manager mode. +const args = new Map(); +for (let i = 2; i < process.argv.length; i += 2) { + const name = process.argv[i], value = process.argv[i + 1]; + if (!['--url', '--agent-url', '--web-url', '--model', '--harness', '--agent-id', '--environment-id'].includes(name) || !value || args.has(name)) + throw new Error("Usage: node configure.mjs --url URL --model MODEL_ID [--agent-url URL] [--web-url URL] [--harness codex|claude_sdk] [--agent-id UUID --environment-id UUID]"); + args.set(name, value); +} +if (!args.has('--url') || !args.has('--model')) throw new Error("--url and --model are required."); +const harness = args.get('--harness') || 'codex'; +if (!['codex', 'claude_sdk'].includes(harness)) throw new Error("This example requires the Node runtime in Codex or Claude Code."); +const token = process.env.PARSAR_TOKEN; +const api = createParsarClient(args.get('--url'), { token }); +const agentURL = args.get('--agent-url') || args.get('--url'); +createParsarClient(agentURL, { token }); +const webURL = new URL(args.get('--web-url') || args.get('--url')).origin; +createParsarClient(args.get('--web-url') || args.get('--url')); +const agentId = args.get('--agent-id') || randomUUID(); +const runtimeId = args.get('--environment-id') || randomUUID(); +const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +if (!uuid.test(agentId) || !uuid.test(runtimeId)) throw new Error("Resource IDs must be UUIDs."); +const models = await api('GET', '/app/models'); +if (!models.some((model) => model.id === args.get('--model') && model.enabled !== false)) + throw new Error("Choose an enabled model from /app/models."); +// Refuse to overwrite existing resources. To update, use the normal GET/PUT flow. +for (const [kind, id] of [['agents', agentId], ['runtimes', runtimeId]]) { + if ((await api('GET', `/app/${kind}`)).some((row) => row.id === id)) + throw new Error(`${kind}/${id} already exists. Inspect it before editing.`); +} +console.error(JSON.stringify({ agent_id: agentId, environment_id: runtimeId, recovery: 'If a write fails, GET these IDs before retrying. Existing resources are never deleted automatically.' })); +await api('PUT', `/app/runtimes/${runtimeId}`, { + name: 'Parsar 管理环境', + description: '通过 Parsar CLI 管理当前工作空间的配置。', + environment: 'openai_hosted', + env: { PARSAR_URL: agentURL, PARSAR_WEB_URL: webURL, ...(token === undefined ? {} : { PARSAR_TOKEN: token }) }, + files: [ + { path: '/workspace/parsar.mjs', content: await readFile(new URL('../../cli.mjs', import.meta.url), 'utf8') }, + { path: '/workspace/PARSAR.md', content: await readFile(new URL('./GUIDE.md', import.meta.url), 'utf8') }, + ], +}); +await api('PUT', `/app/agents/${agentId}`, { + name: 'parsar他爹', + description: '创建和调整 Agent、环境模板,引导开通飞书机器人,复用已有资源并验证。', + harness, + model_id: args.get('--model'), + runtime_id: runtimeId, + instructions: '你帮助用户管理 Parsar。收到管理任务时阅读 /workspace/PARSAR.md;简单问候直接简短回应并了解目标,不查询资源。用用户的语言回复,过程说明保持简短。使用 node /workspace/parsar.mjs 调用现有接口。先了解用户目标并查询已有资源,尽量复用;按用户要求直接创建或编辑,不增加重复资源。修改共享资源前说明影响,只修改用户委托的内容。不得读取或输出凭据,不得把管理 CLI 或管理地址放进你创建的普通业务 Agent。用户要开通飞书机器人时,按指南完成选 Agent、注册或复用应用、等待用户授权、绑定和接入检查;不要求用户在聊天里提供密钥,不代替用户授权,不把配置完成说成收发验证成功。缺少必要业务信息才提问。完成后给出已保存资源的网页链接,说明哪些经过试运行;不可把仅保存配置称为运行成功。', + skill_ids: [], mcp_ids: [], capability_ids: [], + ask_user: true, enabled: true, +}); +console.log(JSON.stringify({ agent_id: agentId, environment_id: runtimeId, agent_url: `${webURL}/#/agents/${agentId}` }, null, 2)); diff --git a/example/parsar/package.json b/example/parsar/package.json index 45500e2f6..f9ec82847 100644 --- a/example/parsar/package.json +++ b/example/parsar/package.json @@ -8,11 +8,13 @@ "build": "vite build", "start": "node server.mjs", "typecheck": "tsc --noEmit", - "test": "node --test tests/server.test.mjs tests/product.test.mjs", + "test": "node --test tests/server.test.mjs tests/product.test.mjs tests/feishu.test.mjs tests/cli.test.mjs", "test:e2e": "playwright test --config playwright.config.ts", "test:live": "node tests/live.mjs" }, "dependencies": { + "@larksuiteoapi/node-sdk": "^1.75.0", + "@modelcontextprotocol/sdk": "^1.32.1", "@oac/agents-client": "workspace:*", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-select": "2.3.2", @@ -22,8 +24,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fflate": "^0.8.3", + "i18next": "^26.4.4", "lucide-react": "^1.22.0", "motion": "^13.4.3", + "qrcode": "^1.5.4", "react": "^19.2.7", "react-dom": "^19.2.7", "react-markdown": "^10.1.0", @@ -31,17 +35,17 @@ "tailwind-merge": "^3.7.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.1.0", "@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", - "@types/node": "^26.1.0", - "@playwright/test": "^1.63.0" + "vitest": "^4.1.9" }, "engines": { "node": ">=22.13.0" diff --git a/example/parsar/playwright.config.ts b/example/parsar/playwright.config.ts index ac8d9d8dd..8aacfd4c2 100644 --- a/example/parsar/playwright.config.ts +++ b/example/parsar/playwright.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "@playwright/test"; import { homedir } from "node:os"; import { join } from "node:path"; +const appPort = process.env.OAC_EXAMPLE_PORT || "18180"; + export default defineConfig({ testDir: "tests", testMatch: "*.spec.ts", @@ -14,7 +16,7 @@ export default defineConfig({ ), reporter: "line", use: { - baseURL: "http://127.0.0.1:18180", + baseURL: `http://127.0.0.1:${appPort}`, channel: "chrome", reducedMotion: "reduce", screenshot: "only-on-failure", @@ -28,11 +30,11 @@ export default defineConfig({ }, { command: "node server.mjs --dev", - url: "http://127.0.0.1:18180", + url: `http://127.0.0.1:${appPort}`, env: { - OAC_EXAMPLE_CORE_URL: "http://127.0.0.1:18181", - OAC_EXAMPLE_PROJECT_KEY: "fixture-project-key", - OAC_EXAMPLE_PORT: "18180", + OPENAI_BASE_URL: "http://127.0.0.1:18181/v1", + OPENAI_API_KEY: "fixture-project-key", + OAC_EXAMPLE_PORT: appPort, OAC_EXAMPLE_DATA_DIR: join( homedir(), ".oac", diff --git a/example/parsar/pnpm-lock.yaml b/example/parsar/pnpm-lock.yaml index eabc904dc..72663b304 100644 --- a/example/parsar/pnpm-lock.yaml +++ b/example/parsar/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@larksuiteoapi/node-sdk': + specifier: ^1.75.0 + version: 1.75.0(debug@4.4.3) + '@modelcontextprotocol/sdk': + specifier: ^1.32.1 + version: 1.32.1(zod@4.6.7) '@oac/agents-client': specifier: workspace:* version: link:../../packages/agents-client @@ -35,12 +41,18 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 + i18next: + specifier: ^26.4.4 + version: 26.4.4(typescript@5.9.3) lucide-react: specifier: ^1.22.0 version: 1.47.0(react@19.3.0) motion: specifier: ^13.4.3 version: 13.4.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.2.7 version: 19.3.0 @@ -91,6 +103,12 @@ importers: specifier: ^4.1.9 version: 4.1.11(@types/node@26.1.0)(vite@8.3.0(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.8.0)) + ../..: {} + + ../../apps/web: {} + + ../../packages/agents-client: {} + packages: '@esbuild/aix-ppc64@0.28.2': @@ -264,6 +282,12 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -280,6 +304,19 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@larksuiteoapi/node-sdk@1.75.0': + resolution: {integrity: sha512-HVHamfS/skiYsKTCpk6z7oIux3eo0DYQR6HZXGMHsNFZ4rYkhcKKSD9aU7iY26grupl9AP+cg3fisXNcMrqyLA==} + + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@oxc-project/types@0.150.0': resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} @@ -288,6 +325,33 @@ packages: engines: {node: '>=20'} hasBin: true + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -1077,6 +1141,33 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1085,9 +1176,35 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1110,16 +1227,58 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1137,9 +1296,21 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1154,18 +1325,54 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enhanced-resolve@5.25.1: resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1176,13 +1383,41 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1195,6 +1430,31 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + framer-motion@13.4.3: resolution: {integrity: sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ==} peerDependencies: @@ -1206,30 +1466,100 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + i18next@26.4.4: + resolution: {integrity: sha512-O+rqmQUi54OwAgnZ4SAwO6aRrbXZt36VZXWt6zvq0FpC3vJbH46oQRs6u4Uwm1vf8ZI7UqOXQiJhQVTor7vRiQ==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -1239,6 +1569,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -1246,10 +1580,25 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1398,6 +1747,22 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -1412,6 +1777,10 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -1457,6 +1826,14 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -1541,6 +1918,22 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + motion-dom@13.4.2: resolution: {integrity: sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w==} @@ -1566,13 +1959,59 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.2.1: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1583,6 +2022,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + playwright-core@1.63.0: resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} @@ -1593,6 +2036,10 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -1604,6 +2051,35 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: @@ -1661,14 +2137,70 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + rolldown@1.2.9: resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1682,12 +2214,24 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -1719,6 +2263,10 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1728,6 +2276,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1754,6 +2306,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -1777,6 +2333,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -1867,16 +2427,62 @@ packages: jsdom: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1977,6 +2583,10 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@hono/node-server@2.1.5(hono@4.13.13)': + dependencies: + hono: 4.13.13 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -1996,12 +2606,69 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@larksuiteoapi/node-sdk@1.75.0(debug@4.4.3)': + dependencies: + axios: 1.20.0(debug@4.4.3) + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.6.6 + qs: 6.16.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': + dependencies: + '@hono/node-server': 2.1.5(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + '@oxc-project/types@0.150.0': {} '@playwright/test@1.63.0': dependencies: playwright: 1.63.0 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.4': {} @@ -2653,14 +3320,82 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 assertion-error@2.0.1: {} + asynckit@0.4.0: {} + + axios@1.20.0(debug@4.4.3): + dependencies: + follow-redirects: 1.16.1(debug@4.4.3) + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + bail@2.0.2: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -2677,12 +3412,49 @@ snapshots: dependencies: clsx: 2.1.1 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -2691,10 +3463,16 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -2705,13 +3483,42 @@ snapshots: dependencies: dequal: 2.0.3 + dijkstrajs@1.0.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + enhanced-resolve@5.25.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -2740,6 +3547,9 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + optional: true + + escape-html@1.0.3: {} escape-string-regexp@5.0.0: {} @@ -2749,16 +3559,99 @@ snapshots: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.4.0: {} + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.4 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 fflate@0.8.3: {} + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + follow-redirects@1.16.1(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + framer-motion@13.4.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: motion-dom: 13.4.2 @@ -2768,13 +3661,49 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.9 @@ -2799,10 +3728,41 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hono@4.13.13: {} + html-url-attributes@3.0.1: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + i18next@26.4.4(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + ip-address@10.7.4: {} + + ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -2812,12 +3772,24 @@ snapshots: is-decimal@2.0.1: {} + is-fullwidth-code-point@3.0.0: {} + is-hexadecimal@2.0.1: {} is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + + isexe@2.0.0: {} + jiti@2.7.0: {} + jose@6.2.12: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -2916,6 +3888,18 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.identity@3.0.0: {} + + lodash.merge@4.6.2: {} + + lodash.pickby@4.6.0: {} + + long@5.3.2: {} + longest-streak@3.1.0: {} lucide-react@1.47.0(react@19.3.0): @@ -2928,6 +3912,8 @@ snapshots: markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -3081,6 +4067,10 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -3272,6 +4262,18 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + motion-dom@13.4.2: dependencies: motion-utils: 13.3.0 @@ -3290,8 +4292,34 @@ snapshots: nanoid@3.3.19: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.2.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -3302,18 +4330,30 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} + playwright-core@1.63.0: {} playwright@1.63.0: dependencies: playwright-core: 1.63.0 + pngjs@5.0.0: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -3327,6 +4367,47 @@ snapshots: property-information@7.2.0: {} + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 26.1.0 + long: 5.3.2 + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 @@ -3413,6 +4494,12 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + rolldown@1.2.9: dependencies: '@oxc-project/types': 0.150.0 @@ -3434,8 +4521,83 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.9 '@rolldown/binding-win32-x64-msvc': 1.2.9 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + scheduler@0.28.0: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -3444,13 +4606,25 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -3476,12 +4650,20 @@ snapshots: tinyrainbow@3.1.1: {} + toidentifier@1.0.1: {} + trim-lines@3.0.1: {} trough@2.2.0: {} tslib@2.8.1: {} + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript@5.9.3: {} undici-types@8.3.0: {} @@ -3519,6 +4701,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.0): dependencies: react: 19.3.0 @@ -3536,6 +4720,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -3587,12 +4773,55 @@ snapshots: transitivePeerDependencies: - msw + which-module@2.0.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.22.0: {} + + y18n@4.0.3: {} + yaml@2.8.0: optional: true + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} + zwitch@2.0.4: {} diff --git a/example/parsar/server.mjs b/example/parsar/server.mjs index bf81f97e6..bb61ecb04 100644 --- a/example/parsar/server.mjs +++ b/example/parsar/server.mjs @@ -1,3 +1,4 @@ +import { readMediaBytes } from "./shared/media.mjs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { createServer } from "node:http"; @@ -5,11 +6,21 @@ 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 { isIPv4 } from "node:net"; import { openStore, dataPath, AppError } from "./server/store.mjs"; import { productAPI } from "./server/product.mjs"; +import { createFeishuTransport, feishuWorkers } from "./server/feishu.mjs"; const root = fileURLToPath(new URL(".", import.meta.url)); const routes = [ + [/^\/v1\/agents\/sessions$/, ["GET"]], + [/^\/v1\/vaults$/, ["GET", "POST"]], + [/^\/v1\/vaults\/[a-f0-9-]{36}$/, ["GET", "DELETE"]], + [/^\/v1\/vaults\/[a-f0-9-]{36}\/credentials$/, ["GET", "POST"]], + [ + /^\/v1\/vaults\/[a-f0-9-]{36}\/credentials\/[a-f0-9-]{36}$/, + ["GET", "POST", "DELETE"], + ], [/^\/v1\/agents\/environments\/[a-f0-9-]{36}$/, ["GET"]], [/^\/v1\/agents\/environments\/[a-f0-9-]{36}\/files$/, ["GET", "POST"]], [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/artifacts$/, ["GET"]], @@ -24,6 +35,10 @@ const routes = [ [/^\/v1\/agents\/[a-f0-9-]{36}$/, ["GET", "POST"]], ]; routes.push( + [ + /^\/v1\/skills\/skill_[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/versions\/[1-9][0-9]*\/content$/, + ["GET"], + ], [/^\/v1\/skills$/, ["GET", "POST"]], [ /^\/v1\/skills\/skill_[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/, @@ -43,13 +58,13 @@ const mime = { }; export function configuration(env = process.env) { - const target = new URL(env.OAC_EXAMPLE_CORE_URL || "http://127.0.0.1:8091"); + const target = new URL(env.OPENAI_BASE_URL || "http://127.0.0.1:8091/v1"); if ( target.username || target.password || target.search || target.hash || - target.pathname !== "/" || + !["/v1", "/v1/"].includes(target.pathname) || !( target.protocol === "https:" || (target.protocol === "http:" && @@ -57,16 +72,20 @@ export function configuration(env = process.env) { ) ) { throw new Error( - "OAC_EXAMPLE_CORE_URL must be an HTTPS origin, or a loopback HTTP origin, without /v1.", + "OPENAI_BASE_URL must be an HTTPS URL, or a loopback HTTP URL, ending in /v1.", ); } - const key = env.OAC_EXAMPLE_PROJECT_KEY?.trim(); + const key = env.OPENAI_API_KEY?.trim(); if (!key || /[\r\n]/.test(key)) - throw new Error("Set OAC_EXAMPLE_PROJECT_KEY to a Project API key."); + throw new Error("Set OPENAI_API_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 }; + // The listen address is also the only accepted Host, so it must be concrete. + const host = env.OAC_EXAMPLE_HOST || "127.0.0.1"; + if (!isIPv4(host) || host === "0.0.0.0") + throw new Error("OAC_EXAMPLE_HOST must be one IPv4 address of this machine."); + return { target: target.origin, key, port, host }; } function fail(res, status, message) { @@ -77,48 +96,76 @@ function fail(res, status, message) { res.end(JSON.stringify({ error: { message } })); } +export function coreClient(config, fetchImpl = fetch) { + return async (path, method, body, key) => { + const response = await fetchImpl(`${config.target}${path}`, { + method, + ...(body === undefined ? {} : { 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."); + if (response.ok && path.endsWith("/content")) { + try { return Buffer.from(await readMediaBytes(response)); } + catch (error) { if (error instanceof RangeError) throw new AppError(413, error.message); throw error; } + } + const value = + response.status === 202 || response.status === 204 + ? undefined + : await response.json(); + if (!response.ok) + throw new AppError( + response.status, + value.error?.message || "Core request failed.", + value.error?.code, + ); + return value; + }; +} + export function createHandler( config, - { fetchImpl = fetch, frontend, store } = {}, + { fetchImpl = fetch, frontend, store, productHandler } = {}, ) { 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, - ); + productHandler || + (store && + productAPI( + store, + coreClient(config, fetchImpl), + fetchImpl, + `http://${config.host || "127.0.0.1"}:${config.port}`, + )); + const hosts = + (config.host || "127.0.0.1") === "127.0.0.1" + ? ["127.0.0.1", "localhost"] + : [config.host]; 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}`]; + const path = req.url.split("?")[0]; + const homeNavigation = + req.method === "GET" && + ["/", "/index.html"].includes(path) && + req.headers["sec-fetch-mode"] === "navigate" && + req.headers["sec-fetch-dest"] === "document"; + const host = /^([^:]+)(?::([1-9]\d{0,4}))?$/.exec(req.headers.host || ""); if ( - !hosts.includes(req.headers.host) || + !host || + !hosts.includes(host[1]) || + (host[2] && Number(host[2]) > 65535) || (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) || - req.headers["sec-fetch-site"] === "cross-site" + (req.headers["sec-fetch-site"] === "cross-site" && + !homeNavigation && + !(req.method === "GET" && path === "/oauth/callback")) ) { return fail( res, @@ -143,7 +190,13 @@ export function createHandler( let size = 0; for await (const chunk of req) { size += chunk.length; - if (size > 1024 * 1024) return fail(res, 413, "Request too large."); + if ( + size > + (url.pathname.startsWith("/app/capabilities/") + ? 45 * 1024 * 1024 + : 1024 * 1024) + ) + return fail(res, 413, "Request too large."); chunks.push(chunk); } let body = {}; @@ -155,6 +208,11 @@ export function createHandler( if (!body || typeof body !== "object" || Array.isArray(body)) throw new AppError(400, "Invalid request."); const value = await product(req.method, url.pathname, body); + if (Buffer.isBuffer(value)) { + res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" }); + res.end(value); + return; + } res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", @@ -266,6 +324,10 @@ export function createHandler( clearTimeout(timer); res.writeHead(upstream.status, { "Content-Type": "application/octet-stream", + ...(upstream.headers.has("content-length") && + !upstream.headers.has("content-encoding") + ? { "Content-Length": upstream.headers.get("content-length") } + : {}), "Content-Disposition": upstream.headers.get("content-disposition") || "attachment", "Cache-Control": "no-store", @@ -339,15 +401,43 @@ if ( }) : undefined; const store = openStore(dataPath(config)); + const core = coreClient(config); + const productHandler = productAPI( + store, + core, + fetch, + `http://${config.host}:${config.port}`, + ); + const feishu = feishuWorkers( + store, + core, + productHandler, + createFeishuTransport, + ); + feishu.start(); + // Prepared Sessions follow configuration changes within one interval. + const refreshPrepared = () => + void productHandler.sessions + .refreshWarm() + .catch((error) => console.error("Prepared sessions:", error.message)); + refreshPrepared(); + setInterval(refreshPrepared, 15000).unref(); + // Running Turns are watched for stalls and failed Environments. + setInterval(() => void productHandler.sessions.turns.monitor(), 1500).unref(); server.on( "request", - createHandler(config, { frontend: vite?.middlewares, store }), + createHandler(config, { + frontend: vite?.middlewares, + store, + productHandler, + }), ); - server.listen(config.port, "127.0.0.1", () => { - process.stdout.write(`Parsar: http://127.0.0.1:${config.port}\n`); + server.listen(config.port, config.host, () => { + process.stdout.write(`Parsar: http://${config.host}:${config.port}\n`); }); for (const signal of ["SIGINT", "SIGTERM"]) - process.on(signal, () => { + process.on(signal, async () => { + await feishu.stop(); server.close(() => store.close()); void vite?.close(); }); diff --git a/example/parsar/server/capabilities.mjs b/example/parsar/server/capabilities.mjs new file mode 100644 index 000000000..957601555 --- /dev/null +++ b/example/parsar/server/capabilities.mjs @@ -0,0 +1,116 @@ +import { AppError, text, uuid } from "./store.mjs"; +import { unzipSync } from "fflate"; +const publicVersion = ({ archive, ...value }) => ({ + ...value, + has_archive: !!archive, +}); +export const publicCapability = (row) => ({ + ...row, + versions: row.versions.map(publicVersion), +}); +export function capabilityAPI(store) { + return async (method, path, body) => { + const match = path.match(/^\/app\/capabilities(?:\/([a-f0-9-]{36}))?$/); + if (!match || (match[1] && !uuid.test(match[1]))) + throw new AppError(404, "Not found."); + const id = match[1], + old = id && store.get("capabilities", id); + if (method === "GET") { + if (id && !old) throw new AppError(404, "能力不存在。"); + return id + ? publicCapability(old) + : store.list("capabilities").map(publicCapability); + } + if (!id) throw new AppError(405, "Method not allowed."); + if (old && old.revision !== body.revision) + throw new AppError(409, "能力已更新,请重新加载。"); + if (method === "DELETE") { + if ( + store.list("agents").some((agent) => agent.capability_ids?.includes(id)) + ) + throw new AppError(409, "能力仍被 Agent 引用。"); + store.remove("capabilities", id); + return {}; + } + if (method !== "PUT") throw new AppError(405, "Method not allowed."); + const kind = old?.kind || body.kind; + if (!["prompt", "knowledge", "plugin"].includes(kind)) + throw new AppError(400, "能力类型无效。"); + const name = text(body.name, "名称", 80, true); + const description = text(body.description || "", "说明", 2000); + const versions = [...(old?.versions || [])]; + if (!old || body.content !== undefined || body.archive) { + const version = { + version: String(versions.length + 1), + created_at: Date.now(), + }; + if (kind === "plugin") { + const archive = text( + body.archive || + old?.versions.find((row) => row.version === old.default_version) + ?.archive, + "Plugin ZIP", + 44 * 1024 * 1024, + true, + ); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(archive)) + throw new AppError(400, "ZIP 编码无效。"); + try { + let size = 0; + const entries = unzipSync(Buffer.from(archive, "base64"), { + filter: (file) => { + size += file.originalSize; + if ( + size > 32 * 1024 * 1024 || + file.name.split("/").includes("..") || + file.name.startsWith("/") || + file.name.includes("\\") + ) + throw new Error(); + return true; + }, + }); + if ( + !Object.keys(entries).some((path) => + /(?:^|\/)\.(?:codex|claude)-plugin\/plugin.json$/.test(path), + ) + ) + throw new Error(); + version.files = Object.keys(entries); + version.mcp_servers = Object.entries(entries) + .filter(([path]) => /(?:^|\/)\.mcp.json$/.test(path)) + .flatMap(([, bytes]) => + Object.entries( + JSON.parse(Buffer.from(bytes).toString()).mcpServers || {}, + ).map(([name, config]) => ({ + name, + transport: config.command ? "stdio" : config.type || "http", + })), + ); + } catch { + throw new AppError( + 400, + "Plugin ZIP 无效,需要标准 Plugin manifest,解压后最多 32 MiB。", + ); + } + version.archive = archive; + } else version.content = text(body.content, "内容", 500000, true); + versions.push(version); + } + const default_version = body.default_version || versions.at(-1).version; + if (!versions.some((row) => row.version === default_version)) + throw new AppError(400, "版本不存在。"); + const value = { + id, + name, + description, + kind, + versions, + default_version, + deprecated: body.deprecated === true, + revision: (old?.revision || 0) + 1, + }; + store.put("capabilities", value); + return publicCapability(value); + }; +} diff --git a/example/parsar/server/feishu-context.mjs b/example/parsar/server/feishu-context.mjs new file mode 100644 index 000000000..0744f0751 --- /dev/null +++ b/example/parsar/server/feishu-context.mjs @@ -0,0 +1,96 @@ +import { normalize } from "@larksuiteoapi/node-sdk"; + +// Feishu's maintained converter owns post/forwarded-message formatting. Reads +// happen after durable ingress, so a slow message API cannot delay the WS ACK. +export function feishuContextReader(client, botIdentity) { + const get = async (id) => { + const result = await client.im.message.get({ path: { message_id: id } }); + if (result.code !== 0) throw new Error("Context unavailable"); + return result.data?.items || []; + }; + const convert = async (item, row, children) => { + const value = await normalize( + { + sender: { + sender_id: { open_id: item.sender?.id || row.sender_id }, + sender_type: "user", + }, + message: { + message_id: item.message_id, + chat_id: row.chat_id, + chat_type: row.chat_type, + message_type: item.msg_type, + content: item.body?.content || "{}", + mentions: item.mentions || [], + }, + }, + { + botIdentity, + stripBotMentions: true, + fetchSubMessages: () => Promise.resolve(children || []), + }, + ); + return { + text: value.content, + resources: value.resources.map((resource) => ({ + ...resource, + message_id: item.message_id, + })), + }; + }; + return async (row) => { + const resources = [...(row.resources || [])]; + const excerpts = [], + notices = []; + let text = row.text; + if (row.raw_content_type === "merge_forward") { + try { + const children = (await get(row.message_id)).slice(0, 50); + const parent = children.find( + (item) => item.message_id === row.message_id, + ); + if (parent) text = (await convert(parent, row, children)).text; + for (const child of children.filter( + (item) => + item.message_id !== row.message_id && + item.msg_type !== "merge_forward", + )) + resources.push(...(await convert(child, row)).resources); + } catch { + notices.push("无法读取合并转发的内容,请直接发送需要处理的消息。"); + } + } + let quote = row.reply_to_message_id; + const seen = new Set([row.message_id]); + for (let depth = 0; quote && depth < 4 && !seen.has(quote); depth++) { + seen.add(quote); + try { + const items = await get(quote), + item = items.find((item) => item.message_id === quote); + if (!item || (item.chat_id && item.chat_id !== row.chat_id)) break; + const value = await convert(item, row, items); + excerpts.unshift( + `Quoted message (${item.sender?.id || "unknown"}):\n${value.text}`, + ); + resources.push(...value.resources); + quote = item.parent_id; + } catch { + notices.push("引用消息暂不可读取,可直接发送相关内容。"); + break; + } + } + const unique = [ + ...new Map( + resources.map((resource) => [ + `${resource.message_id || row.message_id}:${resource.fileKey}`, + resource, + ]), + ).values(), + ]; + return { + text: [...excerpts, text].join("\n\n").slice(-40000), + resources: unique, + notice: notices.join(" "), + }; + }; +} diff --git a/example/parsar/server/feishu-registration.mjs b/example/parsar/server/feishu-registration.mjs new file mode 100644 index 000000000..2cda4fe70 --- /dev/null +++ b/example/parsar/server/feishu-registration.mjs @@ -0,0 +1,210 @@ +import QRCode from "qrcode"; +import { qrImagePattern } from "../shared/media.mjs"; +import { AppError, uuid, text } from "./store.mjs"; + +// The SDK registerApp helper runs an in-memory polling loop and cannot resume a +// persisted device code after restart. This adapter implements the same device +// flow with durable polling state; codes and credentials never leave the server. +export function feishuRegistrationAPI(store, fetchImpl = fetch) { + const active = new Set(); + const publicState = (row) => ({ + id: row.id, + status: row.status, + verification_url: row.verification_url, + qr_url: `/app/feishu/provision/${row.id}/qrcode`, + expires_at: row.expires_at, + interval: row.interval, + error: row.error, + }); + const call = async (action, fields = {}, brand = "feishu") => { + let response, value; + try { + response = await fetchImpl( + `https://${brand === "lark" ? "accounts.larksuite.com" : "accounts.feishu.cn"}/oauth/v1/app/registration`, + { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(15000), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ action, ...fields }), + }, + ); + const raw = await response.text(); + if (raw.length > 65536) throw new Error(); + value = JSON.parse(raw); + } catch { + throw new AppError(502, "无法连接飞书应用注册服务,请重试。"); + } + if ( + !response.ok && + ![ + "authorization_pending", + "slow_down", + "access_denied", + "expired_token", + "invalid_grant", + ].includes(value.error) + ) + throw new AppError(502, "飞书应用注册服务暂不可用,请稍后重试。"); + return value; + }; + return async (method, path, body) => { + const qrId = path.match(qrImagePattern)?.[1]; + if (method === "GET" && qrId && uuid.test(qrId)) { + const row = store.get("feishu_registration", qrId); + if (!row) throw new AppError(404, "注册会话不存在。"); + if (row.status !== "pending" || row.expires_at <= Date.now()) + throw new AppError(410, "授权二维码已失效,请重新发起注册。"); + return QRCode.toBuffer(row.verification_url, { width: 320, margin: 4 }); + } + const id = path.match(/^\/app\/feishu\/provision\/([a-f0-9-]{36})$/)?.[1]; + if (!id || !uuid.test(id)) throw new AppError(404, "Not found."); + if (method !== "POST" || !["begin", "poll"].includes(body.action)) + throw new AppError(400, "注册操作无效。"); + if (active.has(id)) + throw new AppError(409, "应用注册正在处理,请稍后重试。"); + active.add(id); + try { + let row = store.get("feishu_registration", id); + if (body.action === "begin") { + if (row && (row.status === "success" || row.expires_at > Date.now())) + return publicState(row); + const bot = store.get("feishu_bots", id); + if (bot?.app_id) + throw new AppError(409, "机器人已有应用,请新建机器人进行扫码注册。"); + const name = text(body.name || "Feishu", "机器人名称", 80, true); + await call("init"); + const value = await call("begin", { + archetype: "PersonalAgent", + auth_method: "client_secret", + request_user_info: "open_id tenant_brand", + }); + if (!value.device_code || !value.user_code || value.error) + throw new AppError(502, "飞书未返回有效的注册会话。"); + const verification = new URL( + value.verification_uri_complete || + `https://open.feishu.cn/page/cli?user_code=${encodeURIComponent(value.user_code)}`, + ); + if ( + verification.protocol !== "https:" || + ![ + "open.feishu.cn", + "accounts.feishu.cn", + "open.larksuite.com", + "accounts.larksuite.com", + ].includes(verification.hostname) || + verification.username || + verification.password + ) + throw new AppError(502, "飞书返回的授权地址无效。"); + row = { + id, + name, + status: "pending", + device_code: value.device_code, + verification_url: verification.href, + expires_at: + Date.now() + + Math.min(Math.max(Number(value.expires_in) || 300, 30), 1800) * + 1000, + interval: Math.min(Math.max(Number(value.interval) || 5, 1), 60), + next_at: 0, + }; + store.put("feishu_registration", row); + return publicState(row); + } + if (!row) throw new AppError(404, "注册会话不存在,请重新扫码。"); + if (row.status !== "pending") return publicState(row); + if (row.expires_at <= Date.now()) { + row = { + ...row, + status: "error", + error: "expired_token", + device_code: undefined, + }; + store.put("feishu_registration", row); + return publicState(row); + } + if (Date.now() < row.next_at) return publicState(row); + store.put("feishu_registration", { + ...row, + next_at: Date.now() + row.interval * 1000, + }); + const value = await call( + "poll", + { device_code: row.device_code }, + row.brand, + ); + if (value.user_info?.tenant_brand === "lark" && row.brand !== "lark") { + // Match the SDK's one-way regional handoff and retain it across restart. + row = { ...row, brand: "lark", next_at: 0 }; + store.put("feishu_registration", row); + return publicState(row); + } + if (value.client_id && value.client_secret) { + if ( + !/^cli_[0-9a-fA-F]{16}$/.test(value.client_id) || + typeof value.client_secret !== "string" || + value.client_secret.length > 300 + ) + throw new AppError(502, "飞书返回的应用凭据无效。"); + const bot = store.get("feishu_bots", id); + if ( + bot?.app_id || + store + .list("feishu_bots") + .some((bot) => bot.app_id === value.client_id) + ) + throw new AppError(409, "应用已绑定,请检查机器人配置。"); + store.transaction(() => { + store.put("feishu_bots", { + ...bot, + id, + name: bot?.name || row.name, + app_id: value.client_id, + app_secret: value.client_secret, + brand: row.brand === "lark" ? "lark" : "feishu", + enabled: false, + agent_id: bot?.agent_id || "", + direct: bot?.direct === true, + groups: bot?.groups === true, + revision: (bot?.revision || 0) + 1, + }); + row = { ...row, status: "success", device_code: undefined }; + store.put("feishu_registration", row); + }); + } else if ( + ["authorization_pending", "slow_down", undefined, ""].includes( + value.error, + ) + ) { + row = { + ...row, + interval: + value.error === "slow_down" + ? Math.min(row.interval + 5, 60) + : row.interval, + }; + store.put("feishu_registration", { + ...row, + next_at: Date.now() + row.interval * 1000, + }); + } else { + row = { + ...row, + status: "error", + error: ["access_denied", "expired_token", "invalid_grant"].includes( + value.error, + ) + ? value.error + : "registration_failed", + device_code: undefined, + }; + store.put("feishu_registration", row); + } + return publicState(row); + } finally { + active.delete(id); + } + }; +} diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs new file mode 100644 index 000000000..fd709ee22 --- /dev/null +++ b/example/parsar/server/feishu.mjs @@ -0,0 +1,1573 @@ +import { artifactContentPath, imageMime, maxMediaBytes, qrImages, qrImagePattern } from "../shared/media.mjs"; +import { createHash, randomUUID } from "node:crypto"; +const stableId = (value) => { + const hex = createHash("sha256").update(value).digest("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +}; +import { feishuContextReader } from "./feishu-context.mjs"; +import { explainFailure, turnsAPI } from "./turns.mjs"; +import { typicalWait, waitingHeadline } from "../shared/turn-status.mjs"; +import { AppError, text, uuid, transientError } from "./store.mjs"; + +const stamp = () => Date.now(); +const sendExpired = (row) => row.first_attempt_at && stamp() - row.first_attempt_at >= 55 * 60000; +export const publicFeishu = ({ app_secret, ...record }) => ({ + ...record, + has_app_secret: !!app_secret, +}); +export function feishuAPI(store) { + return async (method, path, body) => { + const retryMatch = path.match( + /^\/app\/feishu\/deliveries\/(inbound|outbound)\/([a-f0-9-]{36})$/, + ); + if (retryMatch && method === "PUT" && uuid.test(retryMatch[2])) { + const kind = `feishu_${retryMatch[1]}`, + row = store.get(kind, retryMatch[2]); + if (!row) throw new AppError(404, "投递记录不存在。"); + if (row.state !== "failed") + throw new AppError(409, "此记录不需要手动重试。"); + if ( + kind === "feishu_outbound" && + sendExpired(row) + ) + throw new AppError( + 409, + "飞书去重窗口已过。请人工检查原话题,不能安全自动重发。", + ); + store.put(kind, { + ...row, + state: "queued", + attempts: 0, + error: undefined, + next_at: 0, + }); + return {}; + } + if (path === "/app/feishu/bots" && method === "GET") + return store.list("feishu_bots").map((row) => ({ + ...publicFeishu(row), + health: store.get("feishu_health", row.id) || { state: "stopped" }, + })); + if (path === "/app/feishu/deliveries" && method === "GET") + return [ + ...store + .list("feishu_inbound") + .map((row) => ({ ...row, direction: "inbound" })), + ...store + .list("feishu_outbound") + .map((row) => ({ ...row, direction: "outbound" })), + ] + .sort((a, b) => b.created_at - a.created_at) + .filter( + (row, index) => + index < 100 || + ["queued", "submitting", "failed"].includes(row.state), + ) + .map(({ text, payload, event, resources, media, uploaded_media, context, ...row }) => { + const sessionId = store.get("feishu_threads", row.thread_id)?.session_id; + return { + ...row, + session_id: + sessionId && store.get("sessions", sessionId)?.core_session_id + ? sessionId + : undefined, + }; + }); + const botMatch = path.match(/^\/app\/feishu\/bots\/([a-f0-9-]{36})$/); + if (!botMatch || !uuid.test(botMatch[1])) + throw new AppError(404, "Not found."); + const id = botMatch[1]; + const old = store.get("feishu_bots", id) || { + id, + name: "", + revision: 0, + enabled: false, + app_id: "", + app_secret: "", + agent_id: "", + direct: false, + groups: false, + }; + if (method === "GET") return publicFeishu(old); + if (method === "DELETE") { + if (old.enabled) throw new AppError(409, "请先停用机器人。"); + if ( + [ + ...store.list("feishu_inbound"), + ...store.list("feishu_outbound"), + ].some( + (row) => + row.app_id === old.app_id && + !["delivered", "rejected"].includes(row.state), + ) + ) + throw new AppError(409, "机器人仍有未完成投递,请先处理。"); + store.remove("feishu_bots", id); + return {}; + } + if (method !== "PUT") throw new AppError(405, "Method not allowed."); + if (old.revision !== body.revision) + throw new AppError(409, "配置已更新,请重新加载。"); + if (body.brand !== undefined && !["feishu", "lark"].includes(body.brand)) + throw new AppError(400, "机器人地区无效。"); + const name = text(body.name || "Feishu", "机器人名称", 80, true); + const app_id = text(body.app_id, "App ID", 100).trim(); + if ( + app_id && + store + .list("feishu_bots") + .some((row) => row.id !== id && row.app_id === app_id) + ) + throw new AppError(409, "这个 App ID 已由另一机器人连接。"); + const secret = body.app_secret + ? text(body.app_secret, "App Secret", 300, true).trim() + : old.app_secret; + if (app_id !== old.app_id && !body.app_secret) + throw new AppError(400, "更换 App ID 时请同时填写 App Secret。"); + if (typeof body.enabled !== "boolean") + throw new AppError(400, "飞书配置无效。"); + const agent_id = text(body.agent_id || "", "Agent", 36); + const agent = agent_id && store.get("agents", agent_id); + if (agent_id && !agent) throw new AppError(400, "Agent 不存在。"); + if ( + agent_id && + store + .list("feishu_bots") + .some((row) => row.id !== id && row.agent_id === agent_id) + ) + throw new AppError(409, "这个 Agent 已绑定另一个机器人。"); + const direct = body.direct === true; + // Group membership is the group boundary: any group the bot joins can mention it. + const groups = body.groups === true; + if ( + body.enabled && + (!/^cli_[0-9a-fA-F]{16}$/.test(app_id) || + !secret || + !agent || + (!direct && !groups)) + ) + throw new AppError( + 400, + "启用前请配置 App ID、App Secret、Agent,并开启私聊或群聊。", + ); + if (body.enabled && !store.get("runtimes", agent.runtime_id || "")) + throw new AppError(400, "Agent 未配置环境模板。"); + const value = { + id, + name, + app_id, + app_secret: secret, + brand: body.brand || old.brand || "feishu", + enabled: body.enabled, + agent_id, + direct, + groups, + revision: old.revision + 1, + }; + store.put("feishu_bots", value); + return publicFeishu(value); + }; +} + +const cardTones = { + in_progress: ["处理中", "blue"], + requires_action: ["需要你补充信息", "orange"], + completed: ["已完成", "green"], + cancelled: ["已停止", "grey"], + failed: ["执行失败", "red"], +}; +const grey = (text) => `${text}`; + +// Budget serialized UTF-8 bytes, including JSON escaping, for Feishu cards. +function excerpt(value, limit) { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) || ""; + if (Buffer.byteLength(JSON.stringify(text)) <= limit) return text; + const prefix = []; + let size = 0; + for (const char of text) { + size += Buffer.byteLength(JSON.stringify(char)) - 2; + if (size > limit - 60) break; + prefix.push(char); + } + return prefix.join("") + "\n…(内容较长,已截取)"; +} + +function panel({ title, content, elements }) { + return { + tag: "collapsible_panel", + expanded: false, + vertical_spacing: "8px", + padding: "0px 0px 8px 0px", + header: { + title: { tag: "markdown", content: grey(title) }, + width: "fill", + padding: "8px 0px 8px 0px", + icon: { tag: "standard_icon", token: "down-small-ccm_outlined", size: "16px 16px" }, + icon_position: "right", + icon_expanded_angle: -180, + }, + elements: elements || [{ tag: "markdown", content }], + }; +} + +// One restrained status line replaces the large colored banner. +function statusCard(status, body, panels = []) { + const [title, color] = cardTones[status] || cardTones.failed; + return JSON.stringify({ + schema: "2.0", + config: { update_multi: true, summary: { content: title } }, + body: { + padding: "16px 16px 12px 16px", + vertical_spacing: "8px", + elements: [ + { tag: "markdown", text_size: "notation", content: `${title}` }, + { tag: "markdown", content: excerpt(body, status === "requires_action" ? 8000 : 12000) }, + ...(panels.length ? [{ tag: "hr" }, ...panels.map(panel)] : []), + ], + }, + }); +} + +export function feishuCard(payload) { + const text = String(payload); + if (text.startsWith("{")) return JSON.parse(text); + const [title, ...rest] = text.split("\n"); + const body = rest.join("\n").trim(); + if (title === "执行失败" && body) return JSON.parse(statusCard("failed", body)); + return { + schema: "2.0", + config: { update_multi: true }, + body: { elements: [{ tag: "markdown", content: text }] }, + }; +} + +export function progressCard(text, notice) { + return statusCard("in_progress", `**${text}**${notice ? `\n${grey(notice)}` : ""}`); +} + +function questionText(action, includeOptions = true) { + let args = action.arguments; + try { if (typeof args === "string") args = JSON.parse(args); } catch { args = {}; } + return [ + typeof args?.question === "string" ? args.question : "请补充信息", + includeOptions && Array.isArray(args?.options) ? args.options.filter((x) => typeof x === "string").map((x) => `- ${x}`).join("\n") : "", + ].filter(Boolean).join("\n\n"); +} + +function step(item) { + if (item.type === "command_execution" || typeof item.command === "string") { + const command = String(item.command || "").replace(/\s+/g, " ").trim() + .replace(/^\/bin\/(?:ba|z)?sh -lc (['"])([\s\S]*)\1$/, "$2"); + return ["运行命令", command.length > 56 ? `${command.slice(0, 55)}…` : command]; + } + if (item.type === "mcp_call") return ["调用工具", [item.server_label, item.name].filter(Boolean).join(" · ")]; + if (item.type === "function_call") return ["调用工具", item.name || ""]; + if (item.type === "web_search_call") return ["搜索网页", item.action?.query || item.action?.url || ""]; + if (item.type === "file_change") return ["修改文件", ""]; + return ["执行步骤", item.name || item.type || ""]; +} + +function stepPanel(item) { + const [label, detail] = step(item); + const state = { completed: "完成", failed: "失败", incomplete: "未完成", in_progress: "进行中" }[item.status] || "状态未知"; + const duration = typeof item.duration_ms === "number" ? ` · ${(item.duration_ms / 1000).toFixed(1)}s` : ""; + const fields = [ + ["命令", item.command], ["工作目录", item.cwd], ["参数", item.arguments ?? item.action], + ["输出", item.output], ["错误", item.error], ["退出码", item.exit_code], + ].filter(([, value]) => value !== undefined && value !== null && value !== ""); + // Plain text keeps tool output literal, including code fences and mention markup. + return panel({ + title: `${state} · ${label}${detail ? ` · ${excerpt(detail, 180).replace(/[<>]/g, "")}` : ""}${duration}`, + elements: [{ tag: "div", text: { tag: "plain_text", content: excerpt(fields.map(([name, value]) => `${name}\n${excerpt(value, 700)}`).join("\n\n") || "暂无详细输出。", 1000) } }], + }); +} + +// A tool-result answer resumes the same Core Turn. Display only the exchange +// after that answer, not every earlier question and command in the Turn. +function exchangeItems(items) { + const questions = new Set(items.filter((x) => x.type === "function_call" && x.name === "ask_user").map((x) => x.call_id)); + const boundary = items.findLastIndex((x) => x.type === "message" && x.role === "user" || x.type === "function_call_output" && questions.has(x.call_id)); + return items.slice(boundary + 1); +} + +export function formatTurnCard(status, items = [], error, turn, questions = []) { + items = exchangeItems(items); + const text = (item) => (item.content || []).filter((p) => p.type === "output_text").map((p) => (p.text || "").trim()).filter(Boolean).join("\n"); + const messages = items.filter((x) => x.type === "message" && x.role === "assistant"); + const latest = messages.at(-1); + const readable = (value) => value + .replace(/!\[[^\]\n]*\]\((\/app\/feishu\/provision\/[a-f0-9-]{36}\/qrcode)\)/g, "(授权二维码见图片消息)") + .replace(/!?\[([^\]]*)\]\((?:\/workspace\/|\.\/)?outputs\/[^)]*\)/g, "$1(见附件)"); + const authorization = messages.filter((item) => qrImages(text(item)).length); + const authorizationText = authorization.map((item) => readable(text(item))).join("\n\n"); + const answer = latest?.phase === "commentary" || authorization.includes(latest) ? "" : latest ? readable(text(latest)) : ""; + const reasoning = items.filter((x) => x.type === "reasoning").flatMap((x) => x.summary || x.content || []) + .map((p) => typeof p === "string" ? p : p?.text || "").filter(Boolean).join("\n\n"); + const commentary = messages.filter((x) => !authorization.includes(x) && (status === "in_progress" || x !== latest || x.phase === "commentary")).map(text).filter(Boolean).join("\n\n"); + const outputs = new Map(items.filter((x) => x.type === "function_call_output").map((x) => [x.call_id, x])); + const steps = items.filter((x) => !["message", "agent_message", "reasoning", "function_call_output"].includes(x.type) && !(x.type === "function_call" && x.name === "ask_user")) + .map((x) => { + const output = x.type === "function_call" ? outputs.get(x.call_id) : undefined; + return output ? { ...x, status: output.status, output: output.output, error: output.error } : x; + }); + const panels = []; + if (reasoning) panels.push({ title: "思考摘要", content: excerpt(reasoning, 3000) }); + if (commentary) panels.push({ title: "过程说明", content: excerpt(commentary, 1500) }); + if (steps.length) panels.push({ + title: `工具调用 · ${steps.length} 次`, + elements: [ + ...(steps.length > 6 ? [{ tag: "markdown", content: grey(`展示最近 6 次,共 ${steps.length} 次;完整记录保留在网页会话。`) }] : []), + ...steps.slice(-6).map(stepPanel), + ], + }); + if (status === "in_progress") { + const last = items.filter((x) => x.role !== "user" && x.type !== "function_call_output").at(-1); + const current = !last ? waitingHeadline(turn) : last.type === "message" ? "正在整理回复" : last.type === "reasoning" ? "正在思考" : last.status === "in_progress" ? `正在${step(last)[0]}` : "正在整理结果"; + const done = steps.filter((x) => x.status === "completed").length; + const failed = steps.filter((x) => x.status === "failed").length; + const count = steps.length ? `工具调用 ${steps.length} 次 · 完成 ${done} 次${failed ? ` · 失败 ${failed} 次` : ""}` : ""; + const typical = !last && turn?.expected_s && turn.stage !== "retrying" ? typicalWait.replace("{{value0}}", turn.expected_s) : ""; + return statusCard(status, [`**${current}**`, grey([count, typical].filter(Boolean).join(" · ")), authorizationText].filter(Boolean).join("\n"), panels); + } + const context = questions.some((action) => questionText(action, false).trim() === answer.trim()) ? "" : answer; + // Put the decision first; keep its explanation available without filling the chat. + // Authorization instructions stay visible alongside the separately sent QR. + const foldContext = status === "requires_action" && questions.length && context; + if (foldContext) panels.unshift({ title: "补充说明", content: excerpt(context, 4000) }); + const body = status === "requires_action" + ? [...questions.map((action) => questionText(action)), authorizationText, foldContext ? "" : context, grey("直接回复这条消息即可继续。")].filter(Boolean).join("\n\n") + : status === "completed" ? [authorizationText, answer].filter(Boolean).join("\n\n") || "已完成处理。" + : status === "cancelled" ? [authorizationText, answer].filter(Boolean).join("\n\n") || "已按你的要求停止。" + : [authorizationText, answer || "这次没有完成。", error ? `${error}` : "", grey("直接回复即可重试。")].filter(Boolean).join("\n\n"); + return statusCard(status, body, panels); +} + +export async function createFeishuTransport(settings, receive) { + const { Client, WSClient, EventDispatcher, normalize, Domain } = await import( + "@larksuiteoapi/node-sdk" + ); + // SDK diagnostics may contain authenticated WebSocket URLs and message bodies. + const logger = { error() {}, warn() {}, info() {}, debug() {}, trace() {} }; + const options = { + appId: settings.app_id, + appSecret: settings.app_secret, + domain: settings.brand === "lark" ? Domain.Lark : Domain.Feishu, + logger, + }; + const client = new Client(options); + const ws = new WSClient({ ...options, handshakeTimeoutMs: 15000 }); + let readContext; + return { + ...feishuOperations(client), + readContext: (row) => readContext(row), + async connect() { + const result = await client.request({ + method: "GET", + url: "/open-apis/bot/v3/info", + }); + const bot = result.bot || result.data?.bot; + if (result.code !== 0 || !bot?.open_id) + throw new Error("Cannot resolve Feishu bot identity"); + readContext = feishuContextReader(client, { + openId: bot.open_id, + name: bot.app_name || "Parsar", + }); + const dispatcher = new EventDispatcher({ logger }).register({ + "im.message.receive_v1": async (event) => { + if ( + event.sender?.sender_type !== "user" || + // The bot serves its own tenant; external contacts are ignored. + (event.tenant_key && + event.sender.tenant_key && + event.sender.tenant_key !== event.tenant_key) || + ![ + "text", + "post", + "image", + "file", + "audio", + "media", + "merge_forward", + ].includes(event.message?.message_type) + ) + return; + const message = await normalize(event, { + botIdentity: { + openId: bot.open_id, + name: bot.app_name || "Parsar", + }, + stripBotMentions: true, + }); + // Unlike the SDK's convenience Channel queue, the dispatcher awaits + // this commit before acknowledging. Storage failure returns a retryable ACK. + receive(message); + }, + }); + await ws.start({ eventDispatcher: dispatcher }); + }, + async disconnect() { + ws.close({ force: true }); + }, + getConnectionStatus() { + return ws.getConnectionStatus(); + }, + }; +} + +export function feishuOperations(client) { + const confirmed = (result) => { + if (result.code !== 0) + throw new Error("Feishu did not confirm the operation"); + return result.data; + }; + return { + async upload(bytes, name) { + const image = !!imageMime(bytes); + const key = image ? "image_key" : "file_key"; + const failure = (status, code) => { + // Feishu common error codes: generic-error-code; 99991400 is rate limiting. + const temporary = status >= 500 || [408, 409, 425, 429].includes(status) || [1500, 1503, 1551, 99991400].includes(code); + const definite = !temporary && (status >= 400 || (Number.isInteger(code) && code !== 0)); + return new AppError(definite ? 422 : 503, definite + ? `飞书拒绝上传附件${Number.isInteger(code) ? `(错误码 ${code})` : ""},请检查机器人上传权限、文件格式和大小后重试。` + : "飞书附件上传暂未确认,将重试。"); + }; + let result; + try { + // The generated upload methods discard error codes. Keep the SDK's full envelope. + result = await client.request({ + method: "POST", url: image ? "/open-apis/im/v1/images" : "/open-apis/im/v1/files", + headers: { "Content-Type": "multipart/form-data" }, + data: image ? { image_type: "message", image: bytes } : { file_type: "stream", file_name: name, file: bytes }, + }); + } catch (error) { throw failure(error.response?.status, error.response?.data?.code); } + if (result?.code !== 0 || typeof result.data?.[key] !== "string" || !result.data[key]) + throw failure(200, result?.code); + return { msg_type: image ? "image" : "file", content: JSON.stringify({ [key]: result.data[key] }) }; + }, + async send(row) { + const text = + row.plain_text && /^ou_[a-zA-Z0-9_-]+$/.test(row.sender_id || "") + ? `user ${row.payload}` + : row.payload; + const data = { + msg_type: row.plain_text ? "text" : "interactive", + content: JSON.stringify(row.plain_text ? { text } : feishuCard(text)), + ...row.uploaded_media, + uuid: row.id, + }; + const result = row.reply_to + ? await client.im.message.reply({ + path: { message_id: row.reply_to }, + data: { ...data, reply_in_thread: row.chat_type !== "p2p" }, + }) + : await client.im.message.create({ + params: { receive_id_type: "chat_id" }, + data: { ...data, receive_id: row.chat_id }, + }); + const value = confirmed(result); + if (!value?.message_id) + throw new Error("Feishu did not return a message identifier"); + return { id: value.message_id }; + }, + async edit(id, text) { + const result = await client.im.message.patch({ + path: { message_id: id }, + data: { content: JSON.stringify(feishuCard(text)) }, + }).catch((error) => { + if (error.response?.data?.code) return error.response.data; + throw error; + }); + // Documented PATCH errors: recalled, outside the edit window, deleted. + if ([230011, 230031, 230110].includes(result.code)) + throw new AppError(410, "飞书卡片已不可更新。", "feishu_card_unavailable"); + confirmed(result); + }, + async resource(row, resource) { + return ( + await client.im.messageResource.get({ + path: { + message_id: resource.message_id || row.message_id, + file_key: resource.fileKey, + }, + params: { type: resource.type === "image" ? "image" : "file" }, + }) + ).getReadableStream(); + }, + async beginTyping(row) { + return confirmed( + await client.im.messageReaction.create({ + path: { message_id: row.message_id }, + data: { reaction_type: { emoji_type: "Typing" } }, + }), + )?.reaction_id; + }, + async endTyping(row) { + confirmed( + await client.im.messageReaction.delete({ + path: { + message_id: row.message_id, + reaction_id: row.typing_reaction_id, + }, + }), + ); + }, + }; +} + +// The maintained SDK owns authentication, mention parsing and WebSocket lifecycle. +// SQLite owns delivery receipts across reconnects and process restarts. +export function feishuWorker(store, core, product, makeChannel, configId) { + let channel, + active, + timer, + stopping = false, + running; + const health = (state) => + store.put("feishu_health", { + id: configId, + state, + updated_at: stamp(), + }); + const sessions = product.sessions; + // The Turn ledger, bound to this worker's Core client like every other call here. + const ledger = turnsAPI(store, core); + const allowed = (appId, chatType = "group") => { + const config = store.get("feishu_bots", configId); + return Boolean( + config?.enabled && + config.app_id === appId && + (chatType === "p2p" ? config.direct : config.groups), + ); + }; + const threadFor = (appId, message) => + store + .list("feishu_threads") + .find( + (row) => + row.app_id === appId && + row.chat_id === message.chatId && + (row.root_id === + (message.rootId || message.replyToMessageId || message.messageId) || + (message.threadId && row.thread_id === message.threadId) || + store + .list("feishu_outbound") + .some( + (out) => + out.thread_id === row.id && + out.sent_message_id === message.replyToMessageId, + )), + ); + const receive = (appId, message) => { + if ( + !allowed(appId, message.chatType) || + !["group", "p2p"].includes(message.chatType) || + (message.chatType === "group" && + !message.mentionedBot && + // A topic reply that mentions only other people is addressed to them. + (message.mentions?.length || !threadFor(appId, message))) || + !message.senderId || + message.mentionAll + ) + return; + if (!message.content?.trim() || message.content.length > 40000) return; + const id = stableId(`${appId}:${message.messageId}`); + if (store.get("feishu_inbound", id)) return; + let thread = + message.chatType === "p2p" && + !["/new", "/reset"].includes(message.content.trim()) + ? store + .list("feishu_threads") + .find( + (row) => + row.app_id === appId && + row.chat_id === message.chatId && + row.sender_id === message.senderId && + row.chat_type === "p2p", + ) + : threadFor(appId, message); + if ( + thread && + !store.get("sessions", thread.session_id) && + !message.content.trim().startsWith("/") && + (store + .get("feishu_inbound", thread.first_message_id) + ?.text.startsWith("/") || + ["rejected", "delivered"].includes( + store.get("feishu_inbound", thread.first_message_id)?.state, + )) + ) { + thread = { ...thread, first_message_id: id }; + } + const root = + message.rootId || message.replyToMessageId || message.messageId; + const threadId = + thread?.id || stableId(`${appId}:${message.chatId}:${root}`); + store.transaction(() => { + if (thread) + store.put("feishu_threads", { ...thread, settled_at: undefined }); + if (!thread) + store.put("feishu_threads", { + id: threadId, + app_id: appId, + chat_id: message.chatId, + chat_type: message.chatType, + root_id: root, + thread_id: message.threadId, + context_data: message.context, + session_id: randomUUID(), + agent_id: store.get("feishu_bots", configId).agent_id, + first_message_id: id, + sender_id: message.senderId, + created_at: stamp(), + }); + store.put("feishu_inbound", { + id, + app_id: appId, + chat_id: message.chatId, + chat_type: message.chatType, + thread_id: threadId, + message_id: message.messageId, + sender_id: message.senderId, + text: message.content.trim(), + raw_content_type: message.rawContentType, + reply_to_message_id: message.replyToMessageId, + occurred_at: message.createTime || stamp(), + context_data: message.context, + resources: (message.resources || []).map( + ({ type, fileKey, fileName }) => ({ type, fileKey, fileName }), + ), + state: "queued", + attempts: 0, + created_at: stamp(), + next_at: 0, + }); + }); + }; + const queueReply = ( + id, + thread, + payload, + notify = false, + replyTo = thread.root_id, + ) => { + if (!store.get("feishu_outbound", id)) + store.put("feishu_outbound", { + id, + app_id: thread.app_id, + chat_id: thread.chat_id, + chat_type: thread.chat_type, + thread_id: thread.id, + reply_to: replyTo, + payload, + notify, + state: "queued", + attempts: 0, + created_at: stamp(), + next_at: 0, + }); + }; + const queueMedia = (thread, items, artifacts = []) => { + const sources = artifacts.map((file) => ({ + id: `artifact:${file.id}`, + path: artifactContentPath(file.session_id, file.id), + name: file.path.split("/").at(-1) || "attachment", + })); + for (const item of items.filter((item) => item.type === "message" && item.role === "assistant")) { + const text = (item.content || []).filter((part) => part.type === "output_text").map((part) => part.text).join("\n"); + for (const path of qrImages(text)) + sources.push({ id: `${item.turn_id}:${path}`, path, name: "feishu-authorization.png" }); + } + for (const source of sources) { + const id = stableId(`${thread.id}:media:${source.id}`); + if (store.get("feishu_outbound", id)) continue; + store.transaction(() => { + queueReply(id, thread, ""); + store.put("feishu_outbound", { ...store.get("feishu_outbound", id), media: source }); + }); + } + }; + const reject = (row, reason) => { + store.put("feishu_inbound", { + ...row, + state: "rejected", + media: undefined, + context: undefined, + error: reason, + }); + const thread = store.get("feishu_threads", row.thread_id); + if (thread) queueReply(stableId(`${row.id}:rejected`), thread, reason); + }; + const cancel = async (row) => { + const thread = store.get("feishu_threads", row.thread_id); + if (!thread) return reject(row, "话题关联不可用,请在 Web 中检查。"); + let run = store.get("sessions", thread.session_id); + // Resolve an uncertain initial creation before cancelling its actual Session. + if (run && !run.core_session_id && !run.deleted_at) + run = await sessions(run.id, {}); + store.transaction(() => { + for (const previous of store.list("feishu_inbound").reverse()) { + if (previous.id === row.id) break; + if ( + previous.thread_id === row.thread_id && + !["delivered", "rejected"].includes(previous.state) + ) + store.put("feishu_inbound", { + ...previous, + state: "rejected", + error: "已被后续取消请求撤回,不会重新提交。", + }); + } + if (!run) { + queueReply( + stableId(`${row.id}:cancel`), + thread, + "已撤回此前等待处理的消息;此话题没有已启动的执行。", + ); + store.put("feishu_inbound", { + ...row, + state: "delivered", + error: undefined, + }); + } + }); + if (run) await inbound(row); + }; + const mediaFor = async (row) => { + if (row.media) return row.media; + const thread = store.get("feishu_threads", row.thread_id); + const context = + row.context || + (await channel.readContext(row)); + row = { ...row, context }; + store.put("feishu_inbound", row); + let input = context.text.replace( + /<(?:file|audio|video) key="[^"]*"[^>]*\/>/g, + "", + ); + for (const resource of context.resources || []) + if (resource.type === "image") + input = input.split(`![image](${resource.fileKey})`).join(""); + const title = + row.text + .replace(/!\[image\]\([^)]*\)/g, "") + .replace(/<(?:file|audio|video) key="[^"]*"[^>]*\/>/g, "") + .trim() || + ((context.resources || []).some((resource) => resource.type === "image") + ? "图片消息" + : "文件消息"); + const media = { + images: [], + files: [], + text: input.trim() || title, + title, + notice: context.notice, + }; + if ((context.resources?.length || 0) > 8) + throw new AppError(400, "每条消息最多支持 8 个附件。"); + let total = 0; + for (const resource of context.resources || []) { + const response = await channel.resource(row, resource); + const chunks = []; + for await (const chunk of response) { + total += chunk.length; + if (total > 5 * 1024 * 1024) + throw new AppError(413, "附件合计不能超过 5 MiB。"); + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks); + if (resource.type === "image") { + const mime = bytes + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ? "png" + : bytes[0] === 255 && bytes[1] === 216 + ? "jpeg" + : bytes.toString("ascii", 8, 12) === "WEBP" + ? "webp" + : ""; + if (!mime) throw new AppError(400, "图片需为 PNG、JPEG 或 WebP。"); + media.images.push( + `data:image/${mime};base64,${bytes.toString("base64")}`, + ); + if (JSON.stringify(media.images).length > 900000) + throw new AppError( + 413, + "图片超过公开消息接口大小限制,请缩小后重试。", + ); + } else { + const filename = (resource.fileName || "attachment") + .replace(/[^a-zA-Z0-9_.-]/g, "_") + .slice(-100); + const path = `/workspace/inputs/${row.id}-${media.files.length}-${filename}`; + media.files.push({ + type: "inline", + path, + data: bytes.toString("base64"), + }); + media.text += `\nAttachment: ${path.replace("/workspace/", "./")}`; + } + } + store.put("feishu_inbound", { ...row, media }); + return media; + }; + const inbound = async (row) => { + let thread = store.get("feishu_threads", row.thread_id); + if (!thread) return reject(row, "话题关联不可用,请在 Web 中检查。"); + if (["/new", "/reset", "/help"].includes(row.text)) { + queueReply( + stableId(`${row.id}:command`), + thread, + row.text === "/help" + ? "直接发送消息即可对话。/new 开始新对话(群聊请另开话题并 @机器人);/cancel 停止执行;/answer <回答> 回复 Agent 的提问。" + : row.chat_type === "p2p" + ? "新对话已就绪,请直接发送消息。" + : "群聊请另开话题并 @机器人,开始新对话。当前话题保持不变。", + ); + store.put("feishu_inbound", { ...row, state: "delivered" }); + return; + } + const media = await mediaFor(row); + row = store.get("feishu_inbound", row.id); + const rotateP2PThread = () => { + thread = { + ...thread, + root_id: row.message_id, + agent_id: store.get("feishu_bots", configId).agent_id, + session_id: randomUUID(), + session_ids: [ + ...new Set( + [...(thread.session_ids || []), thread.session_id].filter(Boolean), + ), + ], + first_message_id: row.id, + turn_cursor: undefined, + item_cursor: undefined, + artifact_cursor: undefined, + settled_at: undefined, + active_outbound_id: undefined, + progress_text: undefined, + display_after: undefined, + answered_call_ids: [], + awaiting_user_input: false, + observed_through: undefined, + }; + store.put("feishu_threads", thread); + }; + if ( + thread.first_message_id !== row.id && + row.chat_type === "p2p" && + !row.text.startsWith("/") && + store.get("sessions", thread.session_id)?.deleted_at + ) + rotateP2PThread(); + const progress = (text) => { + const progressId = stableId(`${row.id}:accepted`); + thread = { + ...thread, + active_outbound_id: progressId, + progress_text: text, + display_after: row.display_after, + answered_call_ids: row.event?.type === "agent.session.input.tool_result" + ? [...new Set([...(thread.answered_call_ids || []), row.event.call_id])] + : thread.answered_call_ids, + awaiting_user_input: row.event?.type === "agent.session.input.message", + }; + store.put("feishu_threads", thread); + queueReply( + progressId, + thread, + text, + false, + row.chat_type === "p2p" ? row.message_id : thread.root_id, + ); + }; + const thinking = progressCard( + thread.first_message_id === row.id ? "正在准备工作环境" : "正在思考", + media.notice, + ); + if (thread.first_message_id === row.id) { + if (row.text.startsWith("/")) + return reject(row, "请先发送消息开始对话,再使用命令。"); + await sessions( + thread.session_id, + { + name: media.title.split("\n")[0].slice(0, 80), + agent_id: thread.agent_id, + input: media.text, + images: media.images, + files: media.files, + }, + { source: "feishu", feishu_chat_type: row.chat_type }, + ); + progress(thinking); + } else { + let run = store.get("sessions", thread.session_id); + if (!run || run.deleted_at) + return reject( + row, + row.chat_type === "p2p" + ? "此对话已不可用,请发送 /new 开始新对话。" + : "此对话已不可用,请另开话题并 @机器人。", + ); + if (!run.core_session_id) run = await sessions(run.id, {}); + if ( + row.text.startsWith("/") && + row.text !== "/cancel" && + !row.text.startsWith("/answer ") + ) + return reject( + row, + "支持的命令:/new、/cancel、/answer、/help。其他消息会交给 Agent。", + ); + try { + if ( + media.files.length && + !row.files_uploaded && + row.text !== "/cancel" + ) { + const session = await core( + `/v1/agents/sessions/${run.core_session_id}`, + "GET", + ); + if (session.environment?.type !== "openai_hosted") + throw new AppError(400, "附件需要托管运行时。"); + if (session.status !== "idle") + throw new AppError(409, "等待当前执行结束后写入附件。"); + for (const file of media.files) + await core( + `/v1/agents/environments/${session.environment.id}/files`, + "POST", + file, + ); + row = { ...row, files_uploaded: true }; + store.put("feishu_inbound", row); + } + let answer; + if (!row.event && row.text !== "/cancel") { + const session = await core( + `/v1/agents/sessions/${run.core_session_id}`, + "GET", + ); + const questions = (session.required_actions || []).filter( + (action) => + action.type === "function_call" && action.name === "ask_user", + ); + const match = row.text.match(/^\/answer\s+(\S+)\s+([\s\S]+)$/); + const question = match + ? questions.find((action) => action.call_id === match[1]) + : questions.length === 1 + ? questions[0] + : undefined; + if (match && !question) + return reject(row, "这个问题已结束或标识无效,请查看最新提问。"); + if (question) + answer = { + type: "agent.session.input.tool_result", + call_id: question.call_id, + turn_id: question.turn_id, + success: true, + output: match ? match[2] : row.text, + }; + } + const event = + row.event || + answer || + (row.text === "/cancel" + ? { type: "agent.session.input.cancel" } + : { + type: "agent.session.input.message", + input: [ + { + role: "user", + content: [ + { type: "input_text", text: media.text }, + ...media.images.map((image_url) => ({ + type: "input_image", + image_url, + })), + ], + }, + ], + }); + // Freeze the display boundary before submitting any follow-up: Core may + // acknowledge it before its resumed output becomes visible. + if (!row.event && event.type !== "agent.session.input.cancel") { + if (event.type === "agent.session.input.message" && thread.awaiting_user_input) + throw new AppError(409, "等待上一条输入出现在执行记录中。"); + const prior = await history(run.core_session_id, "items", thread.item_cursor); + if (thread.observed_through && !prior.some((item) => item.id === thread.observed_through)) + throw new AppError(409, "等待执行记录更新后继续。"); + row = { ...row, display_after: prior.at(-1)?.id }; + } + row = { ...row, state: "submitting", event }; + store.put("feishu_inbound", row); + // Messages go through the Turn ledger; answers and cancellations go to Core directly. + if (event.type === "agent.session.input.message") + await ledger.deliver(run, event.input, { id: row.id, key: row.id, source: "feishu" }); + else + await core( + `/v1/agents/sessions/${run.core_session_id}/events`, + "POST", + { events: [event] }, + row.id, + ); + } catch (error) { + if ( + row.chat_type === "p2p" && + row.text !== "/cancel" && + (error?.code === "environment_unavailable" || + /environment is no longer available/i.test(error?.message || "")) + ) { + rotateP2PThread(); + row = { + ...row, + state: "queued", + event: undefined, + files_uploaded: undefined, + }; + store.put("feishu_inbound", row); + return inbound(row); + } + throw error; + } + if (row.text === "/cancel") + queueReply( + stableId(`${row.id}:cancel`), + thread, + "已请求取消,等待 Agent 确认停止。", + ); + else progress(thinking); + } + store.put("feishu_inbound", { + ...row, + state: "delivered", + media: undefined, + context: undefined, + error: undefined, + }); + }; + const history = async (sessionId, resource, after) => { + const rows = [], + cursors = new Set(after ? [after] : []); + for (;;) { + const page = await core( + `/v1/agents/sessions/${sessionId}/${resource}?order=asc&limit=100${after ? `&after=${encodeURIComponent(after)}` : ""}`, + "GET", + ); + rows.push(...page.data); + if (!page.has_more) return rows; + if (!page.last_id || cursors.has(page.last_id)) + throw new AppError(422, "Invalid history cursor"); + after = page.last_id; + cursors.add(after); + } + }; + const observe = async (thread) => { + const run = store.get("sessions", thread.session_id); + if (!run?.core_session_id || run.deleted_at) return; + const sid = run.core_session_id; + const [session, turns] = await Promise.all([ + core(`/v1/agents/sessions/${sid}`, "GET"), + history(sid, "turns", thread.turn_cursor), + ]); + if (["idle", "failed", "requires_action"].includes(session.status)) { + for (const row of store + .list("feishu_inbound") + .filter( + (row) => + row.thread_id === thread.id && + row.typing_reaction_id && + ["delivered", "rejected", "failed"].includes(row.state), + )) { + try { + await channel.endTyping(row); + store.put("feishu_inbound", { + ...row, + typing_reaction_id: undefined, + }); + } catch { + /* Reactions are best effort; retry removal on the next observation. */ + } + } + } + let questions = (session.required_actions || []).filter((action) => action.type === "function_call" && action.name === "ask_user" && !thread.answered_call_ids?.includes(action.call_id)); + const displayed = (items) => { + if (thread.observed_through && !items.some((item) => item.id === thread.observed_through)) return undefined; + let boundary = thread.display_after ? items.findIndex((item) => item.id === thread.display_after) : -1; + if (thread.display_after && boundary < 0) return undefined; // Wait for a snapshot that includes the submitted input's boundary. + const lastAnswer = thread.answered_call_ids?.at(-1); + if (lastAnswer) { + const answered = items.findIndex((item) => item.type === "function_call" && item.call_id === lastAnswer); + if (answered < 0) return undefined; + boundary = Math.max(boundary, answered); + } + const answeredCalls = new Set(items.filter((item) => item.type === "function_call_output").map((item) => item.call_id)); + questions = questions.filter((action) => !answeredCalls.has(action.call_id)); + const currentItems = items.slice(boundary + 1); + if (thread.awaiting_user_input) { + const input = currentItems.findIndex((item) => item.type === "message" && item.role === "user"); + if (input < 0) return undefined; + return currentItems.slice(input + 1); + } + return currentItems; + }; + const recovering = ledger.recovering(run.id); + // A Turn the monitor cancelled or retried is never reported; a failed or + // stopped Turn waits until the monitor has decided not to retry it. + const unseen = turns.filter( + (turn) => + ["completed", "failed", "cancelled"].includes(turn.status) && + !ledger.superseded(run.id, turn.id) && + (turn.status === "completed" || !recovering) && + !store.get("feishu_outbound", stableId(`${thread.id}:${turn.id}`)), + ); + if (session.status === "failed" && !turns.length && !thread.turn_cursor && !recovering) + queueReply( + stableId(`${thread.id}:initial-failed:${sid}`), + thread, + `执行失败\n\n${ledger.summary(run.id)?.failure || "执行环境准备失败,请重新发送消息重试。"}`, + ); + const progressId = + thread.active_outbound_id || + stableId(`${thread.first_message_id}:accepted`); + let progress = store.get("feishu_outbound", progressId); + const progressReplyTo = progress?.reply_to || thread.root_id; + const questionBase = progress?.sent_message_id ? progress.id : thread.id; + if ((progress || session.status === "requires_action") && (["in_progress", "requires_action"].includes(session.status) || recovering) && !unseen.length) { + const snapshot = await history(sid, "items", thread.item_cursor); + const currentItems = displayed(snapshot); + if (!currentItems) return; + const observedThrough = snapshot.at(-1)?.id; + if (thread.observed_through !== observedThrough || thread.awaiting_user_input) { + thread = { ...thread, observed_through: observedThrough, awaiting_user_input: false }; + store.put("feishu_threads", thread); + } + const items = currentItems.filter( + (item) => item.turn_id === turns.at(-1)?.id && !ledger.superseded(run.id, item.turn_id), + ); + queueMedia(thread, items); + const text = formatTurnCard(session.status === "requires_action" && questions.length ? "requires_action" : "in_progress", items, undefined, ledger.summary(run.id), questions); + if (progress?.sent_message_id) { + if (thread.progress_text !== text) { + try { + await channel.edit(progress.sent_message_id, text, thread); + thread = { ...thread, progress_text: text }; + store.put("feishu_threads", thread); + store.put("feishu_outbound", { ...progress, payload: text, state: "delivered", error: undefined }); + } catch (error) { + // Unknown outcomes retry the same card; only confirmed loss replaces it. + if (questions.length && error.code === "feishu_card_unavailable") progress = undefined; + } + } + } else if (progress?.state === "queued" && progress.payload !== text) { + progress = { ...progress, payload: text }; + store.put("feishu_outbound", progress); + thread = { ...thread, progress_text: text }; + store.put("feishu_threads", thread); + } + if ((!progress || progress.state === "failed" && !progress.sent_message_id && !sendExpired(progress)) && questions.length) { + const questionId = stableId(`${questionBase}:question:${questions.map((x) => x.call_id).join(":")}`); + queueReply(questionId, thread, text, true, progressReplyTo); + thread = { ...thread, active_outbound_id: questionId, progress_text: text }; + store.put("feishu_threads", thread); + } + } + if (!unseen.length) { + const pending = store + .list("feishu_inbound") + .some( + (row) => + row.thread_id === thread.id && + ["queued", "submitting"].includes(row.state), + ); + const settled_at = + ["idle", "failed"].includes(session.status) && !pending + ? thread.settled_at || stamp() + : undefined; + if (settled_at !== thread.settled_at) + store.put("feishu_threads", { ...thread, settled_at }); + return; + } + const [items, artifacts] = await Promise.all([ + history(sid, "items", thread.item_cursor), + history(sid, "artifacts", thread.artifact_cursor), + ]); + const currentItems = displayed(items); + if (!currentItems) return; + for (const turn of unseen) { + const turnItems = items.filter((item) => item.turn_id === turn.id); + if (turn.status === "completed" && (thread.answered_call_ids || []).some((callId) => + turnItems.some((item) => item.type === "function_call" && item.call_id === callId) && + !turnItems.some((item) => item.type === "function_call_output" && item.call_id === callId))) return; + queueMedia(thread, turnItems, artifacts.filter((file) => file.turn_id === turn.id)); + const text = formatTurnCard( + turn.status, + currentItems.filter((item) => item.turn_id === turn.id), + turn.status === "failed" + ? ledger.summary(run.id)?.failure || explainFailure(turn.error) + : turn.error?.message, + ); + const receiptId = stableId(`${thread.id}:${turn.id}`); + if (progress?.sent_message_id) { + try { + await channel.edit(progress.sent_message_id, text, thread); + store.put("feishu_outbound", { ...progress, payload: text, state: "delivered", error: undefined }); + store.put("feishu_outbound", { + id: receiptId, + app_id: thread.app_id, + chat_id: thread.chat_id, + chat_type: thread.chat_type, + thread_id: thread.id, + reply_to: progress.reply_to || thread.root_id, + payload: text, + notify: false, + state: "delivered", + sent_message_id: progress.sent_message_id, + attempts: 1, + created_at: stamp(), + next_at: 0, + }); + thread = { + ...thread, + progress_text: text, + active_outbound_id: undefined, + }; + progress = undefined; + continue; + } catch (error) { + if (error.code !== "feishu_card_unavailable") throw error; + // A confirmed uneditable card falls back to sending a result card. + } + } else if (progress && progress.state === "queued") { + store.put("feishu_outbound", { ...progress, payload: text }); + store.put("feishu_outbound", { + id: receiptId, + app_id: thread.app_id, + chat_id: thread.chat_id, + chat_type: thread.chat_type, + thread_id: thread.id, + reply_to: progress.reply_to || thread.root_id, + payload: text, + notify: false, + state: "delivered", + sent_message_id: progress.id, + attempts: 1, + created_at: stamp(), + next_at: 0, + }); + thread = { + ...thread, + progress_text: text, + active_outbound_id: undefined, + }; + progress = undefined; + continue; + } + queueReply(receiptId, thread, text); + } + // Advance only through terminal Turns. Keep output for an active Turn available. + const terminal = turns.findIndex( + (turn) => !["completed", "failed", "cancelled"].includes(turn.status), + ); + const completed = terminal < 0 ? turns : turns.slice(0, terminal); + const ids = new Set(completed.map((turn) => turn.id)); + const cursor = (rows, previous) => { + let value = previous; + for (const row of rows) { + if (!ids.has(row.turn_id)) break; + value = row.id; + } + return value; + }; + if (completed.length) + store.put("feishu_threads", { + ...thread, + turn_cursor: completed.at(-1).id, + answered_call_ids: (thread.answered_call_ids || []).filter((callId) => !items.some((item) => item.type === "function_call" && item.call_id === callId && ids.has(item.turn_id))), + display_after: ids.has(items.find((item) => item.id === thread.display_after)?.turn_id) ? undefined : thread.display_after, + awaiting_user_input: false, + observed_through: ids.has(items.find((item) => item.id === thread.observed_through)?.turn_id) ? undefined : thread.observed_through, + item_cursor: cursor(items, thread.item_cursor), + artifact_cursor: cursor(artifacts, thread.artifact_cursor), + }); + }; + const retry = (kind, row, error) => { + const attempts = row.attempts + 1; + const definite = !transientError(error); + store.put(kind, { + ...store.get(kind, row.id), + attempts, + state: definite ? "failed" : store.get(kind, row.id).state, + error: definite + ? error.message + : "连接或请求未确认,将按原标识重试;失败记录保留以供检查。", + next_at: + stamp() + + (error instanceof AppError && error.status === 409 + ? 10000 + : Math.min(300000, 1000 * 2 ** attempts)), + }); + if (kind === "feishu_inbound" && definite) { + const thread = store.get("feishu_threads", row.thread_id); + if (thread) + queueReply( + stableId(`${row.id}:failed`), + thread, + `执行失败\n\n消息处理未完成(${error.message || "服务不可用"}),请重新发送消息重试。`, + ); + } + }; + const deliver = async (current) => { + for (const row of store.list("feishu_outbound").reverse()) { + if (!current()) return; + if ( + !allowed(row.app_id, row.chat_type) || + row.state !== "queued" || + row.next_at > stamp() + ) + continue; + // Never replay an uncertain send outside the Feishu’s one-hour deduplication window. + if (sendExpired(row)) { + store.put("feishu_outbound", { + ...row, + state: "failed", + error: "回传结果未确认且幂等窗口已过,请人工检查飞书话题。", + }); + continue; + } + store.put("feishu_outbound", { + ...row, + first_attempt_at: row.first_attempt_at || stamp(), + }); + try { + const thread = store.get("feishu_threads", row.thread_id); + const root = row.reply_to || thread?.root_id; + let uploaded = row.uploaded_media; + if (row.media && !uploaded) { + const bytes = qrImagePattern.test(row.media.path) + ? await product("GET", row.media.path, {}) + : await core(row.media.path, "GET"); + if (!Buffer.isBuffer(bytes) || !bytes.length || bytes.length > maxMediaBytes) + throw new AppError(413, "附件为空或超过 5 MiB,请在网页会话中下载。"); + uploaded = await channel.upload(bytes, row.media.name); + store.put("feishu_outbound", { ...store.get("feishu_outbound", row.id), uploaded_media: uploaded }); + } + const result = await channel.send({ + ...row, + uploaded_media: uploaded, + reply_to: root, + context_data: thread?.context_data, + sender_id: thread?.sender_id, + }); + // A deduplicated retry can return the card sent before its ACK was lost. + // Apply the current payload before recording delivery as confirmed. + if (row.first_attempt_at && !row.media) { + store.put("feishu_outbound", { ...store.get("feishu_outbound", row.id), sent_message_id: result.id }); + if (thread?.active_outbound_id === row.id) + store.put("feishu_threads", { ...thread, progress_text: undefined }); + await channel.edit(result.id, row.payload, thread); + } + if (thread && !thread.root_id) + store.put("feishu_threads", { + ...store.get("feishu_threads", thread.id), + root_id: result.id, + }); + store.transaction(() => { + store.put("feishu_outbound", { + ...store.get("feishu_outbound", row.id), + state: "delivered", + sent_message_id: result.id, + error: undefined, + }); + }); + } catch (error) { + retry("feishu_outbound", row, error); + } + } + }; + const tick = async () => { + const config = store.get("feishu_bots", configId); + if (!config?.enabled) { + if (channel) await channel.disconnect(); + channel = undefined; + active = undefined; + health("stopped"); + return; + } + if ( + !active || + active.revision !== config.revision || + channel.getConnectionStatus().state === "failed" + ) { + if (channel) await channel.disconnect(); + channel = undefined; + active = undefined; + health("connecting"); + const next = await makeChannel(config, (message) => + receive(config.app_id, message), + ); + channel = next; + try { + await channel.connect(); + active = config; + } catch { + await channel.disconnect(); + channel = undefined; + health("error"); + return; + } + } + active = config; + const current = () => + store.get("feishu_bots", configId)?.revision === active.revision; + if (!current()) return; + health(channel.getConnectionStatus().state); + await deliver(current); + const rows = store.list("feishu_inbound").reverse(); + const cancellations = new Map(); + for (const row of rows) + if ( + row.text === "/cancel" && + ["queued", "submitting"].includes(row.state) + ) + cancellations.set(row.thread_id, row); + const blocked = new Set( + rows.filter((row) => row.state === "failed").map((row) => row.thread_id), + ); + for (const row of cancellations.values()) { + if (!current()) return; + if (!allowed(row.app_id, row.chat_type)) continue; + if (row.next_at <= stamp()) { + try { + await cancel(row); + } catch (error) { + retry("feishu_inbound", row, error); + } + } + if (store.get("feishu_inbound", row.id).state === "delivered") + blocked.delete(row.thread_id); + else blocked.add(row.thread_id); + } + for (const original of rows) { + if (!current()) return; + let row = store.get("feishu_inbound", original.id); + if ( + !allowed(row.app_id, row.chat_type) || + row.text === "/cancel" || + ["delivered", "failed", "rejected"].includes(row.state) || + blocked.has(row.thread_id) + ) + continue; + if (row.next_at <= stamp()) { + try { + if (!row.typing_attempted && !row.text.startsWith("/")) { + store.put("feishu_inbound", { ...row, typing_attempted: true }); + try { + const receipt = await channel.beginTyping(row); + if (receipt) + store.put("feishu_inbound", { + ...store.get("feishu_inbound", row.id), + typing_reaction_id: receipt, + }); + } catch { + /* Missing reaction scope must not prevent task execution. */ + } + row = store.get("feishu_inbound", row.id); + } + await inbound(row); + } catch (error) { + retry("feishu_inbound", row, error); + } + } + if ( + !["delivered", "rejected"].includes( + store.get("feishu_inbound", row.id).state, + ) + ) + blocked.add(row.thread_id); + } + for (const thread of store.list("feishu_threads")) { + if (!current()) return; + if (!allowed(thread.app_id, thread.chat_type)) continue; + if (thread.settled_at && stamp() - thread.settled_at > 5 * 60000) + continue; + try { + await observe(thread); + } catch (error) { + health("core_unavailable"); + if ( + !(error instanceof AppError) || + error.status >= 500 || + error.status === 429 + ) + break; + } + } + await deliver(current); + }; + const cycle = async () => { + if (stopping) return; + running = tick().catch(() => health("error")); + await running; + if (!stopping) timer = setTimeout(cycle, 5000); + }; + return { + receive, + tick, + start() { + void cycle(); + }, + async stop() { + stopping = true; + clearTimeout(timer); + await running; + await channel?.disconnect(); + }, + }; +} + +export function feishuWorkers(store, core, product, makeChannel) { + const workers = new Map(); + let timer, + stopping = false, + running; + const tick = async () => { + const ids = new Set(store.list("feishu_bots").map((row) => row.id)); + for (const [id, worker] of workers) + if (!ids.has(id)) { + await worker.stop(); + workers.delete(id); + } + for (const id of ids) { + if (!workers.has(id)) + workers.set(id, feishuWorker(store, core, product, makeChannel, id)); + try { + await workers.get(id).tick(); + } catch { + store.put("feishu_health", { id, state: "error", updated_at: stamp() }); + } + } + }; + const cycle = async () => { + if (stopping) return; + running = tick(); + try { + await running; + } finally { + if (!stopping) timer = setTimeout(cycle, 5000); + } + }; + return { + tick, + start() { + void cycle(); + }, + async stop() { + stopping = true; + clearTimeout(timer); + await running; + await Promise.all([...workers.values()].map((worker) => worker.stop())); + }, + }; +} diff --git a/example/parsar/server/mcp-oauth.mjs b/example/parsar/server/mcp-oauth.mjs new file mode 100644 index 000000000..3f2df2667 --- /dev/null +++ b/example/parsar/server/mcp-oauth.mjs @@ -0,0 +1,179 @@ +import { randomUUID } from "node:crypto"; +import { + auth, + selectClientAuthMethod, + selectResourceURL, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { AppError, text, uuid } from "./store.mjs"; + +const https = (value) => { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.hash) + throw new AppError( + 400, + "OAuth requires an HTTPS URL without credentials or a fragment.", + ); + return url.toString(); +}; + +// The MCP SDK owns discovery, issuer binding, registration and PKCE. Product +// state only survives the browser redirect; the public Vault owns saved tokens. +export function mcpOAuthAPI(store, core, origin, fetchImpl = fetch) { + const active = new Set(); + const network = async (url, options) => { + https(String(url)); + return fetchImpl(url, { + ...options, + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + }; + return async (method, path, body) => { + const match = path.match(/^\/app\/mcp-oauth\/([a-f0-9-]{36})$/); + if (!match || !uuid.test(match[1]) || method !== "POST") + throw new AppError(404, "Not found."); + const id = match[1]; + if (active.has(id)) + throw new AppError(409, "Authorization is already processing."); + active.add(id); + try { + let row = store.get("mcp_oauth", id); + if (!row) { + if (body.action !== "begin") + throw new AppError(404, "Authorization not found."); + if (!uuid.test(body.vault_id || "")) + throw new AppError(400, "Select a Vault."); + await core(`/v1/vaults/${body.vault_id}`, "GET"); + row = { + id, + state: randomUUID(), + name: text(body.name, "Credential name", 80, true), + url: https(body.url), + vault_id: body.vault_id, + redirect: `${origin}/oauth/callback`, + expires_at: Date.now() + 15 * 60000, + }; + store.put("mcp_oauth", row); + } + if (body.action === "complete" && body.state !== row.state) + throw new AppError(400, "Authorization state does not match."); + if (row.credential_id) + return { credential_id: row.credential_id, vault_id: row.vault_id }; + if (Date.now() > row.expires_at) { + store.remove("mcp_oauth", id); + throw new AppError(410, "Authorization expired; start again."); + } + const save = (values) => { + row = { ...row, ...values }; + store.put("mcp_oauth", row); + }; + const provider = { + redirectUrl: row.redirect, + clientMetadata: { + client_name: "Parsar", + redirect_uris: [row.redirect], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + state: () => `${id}:${row.state}`, + clientInformation: () => row.client, + saveClientInformation: (client) => save({ client }), + tokens: () => row.tokens, + saveTokens: (tokens) => save({ tokens, token_time: Date.now() }), + redirectToAuthorization: (url) => + save({ authorization_url: https(String(url)) }), + saveCodeVerifier: (verifier) => save({ verifier }), + codeVerifier: () => row.verifier, + discoveryState: () => row.discovery, + saveDiscoveryState: (discovery) => save({ discovery }), + }; + if (body.action === "begin") { + if (!row.authorization_url) + await auth(provider, { serverUrl: row.url, fetchFn: network }); + return { authorization_url: row.authorization_url }; + } + if (body.action !== "complete" || !body.code) + throw new AppError(400, "Missing authorization code."); + if (!row.tokens) + await auth(provider, { + serverUrl: row.url, + authorizationCode: text(body.code, "Authorization code", 10000, true), + fetchFn: network, + }); + if (!row.tokens?.access_token) + throw new AppError( + 502, + "Authorization did not return an access token.", + ); + // Vault creation has no idempotency key. Recover a lost response through + // a unique product operation name before issuing another create request. + const name = `${row.name} · ${id}`; + let after, existing; + do { + const page = await core( + `/v1/vaults/${row.vault_id}/credentials?limit=100${after ? `&after=${encodeURIComponent(after)}` : ""}`, + "GET", + ); + existing = page.data.find( + (item) => item.name === name && item.auth.mcp_server_url === row.url, + ); + after = page.has_more ? page.last_id : undefined; + } while (after && !existing); + if (!existing) { + const authValue = { + type: "mcp_oauth", + mcp_server_url: row.url, + access_token: row.tokens.access_token, + }; + if (row.tokens.expires_in) + authValue.expires_at = new Date( + row.token_time + row.tokens.expires_in * 1000, + ).toISOString(); + if (row.tokens.refresh_token) { + const metadata = row.discovery.authorizationServerMetadata; + const method = selectClientAuthMethod( + row.client, + metadata?.token_endpoint_auth_methods_supported || [], + ); + const resource = await selectResourceURL( + row.url, + provider, + row.discovery.resourceMetadata, + ); + authValue.refresh = { + token_endpoint: https( + metadata?.token_endpoint || + new URL("/token", row.discovery.authorizationServerUrl).href, + ), + client_id: row.client.client_id, + refresh_token: row.tokens.refresh_token, + token_endpoint_auth: { + type: method, + ...(method !== "none" + ? { client_secret: row.client.client_secret } + : {}), + }, + ...(resource ? { resource: String(resource) } : {}), + ...(row.tokens.scope ? { scope: row.tokens.scope } : {}), + }; + } + existing = await core( + `/v1/vaults/${row.vault_id}/credentials`, + "POST", + { name, auth: authValue }, + ); + } + store.put("mcp_oauth", { + id, + state: row.state, + vault_id: row.vault_id, + credential_id: existing.id, + expires_at: row.expires_at, + }); + return { credential_id: existing.id, vault_id: row.vault_id }; + } finally { + active.delete(id); + } + }; +} diff --git a/example/parsar/server/product.mjs b/example/parsar/server/product.mjs index f3a9a6d3c..3257d2fec 100644 --- a/example/parsar/server/product.mjs +++ b/example/parsar/server/product.mjs @@ -1,8 +1,28 @@ +import { mcpOAuthAPI } from "./mcp-oauth.mjs"; +import { previewRegistrySkill } from "./skills-directory.mjs"; +import { feishuRegistrationAPI } from "./feishu-registration.mjs"; +import { capabilityAPI } from "./capabilities.mjs"; +import { randomUUID } from "node:crypto"; import { providerAPI, publicProvider } from "./providers.mjs"; -import { runtimeProfile } from "./runtime-profile.mjs"; +import { runtimeProfile, publicRuntime } from "./runtime-profile.mjs"; +import { feishuAPI } from "./feishu.mjs"; +import { trialAPI } from "./trials.mjs"; import { sessionAPI } from "./sessions.mjs"; import { AppError, text, uuid } from "./store.mjs"; +import { + harnessOptions, + executionOptionFields, + unsupportedOption, +} from "../shared/harness-options.mjs"; +import { maxFiles, maxPlugins, maxSkills } from "../shared/environment-limits.mjs"; +const optionLabels = { + reasoning_effort: "推理强度", + reasoning_summary: "推理摘要", + web_search: "网页搜索方式", + verbosity: "输出详细度", + service_tier: "服务层级", +}; const kinds = new Set([ "providers", "models", @@ -11,17 +31,38 @@ const kinds = new Set([ "agents", "sessions", ]); -export function productAPI(store, core, fetchImpl = fetch) { +export function productAPI(store, core, fetchImpl = fetch, origin) { const sessions = sessionAPI(store, core); const publicRow = (kind, row) => { if (kind === "providers") return publicProvider(row); + if (kind === "runtimes") return publicRuntime(row); if (kind === "sessions") { const { request, ...record } = row; - return record; + return { ...record, turn: sessions.turns.summary(record.id) }; } return row; }; + const capabilities = capabilityAPI(store); + const oauth = mcpOAuthAPI(store, core, origin, fetchImpl); + const feishu = feishuAPI(store); + const registration = feishuRegistrationAPI(store, fetchImpl); const providers = providerAPI(store, fetchImpl); + const trials = trialAPI(store, core); + const sessionAfter = (run) => async (id, body) => { + await run(id, body); + return publicRow("sessions", store.get("sessions", id)); + }; + const actions = { + "POST sessions/messages": sessionAfter(sessions.turns.send), + "POST sessions/retry": sessionAfter(sessions.turns.retry), + "GET agents/health": (id) => { + if (!store.get("agents", id)) throw new AppError(404, "Agent 不存在。"); + return sessions.turns.health(id); + }, + "POST models/test": (id) => providers.test(id), + "POST runtimes/trial": (id) => trials.start(id), + "GET runtimes/trial": (id) => trials.get(id), + }; const touchProviders = (...ids) => { for (const id of new Set(ids.filter(Boolean))) { const provider = store.get("providers", id); @@ -38,19 +79,84 @@ export function productAPI(store, core, fetchImpl = fetch) { return id; }; const references = (kind, ids) => { - if (!Array.isArray(ids) || ids.length > 8) - throw new AppError(400, "最多选择 8 个资源。"); + if (!Array.isArray(ids)) throw new AppError(400, "请选择有效的资源。"); return [...new Set(ids.map((id) => requireReference(kind, id)))]; }; - return async (method, path, body) => { + const handle = async (method, path, body) => { + if (path.startsWith("/app/mcp-oauth/")) return oauth(method, path, body); + if (path === "/app/capabilities" || path.startsWith("/app/capabilities/")) + return capabilities(method, path, body); + if (path === "/app/settings") { + const old = store.get("settings", "workspace") || { + id: "workspace", + name: "Parsar", + revision: 0, + }; + if (method === "GET") return old; + if (method !== "PUT") throw new AppError(405, "Method not allowed."); + if (body.revision !== old.revision) + throw new AppError(409, "设置已更新,请重新加载。"); + const value = { + ...old, + name: text(body.name, "名称", 80, true), + revision: old.revision + 1, + }; + store.put("settings", value); + return value; + } + // Files every recent conversation produced, newest first. Core keeps + // artifacts with the Session, so listing them wakes no sandbox. + if (path === "/app/artifacts" && method === "GET") { + const recent = store + .list("sessions") + .filter((row) => row.core_session_id && !row.deleted_at) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, 50); + const rows = []; + for (let i = 0; i < recent.length; i += 5) + for (const [row, page] of await Promise.all( + recent.slice(i, i + 5).map(async (row) => [ + row, + await core(`/v1/agents/sessions/${row.core_session_id}/artifacts?limit=20&order=desc`, "GET").catch(() => ({ data: [] })), + ]), + )) + for (const file of page.data || []) + rows.push({ + id: file.id, + path: file.path, + size_bytes: file.size_bytes, + created_at: file.created_at, + session_id: row.id, + core_session_id: row.core_session_id, + conversation: row.name, + }); + return rows.sort((a, b) => b.created_at - a.created_at); + } + if (path === "/app/audit" && method === "GET") + return store.list("audit").sort((a, b) => b.created_at - a.created_at); + if (path === "/app/skills/registry/preview" && method === "POST") + return previewRegistrySkill(body); + if (path.startsWith("/app/feishu/provision/")) + return registration(method, path, body); + if (path === "/app/feishu" || path.startsWith("/app/feishu/")) + return feishu(method, path, body); if (path === "/app/providers/discover" && method === "POST") return providers.discover(body); + if (path === "/app/turns" && method === "GET") return sessions.turns.overview(); + // Actions on one resource: `/app/{kind}/{id}/{action}`. + const action = path.match(/^\/app\/([a-z]+)\/([a-f0-9-]{36})\/([a-z]+)$/); + const handler = action && actions[`${method} ${action[1]}/${action[3]}`]; + if (action && handler) return handler(action[2], 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)); + if (!id) + return store + .list(kind) + .filter((row) => kind !== "sessions" || !row.deleted_at) + .map((row) => publicRow(kind, row)); const value = store.get(kind, id); if (!value) throw new AppError(404, "记录不存在。"); return kind === "providers" @@ -64,16 +170,48 @@ export function productAPI(store, core, fetchImpl = fetch) { } if (!id) throw new AppError(405, "Method not allowed."); if (kind === "sessions") { + if (["PATCH", "DELETE"].includes(method)) { + const row = store.get("sessions", id); + if (!row || row.deleted_at) throw new AppError(404, "会话不存在。"); + if (!row.core_session_id) + throw new AppError(409, "请先恢复创建,以确认实际会话。"); + if (method === "PATCH") { + const name = text(body.name, "名称", 80, true); + const current = await core( + `/v1/agents/sessions/${row.core_session_id}`, + "GET", + ); + await core(`/v1/agents/sessions/${row.core_session_id}`, "POST", { + metadata: { ...current.metadata, title: name }, + }); + const updated = { ...row, name }; + store.put("sessions", updated); + return publicRow("sessions", updated); + } + try { + await core(`/v1/agents/sessions/${row.core_session_id}`, "DELETE"); + } catch (error) { + if (!(error instanceof AppError && error.status === 404)) throw error; + } + store.put("sessions", { ...row, deleted_at: Date.now() }); + return { deleted: true }; + } if (method !== "PUT") throw new AppError(405, "Method not allowed."); - return sessions(id, body); + return publicRow("sessions", await sessions(id, body)); } const previous = store.get(kind, id); if (method === "DELETE") { const used = + store.list("feishu_bots").some((row) => row.agent_id === id) || store.list("models").some((row) => row.provider_id === id) || store .list("agents") - .some((row) => row.model_id === id || row.mcp_ids.includes(id)) || + .some( + (row) => + row.model_id === id || + row.runtime_id === id || + row.mcp_ids.includes(id), + ) || store .list("sessions") .some((row) => row.agent_id === id || row.runtime_id === id); @@ -94,6 +232,24 @@ export function productAPI(store, core, fetchImpl = fetch) { revision: (previous?.revision || 0) + 1, }; if (kind === "models") { + value.enabled = body.enabled !== false; + if (body.pricing !== undefined) { + if ( + !body.pricing || + ![body.pricing.input, body.pricing.output].every( + (price) => + typeof price === "number" && + Number.isFinite(price) && + price >= 0 && + price <= 1000000, + ) + ) + throw new AppError(400, "价格需为非负的每百万 Token 美元金额。"); + value.pricing = { + input: body.pricing.input, + output: body.pricing.output, + }; + } value.model = text(body.model, "模型 ID", 200, true).trim(); value.provider_id = requireReference("providers", body.provider_id); if ( @@ -124,23 +280,79 @@ export function productAPI(store, core, fetchImpl = fetch) { ) throw new AppError(400, "MCP 使用不含凭据的 HTTPS 地址。"); value.url = url.href; + if (body.vault_id && !uuid.test(body.vault_id)) + throw new AppError(400, "凭据库标识无效。"); + if ( + body.credential_id && + (!body.vault_id || !uuid.test(body.credential_id)) + ) + throw new AppError(400, "凭据标识无效。"); + value.vault_id = body.vault_id || ""; + value.credential_id = body.credential_id || ""; 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)); + Object.assign(value, runtimeProfile(body, previous)); } if (kind === "agents") { - value.model_id = requireReference("models", body.model_id); - if (!["codex", "claude_sdk", "mcode"].includes(body.harness)) + if (!harnessOptions[body.harness]) throw new AppError(400, "请选择执行引擎。"); value.harness = body.harness; + if (!body.model_id) throw new AppError(400, "请选择模型。"); + value.model_id = requireReference("models", body.model_id); + for (const field of executionOptionFields) + value[field] = typeof body[field] === "string" ? body[field] : ""; + value.web_search ||= "disabled"; + const unsupported = unsupportedOption(value); + if (unsupported) + throw new AppError( + 400, + `${harnessOptions[value.harness].label} 不支持所选的${optionLabels[unsupported]}。`, + ); + value.description = text(body.description || "", "描述", 500); value.instructions = text(body.instructions, "指令", 100000); + value.enabled = body.enabled !== false; + value.ask_user = body.ask_user === true; + if (!body.runtime_id) throw new AppError(400, "请选择环境模板。"); + value.runtime_id = requireReference("runtimes", body.runtime_id); + value.capability_ids = references( + "capabilities", + body.capability_ids || [], + ); + // Knowledge items become initial files beside the template's own files. + const kinds = value.capability_ids.map( + (capabilityId) => store.get("capabilities", capabilityId).kind, + ); + if (kinds.filter((kind) => kind === "plugin").length > maxPlugins) + throw new AppError(400, `最多选择 ${maxPlugins} 个 Plugin。`); + if ( + kinds.filter((kind) => kind === "knowledge").length + + (store.get("runtimes", value.runtime_id).files || []).length > + maxFiles + ) + throw new AppError( + 400, + `知识和环境模板文件合计最多 ${maxFiles} 个。`, + ); + value.capability_versions = {}; + for (const capabilityId of value.capability_ids) { + const version = body.capability_versions?.[capabilityId]; + if ( + version && + !store + .get("capabilities", capabilityId) + .versions.some((row) => row.version === version) + ) + throw new AppError(400, "能力版本不存在。"); + if (version) value.capability_versions[capabilityId] = version; + } value.mcp_ids = references("mcps", body.mcp_ids); + if (Array.isArray(body.skill_ids) && body.skill_ids.length > maxSkills) + throw new AppError(400, `最多选择 ${maxSkills} 个 Skill。`); if ( !Array.isArray(body.skill_ids) || - body.skill_ids.length > 8 || body.skill_ids.some( (id) => typeof id !== "string" || @@ -149,12 +361,43 @@ export function productAPI(store, core, fetchImpl = fetch) { ) throw new AppError(400, "技能配置无效。"); value.skill_ids = [...new Set(body.skill_ids)]; + value.skill_versions = {}; + if ( + body.skill_versions !== undefined && + (!body.skill_versions || + typeof body.skill_versions !== "object" || + Array.isArray(body.skill_versions)) + ) + throw new AppError(400, "技能版本配置无效。"); + for (const skillId of value.skill_ids) { + const version = body.skill_versions?.[skillId]; + if ( + version !== undefined && + version !== "" && + (typeof version !== "string" || !/^[1-9][0-9]*$/.test(version)) + ) + throw new AppError(400, "技能版本无效。"); + if (version) value.skill_versions[skillId] = version; + } } store.transaction(() => { store.put(kind, value); if (kind === "models") touchProviders(previous?.provider_id, value.provider_id); }); - return value; + return publicRow(kind, value); + }; + const handler = async (method, path, body) => { + const result = await handle(method, path, body); + if (method !== "GET") + store.put("audit", { + id: randomUUID(), + method, + path, + created_at: Date.now(), + }); + return result; }; + handler.sessions = sessions; + return handler; } diff --git a/example/parsar/server/providers.mjs b/example/parsar/server/providers.mjs index fcd64477a..e4daaf488 100644 --- a/example/parsar/server/providers.mjs +++ b/example/parsar/server/providers.mjs @@ -47,6 +47,41 @@ function connection(body, previous) { export function providerAPI(store, fetchImpl) { return { + // One tiny generation with the saved connection, in the protocol its URL serves. + async test(modelId) { + const model = store.get("models", modelId); + if (!model) throw new AppError(404, "模型不存在。"); + const provider = store.get("providers", model.provider_id); + if (!provider?.base_url || !provider.api_key) + throw new AppError(400, "请先配置 Provider 的 Base URL 和 API Key。"); + const base = provider.base_url.replace(/\/$/, ""); + const anthropic = base.endsWith("/anthropic"); + const started = Date.now(); + try { + const response = await fetchImpl( + anthropic ? `${base}/v1/messages` : `${base.endsWith("/v1") ? base : `${base}/v1`}/responses`, + { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(30000), + headers: anthropic + ? { "x-api-key": provider.api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json" } + : { Authorization: `Bearer ${provider.api_key}`, "Content-Type": "application/json" }, + body: JSON.stringify( + anthropic + ? { model: model.model, max_tokens: 16, messages: [{ role: "user", content: "Reply with OK." }] } + : { model: model.model, max_output_tokens: 16, input: "Reply with OK." }, + ), + }, + ); + const latency_ms = Date.now() - started; + if (response.ok) return { ok: true, latency_ms }; + const detail = (await response.text()).replaceAll(provider.api_key, "***").slice(0, 300); + return { ok: false, latency_ms, error: `HTTP ${response.status}${detail ? ` · ${detail}` : ""}` }; + } catch (error) { + return { ok: false, latency_ms: Date.now() - started, error: error.name === "TimeoutError" ? "30 秒内没有响应" : "无法连接" }; + } + }, async discover(body) { const { base_url, api_key } = connection( body, @@ -134,6 +169,7 @@ export function providerAPI(store, fetchImpl) { for (const row of selected) { const old = existing.find((entry) => entry.model === row.model); store.put("models", { + ...old, ...row, id: old?.id || randomUUID(), provider_id: id, diff --git a/example/parsar/server/runtime-profile.mjs b/example/parsar/server/runtime-profile.mjs index adefda835..bfcbc2d4f 100644 --- a/example/parsar/server/runtime-profile.mjs +++ b/example/parsar/server/runtime-profile.mjs @@ -1,46 +1,127 @@ +import { botFields, environmentVariableName } from "../shared/bot-bindings.mjs"; 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; - }; +// Product-owned preparation. Core validates the public Environment request again. +export function runtimeProfile(body, previous) { + if (body.environment !== undefined && body.environment !== "openai_hosted") + throw new AppError(400, "Parsar 仅支持托管工作环境。"); + const packages = {}; + for (const manager of ["npm", "python"]) { + const entries = body.packages?.[manager] ?? []; + if ( + !Array.isArray(entries) || + entries.length > 100 || + entries.some( + (v) => + typeof v !== "string" || + !v.trim() || + v.startsWith("-") || + /[\x00-\x20\x7f]/.test(v) || + v.length > 512, + ) + ) + throw new AppError(400, "软件包格式无效,请每行填写一个包名和可选版本。"); + packages[manager] = [...new Set(entries)]; + } + if ( + body.packages && + Object.keys(body.packages).some((k) => !["npm", "python"].includes(k)) + ) + throw new AppError(400, "环境模板支持 npm 和 Python 软件包。"); + const env = Object.create(null); + const input = body.env === undefined ? previous?.env || {} : body.env; if ( - body.capability_directories !== undefined && - (!Array.isArray(body.capability_directories) || - body.capability_directories.length > 8) + !input || + typeof input !== "object" || + Array.isArray(input) || + Object.keys(input).length > 100 ) - throw new AppError(400, "能力目录最多 8 个。"); - const directories = (body.capability_directories || []).map(directory); - if (new Set(directories).size !== directories.length) - throw new AppError(400, "能力目录不能重复。"); + throw new AppError(400, "环境变量格式无效。"); + for (const [name, value] of Object.entries(input)) { + if (!environmentVariableName(name)) + throw new AppError(400, "环境变量名称无效或被运行环境保留。"); + const selected = value === null ? previous?.env?.[name] : value; + if ( + typeof selected !== "string" || + selected.includes("\0") || + selected.length > 8192 + ) + throw new AppError(400, "请填写新环境变量的值;已有变量留空可保留。"); + env[name] = selected; + } + const bot_env = body.bot_env ?? {}; + if ( + !bot_env || + typeof bot_env !== "object" || + Array.isArray(bot_env) || + Object.keys(bot_env).length > 20 + ) + throw new AppError(400, "机器人变量映射格式无效。"); + for (const [name, source] of Object.entries(bot_env)) { + if ( + !environmentVariableName(name) || + Object.hasOwn(env, name) || + typeof source !== "string" || + !Object.hasOwn(botFields, source) + ) + throw new AppError( + 400, + "机器人变量名称必须有效、不能与固定变量重复,且只能选择机器人字段。", + ); + } + const files = body.files ?? []; + if (!Array.isArray(files) || files.length > 20) + throw new AppError(400, "初始文件最多 20 个。"); + const paths = new Set(); + const normalized = files.map((file) => { + const path = text(file.path, "文件路径", 1024, true); + if ( + !path.startsWith("/workspace/") || + posix.normalize(path) !== path || + path.endsWith("/") || + /[\\\x00-\x1f\x7f]/.test(path) || + paths.has(path) + ) + throw new AppError(400, "文件路径必须在 /workspace/ 内且不能重复。"); + paths.add(path); + return { path, content: text(file.content, "文件内容", 100000) }; + }); + if (Buffer.byteLength(JSON.stringify(normalized)) > 500000) + throw new AppError(400, "初始文件总大小不能超过 500 KB。"); + return { + environment: "openai_hosted", + description: text(body.description || "", "说明", 1000), + packages, + setup_script: text(body.setup_script || "", "初始化脚本", 100000), + env, + bot_env, + files: normalized, + prepare: body.prepare === true, + }; +} + +export function publicRuntime({ env, ...row }) { + return { + ...row, + env: Object.fromEntries(Object.keys(env || {}).map((key) => [key, null])), + }; +} + +export function runtimeEnvironment(runtime) { + if (runtime.environment !== "openai_hosted") + throw new AppError(400, "Parsar 仅支持托管工作环境,请重新配置环境模板。"); return { - environment: "self_hosted", - platform: body.platform, - workspace_directory: directory(body.workspace_directory), - capability_directories: directories, + type: "openai_hosted", + packages: runtime.packages || { npm: [], python: [] }, + env: { ...runtime.env }, + setup_commands: runtime.setup_script + ? [{ command: runtime.setup_script, cwd: "/workspace" }] + : [], + files: (runtime.files || []).map(({ path, content }) => ({ + type: "inline", + path, + data: Buffer.from(content).toString("base64"), + })), }; } diff --git a/example/parsar/server/sessions.mjs b/example/parsar/server/sessions.mjs index 46e4d5053..f527e3f2c 100644 --- a/example/parsar/server/sessions.mjs +++ b/example/parsar/server/sessions.mjs @@ -1,116 +1,267 @@ -import { sessionRestriction } from "../shared/session-profile.mjs"; -import { zipSync, strToU8 } from "fflate"; +import { createHash, randomUUID } from "node:crypto"; +import { runtimeEnvironment } from "./runtime-profile.mjs"; import { AppError, text, uuid } from "./store.mjs"; +import { harnessOptions } from "../shared/harness-options.mjs"; +import { turnsAPI } from "./turns.mjs"; -function requestFor(store, id, body) { +// A warm Session matches a cold creation only when every frozen byte except the +// first input and the per-conversation metadata is the same. +const fingerprint = ({ input, metadata, ...request }) => + createHash("sha256").update(JSON.stringify(request)).digest("hex"); +const warmAge = 12 * 3600_000; + +export function sessionBot(store, agentId) { + return store.list("feishu_bots").find((bot) => bot.agent_id === agentId); +} + +export function prepareSession(store, id, body, origin = {}, warm = false) { const agent = store.get("agents", body.agent_id); - const runtime = store.get("runtimes", body.runtime_id); - if (!agent || !runtime) - throw new AppError(400, "请选择有效的 Agent 和运行时。"); + if (!agent) throw new AppError(400, "请选择有效的 Agent。"); + if (agent.enabled === false) + throw new AppError(400, "Agent 已停用,请先启用。"); + const runtime = store.get("runtimes", agent.runtime_id || ""); + if (!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)) + if (!model || model.enabled === false || 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"; - const restriction = sessionRestriction(agent, runtime); - if (restriction) throw new AppError(400, restriction); - let modelProvider; - if (environment.type !== "none") { - 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, - }; - } - if (selfHosted) { - environment.workspace_directory = runtime.workspace_directory; - environment.capability_directories = runtime.capability_directories || []; - } - const tools = [{ type: "web_search", mode: "disabled" }]; - if (environment.type === "none" || selfHosted) { - tools.push( - ...mcps.map((mcp) => ({ - type: "mcp", - server_label: mcp.label, - transport: { type: "http", server_url: mcp.url }, - connection_origin: "service", - required: true, - })), + const botEnv = Object.entries(runtime.bot_env || {}); + const bot = sessionBot(store, agent.id); + if (botEnv.length && !bot?.app_secret) + throw new AppError( + 400, + "环境模板需要飞书机器人身份,请先在飞书接入中把机器人绑定到这个 Agent。", + ); + const environment = runtimeEnvironment(runtime); + for (const [name, field] of botEnv) + environment.env[name] = field === "brand" ? bot.brand || "feishu" : bot[field]; + 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。", ); - } else { + const modelProvider = { + protocol: harnessOptions[agent.harness].protocol, + base_url: provider.base_url, + api_key: provider.api_key, + }; + const tools = [ + { type: "web_search", mode: agent.web_search || "disabled" }, + ]; + if (agent.ask_user) + tools.push({ + type: "function", + name: "ask_user", + description: + "Ask the user a business question when essential information is missing. Never request execution permission or approval; execution is bypass-only.", + parameters: { + type: "object", + properties: { + question: { type: "string" }, + options: { type: "array", items: { type: "string" } }, + }, + required: ["question", "options"], + additionalProperties: false, + }, + }); + tools.push( + ...mcps.map((mcp) => ({ + type: "mcp", + server_label: mcp.label, + transport: { type: "http", server_url: mcp.url }, + connection_origin: "environment", + // MiniMax Code admits only optional MCP initialization. + required: agent.harness !== "mcode", + ...(mcp.credential_id ? { credential_id: mcp.credential_id } : {}), + })), + ); + { environment.skills = agent.skill_ids.map((skill_id) => ({ type: "skill_reference", skill_id, + ...(agent.skill_versions?.[skill_id] + ? { version: agent.skill_versions[skill_id] } + : {}), })); - if (mcps.length) { - 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" }; + if (mcps.length) environment.network = { access: "enabled" }; + } + const images = body.images || []; + const files = body.files || []; + if ( + !Array.isArray(images) || + images.length > 8 || + images.some( + (url) => + typeof url !== "string" || + !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(url), + ) || + JSON.stringify(images).length > 900000 + ) + throw new AppError(400, "图片格式或大小无效。"); + if (images.length && agent.harness === "mcode") + throw new AppError( + 400, + "当前运行配置不支持图片输入,请使用托管 Codex 或 Claude Code。", + ); + if ( + !Array.isArray(files) || + files.length > 8 || + JSON.stringify(files).length > 7000000 || + files.some( + (file) => + file.type !== "inline" || + !/^\/workspace\/inputs\/[a-zA-Z0-9_.-]+$/.test(file.path) || + typeof file.data !== "string" || + !/^[A-Za-z0-9+/]*={0,2}$/.test(file.data), + ) + ) + throw new AppError(400, "附件格式或大小无效。"); + if (files.length && environment.type !== "openai_hosted") + throw new AppError(400, "附件需要托管运行时。"); + if (files.length) environment.files.push(...files); + const capabilities = (agent.capability_ids || []).map((id) => { + const capability = store.get("capabilities", id); + if (!capability || capability.deprecated) + throw new AppError(400, "Agent 绑定的能力已停用或不存在。"); + const version = capability.versions.find( + (row) => + row.version === + (agent.capability_versions?.[id] || capability.default_version), + ); + if (!version) throw new AppError(400, "Agent 绑定的能力版本不存在。"); + return { ...capability, ...version }; + }); + for (const capability of capabilities) { + if (capability.kind === "prompt") continue; + if (environment.type !== "openai_hosted") + throw new AppError(400, "Plugin 和知识文件需要托管运行时。"); + if (capability.kind === "knowledge") + environment.files = [ + ...(environment.files || []), + { + type: "inline", + path: `/workspace/knowledge/${capability.id}.md`, + data: Buffer.from(capability.content).toString("base64"), + }, + ]; + if (capability.kind === "plugin") environment.plugins = [ + ...(environment.plugins || []), { type: "inline", - name, - description, + name: capability.name, + description: capability.description, source: { type: "base64", media_type: "application/zip", - data: Buffer.from(archive).toString("base64"), + data: capability.archive, }, }, ]; - } } - const name = text(body.name, "会话名称", 80, true); - const input = selfHosted ? undefined : text(body.input, "消息", 100000, true); + const instructions = [ + agent.instructions, + ...capabilities + .filter((row) => row.kind === "prompt") + .map((row) => row.content), + ...capabilities + .filter((row) => row.kind === "knowledge") + .map((row) => `Knowledge: ${row.name} — ./knowledge/${row.id}.md`), + ] + .filter(Boolean) + .join("\n\n"); + const name = warm ? "" : text(body.name, "会话名称", 80, true); + const input = warm ? "" : 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, - }, - } + source: origin.source || "web", + ...(origin.feishu_chat_type + ? { feishu_chat_type: origin.feishu_chat_type } : {}), + pricing: model.pricing, + configuration: { + agent_name: agent.name, + agent_revision: agent.revision, + runtime_revision: runtime.revision, + bot_name: botEnv.length ? bot.name : undefined, + model: model.model, + harness: agent.harness, + environment: runtime.environment, + environment_name: runtime.name, + packages: runtime.packages || { npm: [], python: [] }, + setup_configured: Boolean(runtime.setup_script), + env_names: [ + ...Object.keys(runtime.env || {}), + ...botEnv.map(([name]) => name), + ], + file_paths: (runtime.files || []).map((file) => file.path), + skills: agent.skill_ids.map((id) => ({ + id, + version: agent.skill_versions?.[id] || null, + })), + mcps: mcps.map((mcp) => mcp.label), + capabilities: capabilities.map((row) => ({ + name: row.name, + kind: row.kind, + version: row.version, + })), + }, + created_at: Math.floor(Date.now() / 1000), request: { agent: { model: model.model, - instructions: agent.instructions, + instructions, + ...(agent.reasoning_effort || agent.reasoning_summary + ? { + reasoning: { + ...(agent.reasoning_effort + ? { effort: agent.reasoning_effort } + : {}), + ...(agent.reasoning_summary + ? { summary: agent.reasoning_summary } + : {}), + }, + } + : {}), + ...(agent.service_tier ? { service_tier: agent.service_tier } : {}), x_agents_core: { harness: agent.harness }, multi_agent: { enabled: false }, - text: { verbosity: "medium" }, + text: { verbosity: agent.verbosity || "medium" }, tools, }, environment, - ...(input ? { input } : {}), - ...(modelProvider - ? { x_agents_core: { model_provider: modelProvider } } + ...(mcps.some((mcp) => mcp.vault_id) + ? { + vault_ids: [ + ...new Set(mcps.map((mcp) => mcp.vault_id).filter(Boolean)), + ], + } + : {}), + ...(input + ? { + input: images.length + ? [ + { + role: "user", + content: [ + { type: "input_text", text: input }, + ...images.map((image_url) => ({ + type: "input_image", + image_url, + })), + ], + }, + ] + : input, + } : {}), + x_agents_core: { model_provider: modelProvider }, metadata: { application: "parsar-example", agent_id: agent.id, @@ -123,12 +274,33 @@ function requestFor(store, id, body) { export function sessionAPI(store, core) { const running = new Map(); - const create = async (id, body) => { + const turns = turnsAPI(store, core); + // Hand a prepared Session to this conversation when one matches its frozen + // request. The request stays, so the monitor can still create the Session + // normally if the prepared one does not start. + const claim = (row) => { + if (typeof row.request.input !== "string") return; + const print = fingerprint(row.request); + const warm = store + .list("warm_sessions") + .find((entry) => entry.agent_id === row.agent_id && entry.fingerprint === print); + if (!warm) return; + store.remove("warm_sessions", warm.id); + setTimeout(() => void sessions.refreshWarm().catch(() => {}), 0); + const claimed = { ...row, core_session_id: warm.core_session_id, pending_input: row.request.input }; + store.put("sessions", claimed); + return turns.sendPrepared(claimed); + }; + const create = async (id, body, origin) => { let row = store.get("sessions", id); + if (row?.deleted_at) throw new AppError(410, "会话已删除。"); + if (row?.pending_input) return turns.sendPrepared(row); if (row?.core_session_id) return row; if (!row) { - row = requestFor(store, id, body); + row = prepareSession(store, id, body, origin); store.put("sessions", row); + const claimed = claim(row); + if (claimed) return claimed; } try { // Freeze the request before sending. Retries, including after restart, reuse the same key and bytes. @@ -140,9 +312,12 @@ export function sessionAPI(store, core) { ); if (!uuid.test(session.id)) throw new AppError(502, "Core 返回了无效的会话。"); - const { request, ...record } = row; - record.core_session_id = session.id; + // The frozen request stays until the first output, so a failed + // Environment can be replaced with identical configuration. + const record = { ...row, core_session_id: session.id }; store.put("sessions", record); + if (record.request.input !== undefined) + turns.start(record, { id: `${id}:first`, kind: "first", input: record.request.input }); return record; } catch (error) { if ( @@ -153,12 +328,75 @@ export function sessionAPI(store, core) { throw error; } }; - return (id, body) => { + const sessions = (id, body, origin) => { if (!running.has(id)) running.set( id, - create(id, body).finally(() => running.delete(id)), + create(id, body, origin).finally(() => running.delete(id)), ); return running.get(id); }; + // Keep one prepared Session for each Agent whose environment template asks for + // it. Core prepares an Environment as soon as a Session exists, so the first + // message skips setup. Overlapping refreshes are skipped. + let refreshing = false; + sessions.turns = turns; + sessions.refreshWarm = async (now = Date.now()) => { + if (refreshing) return; + refreshing = true; + try { + await refresh(now); + } finally { + refreshing = false; + } + }; + const refresh = async (now) => { + for (const agent of store.list("agents")) { + let print; + if ( + agent.enabled !== false && + store.get("runtimes", agent.runtime_id || "")?.prepare + ) + try { + print = fingerprint( + prepareSession(store, randomUUID(), { agent_id: agent.id }, {}, true) + .request, + ); + } catch { + print = undefined; + } + const entries = store + .list("warm_sessions") + .filter((entry) => entry.agent_id === agent.id); + for (const entry of entries) + if (entry.fingerprint !== print || now - entry.created_at > warmAge) { + store.remove("warm_sessions", entry.id); + if (entry.core_session_id) + await core(`/v1/agents/sessions/${entry.core_session_id}`, "DELETE").catch( + () => {}, + ); + } + if (!print || entries.some((entry) => entry.fingerprint === print && now - entry.created_at <= warmAge)) + continue; + const id = randomUUID(); + const { request } = prepareSession(store, id, { agent_id: agent.id }, {}, true); + const session = await core("/v1/agents/sessions", "POST", { + ...request, + metadata: { ...request.metadata, title: "", warm: "true" }, + }, id); + store.put("warm_sessions", { + id, + agent_id: agent.id, + fingerprint: print, + core_session_id: session.id, + created_at: now, + }); + } + for (const entry of store.list("warm_sessions")) + if (!store.get("agents", entry.agent_id)) { + store.remove("warm_sessions", entry.id); + await core(`/v1/agents/sessions/${entry.core_session_id}`, "DELETE").catch(() => {}); + } + }; + return sessions; } diff --git a/example/parsar/server/skills-directory.mjs b/example/parsar/server/skills-directory.mjs new file mode 100644 index 000000000..31d60d0f6 --- /dev/null +++ b/example/parsar/server/skills-directory.mjs @@ -0,0 +1,97 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdir, mkdtemp, readdir, readFile, lstat, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { zipSync } from "fflate"; +import { AppError } from "./store.mjs"; + +const execute = promisify(execFile); +const part = (value) => + typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value); +// Reuse the original product's pinned upstream installer rather than implement +// repository discovery, cloning, and Skills layout resolution ourselves. +export async function previewRegistrySkill(body, run = execute) { + const pieces = typeof body.source === "string" ? body.source.split("/") : []; + if (pieces.length !== 2 || !pieces.every(part) || !part(body.slug)) + throw new AppError(400, "请填写 GitHub owner/repo 和有效的 Skill 名称。"); + const root = join(homedir(), ".oac", "cache", "parsar-skill-imports"); + await mkdir(root, { recursive: true, mode: 0o700 }); + const directory = await mkdtemp(join(root, "import-")); + try { + try { + await run( + "npx", + [ + "--yes", + "skills@1.5.23", + "add", + body.source, + "--skill", + body.slug, + "--agent", + "claude-code", + "--copy", + "--yes", + ], + { + cwd: directory, + timeout: 120000, + maxBuffer: 1024 * 1024, + env: { ...process.env, DISABLE_TELEMETRY: "1" }, + }, + ); + } catch { + throw new AppError( + 502, + "下载 Skill 失败。请检查名称、仓库访问及服务器上的 Node.js、npx 和 Git。", + ); + } + const skill = join(directory, ".claude", "skills", body.slug); + const files = {}; + let total = 0, + count = 0; + const walk = async (path, prefix = "") => { + for (const entry of await readdir(path, { withFileTypes: true })) { + const absolute = join(path, entry.name), + name = prefix + entry.name; + const stat = await lstat(absolute); + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) + throw new AppError(400, "Skill 包含不支持的文件类型。"); + if (stat.isDirectory()) { + await walk(absolute, `${name}/`); + continue; + } + total += stat.size; + count++; + if (total > 32 * 1024 * 1024 || count > 1000) + throw new AppError( + 413, + "Skill 最多包含 1000 个文件,合计不超过 32 MiB。", + ); + files[`${body.slug}/${name}`] = new Uint8Array( + await readFile(absolute), + ); + } + }; + try { + if (!(await lstat(skill)).isDirectory()) + throw new AppError(400, "Skill 根路径必须是目录。"); + await walk(skill); + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError(400, "仓库未返回所选 Skill 的完整目录。"); + } + const instructions = files[`${body.slug}/SKILL.md`]; + if (!instructions) throw new AppError(400, "所选 Skill 缺少 SKILL.md。"); + return { + source: body.source, + slug: body.slug, + archive: Buffer.from(zipSync(files)).toString("base64"), + files: Object.keys(files), + instructions: Buffer.from(instructions).toString("utf8").slice(0, 256000), + }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/example/parsar/server/store.mjs b/example/parsar/server/store.mjs index dfc8be116..dcbf30f03 100644 --- a/example/parsar/server/store.mjs +++ b/example/parsar/server/store.mjs @@ -5,11 +5,26 @@ import { homedir } from "node:os"; import { createHash } from "node:crypto"; export class AppError extends Error { - constructor(status, message) { + constructor(status, message, code) { super(message); this.status = status; + this.code = code; } } +// Public API conflicts can be transient admission contention or terminal failures. +export function transientError(error) { + if (!(error instanceof AppError)) return true; + if ( + [ + "environment_unavailable", + "environment_input_expired", + "environment_input_cancelled", + "idempotency_conflict", + ].includes(error.code) + ) + return false; + return error.status >= 500 || [408, 409, 425, 429].includes(error.status); +} export function dataPath(config, env = process.env) { const directory = env.OAC_EXAMPLE_DATA_DIR || diff --git a/example/parsar/server/trials.mjs b/example/parsar/server/trials.mjs new file mode 100644 index 000000000..89b2c9e70 --- /dev/null +++ b/example/parsar/server/trials.mjs @@ -0,0 +1,60 @@ +import { randomUUID } from "node:crypto"; +import { prepareSession } from "./sessions.mjs"; +import { AppError } from "./store.mjs"; +import { explainFailure } from "./turns.mjs"; + +const limitMs = 5 * 60_000; +const prompt = "只回复 OK,不要调用任何工具。"; + +// A trial run creates one real Session for an Agent that uses the environment +// template, waits for its first reply, records the outcome and deletes the Session. +export function trialAPI(store, core) { + const get = (runtimeId) => { + const trial = store.get("runtime_trials", runtimeId); + if (trial?.state === "running" && Date.now() - trial.started_at > limitMs + 30_000) + return { ...trial, state: "failed", failure: "试运行被中断,请重新开始。" }; + return trial || null; + }; + + const settle = async (trial, sessionId, runtimeName) => { + let result = { state: "failed", failure: "5 分钟内没有完成。" }; + try { + while (Date.now() - trial.started_at < limitMs) { + const session = await core(`/v1/agents/sessions/${sessionId}`, "GET"); + if (session.status === "failed") { + result = { state: "failed", failure: explainFailure(session.error, runtimeName) }; + break; + } + const items = (await core(`/v1/agents/sessions/${sessionId}/items?limit=20`, "GET")).data || []; + if (items.some((item) => item.type === "message" && item.role === "assistant")) { + result = { state: "succeeded" }; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } catch (error) { + result = { state: "failed", failure: explainFailure(error.message, runtimeName) }; + } + store.put("runtime_trials", { ...trial, ...result, seconds: Math.round((Date.now() - trial.started_at) / 1000) }); + await core(`/v1/agents/sessions/${sessionId}`, "DELETE").catch(() => {}); + }; + + const start = async (runtimeId) => { + const runtime = store.get("runtimes", runtimeId); + if (!runtime) throw new AppError(404, "环境模板不存在。"); + const running = get(runtimeId); + if (running?.state === "running") return running; + const agent = store.list("agents").find((a) => a.runtime_id === runtimeId && a.enabled !== false); + if (!agent) throw new AppError(400, "先把这个环境模板绑定到一个 Agent,再试运行。"); + const { request } = prepareSession(store, randomUUID(), { agent_id: agent.id, name: "试运行", input: prompt }); + request.metadata = { ...request.metadata, application: "parsar-trial", title: "试运行" }; + const started_at = Date.now(); + const session = await core("/v1/agents/sessions", "POST", request, randomUUID()); + const trial = { id: runtimeId, state: "running", started_at, agent_name: agent.name, runtime_revision: runtime.revision }; + store.put("runtime_trials", trial); + void settle(trial, session.id, runtime.name); + return trial; + }; + + return { get, start }; +} diff --git a/example/parsar/server/turns.mjs b/example/parsar/server/turns.mjs new file mode 100644 index 000000000..80fff3ea8 --- /dev/null +++ b/example/parsar/server/turns.mjs @@ -0,0 +1,361 @@ +import { AppError } from "./store.mjs"; + +// After this long without any output from a running Core Turn, the Turn is +// cancelled and its input resent once. Cancellation is impossible while an +// Environment is still provisioning, so the clock starts at the Core Turn. +const stallMs = 45_000; +const maxRetries = 1; +// Core keeps input sent to an idle Session for at most five minutes. +const reservationMs = 5.5 * 60_000; +const statsWindowMs = 7 * 24 * 3600_000; +// Typical first-output times before Parsar has its own history, in seconds. +const typicalDefaults = { cold: 25, prepared: 6, followup: 3 }; +const terminal = ["completed", "failed", "cancelled"]; +const unresponsive = "模型服务暂时没有响应,已自动重试仍未恢复。可以稍后重试。"; +const unadmitted = "这条消息没有被执行,请重新发送。"; + +const asInput = (input) => + typeof input === "string" ? [{ role: "user", content: [{ type: "input_text", text: input }] }] : input; + +const median = (values) => { + if (!values.length) return undefined; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +}; + +// Plain-language failure text from Core's Session or Turn error. +export function explainFailure(error, runtimeName = "环境模板") { + const message = typeof error === "string" ? error : error?.message || ""; + const script = message.match(/script "setup_commands\[(\d+)\]" failed with exit code (\d+)/); + if (script) + return `「${runtimeName}」的初始化脚本第 ${Number(script[1]) + 1} 段失败(退出码 ${script[2]})。通常是网络波动,可以直接重试。`; + if (/provision/i.test(message)) return `「${runtimeName}」准备失败。通常是网络或安装问题,可以直接重试。`; + if (/expired|no longer available/i.test(message)) return "这个对话的工作环境已过期,请开始新对话。"; + if (/rate|429/i.test(message)) return "模型服务繁忙(请求过多),请稍后重试。"; + if (/timeout|timed out/i.test(message)) return "模型服务响应超时,可以直接重试。"; + return message ? `执行失败:${message}` : "执行失败,可以直接重试。"; +} + +// One ledger entry per user input, from Web and Feishu alike. The monitor +// watches running entries and recovers them only while nothing has run. +export function turnsAPI(store, core) { + const now = () => Date.now(); + const kindOf = (turn) => (turn.kind === "followup" ? "followup" : turn.prepared ? "prepared" : "cold"); + const runtimeName = (record) => store.get("runtimes", record.runtime_id || "")?.name; + const latest = (sessionId) => + store + .list("turns") + .filter((t) => t.session_id === sessionId) + .sort((a, b) => b.started_at - a.started_at)[0]; + const post = (coreSessionId, event, key) => + core(`/v1/agents/sessions/${coreSessionId}/events`, "POST", { events: [event] }, key); + const message = (input) => ({ type: "agent.session.input.message", input }); + + // Median first-output seconds for one Agent and Turn kind over the stats window. + const typical = (agentId, kind) => { + const since = now() - statsWindowMs; + const values = store + .list("turns") + .filter((t) => t.agent_id === agentId && kindOf(t) === kind && t.output_at && t.started_at > since) + .map((t) => (t.output_at - t.started_at) / 1000); + return Math.round(median(values) ?? typicalDefaults[kind]); + }; + + // The newest Core Turn before an input is sent; the input's Turn is newer, + // or is this one when the input joined a running Turn. + const baseline = async (coreSessionId) => { + const [newest] = (await core(`/v1/agents/sessions/${coreSessionId}/turns?order=desc&limit=1`, "GET")).data || []; + return newest ? { base_turn_id: newest.id, base_running: !terminal.includes(newest.status) } : {}; + }; + + const start = (record, { id, kind, input, source, prepared = false, base = {} }) => { + if (store.get("turns", id)) return store.get("turns", id); + const turn = { + id, + session_id: record.id, + core_session_id: record.core_session_id, + agent_id: record.agent_id, + source: source || record.source || "web", + kind, + prepared, + input: asInput(input), + started_at: now(), + outcome: "running", + stage: kind === "first" && !prepared ? "preparing" : "starting", + retries: 0, + ...base, + }; + store.put("turns", turn); + return turn; + }; + + // Records and sends a follow-up input. The entry is written first so a + // retried send (same id and key) keeps its original baseline. + const deliver = async (record, input, { id, key, source }) => { + const turn = + store.get("turns", id) || + start(record, { id, kind: "followup", input, source, base: await baseline(record.core_session_id) }); + try { + await post(record.core_session_id, message(turn.input), key); + } catch (error) { + if (error instanceof AppError && error.status >= 400 && error.status < 500) store.remove("turns", id); + throw error; + } + return turn; + }; + + // The first input of a claimed prepared Session. Core answers the send only + // once the Turn starts, so it runs in the background; the monitor replaces + // the Session when its Turn does not start. + const sendPrepared = (record) => { + const id = `${record.id}:first`; + start(record, { id, kind: "first", input: record.pending_input, prepared: true }); + void post(record.core_session_id, message(asInput(record.pending_input)), record.id).then( + () => { + const current = store.get("sessions", record.id); + if (current?.core_session_id !== record.core_session_id) return; + const { pending_input, ...sent } = current; + store.put("sessions", sent); + }, + () => { + const turn = store.get("turns", id); + if (turn?.outcome === "running" && turn.core_session_id === record.core_session_id) + store.put("turns", { ...turn, unsent: true }); + }, + ); + return record; + }; + + // A follow-up message from the Web composer. + const send = (sessionId, { text, key }) => { + const record = store.get("sessions", sessionId); + if (!record || record.deleted_at) throw new AppError(404, "会话不存在。"); + if (!record.core_session_id) throw new AppError(409, "会话仍在创建,请稍后再发。"); + if (typeof text !== "string" || !text.trim() || text.length > 100000) throw new AppError(400, "消息无效或过长。"); + if (typeof key !== "string" || !key || key.length > 100) throw new AppError(400, "缺少消息标识。"); + return deliver(record, text, { id: `${sessionId}:${key}`, key, source: "web" }); + }; + + const finish = (turn, outcome, failure) => { + const done = { ...turn, outcome, stage: undefined, done_at: now(), pending_cancel: undefined }; + if (failure) done.failure = failure; + if (outcome === "completed" && !done.output_at) done.output_at = done.done_at; + store.put("turns", done); + return done; + }; + + // Replaces a Session whose first Turn never ran (its Environment failed, or a + // claimed prepared Session did not start) with the identical frozen request. + const recreate = async (turn, record, key, retries) => { + const created = await core("/v1/agents/sessions", "POST", record.request, key); + store.transaction(() => { + const { pending_input, ...rest } = record; + store.put("sessions", { ...rest, core_session_id: created.id, superseded_turns: [] }); + // Feishu cursors belong to the replaced Core Session. + for (const thread of store.list("feishu_threads").filter((t) => t.session_id === record.id)) + store.put("feishu_threads", { ...thread, turn_cursor: undefined, item_cursor: undefined, artifact_cursor: undefined }); + store.put("turns", { ...turn, core_session_id: created.id, retries, prepared: false, unsent: undefined, stage: "preparing" }); + }); + await core(`/v1/agents/sessions/${record.core_session_id}`, "DELETE").catch(() => {}); + }; + + // Sends the input again after a Core Turn ended without output; the ended + // Turn is superseded so neither channel shows it. + const resend = async (turn, coreTurnId) => { + const record = store.get("sessions", turn.session_id); + const retries = turn.retries + 1; + store.put("sessions", { + ...record, + superseded_turns: [...new Set([...(record.superseded_turns || []), coreTurnId])], + }); + await post(record.core_session_id, message(turn.input), `${turn.id}:resend:${retries}`); + store.put("turns", { ...turn, retries, stage: "retrying", pending_cancel: undefined, core_turn_id: undefined }); + }; + + const check = async (turn) => { + const record = store.get("sessions", turn.session_id); + if (!record || record.deleted_at) return finish(turn, "cancelled"); + if (record.core_session_id !== turn.core_session_id) { + store.put("turns", { ...turn, core_session_id: record.core_session_id }); + return; + } + const sid = turn.core_session_id; + const session = await core(`/v1/agents/sessions/${sid}`, "GET"); + const superseded = new Set(record.superseded_turns || []); + // Oldest first. This input's Turn is the first one after its baseline: + // later inputs only start later Turns. An input sent while a Turn runs + // joins that Turn instead of starting one. + const recent = [...((await core(`/v1/agents/sessions/${sid}/turns?order=desc&limit=100`, "GET")).data || [])].reverse(); + const at = turn.base_turn_id ? recent.findIndex((t) => t.id === turn.base_turn_id) : -1; + const coreTurn = + recent.slice(at + 1).find((t) => !superseded.has(t.id)) || (turn.base_running && at >= 0 ? recent[at] : undefined); + if (!coreTurn) { + const replaceable = turn.kind === "first" && record.request && turn.retries < maxRetries; + const notStarted = turn.prepared && (turn.unsent || now() - turn.started_at > stallMs); + if (replaceable && (session.status === "failed" || notStarted)) + return recreate(turn, record, `${record.id}:attempt:${turn.retries + 1}`, turn.retries + 1); + if (session.status === "failed") return finish(turn, "failed", explainFailure(session.error, runtimeName(record))); + // Input sent to an existing Session that Core never admitted has expired. + if ((turn.kind === "followup" || turn.prepared) && session.status === "idle" && now() - turn.started_at > reservationMs) + return finish(turn, "failed", unadmitted); + return; + } + const items = ((await core(`/v1/agents/sessions/${sid}/items?order=desc&limit=50`, "GET")).data || []).filter( + (item) => item.turn_id === coreTurn.id && !(item.type === "message" && item.role === "user"), + ); + let current = { ...turn, core_turn_id: coreTurn.id }; + if (items.length && !current.output_at) { + current.output_at = now(); + // The frozen request is only needed until the first Turn produced output. + if (record.request) { + const { request, ...rest } = record; + store.put("sessions", rest); + } + } + if (coreTurn.status === "completed") return finish(current, "completed"); + if (coreTurn.status === "cancelled") + return current.pending_cancel === coreTurn.id && !items.length + ? resend(current, coreTurn.id) + : finish(current, "cancelled"); + if (coreTurn.status === "failed") + return !items.length && current.retries < maxRetries + ? resend(current, coreTurn.id) + : finish(current, "failed", explainFailure(coreTurn.error || session.error, runtimeName(record))); + if (session.status === "requires_action") current.stage = "waiting_user"; + else if (current.pending_cancel) current.stage = "retrying"; + else current.stage = items.length ? "working" : "starting"; + const stalled = + !items.length && + !current.pending_cancel && + session.status === "in_progress" && + now() - (coreTurn.created_at ? coreTurn.created_at * 1000 : current.started_at) > stallMs; + if (stalled && current.retries < maxRetries) { + await post(sid, { type: "agent.session.input.cancel" }, `${turn.id}:cancel:${current.retries + 1}`); + current = { ...current, pending_cancel: coreTurn.id, stage: "retrying" }; + } else if (stalled) current.failure = unresponsive; + store.put("turns", current); + }; + + let monitoring = false; + const monitor = async () => { + if (monitoring) return; + monitoring = true; + try { + for (const turn of store.list("turns").filter((t) => t.outcome === "running")) + await check(turn).catch(() => { + /* Core may be briefly unreachable; the next pass retries. */ + }); + } finally { + monitoring = false; + } + }; + + // What a Turn looks like to people: its stage, how long it has waited and + // how long this Agent usually takes. + const describe = (turn) => { + const kind = kindOf(turn); + const expected_s = typical(turn.agent_id, kind); + const elapsed_s = Math.round(((turn.done_at || now()) - turn.started_at) / 1000); + return { + outcome: turn.outcome, + stage: turn.stage, + kind, + started_at: turn.started_at, + done_at: turn.done_at, + elapsed_s, + expected_s, + first_output_s: turn.output_at ? Math.round((turn.output_at - turn.started_at) / 100) / 10 : null, + slow: turn.outcome === "running" && !turn.output_at && elapsed_s > Math.max(2 * expected_s, expected_s + 15), + retries: turn.retries, + failure: turn.failure, + }; + }; + + // The latest Turn of a conversation, as the UI and Feishu cards show it. + const summary = (sessionId) => { + const turn = latest(sessionId); + return turn && describe(turn); + }; + + // Running and recent Turns across all conversations, for the 运行 view. + const overview = () => { + const at = now(); + const visible = store.list("turns").filter((t) => { + const record = store.get("sessions", t.session_id); + return record && !record.deleted_at; + }); + const latestOf = new Map(); + for (const t of visible) + if (!latestOf.has(t.session_id) || latestOf.get(t.session_id).started_at < t.started_at) latestOf.set(t.session_id, t); + const row = (t) => { + const record = store.get("sessions", t.session_id); + const prompt = (t.input || []).flatMap((m) => m.content || []).find((part) => part.type === "input_text")?.text || ""; + return { + id: t.id, + session_id: t.session_id, + core_session_id: t.core_session_id, + agent_id: t.agent_id, + conversation: record.name, + source: t.source, + chat_type: record.feishu_chat_type, + prompt: prompt.slice(0, 200), + latest: latestOf.get(t.session_id) === t, + ...describe(t), + }; + }; + const running = visible.filter((t) => t.outcome === "running").sort((a, b) => a.started_at - b.started_at); + const recent = visible + .filter((t) => t.outcome !== "running") + .sort((a, b) => b.done_at - a.done_at) + .slice(0, 50); + // Conversations running at each 10-second mark of the last five minutes. + const concurrency = Array.from({ length: 31 }, (_, i) => { + const mark = at - (30 - i) * 10_000; + return visible.filter((t) => t.started_at <= mark && (t.done_at ?? Infinity) > mark).length; + }); + return { + running: running.map(row), + recent: recent.map(row), + stats: { + running: running.length, + waiting_user: running.filter((t) => t.stage === "waiting_user").length, + failed_recent: visible.filter((t) => t.outcome === "failed" && t.done_at > at - 5 * 60_000).length, + concurrency, + }, + }; + }; + + // Seven-day reliability and latency for one Agent. + const health = (agentId) => { + const since = now() - statsWindowMs; + const turns = store.list("turns").filter((t) => t.agent_id === agentId && t.started_at > since); + const settled = turns.filter((t) => ["completed", "failed"].includes(t.outcome)); + const firstOutput = turns.filter((t) => t.output_at).map((t) => (t.output_at - t.started_at) / 1000); + return { + turns: settled.length, + success_rate: settled.length ? settled.filter((t) => t.outcome === "completed").length / settled.length : null, + median_first_output_s: median(firstOutput) ?? null, + retried: turns.filter((t) => t.retries > 0).length, + }; + }; + + // Manual retry of the latest failed Turn: a failed Environment is replaced, + // otherwise the input is sent again as a new Turn. + const retry = async (sessionId) => { + const record = store.get("sessions", sessionId); + const turn = latest(sessionId); + if (!record || turn?.outcome !== "failed") throw new AppError(409, "没有可以重试的失败执行。"); + const session = await core(`/v1/agents/sessions/${record.core_session_id}`, "GET"); + const id = `${turn.id}:manual:${now()}`; + if (session.status === "failed" && record.request && !turn.core_turn_id) + await recreate(start(record, { id, kind: turn.kind, input: turn.input, source: turn.source }), record, id, 0); + else await deliver(record, turn.input, { id, key: id, source: turn.source }); + }; + + const superseded = (sessionId, coreTurnId) => + (store.get("sessions", sessionId)?.superseded_turns || []).includes(coreTurnId); + const recovering = (sessionId) => store.list("turns").some((t) => t.session_id === sessionId && t.outcome === "running"); + + return { start, sendPrepared, deliver, send, monitor, summary, overview, health, retry, superseded, recovering }; +} diff --git a/example/parsar/shared/bot-bindings.mjs b/example/parsar/shared/bot-bindings.mjs new file mode 100644 index 000000000..864937620 --- /dev/null +++ b/example/parsar/shared/bot-bindings.mjs @@ -0,0 +1,10 @@ +// Fields of the Agent's Feishu bot that an environment template may expose to tools. +export const botFields = { + app_id: "App ID", + app_secret: "App Secret", + brand: "地区", +}; +export const environmentVariableName = (name) => + /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && + !/^(OAC_|CODEX_)/.test(name) && + !["PATH", "OPENAI_API_KEY"].includes(name); diff --git a/example/parsar/shared/environment-limits.d.mts b/example/parsar/shared/environment-limits.d.mts new file mode 100644 index 000000000..128803171 --- /dev/null +++ b/example/parsar/shared/environment-limits.d.mts @@ -0,0 +1,3 @@ +export const maxSkills: number; +export const maxPlugins: number; +export const maxFiles: number; diff --git a/example/parsar/shared/environment-limits.mjs b/example/parsar/shared/environment-limits.mjs new file mode 100644 index 000000000..774f996e8 --- /dev/null +++ b/example/parsar/shared/environment-limits.mjs @@ -0,0 +1,5 @@ +// Core's per-Environment limits (contracts/agents-api/environments.md): +// Skills and Plugins per Environment, and initial files per configuration. +export const maxSkills = 50; +export const maxPlugins = 50; +export const maxFiles = 50; diff --git a/example/parsar/shared/harness-options.d.mts b/example/parsar/shared/harness-options.d.mts new file mode 100644 index 000000000..8dc722e1b --- /dev/null +++ b/example/parsar/shared/harness-options.d.mts @@ -0,0 +1,18 @@ +export type HarnessKind = "codex" | "claude_sdk" | "mcode"; +export type ExecutionOptionField = + | "reasoning_effort" + | "reasoning_summary" + | "web_search" + | "verbosity" + | "service_tier"; +export const harnessOptions: Record< + HarnessKind, + { label: string; protocol: "responses" | "anthropic" } & Record< + ExecutionOptionField, + string[] + > +>; +export const executionOptionFields: ExecutionOptionField[]; +export function unsupportedOption( + agent: { harness: string } & Partial>, +): ExecutionOptionField | "harness" | undefined; diff --git a/example/parsar/shared/harness-options.mjs b/example/parsar/shared/harness-options.mjs new file mode 100644 index 000000000..93eae7ef7 --- /dev/null +++ b/example/parsar/shared/harness-options.mjs @@ -0,0 +1,49 @@ +// Execution options each Harness admits, mirroring Core's Harness declarations +// (contracts/agents-api/harness-capabilities.md). An empty value means the +// Harness default and is never sent. +export const harnessOptions = { + codex: { + label: "Codex", + protocol: "responses", + reasoning_effort: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + reasoning_summary: ["concise", "detailed", "auto"], + web_search: ["disabled", "cached", "live"], + verbosity: ["low", "medium", "high"], + service_tier: ["default", "flex", "priority"], + }, + claude_sdk: { + label: "Claude Code", + protocol: "anthropic", + reasoning_effort: ["low", "medium", "high", "xhigh", "max"], + reasoning_summary: [], + web_search: ["disabled", "live"], + verbosity: ["medium"], + service_tier: [], + }, + mcode: { + label: "MiniMax Code", + protocol: "anthropic", + reasoning_effort: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + reasoning_summary: [], + web_search: ["disabled"], + verbosity: ["medium"], + service_tier: [], + }, +}; + +export const executionOptionFields = [ + "reasoning_effort", + "reasoning_summary", + "web_search", + "verbosity", + "service_tier", +]; + +// Returns the first option the Agent's Harness does not admit. +export function unsupportedOption(agent) { + const declared = harnessOptions[agent.harness]; + if (!declared) return "harness"; + return executionOptionFields.find( + (field) => agent[field] && !declared[field].includes(agent[field]), + ); +} diff --git a/example/parsar/shared/media.mjs b/example/parsar/shared/media.mjs new file mode 100644 index 000000000..5b566e5e4 --- /dev/null +++ b/example/parsar/shared/media.mjs @@ -0,0 +1,41 @@ +// Only persisted product QR images and public Session Artifacts are media sources. +export const qrImagePattern = /^\/app\/feishu\/provision\/([a-f0-9-]{36})\/qrcode$/; +export const maxMediaBytes = 5 * 1024 * 1024; +export const artifactContentPath = (sessionId, artifactId) => + `/v1/agents/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/content`; +export function qrImages(text) { + return [...new Set([...text.matchAll(/!\[[^\]\n]*\]\((\/app\/feishu\/provision\/[a-f0-9-]{36}\/qrcode)\)/g)].map((match) => match[1]))]; +} +export function imageMime(bytes) { + const starts = (signature) => signature.every((byte, index) => bytes[index] === byte); + if (starts([137, 80, 78, 71, 13, 10, 26, 10])) return "image/png"; + if (starts([255, 216, 255])) return "image/jpeg"; + if (starts([71, 73, 70, 56]) && [55, 57].includes(bytes[4]) && bytes[5] === 97) return "image/gif"; + if (starts([82, 73, 70, 70]) && [87, 69, 66, 80].every((byte, i) => bytes[i + 8] === byte)) return "image/webp"; + return ""; +} +export async function readMediaBytes(response) { + if (Number(response.headers.get("content-length")) > maxMediaBytes) { + await response.body?.cancel(); + throw new RangeError("附件不能超过 5 MiB。"); + } + const reader = response.body?.getReader(); + if (!reader) throw new Error("附件内容不可用。"); + const chunks = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > maxMediaBytes) throw new RangeError("附件不能超过 5 MiB。"); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return bytes; +} diff --git a/example/parsar/shared/session-profile.d.mts b/example/parsar/shared/session-profile.d.mts deleted file mode 100644 index 6b4d31f52..000000000 --- a/example/parsar/shared/session-profile.d.mts +++ /dev/null @@ -1,4 +0,0 @@ -export function sessionRestriction( - agent: { harness: string; skill_ids: string[]; mcp_ids: string[] }, - runtime?: { environment: string }, -): string | undefined; diff --git a/example/parsar/shared/session-profile.mjs b/example/parsar/shared/session-profile.mjs deleted file mode 100644 index b99a279f3..000000000 --- a/example/parsar/shared/session-profile.mjs +++ /dev/null @@ -1,12 +0,0 @@ -// Product guidance only; Core remains authoritative for capability validation. -export function sessionRestriction(agent, runtime) { - if (!runtime) return undefined; - if (runtime.environment === "self_hosted") { - if (agent.skill_ids.length) - return "用户机器使用本地能力目录,不能绑定托管 Skill。请先编辑 Agent,移除托管 Skill。"; - if (agent.mcp_ids.length) - return "用户机器的 MCP 需通过本地 Plugin 配置。请先编辑 Agent,移除 MCP 绑定。"; - } - if (runtime.environment === "none" && agent.skill_ids.length) - return "Skills 需要托管运行环境,请选择托管沙箱或移除 Skill 绑定。"; -} diff --git a/example/parsar/shared/turn-status.d.mts b/example/parsar/shared/turn-status.d.mts new file mode 100644 index 000000000..3938f69a5 --- /dev/null +++ b/example/parsar/shared/turn-status.d.mts @@ -0,0 +1,3 @@ +export const typicalWait: string; +export function waitingHeadline(turn?: { stage?: string; slow?: boolean }): string; +export function stageLabel(turn?: { stage?: string; slow?: boolean }): string; diff --git a/example/parsar/shared/turn-status.mjs b/example/parsar/shared/turn-status.mjs new file mode 100644 index 000000000..ac1bc5999 --- /dev/null +++ b/example/parsar/shared/turn-status.mjs @@ -0,0 +1,19 @@ +// What a conversation shows about a running Turn. Web and Feishu cards read +// the same copy; Web translates it, Feishu shows it as is. +export const typicalWait = "通常约 {{value0}} 秒"; + +// Before the first output: where the time goes. +export function waitingHeadline(turn) { + if (turn?.stage === "retrying") return "响应较慢,正在自动重试"; + if (turn?.slow) return "比平时慢,请稍候"; + if (turn?.stage === "preparing") return "正在准备工作环境"; + if (turn?.stage === "starting") return "正在启动"; + return "正在思考"; +} + +// Any stage of a running Turn, including after its first output. +export function stageLabel(turn) { + if (turn?.stage === "waiting_user") return "等你回复"; + if (turn?.stage === "working") return "执行中"; + return waitingHeadline(turn); +} diff --git a/example/parsar/src/AgentDetail.tsx b/example/parsar/src/AgentDetail.tsx deleted file mode 100644 index a0a340e5e..000000000 --- a/example/parsar/src/AgentDetail.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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/AgentHealth.tsx b/example/parsar/src/AgentHealth.tsx new file mode 100644 index 000000000..1bed4d084 --- /dev/null +++ b/example/parsar/src/AgentHealth.tsx @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; +import { tr } from "./lib/i18n"; +import { product } from "./lib/product"; +import { Section } from "./components/shared"; + +type Health = { + turns: number; + success_rate: number | null; + median_first_output_s: number | null; + retried: number; +}; + +// Seven-day reliability and speed, from the Turns Parsar watched for this Agent. +export function AgentHealth({ id }: { id: string }) { + const health = useQuery({ + queryKey: ["agent-health", id], + queryFn: () => product(`agents/${id}/health`), + refetchInterval: 30000, + }); + const data = health.data; + if (!data) return null; + const stats = [ + [tr("成功率"), data.success_rate === null ? "—" : `${Math.round(data.success_rate * 100)}%`], + [ + tr("首次回复"), + data.median_first_output_s === null + ? "—" + : tr("{{value0}} 秒", { value0: data.median_first_output_s.toFixed(1) }), + ], + [tr("自动恢复"), tr("{{value0}} 次", { value0: data.retried })], + ]; + return ( +
+
+ {stats.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ); +} diff --git a/example/parsar/src/AgentPage.tsx b/example/parsar/src/AgentPage.tsx new file mode 100644 index 000000000..701ec11e4 --- /dev/null +++ b/example/parsar/src/AgentPage.tsx @@ -0,0 +1,270 @@ +import { tr } from "./lib/i18n"; +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Check, Send, Trash2 } from "lucide-react"; +import { + product, + useAgents, + useFeishuBots, + type AgentProfile, +} from "./lib/product"; +import { ConfigurationFields, emptyConfiguration } from "./ConfigurationFields"; +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { Textarea } from "./components/ui/textarea"; +import { EmptyState } from "./components/ui/empty-state"; +import { ErrorNotice, Field, PageHeader, Section } from "./components/shared"; +import { randomUuid } from "@oac/agents-client"; +import { AgentHealth } from "./AgentHealth"; + +export function AgentPage({ + id, + copyFrom, +}: { + id?: string; + copyFrom?: string; +}) { + const agents = useAgents(); + const source = agents.data?.find((row) => row.id === (id || copyFrom)); + if (agents.isPending) + return ; + if (id && !source) + return ; + const initial: AgentProfile = + id && source + ? source + : source + ? { + ...source, + id: randomUuid(), + name: tr("{{value0}} 副本", { value0: source.name }), + revision: 0, + } + : { + ...emptyConfiguration, + id: randomUuid(), + name: "", + description: "", + ask_user: true, + revision: 0, + }; + return ( + + ); +} + +// Unsaved edits survive navigation within the tab; a draft only restores onto +// the revision it was started from. +function readDraft(key: string, revision: number) { + try { + const draft = JSON.parse(sessionStorage.getItem(key) || "null"); + return draft?.revision === revision ? (draft.form as AgentProfile) : undefined; + } catch { + return undefined; + } +} + +function AgentEditor({ + initial: loaded, + draftKey, +}: { + initial: AgentProfile; + draftKey: string; +}) { + const cache = useQueryClient(); + const bots = useFeishuBots(); + // The last saved record; later list refreshes never replace an open draft. + const [initial, setInitial] = useState(loaded); + const [restored] = useState(() => readDraft(draftKey, loaded.revision)); + const [form, setFormState] = useState(restored || loaded); + const [attempted, setAttempted] = useState(false); + const setForm = (next: AgentProfile) => { + setSaved(false); + setFormState(next); + if (JSON.stringify(next) === JSON.stringify(initial)) + sessionStorage.removeItem(draftKey); + else + sessionStorage.setItem( + draftKey, + JSON.stringify({ revision: initial.revision, form: next }), + ); + }; + const [saved, setSaved] = useState(false); + const creating = !initial.revision; + const dirty = JSON.stringify(form) !== JSON.stringify(initial); + const missing = { + name: form.name.trim() ? undefined : tr("请填写名称"), + model_id: form.model_id ? undefined : tr("请选择模型"), + runtime_id: form.runtime_id ? undefined : tr("请选择环境模板"), + }; + const errors: Partial = attempted ? missing : {}; + const submit = () => { + if (Object.values(missing).some(Boolean)) { + setAttempted(true); + requestAnimationFrame(() => { + const field = document.querySelector('[data-invalid="true"]'); + field?.scrollIntoView({ block: "center", behavior: "smooth" }); + field + ?.querySelector("input, textarea, button") + ?.focus({ preventScroll: true }); + }); + return; + } + save.mutate(); + }; + const bot = bots.data?.find((row) => row.agent_id === form.id); + const save = useMutation({ + mutationFn: () => product(`agents/${form.id}`, "PUT", form), + onSuccess: (agent) => { + cache.setQueryData(["agents"], (rows = []) => [ + agent, + ...rows.filter((row) => row.id !== agent.id), + ]); + void cache.invalidateQueries({ queryKey: ["agents"] }); + sessionStorage.removeItem(draftKey); + if (creating) location.hash = `/agents/${agent.id}`; + setInitial(agent); + setFormState(agent); + setSaved(true); + }, + }); + const remove = useMutation({ + mutationFn: () => product(`agents/${form.id}`, "DELETE"), + onSuccess: () => { + void cache.invalidateQueries({ queryKey: ["agents"] }); + location.hash = "/agents"; + }, + }); + return ( + // Not a
: resource editors opened from this page render their own forms in portals. +
+
+ + + + + + } + > + {!creating && ( + <> + + + + + )} + + +
+
+ + {restored && dirty && ( +
+ {tr("已恢复上次未保存的修改。")} + +
+ )} +
+ + setForm({ ...form, name: e.target.value })} + /> + + +