diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7973a5e9d..e2ef51e7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,12 +56,12 @@ Core must build, deploy and run independently of product services, frontends and ### 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 task 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/api/index.md b/docs/api/index.md index a68c5be70..d5fa3675b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -23,3 +23,7 @@ Nodes, Runtime daemons and the self-hosted installer call `/api/v1` with their o Operator scripts configure independent filesystem storage through Core-key-authenticated `GET` and `PUT /core/v1/workspace-storage`; see [Workspace storage](../../contracts/agents-api/admin-api.md#workspace-storage). Web and operator scripts update the sandbox idle policy through Core-key-authenticated `PATCH /core/v1/sandbox/deployment`; see [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md). + +The optional [application example](../../CONTRIBUTING.md#optional-application-example) owns its loopback `/app/` product routes. Their caller is its same-origin local UI; in-process workers reuse the product operations. Its server uses a Project API key for public `/v1` calls and accepts no Core administrator credential. + +Its loopback `/mcp/agents/` endpoint serves local MCP clients authenticated with a product-issued Agent token; `/oauth/callback` is a read-only OAuth return page. Product route families, callers and credentials are owned by the [Parsar local API](../../example/parsar/README.md#ownership-and-local-api). These are not Core namespaces. diff --git a/docs/examples.md b/docs/examples.md index 67fd2c34a..e8210f331 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,11 +6,11 @@ Complete applications built on the [Agents API](./api/public-agent-api.md). Each | Example | What it shows | | --- | --- | -| [Parsar Agent workbench](#parsar-agent-workbench) | A product UI: models, Skills, MCP, reusable Agents, and Sessions on any runtime | +| [Parsar Agent workbench](#parsar-agent-workbench) | Projects, task boards, reusable Agents, execution history, recurring work and Feishu dispatch | ## 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 task workspace built on Core, operated through a local Web UI and trusted Feishu groups. Organize projects, assign tasks to reusable Agents, continue execution Sessions, inspect Artifacts and record delivery progress. Interval plans and Feishu dispatch reuse the public Session flow. Execution runs in bypass mode; product accounts and fine-grained permissions are omitted. Source: [`example/parsar`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/README.md). @@ -22,6 +22,8 @@ Each feature maps to one part of the API. Read the code next to the guide sectio | Feature | API used | Guide | Code | | --- | --- | --- | --- | +| Task dispatch and recovery | Public Session creation and durable history | [Create a Session](./api/public-agent-api.md#create-a-session) | [`server/work.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/work.mjs) | +| Feishu and recurring work | Public Sessions, input events, Turns and Items | [Send input](./api/public-agent-api.md#send-input) | [`server/feishu.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/feishu.mjs), [`server/schedules.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/schedules.mjs) | | 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-x_agents_core) | [`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) | diff --git a/docs/zh/api/index.md b/docs/zh/api/index.md index c5cfd3f60..667afe8a2 100644 --- a/docs/zh/api/index.md +++ b/docs/zh/api/index.md @@ -1,7 +1,7 @@ --- title: "API 命名空间和凭据" source: docs/api/index.md -source_hash: 12a1933c91e95828ccd8bf8405a892726c7da265ddf2bbf6bf99bd156af0f96d +source_hash: d913f9c0750ca03c6fd70fcaf10d5662212ee8ed97adc54325b60e0ad1eab2f4 --- Core 提供三个命名空间。每个命名空间都有一种调用方及其独立凭据,凭据只能在其所属命名空间中使用。 @@ -25,3 +25,7 @@ Core 提供三个命名空间。每个命名空间都有一种调用方及其独 运维脚本通过经 Core 密钥认证的 `GET` 和 `PUT /core/v1/workspace-storage` 配置独立文件系统存储;参见[工作区存储](../../../contracts/agents-api/zh/admin-api.md#workspace-storage)。 Web 和运维脚本通过 Core 密钥认证的 `PATCH /core/v1/sandbox/deployment` 更新沙箱空闲策略;参见[沙箱部署](../../../contracts/agents-api/zh/sandbox-deployment.md)。 + +可选的 [应用示例](../../../CONTRIBUTING.md#optional-application-example)拥有自己的回环 `/app/` 产品路由,调用方是同源本地 UI;进程内工作器复用产品操作。其服务器使用 Project API 密钥调用公开 `/v1`,不接受 Core 管理员凭据。 + +示例的回环 `/mcp/agents/` 端点供本机 MCP 客户端使用,以产品签发的 Agent Token 鉴权;`/oauth/callback` 是只读 OAuth 返回页。产品路由组、调用方与凭据由 [Parsar 本地 API](../../../example/parsar/README.zh.md#ownership-and-local-api) 说明,不属于 Core 命名空间。 diff --git a/docs/zh/examples.md b/docs/zh/examples.md index 601436927..5532fa0ee 100644 --- a/docs/zh/examples.md +++ b/docs/zh/examples.md @@ -1,18 +1,18 @@ --- title: "示例" source: docs/examples.md -source_hash: 60a228b1b499d11f5f6f122a5e298962fcc9ced9777688f69f5078a2f5df4c9c +source_hash: 55efc4906a38e5bd9e65aef60f686f17c57f75c0e7356e5f6549b436d5e92137 --- 基于 [Agents API](api/public-agent-api.md)构建的完整应用。每个示例都使用项目 API 密钥连接真实的 Core 安装。 | 示例 | 展示内容 | | --- | --- | -| [Parsar Agent 工作台](#parsar-agent-workbench) | 产品 UI:模型、Skills、MCP、可复用 Agent,以及在任意 Runtime 上运行的 Session | +| [Parsar Agent 工作台](#parsar-agent-workbench) | 项目、任务看板、可复用 Agent、执行历史、定时任务和飞书分配 | ## Parsar Agent 工作台 {#parsar-agent-workbench} -基于 Core 构建的小型单用户产品。保存模型提供商、Skills 和 MCP 服务器,将它们组合成 Agent,然后在托管沙箱、无工作区环境或自己的机器上启动 Session。 +基于 Core 构建的任务工作空间,通过本地 Web UI 和受信任的飞书群操作。组织项目,将任务分配给可复用 Agent,继续执行 Session,检查 Artifacts 并记录交付进度。间隔计划和飞书分配复用公开 Session 流程。执行使用 bypass 模式;不包含产品账号和细粒度权限。 源码:[`example/parsar`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/README.md)。 @@ -24,6 +24,8 @@ source_hash: 60a228b1b499d11f5f6f122a5e298962fcc9ced9777688f69f5078a2f5df4c9c | 功能 | 使用的 API | 指南 | 代码 | | --- | --- | --- | --- | +| 任务分配与恢复 | 公开 Session 创建和持久历史 | [创建 Session](api/public-agent-api.md#create-a-session) | [`server/work.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/work.mjs) | +| 飞书和定时任务 | 公开 Sessions、输入事件、Turns 和 Items | [发送输入](api/public-agent-api.md#send-input) | [`server/feishu.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/feishu.mjs)、[`server/schedules.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/example/parsar/server/schedules.mjs) | | 服务端密钥和请求头 | 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-x-agents-core) | [`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) | diff --git a/example/parsar/README.md b/example/parsar/README.md index 819d6e149..e8b28cb77 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -1,56 +1,146 @@ -# 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 task workspace built on OpenAgentCore’s public `/v1` Agents API. Its dense lists, detail rails, navigation, themes and conversation controls follow the original Parsar UI. Core owns execution; Parsar owns Agents, projects, tasks, capabilities, recurring work and Feishu delivery. Execution always uses bypass mode. Accounts and fine-grained permissions are deferred. Feishu is the supported IM integration. ## 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 only to loopback. Links from other sites may open the homepage as a top-level browser navigation; API requests retain same-origin checks. A local tunnel may use a different port; the browser Host must remain `localhost` or `127.0.0.1`, and write Origins must exactly match that Host, including its port. Use one server process per data directory. + +**设置** changes the workspace name and Chinese/English language. The sidebar and Agent detail rail resize and remember their widths. Theme controls select light, dark or system appearance. **对话**, **运行记录**, **用量** and **审计日志** provide separate views of conversation references, public Turns, token usage and product mutations. Date filters use the browser’s local dates. Usage totals cover all matching records, not just the displayed page. Missing usage remains unknown. Optional model prices are USD per million input/output tokens, frozen when a Session starts; displayed costs are estimates using those flat rates, not provider bills. + +## Agents, capabilities and Sessions + +**构建** has three entries: **Agent 模板** defines what an Agent does, **环境模板** defines its prepared workspace, and **资源库** supplies reusable resources. **接入与设置** contains **飞书接入** and **设置**. Web tasks and conversations start from **工作** and **活动**. + +The Agent detail opens on **配置**. The editor groups **系统提示词**, **模型与环境**, **工具与知识** and **账号与行为**. **主指令** is the Agent’s own instruction text; optional **指令片段** are appended in selection order to produce one system prompt. Resource bindings use a shared searchable selector, show selected resources with version controls where applicable, and provide **查看或编辑** and **移除绑定** actions. Removing a binding does not delete the resource. Instruction fragments, Plugins and knowledge share an eight-resource limit; selectors prevent exceeding it. **管理连接** opens account management without discarding the Agent draft. Creating or editing a model, Skill, MCP, environment or capability inside the editor preserves the Agent draft; a newly created resource is automatically selected. Edits to shared resources also affect other Agents that use them. Drafts are stored separately per Agent and can be reopened from **未保存的草稿**. They stay in this browser tab across navigation and reload until saved or explicitly discarded with **放弃草稿**; provider secrets are never included. Environment lists show preparation summaries, descriptions and the Agents that reference each template. + +**本次运行** in a Session shows its public Core Session/Environment references and the safe configuration summary recorded when creation was first attempted. Retries retain that summary; later template edits do not rewrite it. Skill references without a version pin are labeled as having used the default at creation, not as a known resolved version. Older Sessions without a summary say so. No full request or credential is exposed. + +**模型** groups model IDs under Providers. Fetch a Provider’s OpenAI-shaped `GET /v1/models` catalog or enter IDs manually. Saving commits the Provider and selection together. Keys remain server-side; blank edits preserve the saved key. Discovery sends only that Provider’s key and never follows redirects. Models can be disabled without deleting historical references. Codex and Claude Code workspace Sessions use the selected HTTPS Provider URL/key and harness protocol. Text-only and MiniMax Code use Core’s configured model connection; the creation form explains this distinction. Unsupported combinations fail explicitly; consult the [Harness coverage ledger](../../contracts/agents-api/index.md). + +**资源库** groups models, Skills, MCP, Plugins, instruction fragments, knowledge and credentials used by Agent templates: + +- **Skills** supports SKILL.md creation, ZIP upload, versions, default selection, per-Agent version pins and bounded file previews. **浏览目录** searches the skills.sh index; source/skill can also be entered directly. Preview downloads use the maintained, pinned `skills@1.5.23` CLI in a temporary directory and then 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; Core remains the authoritative Skill store. +- **MCP** provides the original static service directory and HTTPS endpoint configuration. Optional Vault/Credential bindings use official public MCP tools, with environment origin. No anonymous inline compatibility Plugin is generated. +- **凭据** creates Vaults and write-only bearer credentials, replaces tokens and deletes credentials. **OAuth 授权** uses the maintained MCP SDK for discovery, dynamic client registration, issuer binding and PKCE. Its callback is a local page; **保存凭据** stores the OAuth access token and optional refresh configuration through public Vault APIs. OAuth tokens never return to the browser. Providers must support the selected SDK authorization flow and loopback redirects. Authorize again when a provider requires renewed consent. +- Prompt and knowledge capabilities have immutable numbered versions, defaults, pins and deprecation. Prompt versions extend the frozen Agent instructions. Knowledge versions become initial workspace files. Standard Claude/Codex Plugin ZIPs become public Environment Plugins; declared `.mcp.json` servers are shown in the editor. Executables required by stdio servers must exist in the selected environment. +- Plugin capabilities may also include an optional prebuilt **前端扩展 JavaScript(可选)** bundle, at most 1 MiB. It stays in Parsar and is not sent to Core. Installed, enabled clients execute with the local page’s authority; install only trusted code. The shared `window.__PARSAR_PLUGIN_API__` exposes React, `createContext` and `definePlugin` as in Parsar. The slot protocol is [plugin-slots.ts](src/lib/plugin-slots.ts): `workspace.main`, `workspace.content`, `layout.header.actions`, `layout.nav.bottom`, `conversation.header.actions`, `conversation.input.dock`, `conversation.tool-card` and `agent.settings.section`. Tool-card matchers receive the public `mcp_call` item as `item`; extensions must use public item fields rather than an engine’s native payload. Other context props are the local Agent/Session references supplied at each slot. Disabling a capability removes its registered UI. Cross-workspace marketplace publishing depends on the deferred account/workspace model. + +**Agent 模板** combines model, harness, instructions, capabilities and an optional default environment template. Create, copy, enable/disable and edit without allocating execution resources. Edits affect future Sessions. Optional business questions use the declared `ask_user` function: answers appear in the Session and **收件箱**, and return through public function-result events with stable request IDs. This is information gathering, not execution approval. + +**环境模板** defines npm and Python packages, a Bash initialization script, environment variables and initial text files. Parsar runs only in Core-managed workspaces; there is no placement, machine or sandbox-provider selector. Each new Session installs files and packages and completes setup before starting the Agent; a setup failure stops execution. Package specifications may pin versions. Initial files use canonical `/workspace/` paths. Scripts run from the workspace and should use relative paths. System dependencies belong to the managed image, and restricted network execution is not supported, so neither is offered as an editable setting. + +The application owns these editable templates and sends their preparation fields inline through the public Session Environment contract; it does not create a second Core Template resource. Environment variable values are write-only in product responses: `null` preserves an existing named value, a string replaces it, and removing the name deletes it. Keep credentials in variables rather than scripts or file contents; Agent tools can read selected variables. Web, Feishu, schedules and MCP dispatch all freeze the selected template into the same pending creation request. Updates affect only new Sessions, and retries preserve the frozen preparation. **本次运行** shows package names, variable names, initial file paths and whether setup was configured, without variable values or script contents. + +A Session streams replies and tool activity, groups work steps, navigates user turns with Parsar’s original rail, supports follow-up/cancel and reopens durable history. Drafts and observed request/first-text timings survive reloads in the same browser tab. **文件** uploads inputs up to 5 MiB while idle and inserts their paths into the draft. Artifacts are paginated and downloadable after the live workspace is gone; ask the Agent to save deliverables under `./outputs`. Downloads remain binary. **对话** can rename or delete Sessions through public APIs; running work must be stopped before deletion. + +Sandbox list/rebuild/renew and external HTTP/A2A Agent executors remain blocked by the public API boundary. No administrator endpoint, shared Core table or fabricated substitute is used. + +## Tasks and projects + +**项目** stores shared context and an optional HTTPS repository URL. Context joins each new task dispatch; a repository URL does not provision credentials or clone automatically. **任务** offers list/board views, search, project/Agent filters, priorities, comments and run links. Tasks can be planned before execution resources exist. + +**分配执行** freezes the task brief, project context and Agent configuration before creating a public Session. **重新执行** creates another independent Session. **恢复执行** reuses the original frozen request and key after an uncertain response or restart. Pending creation, provisioning or active execution prevents overlapping dispatches. + +Comments are durable product records. **提及已分配的 Agent** starts a task with that comment when no suitable run exists, or sends it into the existing Agent Session. New automatic dispatch requires an environment template. Failed or uncertain delivery stays visible with **重试发送**, preserving the comment’s request identity. Plain comments do not execute work. + +Business states (**待办**, **进行中**, **受阻**, **待验收**, **已完成**) are separate from execution states. Dispatch sets **进行中**. The operator records acceptance after execution settles; **待验收** is optional and never gates engine permissions. Reopen completed tasks before dispatching again. Referenced resources and tasks with execution history cannot be deleted. + +## Recurring work + +**定时任务** uses a task template with either an interval of 5–525600 minutes or a five-field Cron expression and IANA time zone. **立即执行** also works on paused plans and retains a request ID across retries. Each occurrence freezes its template and creates an independent task/Session. Missed intervals coalesce after downtime; an unfinished previous occurrence skips the next interval. The Parsar server must remain running. + +Transient creation failures recover the same occurrence. Definite rejection pauses the plan for correction; a manually recovered task is adopted on resume. Five consecutive observed failed/cancelled automatic runs pause the plan; a completed run resets the count. Re-enabling resets it. Manual runs do not change failure accounting. **执行历史** links tasks/Sessions and shows observed outcomes and delivery errors. Pausing does not cancel admitted work; deleting a plan preserves its tasks and is blocked by uncertain creation. + +A plan can deliver to an enabled Feishu bot’s configured group. The worker posts an initial card, anchors one durable thread, and follows the same Session for results and replies. Changed/disabled routes surface a delivery error in history rather than silently redirecting output. + +## Personal connections + +**我的连接** manages the local operator's external account connections. The first supported service is Feishu. Its OAuth adapter supplies authorization, identity verification, refresh, revocation and connection fields; the common task lifecycle does not know CLI names, installation commands or vendor environment variables. In **Agent 模板**, **所需用户连接** declares the configured Feishu application and **授权范围**. The environment template owns tool installation, static settings and **连接变量映射** (`connection_env`). Each mapping binds one environment variable name to `access_token`, `client_id` or `region`; fixed and mapped variables cannot share a name, and refresh tokens/application secrets are not bindable. Agents with a connection requirement must select an environment with mappings, and environments with mappings require an Agent connection. The mapping is frozen with the task and appears as variable names in **本次运行**; no personal credential is stored in either template. The application's user scopes and refresh-token permission must be enabled in Feishu before consent can succeed. + +A new task freezes its Agent/environment configuration and waits before Core Session creation if authorization is missing. The Web shows the original verification link, QR code and user code; Feishu replies with the link and user code. A durable server poller completes the official OAuth device flow, verifies the returned user identity and resumes the original task without another prompt. Denied or expired flows remain visible and require **重新连接** (or `/connect` in Feishu); **取消任务** or `/cancel` cancels waiting work. Polling and creation retries survive process restarts and use the original execution idempotency key. Refresh tokens and application secrets remain in the restricted product store and are never sent to Core, the browser or the model. + +The local Web operator is a single principal, not a product login or a browser-specific account. Feishu tasks bind to the initiating sender's app-scoped open ID, and authorization must return that same identity. A new consent request retains already granted scopes for the same account/application, so switching Agents does not alternate between disjoint grants. Disconnect before requesting a smaller grant. An already verified connection is reused only for the same application and user; another group member cannot continue a task that uses the initiator's personal connection. Agent MCP clients cannot initiate Agents that require personal connections. Scheduled tasks use the local operator's connection; missing consent keeps their task waiting rather than starting unprivileged work. + +Each new Session receives a short-lived user access token through the public Environment `env` contract and the official CLI's token-only mode. Tokens are refreshed server-side before new execution when needed. The snapshot's expiry is shown in **本次运行**; the public API cannot replace credentials in an existing Environment, so follow-ups after expiry require a new Session. This also means a long-running Turn can encounter token expiry and must report it. **断开连接** immediately blocks further product execution with that grant and requests provider revocation; uncertain revocation is retained and retried, not reported as completed. It does not erase conversation history or artifacts. + +The Feishu adapter follows the [official CLI v1.0.98 device flow](https://github.com/larksuite/cli/blob/v1.0.98/internal/auth/device_flow.go), [token refresh](https://github.com/larksuite/cli/blob/v1.0.98/internal/auth/uat_client.go) and [revocation](https://github.com/larksuite/cli/blob/v1.0.98/internal/auth/revoke.go). Its lifecycle belongs to the product; no Core protocol or privileged endpoint is added. + +## Feishu workspace case + +Parsar is an OpenAgentCore application example; [the Feishu workspace](examples/feishu/) is a case assembled from ordinary Parsar templates. [environment.json](examples/feishu/environment.json) declares packages, static CLI identity settings, files and connection mappings, [setup.sh](examples/feishu/setup.sh) installs the checksum-verified official CLI binary, and [agent.json](examples/feishu/agent.json) defines Codex behavior. They introduce no server route, task executor or Core feature. + +Configure a model, import the official `lark-shared` Skill and desired business Skills, and configure the Feishu application through the existing resource pages. Substitute their IDs below; `--app` is the Parsar bot configuration ID, not the external app ID. The script creates an environment and an Agent using the same `/app/runtimes` and `/app/agents` operations as the UI and prints the Agent link: + +```bash +node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 --model MODEL_ID --app BOT_CONFIGURATION_ID --skills SHARED_SKILL_ID,DOMAIN_SKILL_ID +``` + +The default scope permits a read-only identity trial; use `--scopes` with the business permissions the Agent needs. In **Agent 模板**, start a conversation and authorize through **我的连接** when prompted. The first message can request `lark-cli api GET /open-apis/authen/v1/user_info --as user --json`. The official CLI's external-token mode disables `auth` management commands; the case instructions use the read-only API instead. Other tools can use the same connection lifecycle with their own variable mappings. + +The node must already resolve and reach the service endpoints. Templates run as the hosted environment user and cannot repair node DNS, edit root-owned hosts files or override network policy. In the current mx trial, personal consent, automatic task dispatch and reuse in a new Session are verified, but CLI business calls remain blocked by the installation's private Feishu gateway DNS/network configuration. This case is not qualified as a completed business workflow on that installation. + +## Feishu + +**飞书接入** supports multiple enterprise/personal bots, Feishu/Lark regions, configured groups and an optional direct-message route. Secrets stay in the restricted local database. The official SDK owns authentication, normalization and long connections; no inbound public callback is needed. + +1. **添加机器人** accepts an existing **App ID**/**App Secret**, or **扫码创建机器人** provisions an application through Feishu’s device flow. QR polling resumes after restart, is rate-limited, and never exposes the device code or resulting secret. A provisioned bot stays disabled until routes are configured. +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 group. Grant mention receipt and send-as-bot permissions. Direct messages, message resources, reactions and optional group history also require their respective scopes. Missing history/reaction permissions do not discard an accepted task. +3. For private chat, select **回复私聊的 Agent** and click **启用私聊**. New private-chat bindings follow the Agent’s default environment; existing overrides are preserved. Re-enabling a bot with group routes explicitly warns that those groups resume too. Shared private-chat routes label the selection as a default and retain users’ existing Agent choices. The page shows **已连接,可以去飞书聊天了** only after the bot connection is established. Send a task in the bot’s Feishu private chat; personal-account consent is requested when required by the Agent. **高级设置:群聊、应用凭证与更多选项** contains application credentials, allowed groups, optional project, Agent and environment policy (**使用 Agent 默认环境** or an explicitly selected environment template); enable **启用飞书机器人**. Group members and permitted bot DM users are the execution access boundary. **允许群成员切换 Agent(共享机器人)** enables `/list` and `/select ` for new tasks; selection is persisted per sender/chat. Default inheritance resolves when a new topic creates its task; existing topics retain their environment. Missing defaults and unsupported automatic environments reject explicitly, including `/select`; there is no fallback. +4. Mention the bot for a new group task. Follow-ups in the known thread do not require another mention. DMs continue the sender’s conversation; `/new` or `/reset` starts another. Other bots, unconfigured chats and `@all` are ignored. Existing threads retain their execution binding when routing changes. +5. Text, rich posts, images, files, audio/video resources, quoted message chains and merged forwards are normalized before dispatch. Context reads happen after durable ingress. Attachment limits are eight resources and 5 MiB total; inline images are further bounded by the public message-size limit. Follow-up files wait until the Session is idle. **新话题附带最近群消息(需要历史消息权限)** adds bounded recent context; quoted reads are capped at four levels. Attached bytes are released from delivery receipts after successful dispatch. +6. `/cancel`, `/review`, `/done`, `/reopen` and `/help` operate in the same thread. Cancellation takes priority over queued follow-ups and recovers an uncertain Session creation before cancelling it. `/answer `, or a plain reply when one business question is pending, submits a public function result. Execution permissions are always bypassed. -## Main flow +Accepted messages are committed to SQLite before the WebSocket ACK. Public request IDs and frozen payloads survive retries/restarts. The worker reconciles durable Turns/Items, edits the accepted card with progress and terminal status, sends a result card per terminal Turn, and follows results/questions with a text @ notification. Progress uses five-second polling; it is not a separate model/tool loop. Typing reactions are best effort. Complete history and Artifact downloads remain in the Web Session. Settled threads stop polling after a five-minute grace period and wake on new ingress. -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. Codex and Claude Code 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 and hosted MiniMax Code use Core's deployment connection; the creation dialog states that their Provider is only a catalog group. The example does not yet expose MiniMax Code's required token limits. -2. **Skills:** create a SKILL.md resource or upload a ZIP. Inspect versions, upload a new version, and choose the default. Core validates and stores bundles. -3. **MCP:** save anonymous HTTPS endpoints and bind them to Agents. Hosted Sessions install an inline environment Plugin; text-only Sessions use Core's service-origin MCP. Hosted HTTP MCP supports Claude Code and Codex, not MiniMax Code. Authentication, Vault management and OAuth setup are not included. -4. **Runtimes:** choose a Core-managed sandbox, text-only environment, or user machine. User machines select Linux/macOS/Windows and an existing absolute workspace path, plus an optional local capability directory. Each Session gets its own daemon installation and credential; choosing the same workspace path shares the host's files, so use distinct paths for isolated work. These are placement configurations, not online machine identities. Machine enrollment, fixed-node routing and self-hosted registration are omitted. -5. **Agents:** combine a model, harness, instructions, Skills and MCP services. Create, copy and edit configurations without allocating a runtime. -6. **Sessions:** open an Agent, choose a runtime and send the first message. Read streaming replies and tool activity, continue the conversation, cancel a running turn and reopen history. 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. +**消息记录与排查** expands **最近投递**, which shows the latest 100 receipts plus unresolved records. **重试此投递** retains the original identity. Busy file writes retry after ten seconds; other transient failures use bounded backoff. A failed inbound receipt blocks later messages in that thread. Feishu UUIDs deduplicate for one hour; uncertain outbound sends are not replayed beyond 55 minutes and require manual thread inspection. Disabling a bot pauses ingress/delivery without cancelling Core work. App/route changes never send saved messages through another identity. -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. +## Expose an Agent through MCP -## Ownership and storage +An Agent’s **对外提供** tab creates/replaces/revokes a local MCP token and displays client configuration. The token is shown once; only its hash and 90-day expiry are stored. The endpoint is `http://127.0.0.1:/mcp/agents/` and supports the maintained MCP SDK’s Streamable HTTP transport. It remains loopback-only and is separate from Core’s API. -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. +`ask_agent` uses the Agent’s default environment template to create a normal public Session, returning `run_id` and a conversation link. `get_run` retrieves status and output for a run started through that Agent endpoint; repeat while pending. If initial observation fails after dispatch, `ask_agent` still returns the run identifier with `status: "unknown"`; recover through `get_run` instead of repeating the task. Business questions are answered in Parsar. Tokens cannot read another Agent’s runs. Account-based sharing and public hosting are not included. -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}`. +## Ownership and local API -Use one server process and a dedicated Project for this local single-user example. The server rejects cross-origin writes and keeps the Project key out of browser responses. Session creation freezes a pending request in SQLite before calling Core, and uses the local Session ID as its idempotency key. An uncertain response can be recovered from the Agent's Session list, including after a server restart. Successful creation leaves only the Core Session reference and local title/Agent/runtime relationship; Core owns messages, turns and execution status. The browser consumes public SSE text deltas through a non-buffering proxy, reconnects after interruption, and reconciles with durable history to avoid duplicate final replies. Each send records request-return latency and first nonempty text-delta latency from browser request start. These independent observations remain in sessionStorage across reloads; missing observations are shown as a dash rather than inferred from history. Definite validation rejection removes the pending request so it can be corrected. Use one server process. Deleting a local resource is blocked while another local record references it; Session deletion/archival is outside this example. Skill deletion is a separate explicit Core operation. Earlier templates and instances become Agent configs on startup; they are never presented as actual Sessions. +Core’s Project key stays server-side. Successful Session 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, Credentials and Artifacts. SQLite stores product configuration, capability versions, task/comment records, schedule receipts, 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/bot secrets and pending OAuth credentials reside in it; the Core Project key does not. Builds/caches use `${OAC_DEV_HOME:-$HOME/.oac}`. -A few more implementation details: +| Product route family | Caller and credential | +| --- | --- | +| `/app/providers`, `/app/models`, `/app/agents`, `/app/runtimes`, `/app/mcps`, `/app/sessions` | Same-origin local UI; product workers reuse operations in-process | +| `/app/projects`, `/app/tasks`, task `/runs` and `/activity` | Same-origin local UI and authorized Feishu/schedule operations | +| `/app/connections`, `/app/connections/feishu/`, `/app/sessions//connection` | Same-origin local operator; in-process Feishu dispatch freezes sender identity; responses never contain tokens | +| `/app/schedules`, plan `/run` and `/history` | Same-origin local UI; worker keeps durable occurrence identities | +| `/app/feishu/bots`, `/app/feishu/provision`, `/app/feishu/deliveries` | Same-origin local UI; credentials never returned | +| `/app/capabilities`, capability `/client.js`, `/app/skills/registry/preview` | Same-origin local UI and installed trusted frontend bundles | +| `/app/mcp-oauth/`, `/oauth/callback` | Local OAuth flow; random state, PKCE and same-origin completion POST | +| `/app/agents//mcp-token` | Same-origin local UI; issued token shown only in creation response | +| `/mcp/agents/` | Local MCP client; Agent-scoped bearer token | +| `/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 | -- **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 (not yet exposed by that client); 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`. +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/worker tests, a production build and browser acceptance. Browser fixtures use ports 18180/18181 and isolated data under `~/.oac/tests/parsar-example/`. Install Chrome with `pnpm --filter @oac/parsar-example exec playwright install chrome` when needed. Tests exercise durable retries, duplicate ingress, Feishu normalization/media/context, QR state, schedule failures, OAuth PKCE/Vault persistence, MCP client authentication, frontend plugins 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 +148,4 @@ 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. - -The selected model Provider needs an HTTPS Base URL and API key. Its protocol is Responses for Codex and Anthropic for Claude Code. Core freezes and delivers the configuration to the enrolled executor. Local pending creation requests contain that configuration but browser Session responses omit the entire pending request. - -Self-hosted capability sources are local directories under the current Core API. Managed Skill bindings fail explicitly; configure local Skills/Plugins in the runtime's capability directory before connecting. Bound HTTP MCP services also fail explicitly for self-hosted Sessions; configure them in local Plugins instead. MiniMax Code self-hosted configuration is not included in this example because its required token limits are not exposed. - -The daemon runs with the starting user's permissions and adds no sandbox. Runtime home is separate per Session; stopping it preserves local files. Credential rotation and revocation remain Core console operations. Reuse the original installation directory when reconnecting. +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. diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md new file mode 100644 index 000000000..d9068cb8f --- /dev/null +++ b/example/parsar/README.zh.md @@ -0,0 +1,157 @@ +--- +source: example/parsar/README.md +source_hash: a5a198d59cb8cf06cca3ae0d26ef141ef6d8310948625d31417803e2806359c7 +--- + +# Parsar 工作空间 + +[English](README.md) + +Parsar 是可独立启动的可选产品示例,只通过 OpenAgentCore 公开 `/v1` Agents API 执行任务。密集列表、详情侧栏、导航、主题和会话控件沿用原 Parsar 的设计。Core 管执行;Parsar 管 Agent、项目、任务、能力、定时工作和飞书投递。执行始终使用 bypass。账号和细粒度权限暂缓;本轮 IM 接入仅支持飞书。 + +## 启动 {#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`。服务只监听本机回环地址。允许从其他站点的链接顶层打开首页,API 请求仍检查同源。本地隧道可使用不同端口;浏览器 Host 必须为 `localhost` 或 `127.0.0.1`,写请求的 Origin 必须与该 Host 完全一致,包括端口;每个数据目录只运行一个服务进程。 + +**设置**可修改工作空间名称和中英文界面。侧栏与 Agent 详情栏可调整宽度并记住选择,主题支持浅色、深色和跟随系统。**对话**、**运行记录**、**用量**、**审计日志**分别展示会话引用、公开 Turn、Token 用量和产品变更。日期筛选按浏览器本地日期计算;用量汇总覆盖所有匹配记录,而非仅当前页。缺失用量保持未知。模型可配置每百万输入/输出 Token 的美元价格,在会话创建时冻结;费用按该平价估算,不是服务商账单。 + +## Agent、能力与会话 {#agents-capabilities-and-sessions} + +**构建**包含三个入口:**Agent 模板**定义职责,**环境模板**定义工作环境的准备内容,**资源库**提供可复用资源。**接入与设置**包含**飞书接入**与**设置**;网页任务和会话从**工作**与**活动**进入。 + +Agent 详情默认打开**配置**。编辑器分为**系统提示词**、**模型与环境**、**工具与知识**和**账号与行为**。**主指令**定义 Agent 自身的职责;可选的**指令片段**按选择顺序追加,最终组成一份系统提示词。资源绑定使用统一的可搜索选择器,仅展示已选资源,并按需提供版本选择、**查看或编辑**和**移除绑定**操作;移除绑定不会删除资源。指令片段、Plugins 和知识合计最多绑定 8 个,选择器会限制超额添加。**管理连接**可打开账号管理,并保留 Agent 草稿。在编辑器内新建或编辑模型、Skill、MCP、环境或能力会保留 Agent 草稿,新建资源自动绑定。修改共享资源也会影响使用它的其他 Agent。草稿按 Agent 分别保存,可从**未保存的草稿**恢复,在当前浏览器标签页跨导航和刷新保留,直到保存或选择**放弃草稿**,不包含 Provider 密钥。环境列表展示准备内容摘要、用途说明和引用该模板的 Agent。 + +会话中的**本次运行**展示公开 Core Session/Environment 引用,以及首次尝试创建时记录的安全配置摘要。重试沿用摘要,之后修改模板不会改写历史。未固定版本的 Skill 标注创建时使用默认版本,不冒充已知的实际版本;旧会话未记录摘要会明确说明。不暴露完整请求或凭据。 + +飞书环境策略可选**使用 Agent 默认环境**或显式指定环境模板。继承在新话题创建任务时解析,已有话题保留绑定。默认环境缺失或不支持自动运行时明确拒绝,`/select` 同样验证,不自动回退。 + +**模型**按 Provider 管理模型 ID。可以读取 Provider 返回 OpenAI 格式的 `GET /v1/models` 列表,也可以手动输入。保存时事务提交 Provider 与所选模型。密钥留在服务端,编辑时留空保留旧值;发现模型仅发送该 Provider 的密钥,不跟随重定向。模型可停用而不删除历史引用。Codex、Claude Code 工作空间会话使用所选 HTTPS Provider 地址、密钥和对应 Harness 协议;MiniMax Code 使用 Core 配置的连接,创建表单会说明区别。不支持的组合会明确报错,具体边界见 [Harness 覆盖清单](../../contracts/agents-api/zh/index.md)。 + +**资源库**集中管理供 Agent 模板绑定的模型、Skills、MCP、Plugins、指令片段、知识和凭据: + +- **Skills**支持创建 SKILL.md、上传 ZIP、版本管理、默认版本、Agent 版本固定和有大小限制的文件预览。**浏览目录**搜索 skills.sh 索引,也可直接填写来源仓库和 Skill 名称。服务端使用维护中的固定版本 `skills@1.5.23` CLI 下载至临时目录,再走普通公开 Skill 上传。主机需要 `npx` 和网络连接;导入最多 32 MiB、1,000 个文件。Core 是 Skill 的权威存储。 +- **MCP**保留原服务目录和 HTTPS 端点配置。可绑定公开 Vault/Credential。使用 environment origin;不生成匿名兼容 Plugin。 +- **凭据**可创建 Vault、添加只写 Bearer 凭据、更换 Token 和删除凭据。**OAuth 授权**使用维护中的 MCP SDK 完成发现、动态客户端注册、签发方绑定和 PKCE。本机回调页的**保存凭据**操作通过公开 Vault API 存储访问令牌及可用的刷新配置;OAuth Token 不返回浏览器。服务商需支持所选 SDK 授权流程和回环重定向;需要重新同意时再次授权。 +- 提示词和知识能力有不可变编号版本、默认版本、固定版本和停用状态。提示词扩展冻结的 Agent 指令,知识版本成为初始工作空间文件。标准 Claude/Codex Plugin ZIP 映射为公开 Environment Plugin,编辑器展示 `.mcp.json` 声明的服务;stdio 所需可执行程序必须存在于运行环境。 +- Plugin 能力可附带最多 1 MiB 的预构建**前端扩展 JavaScript(可选)**。代码只留在 Parsar,不发给 Core。启用的前端扩展以本地页面权限执行,应仅安装可信代码。共享 `window.__PARSAR_PLUGIN_API__` 提供原 Parsar 的 React、`createContext` 和 `definePlugin`。插槽协议位于 [plugin-slots.ts](src/lib/plugin-slots.ts),支持 `workspace.main`、`workspace.content`、`layout.header.actions`、`layout.nav.bottom`、`conversation.header.actions`、`conversation.input.dock`、`conversation.tool-card` 和 `agent.settings.section`。工具卡匹配器通过 `item` 读取公开 `mcp_call`,不能依赖执行引擎原生数据;其他上下文为插槽传入的 Agent/Session 引用。停用能力会移除已注册 UI。跨工作空间市场发布依赖暂缓的账号/工作空间模型。 + +**Agent 模板**组合模型、Harness、指令、能力和可选默认环境模板。创建、复制、启停、编辑配置不会分配执行资源,修改作用于新会话。可选业务提问使用声明的 `ask_user` 函数,在会话和**收件箱**中显示,通过带稳定请求标识的公开函数结果事件提交回答。这用于补充信息,不是执行审批。 + +**环境模板**定义 npm 与 Python 软件包、Bash 初始化脚本、环境变量和初始文本文件。Parsar 固定使用 Core 托管工作环境,不提供部署位置、用户机器或沙箱供应商选择。每个新会话先安装文件与软件包,完成初始化后再启动 Agent;初始化失败会停止执行。软件包可固定版本;文件使用规范的 `/workspace/` 路径;脚本从工作目录执行,建议使用相对路径。系统依赖由托管镜像提供,限制网络的执行尚不受支持,因此不将这两项作为可编辑设置。 + +这些可编辑模板由应用拥有,准备字段通过公开 Session Environment 协议内联传入,不另外创建一份 Core Template 资源。产品响应中的环境变量值为只写:`null` 保留同名已有值,字符串替换值,移除名称则删除变量。密钥应放入变量,不写入脚本或文件内容;Agent 工具可以读取所选变量。网页、飞书、定时任务和 MCP 派发都将选定模板冻结到同一套待创建请求中;模板更新仅影响新会话,重试保留原准备配置。**本次运行**展示软件包名称、变量名称、初始文件路径及是否配置脚本,不包含变量值或脚本正文。 + +会话支持流式回复、工具步骤折叠、原 Parsar 用户轮次导航、追问、取消和持久化历史回读。草稿及实际观测的请求/首文本耗时保存在当前浏览器标签页。**文件**在空闲时上传不超过 5 MiB 的输入,将路径加入草稿。Artifact 分页下载不依赖当前工作空间在线;可要求 Agent 将产物写入 `./outputs`。下载保持二进制。**对话**通过公开 API 重命名和删除会话;执行中的会话应先停止。 + +沙箱列表/重建/续期以及外部 HTTP/A2A Agent 执行器受公开 API 边界阻塞,暂不实现;不使用管理员接口、共享 Core 表或伪造替代语义。 + +## 任务与项目 {#tasks-and-projects} + +**项目**保存公共上下文和可选 HTTPS 仓库 URL。上下文进入每次新派发;仓库 URL 不会自动配备密钥或克隆。**任务**提供列表/看板、搜索、项目/Agent 筛选、优先级、评论和执行链接。可先规划,再补执行配置。 + +**分配执行**在创建公开 Session 前冻结任务说明、项目上下文和 Agent 配置。**重新执行**创建独立会话。**恢复执行**在响应丢失或重启后复用原请求及标识;未确认创建、准备中或执行中的会话会阻止重叠派发。 + +评论是持久化产品记录。勾选**提及已分配的 Agent**后,若没有合适执行,则带着评论启动任务;已有执行则继续同一 Agent 会话。自动启动使用选定的环境模板。失败或结果未确认的评论保留**重试发送**,重用原请求标识;普通评论不执行任务。 + +业务状态**待办**、**进行中**、**受阻**、**待验收**、**已完成**独立于执行状态。派发进入**进行中**,执行结束后由操作者记录验收。**待验收**可选,不限制引擎权限。完成的任务需重新打开后才能再派发;有执行历史的任务和仍被引用的资源不能删除。 + +## 定时工作 {#recurring-work} + +**定时任务**基于任务模板,支持 5–525600 分钟间隔或五段 Cron 与 IANA 时区。**立即执行**适用于暂停的计划,重试保留请求标识。每次冻结模板并创建独立任务/Session。停机期间错过的间隔合并为一次,上轮未结束则跳过下一次;Parsar 服务需要持续运行。 + +创建暂时失败时恢复同一次执行。确定拒绝会暂停计划,修改后可启用,已手动恢复的任务会被复用。连续五次自动执行失败或取消后暂停,成功执行清零,重新启用也清零;手动执行不计入连续失败。**执行历史**链接任务和会话,并展示观测结果与投递错误。暂停不取消已提交工作;删除计划保留任务,但未确认创建会阻止删除。 + +计划可向已启用飞书机器人的允许群投递。后台发送初始卡片并建立持久化话题,后续结果与回复继续同一会话;机器人或路由变更导致的投递错误会显示在历史中,不会悄悄转发到其他目标。 + +## 个人连接 {#personal-connections} + +**我的连接**管理本地操作人的外部账号,目前支持飞书。OAuth 适配器负责授权、身份核验、续期、撤销和连接字段;通用任务生命周期不认识 CLI 名称、安装命令或厂商环境变量。**Agent 模板**的**所需用户连接**声明应用和**授权范围**。环境模板负责软件安装、固定配置和**连接变量映射**(`connection_env`):每个变量绑定 `access_token`、`client_id` 或 `region`,不能与固定环境变量重名,也不能绑定刷新令牌或应用密钥。需要连接的 Agent 必须选用配置了映射的环境,带映射的环境也必须搭配账号连接。映射随任务冻结,变量名展示在**本次运行**;个人凭据不保存在任何模板中。飞书应用必须先开通相应用户权限及刷新令牌权限。 + +新任务会冻结 Agent 和环境配置;缺少授权时在创建 Core Session 之前等待。网页展示原始授权链接、二维码和验证码,飞书消息提供链接及验证码。服务端持久化轮询官方 OAuth 设备授权流程,核验账号后自动继续原任务。拒绝或过期需要点击**重新连接**(飞书中使用 `/connect`);**取消任务**或 `/cancel` 取消等待中的工作。轮询和创建重试支持进程重启,并保留原执行幂等键。刷新令牌和应用密钥只保存在受限产品存储,不发送给 Core、浏览器或模型。 + +本地网页操作人是单一身份,不是产品账号或浏览器独立账号。飞书任务绑定发起人的应用内 open ID,授权结果必须是同一人。同一账号与应用的新授权请求会保留已有权限,避免在不同 Agent 之间切换时反复覆盖授权;需要缩小权限时先断开连接。已核验连接仅按同一应用和账号复用;群内其他成员不能继续使用发起人的个人连接。Agent MCP 客户端不能启动需要个人连接的 Agent。定时任务使用本地操作人的连接,缺少授权时保留任务等待。 + +每个新 Session 通过公开 Environment 的 `env` 合同接收短期访问令牌,服务端在必要时先续期。**本次运行**显示凭据快照有效期;公开 API 无法替换已有 Environment 的凭据,因此过期后必须新开会话,长时间运行的 Turn 也可能遇到过期。**断开连接**立即阻止产品继续使用该授权执行,并请求服务端撤销;未确认的撤销持久化重试,不宣称已完成。历史和产物仍保留。 + +飞书适配器遵循[官方 CLI v1.0.98 设备授权流程](https://github.com/larksuite/cli/blob/v1.0.98/internal/auth/device_flow.go)、[续期](https://github.com/larksuite/cli/blob/v1.0.98/internal/auth/uat_client.go)和[撤销](https://github.com/larksuite/cli/blob/v1.0.98/internal/auth/revoke.go)。这些生命周期属于产品,不增加 Core 协议或特权端点。 + +## 飞书工作环境案例 {#feishu-workspace-case} + +Parsar 是 OpenAgentCore 的应用示例;[飞书工作环境](examples/feishu/)是用普通 Parsar 模板组合的案例。[environment.json](examples/feishu/environment.json)声明软件包、CLI 固定身份配置、文件及连接映射,[setup.sh](examples/feishu/setup.sh)安装并校验官方 CLI 二进制,[agent.json](examples/feishu/agent.json)定义 Codex 的工作方式。案例不增加服务端路由、执行器或 Core 功能。 + +通过现有资源页面配置模型、导入官方 `lark-shared` 和业务 Skill,并配置飞书应用。把对应 ID 填入下列命令;`--app` 是 Parsar 中机器人配置的 ID,不是外部 App ID。脚本使用与网页相同的 `/app/runtimes` 和 `/app/agents` 操作创建环境与 Agent,并打印 Agent 链接: + +```bash +node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 --model MODEL_ID --app BOT_CONFIGURATION_ID --skills SHARED_SKILL_ID,DOMAIN_SKILL_ID +``` + +默认权限用于只读身份试用;用 `--scopes` 指定 Agent 需要的业务权限。在**Agent 模板**中开启对话,按提示通过**我的连接**授权。第一条消息可以要求执行 `lark-cli api GET /open-apis/authen/v1/user_info --as user --json`。官方 CLI 的外部令牌模式禁用 `auth` 管理命令,因此案例使用只读身份 API。其他工具可以通过自己的变量映射使用同一连接生命周期。 + +运行节点需要预先能够解析并访问服务端点。模板以托管环境用户执行,无法修复节点 DNS、修改 root 所有的 hosts 文件或覆盖网络策略。目前 mx 试用已经验证个人授权、自动启动任务和新 Session 复用,但 CLI 业务调用仍被安装环境的飞书内部网关 DNS/网络配置阻塞;不能据此宣称该环境上的业务闭环已经通过。 + +## 飞书 {#feishu} + +**飞书接入**支持多个企业/个人机器人、飞书/Lark 区域、配置的群及可选私聊路由。密钥保存在受限本地数据库;官方 SDK 负责鉴权、标准化和长连接,无需公网入站回调。 + +1. **添加机器人**可填写已有 **App ID**/**App Secret**,或通过**扫码创建机器人**的设备流程创建应用。轮询支持重启恢复与节流,不公开设备码及最终密钥。创建后默认停用,需配置路由再启用。 +2. 在开发者后台启用机器人、长连接事件投递与 `im.message.receive_v1`,发布应用并拉入群。授权提及消息接收和以机器人身份发送;私聊、消息资源、表情反馈及可选群历史另需对应权限。缺少历史或表情权限不会丢弃已接收任务。 +3. 私聊时,选择**回复私聊的 Agent**,点击**启用私聊**。新建私聊接入跟随 Agent 默认环境,已有环境覆盖设置保留。重新启用带有群聊配置的机器人时,会明确提示群聊也将恢复;共享私聊接入明确标注为默认 Agent,并保留用户已有选择。实际连接建立后才显示**已连接,可以去飞书聊天了**;在飞书私聊机器人发送任务,需要个人账号时会引导授权。**高级设置:群聊、应用凭证与更多选项**保留应用凭证、群聊、项目、环境覆盖、共享 Agent 切换及启停配置。群成员和可私聊机器人的用户构成执行访问边界。**允许群成员切换 Agent(共享机器人)**开启后,可通过 `/list`、`/select <名称或 ID>` 选择新任务的 Agent;偏好按发送者和聊天保存。缺少默认环境或不支持自动运行的环境会明确拒绝,不会自动回退。修改配置仅影响新任务,已有话题保留执行绑定。 +4. 群中新任务需提及机器人,已识别话题内的追问不再需要提及。私聊延续该发送者的会话;`/new`、`/reset` 新开会话。其他机器人、未配置聊天和 `@all` 不触发工作。路由变化不改变已有话题的执行绑定。 +5. 支持文本、富文本、图片、文件、音视频资源、引用消息链和合并转发。上下文读取发生在持久化接收之后。每条最多八个附件、合计 5 MiB,内联图片另受公开消息体大小限制;追问文件等待会话空闲再写入。**新话题附带最近群消息(需要历史消息权限)**添加有数量上限的群上下文;引用链最多四层。成功派发后清理投递记录中的附件字节。 +6. 同一话题可使用 `/cancel`、`/review`、`/done`、`/reopen`、`/help`。取消优先于排队追问,创建未确认时先恢复实际 Session 再取消。`/answer <回答>`,或仅有一个业务问题时直接回复,会提交公开函数结果。执行权限始终 bypass。 + +消息写入 SQLite 后才确认 WebSocket ACK。公开请求标识及冻结载荷可跨重试/重启恢复。后台核对持久化 Turns/Items,更新接收卡片上的进度和最终状态,为每个终结 Turn 发送结果卡片,并在结果/提问后发送文本 @ 提醒。进度每五秒轮询,不运行另一套模型/工具循环;输入中表情为尽力提供。完整历史和 Artifact 下载保留在 Web。空闲话题经过五分钟宽限后停止轮询,新消息会唤醒。 + +**消息记录与排查**展开后,**最近投递**显示最新 100 条及所有未解决记录。**重试此投递**复用原标识。繁忙时文件写入十秒后重试,其他暂时错误按有上限的退避重试;失败消息阻止同话题后续消息。飞书 UUID 去重窗口为一小时,未确认回传超过 55 分钟后不自动重放,需要人工检查原话题。停用机器人会暂停接收和回传,不取消 Core 工作;应用或路由变化不会换身份发送旧消息。 + +## 通过 MCP 提供 Agent {#expose-an-agent-through-mcp} + +Agent 的**对外提供**标签页可创建、替换、撤销本机 MCP Token,并展示客户端配置。密钥仅显示一次,数据库只保存哈希和 90 天有效期。端点为 `http://127.0.0.1:/mcp/agents/`,使用维护中的 MCP SDK Streamable HTTP,仅在本机开放,独立于 Core API。 + +`ask_agent` 使用 Agent 默认的环境模板创建普通公开 Session,返回 `run_id` 和会话链接。`get_run` 获取通过该 Agent 端点启动的任务状态和输出,排队/运行时可继续查询。若执行已提交但首次读取状态失败,`ask_agent` 仍返回执行编号和 `status: "unknown"`,应通过 `get_run` 恢复查询,不要重复发起任务。业务问题在 Parsar 回答;Token 不能读取其他 Agent 的任务。不包含基于账号的共享或公网托管。 + +## 数据归属与本地 API {#ownership-and-local-api} + +Core Project key 留在服务端。创建成功后只保留 Core 引用和产品元数据;未确认时保留冻结的公开请求。执行、Items、Turns、Skills、Vaults、Credentials、Artifacts 以 Core 为权威。SQLite 保存产品配置、能力版本、任务/评论、定时回执、飞书投递及临时 OAuth/注册状态。默认目录 `~/.oac/data/parsar-example/`,`OAC_EXAMPLE_DATA_DIR` 可设为其他绝对路径;Core 来源和 Project key 指纹决定文件。停服后备份。Provider/机器人密钥及待完成 OAuth 凭据在数据库中,Core Project key 不在。构建与缓存使用 `${OAC_DEV_HOME:-$HOME/.oac}`。 + +| 产品路由组 | 调用方与凭据 | +| --- | --- | +| `/app/providers`、`/app/models`、`/app/agents`、`/app/runtimes`、`/app/mcps`、`/app/sessions` | 同源本地 UI;产品后台在进程内复用操作 | +| `/app/projects`、`/app/tasks`、任务 `/runs` 和 `/activity` | 同源本地 UI 和已授权飞书/定时操作 | +| `/app/schedules`、计划 `/run` 和 `/history` | 同源本地 UI;后台保留稳定执行标识 | +| `/app/feishu/bots`、`/app/feishu/provision`、`/app/feishu/deliveries` | 同源本地 UI;不返回服务端密钥 | +| `/app/capabilities`、能力 `/client.js`、`/app/skills/registry/preview` | 同源本地 UI 和安装的可信前端扩展 | +| `/app/mcp-oauth/`、`/oauth/callback` | 本地 OAuth;随机 state、PKCE、同源完成 POST | +| `/app/agents//mcp-token` | 同源本地 UI;新 Token 仅创建响应可见 | +| `/mcp/agents/` | 本机 MCP 客户端;Agent 范围 Bearer Token | +| `/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/` 独立数据。需要时运行 `pnpm --filter @oac/parsar-example exec playwright install chrome` 安装 Chrome。测试覆盖稳定重试、重复接收、飞书标准化/附件/上下文、二维码状态、计划失败、OAuth PKCE/Vault、MCP 客户端鉴权、前端扩展与 UI 流程。测试夹具不能证明真实模型或飞书服务可用。 + +可选真实浏览器验收需要先启动连接独立真实 Project 的示例: + +```sh +OAC_EXAMPLE_LIVE_URL=http://127.0.0.1:18180 \ +OAC_EXAMPLE_LIVE_MODEL='' \ +pnpm --filter @oac/parsar-example test:live +``` + +它会执行真实模型并保留资源以便检查。飞书真实验收需要指定测试机器人、相应权限及允许测试的聊天;仅保存配置不等于投递验证通过。 diff --git a/example/parsar/examples/feishu/agent.json b/example/parsar/examples/feishu/agent.json new file mode 100644 index 000000000..89b45e418 --- /dev/null +++ b/example/parsar/examples/feishu/agent.json @@ -0,0 +1,8 @@ +{ + "name": "飞书工作助手", + "harness": "codex", + "instructions": "你是飞书工作助手。使用环境中已安装的官方 lark-cli 完成用户明确委托的工作。先读取 lark-shared 和相应业务 Skill,并根据 CLI --help 确认命令;不得自行安装或修复工具。\n\n个人凭据由账号连接提供,使用 --as user。外部令牌模式禁止 auth 管理命令;不要运行 config init、auth login/status/scopes。whoami 的本地 tokenStatus 不代表外部令牌是否有效。身份核验使用 lark-cli api GET /open-apis/authen/v1/user_info --as user --json,只报告账号姓名及验证结果,不打印密钥、令牌或环境变量。已授权范围以我的连接中的记录为准。\n\n任务直接执行,不请求引擎审批。缺少目标或业务信息时再询问;不得发送无关消息或修改无关资源。遵守工具明确的高风险确认要求。缺少权限时报告具体 scope,引导在 Agent 模板更新所需权限、重新授权后开启新会话;凭据过期时提示开启新会话。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..906a4d4a0 --- /dev/null +++ b/example/parsar/examples/feishu/configure.mjs @@ -0,0 +1,72 @@ +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", "--app", "--skills", "--scopes"].includes(name) || + !value || + args.has(name) + ) + throw new Error( + "Usage: node configure.mjs --url URL --model MODEL_ID --app BOT_ID --skills SKILL_ID[,SKILL_ID] [--scopes SCOPES]", + ); + args.set(name, value); +} +for (const name of ["--url", "--model", "--app", "--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; +agent.connection_requirement = { + service: "feishu", + bot_id: args.get("--app"), + scopes: args.get("--scopes") || "contact:user.base:readonly", +}; +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; +} +console.log( + JSON.stringify({ + agent_id: agentId, + environment_id: environmentId, + agent_url: `${base.origin}/#/agents/${agentId}?view=full`, + }), +); diff --git a/example/parsar/examples/feishu/environment.json b/example/parsar/examples/feishu/environment.json new file mode 100644 index 000000000..a8294a4ec --- /dev/null +++ b/example/parsar/examples/feishu/environment.json @@ -0,0 +1,21 @@ +{ + "name": "飞书工作环境", + "description": "安装官方 lark-cli;通过模板声明的变量读取用户连接。需要运行节点能够访问飞书开放平台。", + "environment": "openai_hosted", + "packages": { "npm": ["@larksuite/cli@1.0.98"], "python": [] }, + "env": { + "npm_config_ignore_scripts": "true", + "LARKSUITE_CLI_DEFAULT_AS": "user", + "LARKSUITE_CLI_STRICT_MODE": "user", + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1" + }, + "connection_env": { + "LARKSUITE_CLI_USER_ACCESS_TOKEN": "access_token", + "LARKSUITE_CLI_APP_ID": "client_id", + "LARKSUITE_CLI_BRAND": "region" + }, + "files": [ + { "path": "/workspace/WORKSPACE.md", "content": "# 飞书工作环境\n\n官方 lark-cli 已在 Agent 启动前安装。个人凭据由 Parsar 根据环境模板注入,禁止输出令牌。外部凭据模式不支持 auth 管理命令;读取 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..b7d4373b9 --- /dev/null +++ b/example/parsar/examples/feishu/setup.sh @@ -0,0 +1,50 @@ +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://github.com/larksuite/cli/releases/download/v${version}/${asset}`, + `https://registry.npmmirror.com/-/binary/lark-cli/v${version}/${asset}`, + ]; + let bytes; + for (const url of urls) { + try { + const response = await fetch(url, {signal: AbortSignal.timeout(60000)}); + 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 +mkdir -p outputs +printf 'lark-cli prepared before Agent startup\n' > outputs/environment-ready.txt diff --git a/example/parsar/package.json b/example/parsar/package.json index 45500e2f6..c72209f11 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/work.test.mjs tests/connections.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", @@ -21,9 +23,12 @@ "@tanstack/react-query": "^5.103.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cron-parser": "^5.10.2", "fflate": "^0.8.3", + "i18next": "^26.4.4", "lucide-react": "^1.22.0", "motion": "^13.4.3", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-markdown": "^10.1.0", @@ -31,17 +36,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..c8aec54f0 100644 --- a/example/parsar/playwright.config.ts +++ b/example/parsar/playwright.config.ts @@ -30,8 +30,8 @@ export default defineConfig({ command: "node server.mjs --dev", url: "http://127.0.0.1:18180", env: { - OAC_EXAMPLE_CORE_URL: "http://127.0.0.1:18181", - OAC_EXAMPLE_PROJECT_KEY: "fixture-project-key", + OPENAI_BASE_URL: "http://127.0.0.1:18181/v1", + OPENAI_API_KEY: "fixture-project-key", OAC_EXAMPLE_PORT: "18180", OAC_EXAMPLE_DATA_DIR: join( homedir(), diff --git a/example/parsar/pnpm-lock.yaml b/example/parsar/pnpm-lock.yaml index eabc904dc..7f538c1d7 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 + '@modelcontextprotocol/sdk': + specifier: ^1.32.1 + version: 1.32.1(zod@4.6.7) '@oac/agents-client': specifier: workspace:* version: link:../../packages/agents-client @@ -32,15 +38,24 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cron-parser: + specifier: ^5.10.2 + version: 5.10.2 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.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.3.0) react: specifier: ^19.2.7 version: 19.3.0 @@ -91,6 +106,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 +285,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 +307,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 +328,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 +1144,25 @@ 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==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1085,9 +1171,31 @@ 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'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1114,12 +1222,48 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + 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'} + + cron-parser@5.10.2: + resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==} + engines: {node: '>=18'} + + 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'} @@ -1140,6 +1284,14 @@ packages: 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 +1306,48 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + 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==} + + 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 +1358,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 +1405,27 @@ 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'} + + 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 +1437,96 @@ 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-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==} @@ -1246,10 +1543,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 +1710,18 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + 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==} @@ -1406,12 +1730,20 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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 +1789,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 +1881,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 +1922,43 @@ 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==} + 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-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 +1969,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'} @@ -1604,6 +1994,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.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + 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 +2080,60 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + 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'} + + 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,6 +2147,10 @@ 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==} @@ -1719,6 +2188,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 +2201,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 +2231,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 +2258,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 +2352,44 @@ packages: jsdom: optional: true + 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 + 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 + yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} hasBin: true + 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 +2490,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 +2513,69 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@larksuiteoapi/node-sdk@1.75.0': + dependencies: + axios: 1.20.0 + 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 +3227,74 @@ 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 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 assertion-error@2.0.1: {} + asynckit@0.4.0: {} + + axios@1.20.0: + dependencies: + follow-redirects: 1.16.1 + 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 + ccount@2.0.1: {} chai@6.2.2: {} @@ -2679,10 +3313,39 @@ snapshots: clsx@2.1.1: {} + 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 + + cron-parser@5.10.2: + dependencies: + luxon: 3.7.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: {} @@ -2695,6 +3358,10 @@ snapshots: 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 +3372,38 @@ snapshots: dependencies: dequal: 2.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: {} + + 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 +3432,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 +3444,92 @@ 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 + + follow-redirects@1.16.1: {} + + 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 +3539,47 @@ 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-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 +3604,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: @@ -2816,8 +3652,18 @@ snapshots: 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,18 +3762,30 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.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): dependencies: react: 19.3.0 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 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 +3939,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 +4134,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 +4164,24 @@ 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 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -3302,12 +4192,20 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parseurl@1.3.3: {} + + 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: @@ -3327,6 +4225,45 @@ 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.react@4.2.0(react@19.3.0): + dependencies: + react: 19.3.0 + + 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 +4350,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + rolldown@1.2.9: dependencies: '@oxc-project/types': 0.150.0 @@ -3434,8 +4373,81 @@ 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 + + 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,6 +4456,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} stringify-entities@4.0.4: @@ -3476,12 +4490,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 +4541,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 +4560,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -3587,12 +4613,26 @@ snapshots: transitivePeerDependencies: - msw + 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 + wrappy@1.0.2: {} + + ws@8.22.0: {} + yaml@2.8.0: optional: true + 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..554abf530 100644 --- a/example/parsar/server.mjs +++ b/example/parsar/server.mjs @@ -1,3 +1,5 @@ +import { guardConnectionInput } from "./server/connections.mjs"; +import { agentMCPHandler } from "./server/agent-mcp.mjs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { createServer } from "node:http"; @@ -7,9 +9,19 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { buildDirectory } from "./paths.mjs"; import { openStore, dataPath, AppError } from "./server/store.mjs"; import { productAPI } from "./server/product.mjs"; +import { scheduleWorker } from "./server/schedules.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 +36,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 +59,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,12 +73,12 @@ 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."); @@ -77,48 +93,71 @@ 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."); + 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://127.0.0.1:${config.port}`, + )); + const agentMCP = + store && agentMCPHandler(store, coreClient(config, fetchImpl), product); 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 = /^(?:127\.0\.0\.1|localhost)(?::([1-9]\d{0,4}))?$/.exec( + req.headers.host || "", + ); if ( - !hosts.includes(req.headers.host) || + !host || + (host[1] && Number(host[1]) > 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, @@ -127,6 +166,44 @@ export function createHandler( ); } const url = new URL(req.url, `http://${req.headers.host}`); + const mcpMatch = url.pathname.match(/^\/mcp\/agents\/([a-f0-9-]{36})$/); + if (mcpMatch && agentMCP) { + try { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 1024 * 1024) return fail(res, 413, "Request too large."); + chunks.push(chunk); + } + const body = size ? JSON.parse(Buffer.concat(chunks)) : undefined; + await agentMCP(mcpMatch[1], req, res, body); + } catch { + if (!res.headersSent) fail(res, 400, "Invalid MCP request."); + else res.destroy(); + } + return; + } + + const clientMatch = url.pathname.match( + /^\/app\/capabilities\/([a-f0-9-]{36})\/client.js$/, + ); + if (clientMatch && req.method === "GET" && store) { + const capability = store.get("capabilities", clientMatch[1]); + const bundle = + capability && + !capability.deprecated && + capability.versions.find( + (row) => row.version === capability.default_version, + )?.client_bundle; + if (!bundle) return fail(res, 404, "Plugin client not found."); + res.writeHead(200, { + "Content-Type": "text/javascript; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(bundle); + return; + } if (url.pathname.startsWith("/app/")) { if ( req.method !== "GET" && @@ -143,7 +220,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 = {}; @@ -217,6 +300,23 @@ export function createHandler( return fail(res, 413, "Request too large."); chunks.push(chunk); } + if ( + store && + req.method === "POST" && + url.pathname.endsWith("/events") + ) { + let input; + try { + input = JSON.parse(Buffer.concat(chunks)); + } catch { + return fail(res, 400, "Invalid JSON."); + } + try { + guardConnectionInput(store, url.pathname, input); + } catch (error) { + return fail(res, error.status || 502, error.message); + } + } const headers = { Authorization: `Bearer ${config.key}`, "OpenAI-Beta": "agents=v1", @@ -266,6 +366,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 +443,53 @@ if ( }) : undefined; const store = openStore(dataPath(config)); + const rawCore = coreClient(config); + const core = (path, method, body, idempotencyKey) => { + if (method === "POST") guardConnectionInput(store, path, body); + return rawCore(path, method, body, idempotencyKey); + }; + const productHandler = productAPI( + store, + core, + fetch, + `http://127.0.0.1:${config.port}`, + ); + const feishu = feishuWorkers( + store, + core, + productHandler, + createFeishuTransport, + ); + let connectionTimer, connectionRunning; + const pollConnections = () => { + connectionRunning = productHandler + .tickConnections() + .catch(() => {}) + .finally(() => { + if (connectionTimer !== null) + connectionTimer = setTimeout(pollConnections, 2000); + }); + }; + pollConnections(); + feishu.start(); + const schedules = scheduleWorker(store, productHandler, core); + schedules.start(); 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`); }); for (const signal of ["SIGINT", "SIGTERM"]) - process.on(signal, () => { + process.on(signal, async () => { + clearTimeout(connectionTimer); + connectionTimer = null; + await Promise.all([feishu.stop(), schedules.stop(), connectionRunning]); server.close(() => store.close()); void vite?.close(); }); diff --git a/example/parsar/server/agent-mcp.mjs b/example/parsar/server/agent-mcp.mjs new file mode 100644 index 000000000..a1563efa3 --- /dev/null +++ b/example/parsar/server/agent-mcp.mjs @@ -0,0 +1,231 @@ +import { + createHash, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { AppError, uuid, text, transientError } from "./store.mjs"; +import { requireAutomaticRuntime } from "./sessions.mjs"; +const digest = (token) => createHash("sha256").update(token).digest(); +const publicToken = (row) => + row ? { created_at: row.created_at, expires_at: row.expires_at } : null; + +export function agentMCPTokenAPI(store) { + return (method, path) => { + const match = path.match(/^\/app\/agents\/([a-f0-9-]{36})\/mcp-token$/); + if (!match || !uuid.test(match[1]) || !store.get("agents", match[1])) + throw new AppError(404, "Agent not found."); + const id = match[1]; + if (method === "GET") + return { credential: publicToken(store.get("agent_mcp_tokens", id)) }; + if (method === "DELETE") { + store.remove("agent_mcp_tokens", id); + return {}; + } + if (method !== "POST") throw new AppError(405, "Method not allowed."); + const token = randomBytes(32).toString("base64url"); + const row = { + id, + hash: digest(token).toString("hex"), + created_at: Date.now(), + expires_at: Date.now() + 90 * 86400000, + }; + store.put("agent_mcp_tokens", row); + return { credential: publicToken(row), token }; + }; +} + +export function agentMCPHandler(store, core, product) { + return async (id, req, res, body) => { + const stored = store.get("agent_mcp_tokens", id); + const token = req.headers.authorization?.match( + /^Bearer ([A-Za-z0-9_-]+)$/, + )?.[1]; + const agent = store.get("agents", id); + if ( + !agent || + agent.enabled === false || + !stored || + stored.expires_at <= Date.now() || + !token || + !timingSafeEqual(digest(token), Buffer.from(stored.hash, "hex")) + ) { + res.writeHead(401, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }); + res.end( + JSON.stringify({ error: "Invalid or expired Agent MCP credential." }), + ); + return; + } + const reference = (record) => ({ + run_id: record.id, + conversation_id: record.id, + conversation_path: `/#/sessions/${record.id}`, + }); + const output = async (record) => { + const base = reference(record); + if (!record.core_session_id) return { ...base, status: "queued" }; + const prefix = `/v1/agents/sessions/${record.core_session_id}`; + const [session, turns] = await Promise.all([ + core(prefix, "GET"), + core(`${prefix}/turns?order=desc&limit=1`, "GET"), + ]); + const turn = turns.data[0]; + let after, + result = ""; + do { + const page = await core( + `${prefix}/items?order=asc&limit=100${after ? `&after=${encodeURIComponent(after)}` : ""}`, + "GET", + ); + for (const item of page.data) + if (item.type === "message" && item.role === "assistant") + result += + (item.content || []) + .filter((part) => part.type === "output_text") + .map((part) => part.text) + .join("\n") + "\n"; + if (page.has_more && (!page.last_id || page.last_id === after)) + throw new Error("Invalid history cursor"); + after = page.has_more ? page.last_id : undefined; + } while (after); + return { + ...base, + status: + session.status === "idle" && turn ? turn.status : session.status, + result: result.trim(), + ...(session.error ? { reason: session.error.message } : {}), + }; + }; + const server = new Server( + { name: `Parsar: ${agent.name}`, version: "1.0.0" }, + { + capabilities: { tools: {} }, + instructions: + "Call ask_agent to start a task, then get_run to retrieve its result. Answer business questions in Parsar. Execution uses bypass mode.", + }, + ); + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "ask_agent", + description: `Ask ${agent.name} to perform a task using its configured model, runtime and capabilities.`, + inputSchema: { + type: "object", + properties: { + prompt: { type: "string", minLength: 1, maxLength: 32000 }, + }, + required: ["prompt"], + additionalProperties: false, + }, + }, + { + name: "get_run", + description: + "Retrieve the current status and answer of a task started through this Agent MCP, recovering pending submission if needed. Call again while queued or running.", + inputSchema: { + type: "object", + properties: { run_id: { type: "string", format: "uuid" } }, + required: ["run_id"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + }, + ], + })); + server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + let record; + if (request.params.name === "ask_agent") { + const prompt = text( + request.params.arguments?.prompt, + "Prompt", + 32000, + true, + ).trim(); + if (!prompt) throw new AppError(400, "Prompt cannot be empty."); + if (store.get("agents", id)?.connection_requirement) + throw new AppError( + 400, + "此 Agent 需要个人连接,请从网页或飞书本人入口发起任务。", + ); + requireAutomaticRuntime(store, agent.runtime_id); + const runId = randomUUID(); + try { + record = await product("PUT", `/app/sessions/${runId}`, { + agent_id: id, + runtime_id: agent.runtime_id, + name: `MCP · ${agent.name}`.slice(0, 80), + input: prompt, + }); + } catch (error) { + record = store.get("sessions", runId); + if (!record || !transientError(error)) throw error; + } + store.put("agent_mcp_runs", { id: runId, agent_id: id }); + } else if (request.params.name === "get_run") { + const runId = request.params.arguments?.run_id; + if ( + !uuid.test(runId || "") || + store.get("agent_mcp_runs", runId)?.agent_id !== id + ) + throw new AppError(404, "Run not found."); + record = store.get("sessions", runId); + if (!record || record.deleted_at) + throw new AppError(404, "Run not found."); + if (!record.core_session_id) + record = await product("PUT", `/app/sessions/${runId}`, {}); + } else throw new AppError(404, "Unknown tool."); + let value; + try { + value = await output(record); + } catch (error) { + if (request.params.name !== "ask_agent") throw error; + // Dispatch is already durable. Preserve the handle even when its + // immediate status read fails, so the client can recover via get_run. + value = { + ...reference(record), + status: "unknown", + reason: + "Run submitted; status is temporarily unavailable. Call get_run with this run_id.", + }; + } + return { + content: [{ type: "text", text: JSON.stringify(value) }], + structuredContent: value, + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: + error instanceof AppError + ? error.message + : "Could not complete the request; inspect the run in Parsar.", + }, + ], + }; + } + }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + void transport.close(); + void server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res, body); + }; +} diff --git a/example/parsar/server/capabilities.mjs b/example/parsar/server/capabilities.mjs new file mode 100644 index 000000000..a1e3b9ac6 --- /dev/null +++ b/example/parsar/server/capabilities.mjs @@ -0,0 +1,132 @@ +import { AppError, text, uuid } from "./store.mjs"; +import { unzipSync } from "fflate"; +const publicVersion = ({ archive, client_bundle, ...value }) => ({ + ...value, + has_archive: !!archive, + has_client_bundle: !!client_bundle, +}); +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 || + body.client_bundle !== undefined + ) { + 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; + const client = + body.client_bundle ?? + old?.versions.find((row) => row.version === old.default_version) + ?.client_bundle; + if (client !== undefined) + version.client_bundle = text( + client, + "Client JavaScript", + 1024 * 1024, + ); + } 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/connections.mjs b/example/parsar/server/connections.mjs new file mode 100644 index 000000000..9f179138b --- /dev/null +++ b/example/parsar/server/connections.mjs @@ -0,0 +1,506 @@ +import { createHash, randomUUID } from "node:crypto"; +import { AppError, text, uuid } from "./store.mjs"; + +const key = (...parts) => + createHash("sha256").update(parts.join("\n")).digest("hex"); +export const connectionScopes = (value) => + [ + ...new Set( + text(value || "", "授权范围", 3000) + .split(/\s+/) + .filter(Boolean), + ), + ].sort(); +const ownerKey = (owner) => + owner.kind === "local" ? "local" : key(owner.app_id, owner.subject_id); +const safeLogin = (row) => + row && { + id: row.id, + status: row.status, + verification_url: row.verification_url, + user_code: row.user_code, + expires_at: row.expires_at, + error: row.error, + }; +const safeConnection = (row) => + row && { + id: row.id, + app_id: row.app_id, + subject_id: row.subject_id, + name: row.name, + status: row.status, + revocation_pending: row.revocation_pending, + scopes: row.scopes, + expires_at: row.expires_at, + }; + +// Durable OAuth device-grant lifecycle. Provider wire behavior is supplied by the adapter. +// Templates own all runtime bindings; the lifecycle never selects tools or CLI variables. +export function userConnections(store, adapter) { + const locks = new Map(); + const locked = (id, fn) => { + if (!locks.has(id)) + locks.set( + id, + Promise.resolve() + .then(fn) + .finally(() => locks.delete(id)), + ); + return locks.get(id); + }; + const tokenData = (value, old = {}) => { + if ( + typeof value.access_token !== "string" || + !value.access_token || + (value.token_type && value.token_type.toLowerCase() !== "bearer") || + !(Number(value.expires_in) > 0) + ) + throw new AppError(502, "授权服务未返回受支持的用户授权,请重新连接。"); + return { + access_token: value.access_token, + refresh_token: value.refresh_token || old.refresh_token, + expires_at: Date.now() + Number(value.expires_in) * 1000, + refresh_expires_at: value.refresh_token_expires_in + ? Date.now() + Number(value.refresh_token_expires_in) * 1000 + : old.refresh_expires_at, + scopes: typeof value.scope === "string" ? value.scope : old.scopes || "", + }; + }; + const selected = (requirement, owner) => { + const subject = adapter.expectedSubject(requirement, owner); + if (subject) + return store.get("user_connections", key(requirement.app_id, subject)); + const binding = store.get( + "connection_bindings", + key("local", requirement.app_id), + ); + return binding && store.get("user_connections", binding.connection_id); + }; + const hasScopes = (row, requirement) => + connectionScopes(requirement.scopes).every((s) => + connectionScopes(row.scopes).includes(s), + ); + const refresh = (row) => + locked(`refresh:${row.id}`, async () => { + row = store.get("user_connections", row.id); + if (row.status !== "connected" || row.expires_at > Date.now() + 120000) + return row; + if ( + !row.refresh_token || + (row.refresh_expires_at && row.refresh_expires_at <= Date.now()) + ) { + row = { + ...row, + status: "reconnect_required", + access_token: undefined, + refresh_token: undefined, + }; + } else { + const value = await adapter.refresh(row); + if (value.error) { + if ( + !["invalid_grant", "invalid_token", "access_denied"].includes( + value.error, + ) + ) + throw new AppError(502, "账号授权续期暂时失败,请稍后重试。"); + row = { + ...row, + status: "reconnect_required", + access_token: undefined, + refresh_token: undefined, + }; + } else row = { ...row, ...tokenData(value, row) }; + } + // Disconnect may run while the provider request is in flight. + if (store.get("user_connections", row.id)?.generation !== row.generation) + return store.get("user_connections", row.id); + store.put("user_connections", row); + return row; + }); + const revoke = (row) => + locked(`revoke:${row.id}`, async () => { + row = store.get("user_connections", row.id); + if (!row?.revocation_pending) return; + await adapter.revoke(row); + if (store.get("user_connections", row.id)?.generation === row.generation) + store.put("user_connections", { + ...row, + access_token: undefined, + refresh_token: undefined, + revocation_pending: false, + }); + }); + const begin = (requirement, owner, force = false) => + locked(`login:${ownerKey(owner)}:${requirement.app_id}`, async () => { + if (selected(requirement, owner)?.revocation_pending) + throw new AppError(409, "正在撤销旧授权,请稍后重新连接。"); + const existing = store + .list("connection_logins") + .find( + (r) => + r.owner_key === ownerKey(owner) && + r.requirement.app_id === requirement.app_id && + r.status === "pending" && + r.expires_at > Date.now() && + r.requirement.scopes === requirement.scopes, + ); + if (existing && !force) return safeLogin(existing); + const prior = selected(requirement, owner); + const scopes = [ + ...new Set([ + ...connectionScopes(requirement.scopes), + ...connectionScopes( + prior?.status === "connected" ? prior.scopes : "", + ), + ]), + ].join(" "); + const value = await adapter.authorize({ ...requirement, scopes }); + const row = { + id: randomUUID(), + owner, + owner_key: ownerKey(owner), + requirement, + status: "pending", + device_code: value.device_code, + verification_url: value.verification_url, + user_code: value.user_code, + expires_at: + Date.now() + Math.min(Number(value.expires_in) || 240, 1800) * 1000, + interval: Math.max(5, Number(value.interval) || 5), + next_at: Date.now() + 5000, + }; + store.transaction(() => { + for (const old of store + .list("connection_logins") + .filter( + (r) => + r.owner_key === row.owner_key && + r.requirement.app_id === requirement.app_id && + r.status === "pending", + )) + store.put("connection_logins", { + ...old, + status: "cancelled", + device_code: undefined, + }); + store.put("connection_logins", row); + }); + return safeLogin(row); + }); + const poll = (id) => + locked(`poll:${id}`, async () => { + let row = store.get("connection_logins", id); + if (!row || row.status !== "pending") return; + if (row.expires_at <= Date.now()) { + store.put("connection_logins", { + ...row, + status: "expired", + device_code: undefined, + }); + return; + } + if (row.next_at > Date.now()) return; + row = { ...row, next_at: Date.now() + row.interval * 1000 }; + store.put("connection_logins", row); + const value = row.pending_tokens || (await adapter.exchange(row)); + if (store.get("connection_logins", id)?.status !== "pending") return; + if (value.error) { + if (value.error === "authorization_pending") return; + if (value.error === "slow_down") { + store.put("connection_logins", { + ...row, + interval: Math.min(row.interval + 5, 60), + next_at: Date.now() + (row.interval + 5) * 1000, + }); + return; + } + store.put("connection_logins", { + ...row, + status: value.error === "expired_token" ? "expired" : "denied", + error: "授权未完成,请重新连接。", + device_code: undefined, + }); + return; + } + if (!row.pending_tokens) { + row = { + ...row, + pending_tokens: tokenData(value), + device_code: undefined, + }; + store.put("connection_logins", row); + } + const tokens = row.pending_tokens; + const user = await adapter.identity(row.requirement, tokens); + const expected = adapter.expectedSubject(row.requirement, row.owner); + if (expected && user.subject_id !== expected) { + store.put("connection_logins", { + ...row, + status: "denied", + error: "授权账号与发起任务的账号不一致,请使用自己的账号重新授权。", + device_code: undefined, + pending_tokens: undefined, + }); + return; + } + if (store.get("connection_logins", id)?.status !== "pending") return; + const connection = { + id: key(row.requirement.app_id, user.subject_id), + bot_id: row.requirement.bot_id, + app_id: row.requirement.app_id, + subject_id: user.subject_id, + name: user.name, + ...tokens, + status: "connected", + generation: randomUUID(), + }; + store.transaction(() => { + store.put("user_connections", connection); + if (row.owner.kind === "local") + store.put("connection_bindings", { + id: key("local", row.requirement.app_id), + connection_id: connection.id, + }); + store.put("connection_logins", { + ...row, + status: hasScopes(connection, row.requirement) + ? "connected" + : "insufficient_scope", + error: hasScopes(connection, row.requirement) + ? undefined + : "已登录,但未授予任务所需权限,请检查应用权限并重新授权。", + connection_id: connection.id, + device_code: undefined, + pending_tokens: undefined, + }); + }); + }); + return { + requirement: adapter.requirement, + owner: adapter.owner, + begin, + async prepare(row) { + const requirement = row.connection_requirement; + if (!requirement) return row; + if (row.connection_dispatch_pending) return row; + if (row.connection) { + const current = store.get("user_connections", row.connection.id); + if ( + row.connection.expires_at <= Date.now() || + current?.generation !== row.connection.generation || + current.status !== "connected" + ) + throw new AppError( + 428, + "此会话的账号连接已失效,请开启新会话。", + "connection_expired", + ); + return row; + } + if (!Object.keys(row.connection_env || {}).length) + throw new AppError( + 409, + "此任务未配置连接变量映射,请配置环境模板后开启新会话。", + ); + const owner = row.connection_owner; + let connection = selected(requirement, owner); + if ( + connection?.status === "connected" && + hasScopes(connection, requirement) + ) + connection = await refresh(connection); + if ( + !connection || + connection.status !== "connected" || + !hasScopes(connection, requirement) || + connection.expires_at <= Date.now() + 60000 + ) { + const previous = + row.connection_wait && + store.get("connection_logins", row.connection_wait.id); + // Terminal authorizations need an explicit retry, never repeated consent loops. + const login = + previous && previous.status !== "connected" + ? safeLogin(previous) + : await begin(requirement, owner); + return { ...row, connection_wait: login }; + } + const values = adapter.values(requirement, connection); + const env = Object.fromEntries( + Object.entries(row.connection_env || {}).map(([name, source]) => { + if ( + !Object.hasOwn(values, source) || + typeof values[source] !== "string" + ) + throw new AppError(400, "连接无法提供环境模板需要的字段。"); + return [name, values[source]]; + }), + ); + return { + ...row, + connection_wait: undefined, + configuration: { + ...row.configuration, + connection: { + service: adapter.service, + name: connection.name, + expires_at: connection.expires_at, + }, + }, + connection: { + id: connection.id, + name: connection.name, + subject_id: connection.subject_id, + generation: connection.generation, + expires_at: connection.expires_at, + }, + request: { + ...row.request, + environment: { + ...row.request.environment, + env: { + ...row.request.environment.env, + ...env, + }, + }, + }, + }; + }, + async tick() { + for (const row of store + .list("user_connections") + .filter((r) => r.revocation_pending)) { + try { + await revoke(row); + } catch { + /* Retry provider revocation without restoring local access. */ + } + } + for (const row of store + .list("connection_logins") + .filter((r) => r.status === "pending")) { + try { + await poll(row.id); + } catch { + /* Keep the durable request for a later poll. */ + } + } + }, + async api(method, path, body) { + if (path === "/app/connections" && method === "GET") + return adapter.applications().map((application) => { + const requirement = application; + return { + ...application, + label: adapter.label, + default_scopes: adapter.defaultScopes, + required_scopes: [ + ...new Set( + store + .list("agents") + .filter( + (a) => + a.connection_requirement?.bot_id === application.bot_id, + ) + .flatMap((a) => + connectionScopes(a.connection_requirement.scopes), + ), + ), + ].join(" "), + connection: safeConnection( + selected(requirement, { kind: "local" }), + ), + login: safeLogin( + store + .list("connection_logins") + .find( + (r) => + r.owner.kind === "local" && + r.requirement.app_id === application.app_id, + ), + ), + }; + }); + const match = path.match( + /^\/app\/connections\/([^/]+)\/([a-f0-9-]{36})$/, + ); + const id = match?.[2]; + if (!id || !uuid.test(id) || match[1] !== adapter.service) + throw new AppError(404, "Not found."); + const requirement = adapter.requirement({ + service: adapter.service, + bot_id: id, + scopes: body?.scopes || adapter.defaultScopes, + }); + if (method === "POST") return begin(requirement, { kind: "local" }, true); + if (method !== "DELETE") throw new AppError(405, "Method not allowed."); + const row = selected(requirement, { kind: "local" }); + if (row) + store.put("user_connections", { + ...row, + status: "disconnected", + generation: randomUUID(), + revocation_pending: true, + }); + for (const login of store + .list("connection_logins") + .filter( + (r) => + r.owner.kind === "local" && + r.requirement.app_id === requirement.app_id && + r.status === "pending", + )) + store.put("connection_logins", { + ...login, + status: "cancelled", + device_code: undefined, + }); + for (const session of store + .list("sessions") + .filter( + (r) => + r.connection_owner?.kind === "local" && + r.connection_requirement?.app_id === requirement.app_id && + r.connection_wait && + !r.core_session_id, + )) + store.put("sessions", { + ...session, + connection_wait: { ...session.connection_wait, status: "cancelled" }, + }); + if (row) { + try { + await revoke(row); + } catch { + /* Background polling retries revocation. */ + } + } + return { disconnected: true }; + }, + }; +} + +export function guardConnectionInput(store, path, body) { + const id = path.match( + /^\/v1\/agents\/sessions\/([a-f0-9-]{36})\/events$/, + )?.[1]; + if ( + !id || + !body?.events?.some((e) => e.type !== "agent.session.input.cancel") + ) + return; + const row = store.list("sessions").find((r) => r.core_session_id === id); + if (!row?.connection) return; + const connection = store.get("user_connections", row.connection.id); + if ( + row.connection.expires_at <= Date.now() || + connection?.generation !== row.connection.generation || + connection.status !== "connected" + ) + throw new AppError( + 428, + "此会话的账号连接已失效,请开启新会话。", + "connection_expired", + ); +} diff --git a/example/parsar/server/feishu-connection.mjs b/example/parsar/server/feishu-connection.mjs new file mode 100644 index 000000000..e2b858bbe --- /dev/null +++ b/example/parsar/server/feishu-connection.mjs @@ -0,0 +1,195 @@ +import { AppError } from "./store.mjs"; +import { connectionScopes as scopesOf } from "./connections.mjs"; + +// Feishu owns provider wire details and the app-scoped sender identity boundary. +// It does not know CLI commands, environment variable names, or task execution. +export function feishuConnection(store, fetchImpl = fetch) { + function requirement(value) { + if (!value) return undefined; + const bot = store.get("feishu_bots", value.bot_id); + const scopes = scopesOf(value.scopes); + if ( + value.service !== "feishu" || + !bot?.app_id || + !bot.app_secret || + !scopes.length || + scopes.length > 50 || + scopes.some((s) => !/^[a-zA-Z0-9_:.-]+$/.test(s)) + ) + throw new AppError(400, "请选择已配置的飞书应用和有效的授权范围。"); + return { + service: "feishu", + bot_id: bot.id, + app_id: bot.app_id, + scopes: scopes.join(" "), + }; + } + function owner(sessionId) { + const thread = store + .list("feishu_threads") + .find((t) => t.session_id === sessionId); + return thread + ? { kind: "feishu", app_id: thread.app_id, subject_id: thread.sender_id } + : { kind: "local" }; + } + const app = (requirement) => { + const bot = store.get("feishu_bots", requirement.bot_id); + if (!bot?.app_secret || bot.app_id !== requirement.app_id) + throw new AppError(409, "飞书应用配置已改变,请重新选择连接应用。"); + return bot; + }; + const request = async (url, options) => { + let response, value; + try { + response = await fetchImpl(url, { + ...options, + redirect: "error", + signal: AbortSignal.timeout(15000), + }); + const raw = await response.text(); + if (raw.length > 65536) throw new Error(); + value = raw ? JSON.parse(raw) : {}; + } catch { + throw new AppError(502, "无法连接飞书授权服务,请稍后重试。"); + } + if (!response.ok && !value.error) + throw new AppError(502, "飞书授权服务暂不可用。"); + return value; + }; + const accounts = (bot) => + `https://accounts.${bot.brand === "lark" ? "larksuite.com" : "feishu.cn"}`; + const post = (bot, path, fields, basic = false) => + request(accounts(bot) + path, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(basic + ? { + Authorization: `Basic ${Buffer.from(`${bot.app_id}:${bot.app_secret}`).toString("base64")}`, + } + : {}), + }, + body: new URLSearchParams(fields), + }); + const tokenFields = (bot, fields) => ({ + client_id: bot.app_id, + client_secret: bot.app_secret, + ...fields, + }); + return { + service: "feishu", + label: "飞书账号", + defaultScopes: "contact:user.base:readonly", + requirement, + owner, + applications: () => + store + .list("feishu_bots") + .filter((b) => b.app_id && b.app_secret) + .map((b) => ({ + service: "feishu", + bot_id: b.id, + app_id: b.app_id, + name: b.name, + })), + expectedSubject(requirement, owner) { + if (owner.kind === "local") return undefined; + if (owner.kind !== "feishu" || owner.app_id !== requirement.app_id) + throw new AppError(400, "飞书入口与 Agent 的连接应用必须一致。"); + return owner.subject_id; + }, + async authorize(requirement) { + const bot = app(requirement); + const value = await post( + bot, + "/oauth/v1/device_authorization", + { + client_id: bot.app_id, + scope: [ + ...new Set([...scopesOf(requirement.scopes), "offline_access"]), + ].join(" "), + }, + true, + ); + if (value.error || !value.device_code || !value.verification_uri) + throw new AppError( + 502, + "无法发起飞书授权,请检查应用的用户权限是否已开通。", + ); + const url = new URL( + value.verification_uri_complete || value.verification_uri, + ); + if ( + url.protocol !== "https:" || + url.username || + url.password || + ![ + "accounts.feishu.cn", + "open.feishu.cn", + "accounts.larksuite.com", + "open.larksuite.com", + ].includes(url.hostname) + ) + throw new AppError(502, "飞书返回了无效授权地址。"); + return { ...value, verification_url: url.href }; + }, + exchange(row) { + const bot = app(row.requirement); + return post( + bot, + "/oauth/v3/token", + tokenFields(bot, { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: row.device_code, + }), + ); + }, + refresh(row) { + const bot = app(row); + return request(accounts(bot) + "/oauth/v3/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + tokenFields(bot, { + grant_type: "refresh_token", + refresh_token: row.refresh_token, + }), + ), + }); + }, + async identity(requirement, tokens) { + const bot = app(requirement); + const info = await request( + `https://open.${bot.brand === "lark" ? "larksuite.com" : "feishu.cn"}/open-apis/authen/v1/user_info`, + { headers: { Authorization: `Bearer ${tokens.access_token}` } }, + ); + if (info.code !== 0 || !info.data?.open_id) + throw new AppError(502, "无法确认授权账号身份,请重新连接。"); + return { + subject_id: info.data.open_id, + name: info.data.name || info.data.en_name || info.data.open_id, + }; + }, + async revoke(row) { + const bot = app(row); + for (const field of ["refresh_token", "access_token"]) { + if (!row[field]) continue; + const value = await post( + bot, + "/oauth/v1/revoke", + tokenFields(bot, { token: row[field], token_type_hint: field }), + ); + if (value.error || (value.code && value.code !== 0)) + throw new AppError(502, "撤销飞书授权暂未确认,将自动重试。"); + } + }, + values(requirement, connection) { + const bot = app(requirement); + return { + access_token: connection.access_token, + client_id: bot.app_id, + region: bot.brand === "lark" ? "lark" : "feishu", + }; + }, + }; +} diff --git a/example/parsar/server/feishu-context.mjs b/example/parsar/server/feishu-context.mjs new file mode 100644 index 000000000..edb15857f --- /dev/null +++ b/example/parsar/server/feishu-context.mjs @@ -0,0 +1,149 @@ +import { normalize } from "@larksuiteoapi/node-sdk"; + +// Feishu's maintained converter owns post/forwarded-message formatting. Reads +// happen after durable ingress, so a slow history API cannot delay the WS ACK. +export function feishuContextReader( + client, + botIdentity, + historyEnabled = false, +) { + const historyCache = new Map(); + 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, initial) => { + 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; + } + } + if (initial && historyEnabled && row.chat_type === "group") { + let cached = historyCache.get(row.chat_id); + if (!cached || cached.expires < Date.now()) { + try { + const result = await client.im.message.list({ + params: { + container_id_type: "chat", + container_id: row.chat_id, + page_size: 20, + sort_type: "ByCreateTimeDesc", + end_time: String( + Math.floor((row.occurred_at || row.created_at) / 1000), + ), + }, + }); + if (result.code !== 0) throw new Error("History unavailable"); + cached = { + expires: Date.now() + 30000, + items: result.data?.items || [], + }; + } catch { + cached = { + expires: Date.now() + 300000, + items: [], + notice: "群历史暂不可读取,请检查机器人的历史消息权限。", + }; + } + historyCache.set(row.chat_id, cached); + if (historyCache.size > 100) + historyCache.delete(historyCache.keys().next().value); + } + if (cached.notice) notices.push(cached.notice); + const lines = []; + for (const item of [...cached.items].reverse()) { + if ( + seen.has(item.message_id) || + Number(item.create_time) > (row.occurred_at || row.created_at) || + item.msg_type === "merge_forward" + ) + continue; + const value = await convert(item, row); + lines.push(`${item.sender?.id || "unknown"}: ${value.text}`); + } + if (lines.length) + excerpts.unshift( + `Recent group messages (context):\n${lines.join("\n").slice(-12000)}`, + ); + } + 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..ab1922d6c --- /dev/null +++ b/example/parsar/server/feishu-registration.mjs @@ -0,0 +1,197 @@ +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, + 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 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, + routes: bot?.routes || [], + 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..bd5e86643 --- /dev/null +++ b/example/parsar/server/feishu.mjs @@ -0,0 +1,1499 @@ +import { createHash, randomUUID } from "node:crypto"; +import { taskStatuses } from "../shared/work-profile.mjs"; +import { sessionRestriction } from "../shared/session-profile.mjs"; +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)}`; +}; +const selectionId = (app, chat, sender) => stableId(`${app}:${chat}:${sender}`); +import { feishuContextReader } from "./feishu-context.mjs"; +import { AppError, text, uuid, transientError } from "./store.mjs"; +import { requireAutomaticRuntime } from "./sessions.mjs"; + +const stamp = () => Date.now(); +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" && + row.first_attempt_at && + stamp() - row.first_attempt_at > 55 * 60000 + ) + 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, context, ...row }) => { + const taskId = + row.task_id || store.get("feishu_threads", row.thread_id)?.task_id; + return { + ...row, + task_id: taskId && store.get("tasks", taskId) ? taskId : 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: "", + routes: [], + }; + 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, "机器人仍有未完成投递,请先处理。"); + if (store.list("schedules").some((row) => row.delivery?.bot_id === id)) + 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" || + !Array.isArray(body.routes) || + body.routes.length > 20 + ) + throw new AppError(400, "飞书配置无效;最多配置 20 个群。"); + const routes = body.routes.map((route) => { + const chat_id = text(route.chat_id, "群 ID", 100, true).trim(); + if (chat_id !== "p2p" && !/^oc_[a-zA-Z0-9_-]+$/.test(chat_id)) + throw new AppError(400, "群 ID 应以 oc_ 开头,私聊规则填写 p2p。"); + const agent = store.get( + "agents", + text(route.agent_id, "Agent", 36, true), + ), + runtime = store.get( + "runtimes", + text(route.runtime_id || agent?.runtime_id || "", "环境模板", 36), + ); + if (!agent || (route.runtime_id && !runtime)) + throw new AppError(400, "请选择群对应的 Agent 和环境模板。"); + if (body.enabled) { + if (!runtime) + throw new AppError(400, "Agent 未配置有效的默认环境模板。"); + requireAutomaticRuntime(store, runtime.id); + const restriction = sessionRestriction(agent, runtime); + if (restriction) throw new AppError(400, restriction); + } + if (route.project_id && !store.get("projects", route.project_id)) + throw new AppError(400, "项目不存在。"); + return { + chat_id, + agent_id: agent.id, + runtime_id: route.runtime_id || "", + project_id: route.project_id || "", + allow_agent_selection: route.allow_agent_selection === true, + }; + }); + if (new Set(routes.map((r) => r.chat_id)).size !== routes.length) + throw new AppError(400, "每个群只能有一条分配规则。"); + if ( + body.enabled && + (!/^cli_[0-9a-fA-F]{16}$/.test(app_id) || !secret || !routes.length) + ) + throw new AppError( + 400, + "启用前请配置 App ID、App Secret 和至少一个允许的群。", + ); + const value = { + id, + name, + app_id, + app_secret: secret, + brand: body.brand || old.brand || "feishu", + history_context: body.history_context === true, + enabled: body.enabled, + routes, + revision: old.revision + 1, + }; + store.put("feishu_bots", value); + return publicFeishu(value); + }; +} + +export function feishuCard(text) { + const [title, ...body] = String(text).split("\n"); + return { + config: { wide_screen_mode: true }, + header: { title: { tag: "plain_text", content: title.slice(0, 100) } }, + elements: [{ tag: "markdown", content: body.join("\n").trim() || title }], + }; +} + +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, initial) => readContext(row, initial), + 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" }, + settings.history_context, + ); + const dispatcher = new EventDispatcher({ logger }).register({ + "im.message.receive_v1": async (event) => { + if ( + event.sender?.sender_type !== "user" || + ![ + "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 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)), + 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) { + confirmed( + await client.im.message.patch({ + path: { message_id: id }, + data: { content: JSON.stringify(feishuCard(text)) }, + }), + ); + }, + 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 allowed = (appId, chatId, chatType = "group") => { + const config = store.get("feishu_bots", configId); + return ( + config?.enabled && + config.app_id === appId && + config.routes.some((r) => + chatType === "p2p" ? r.chat_id === "p2p" : r.chat_id === chatId, + ) + ); + }; + 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.chatId, message.chatType) || + !["group", "p2p"].includes(message.chatType) || + (message.chatType === "group" && + !message.mentionedBot && + !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; + const route = store + .get("feishu_bots", configId) + .routes.find((r) => + message.chatType === "p2p" + ? r.chat_id === "p2p" + : r.chat_id === message.chatId, + ); + 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("tasks", thread.task_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, + task_id: randomUUID(), + session_id: randomUUID(), + agent_id: route.agent_id, + shared: route.allow_agent_selection, + runtime_id: route.runtime_id, + project_id: route.project_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) => { + 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, + task_id: thread.task_id, + reply_to: thread.root_id, + payload, + notify, + state: "queued", + attempts: 0, + created_at: stamp(), + next_at: 0, + }); + }; + 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) => { + let thread = store.get("feishu_threads", row.thread_id); + if (!thread) return reject(row, "话题关联不可用,请在 Web 中检查。"); + let run = + store.get("sessions", thread.session_id) || + store.list("sessions").find((value) => value.task_id === thread.task_id); + if (run?.connection_requirement && row.sender_id !== thread.sender_id) + return reject(row, "此任务使用发起者的个人连接,请新建自己的任务。"); + if (run?.connection_wait && !run.core_session_id) { + await product("DELETE", `/app/sessions/${run.id}`, {}); + store.put("feishu_inbound", { ...row, state: "delivered" }); + queueReply( + stableId(`${row.id}:cancel`), + thread, + "已取消等待授权的任务。", + ); + return; + } + // Resolve an uncertain initial creation before cancelling its actual Session. + if (run && !run.core_session_id) + run = await product( + "PUT", + `/app/tasks/${thread.task_id}/runs/${run.id}`, + {}, + ); + if (run && run.id !== thread.session_id) { + thread = { ...thread, session_id: run.id }; + store.put("feishu_threads", thread); + } + 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, thread?.first_message_id === row.id)); + 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 中检查。"); + const route = store + .get("feishu_bots", configId) + .routes.find((value) => + row.chat_type === "p2p" + ? value.chat_id === "p2p" + : value.chat_id === row.chat_id, + ); + if (["/new", "/reset", "/help"].includes(row.text)) { + queueReply( + stableId(`${row.id}:command`), + thread, + row.text === "/help" + ? "发送任务开始工作。/list 列出 Agent;/select <名称或 ID> 切换;/new 或 /reset 新建私聊对话(群聊请另开话题);/cancel 停止执行;/review 待验收;/done 完成任务;/reopen 重新打开;/answer <回答> 回复问题。" + : row.chat_type === "p2p" + ? "新对话已就绪,请发送任务。" + : "群聊请另开话题并提及机器人,以开始新对话。当前话题保持不变。", + ); + store.put("feishu_inbound", { ...row, state: "delivered" }); + return; + } + if (row.text === "/list" || /^\/select(?:\s|$)/.test(row.text)) { + if (!route?.allow_agent_selection) + return reject( + row, + "此群使用固定 Agent;可在 Web 飞书配置中开启共享机器人切换。", + ); + const agents = store.list("agents"); + let selection, reply; + if (row.text === "/list") { + reply = + agents + .map((agent) => `${agent.name} — ${agent.id}`) + .join("\n") + .slice(0, 5000) + + "\n\n发送 /select <完整名称或 ID> 选择新任务的 Agent。选择仅影响你在此群发起的新任务;已有话题保持原会话。"; + } else { + const target = row.text.slice(7).trim(); + const matches = agents.filter( + (agent) => agent.id === target || agent.name === target, + ); + if (matches.length !== 1) + return reject( + row, + "请用 /list 查看 Agent,再用唯一名称或完整 ID 选择。", + ); + const agent = matches[0], + runtime = store.get("runtimes", route.runtime_id || agent.runtime_id); + if (!runtime) + return reject( + row, + "请为所选 Agent 配置默认环境模板,或为飞书接入指定环境模板。", + ); + try { + requireAutomaticRuntime(store, runtime.id); + } catch (error) { + return reject(row, error.message); + } + const restriction = sessionRestriction(agent, runtime); + if (restriction) return reject(row, restriction); + selection = { + id: selectionId(row.app_id, row.chat_id, row.sender_id), + agent_id: agent.id, + }; + reply = `已选择 ${agent.name}。你在此群发起的新任务将使用这个 Agent;已有话题保持原会话。`; + } + store.transaction(() => { + if (selection) store.put("feishu_selections", selection); + queueReply(stableId(`${row.id}:selection`), thread, reply); + store.put("feishu_inbound", { + ...row, + state: "delivered", + error: undefined, + }); + }); + return; + } + const media = await mediaFor(row); + row = store.get("feishu_inbound", row.id); + const first = thread.first_message_id === row.id; + let task = store.get("tasks", thread.task_id); + if (!task) { + if (!first) + return reject(row, "话题任务不可用,请重新开一个话题描述任务。"); + if (row.text.startsWith("/")) + return reject(row, "请先 @机器人 描述任务,再在同一话题中发送命令。"); + if (!thread.assignment_resolved) { + const selected = + thread.shared && route?.allow_agent_selection + ? store.get( + "feishu_selections", + selectionId(row.app_id, row.chat_id, row.sender_id), + ) + : undefined; + const agent = store.get( + "agents", + selected?.agent_id || thread.agent_id, + ); + if (!agent) throw new AppError(400, "Agent 不存在。"); + const runtime = store.get( + "runtimes", + thread.runtime_id || agent.runtime_id, + ); + if (!runtime) + throw new AppError(400, "Agent 未配置有效的默认环境模板。"); + requireAutomaticRuntime(store, runtime.id); + const restriction = sessionRestriction(agent, runtime); + if (restriction) throw new AppError(400, restriction); + thread = { + ...thread, + agent_id: agent.id, + runtime_id: runtime.id, + assignment_resolved: true, + }; + store.put("feishu_threads", thread); + } + task = await product("PUT", `/app/tasks/${thread.task_id}`, { + name: media.title.split("\n")[0].slice(0, 80), + description: media.text, + project_id: thread.project_id, + agent_id: thread.agent_id, + runtime_id: thread.runtime_id, + status: "backlog", + priority: "normal", + }); + } + if (first) { + // A manually recovered task may already have the occurrence's Session. + if (!store.get("sessions", thread.session_id)) { + const recovered = store + .list("sessions") + .find((run) => run.task_id === task.id); + if (recovered) { + thread = { ...thread, session_id: recovered.id }; + store.put("feishu_threads", thread); + } + } + if (!store.get("sessions", thread.session_id)) + requireAutomaticRuntime(store, task.runtime_id); + const dispatched = await product( + "PUT", + `/app/tasks/${task.id}/runs/${thread.session_id}`, + { + revision: task.revision, + images: media.images, + files: media.files, + }, + ); + queueReply( + stableId(`${row.id}:accepted`), + thread, + `${media.notice ? media.notice + "\n" : ""}已接收任务「${task.name}」。${dispatched.connection_wait ? "正在等待你连接飞书账号,授权后自动继续。" : "正在交给 Agent 执行。"}\n请在本话题中直接追问;/cancel 取消执行,/review 提交验收,/done 确认完成,/reopen 重新打开。\n任务编号:${task.id}(在 Parsar「任务」中搜索)`, + ); + } else { + const run = store.get("sessions", thread.session_id); + if (run?.deleted_at) + return reject(row, "此话题会话已删除,请用 /new 或新话题开始。"); + if (run?.connection_requirement && row.sender_id !== thread.sender_id) + return reject(row, "此任务使用发起者的个人连接,请新建自己的任务。"); + if (run?.connection_wait && !run.core_session_id) { + if (row.text === "/connect") + await product("POST", `/app/sessions/${run.id}/connection`, {}); + else if (row.text === "/cancel") { + store.put("sessions", { + ...run, + deleted_at: Date.now(), + connection_wait: undefined, + }); + queueReply( + stableId(`${row.id}:cancel`), + thread, + "已取消等待授权的任务。", + ); + } else + return reject( + row, + "任务正在等待连接,授权后会自动继续。链接过期可发送 /connect,取消可发送 /cancel。", + ); + store.put("feishu_inbound", { ...row, state: "delivered" }); + return; + } + if (!run?.core_session_id) + return reject(row, "执行会话未确认,请先在 Web 中恢复原执行。"); + const commands = { + "/review": "review", + "/done": "done", + "/reopen": "backlog", + }; + if (commands[row.text]) { + if (row.state !== "submitting") { + try { + await product("PUT", `/app/tasks/${task.id}`, { + ...task, + status: commands[row.text], + }); + } catch (error) { + if (error instanceof AppError && error.status === 409) + return reject(row, error.message); + throw error; + } + store.put("feishu_inbound", { ...row, state: "submitting" }); + } + queueReply( + stableId(`${row.id}:command`), + thread, + `任务状态已更新:${taskStatuses[commands[row.text]]}。`, + ); + } else { + if (!row.event && task.status === "done" && row.text !== "/cancel") + return reject(row, "任务已完成,请先发送 /reopen 重新打开。"); + if ( + row.text.startsWith("/") && + row.text !== "/cancel" && + !row.text.startsWith("/answer ") + ) + return reject( + row, + "支持的命令:/list、/select、/cancel、/review、/done、/reopen。其他消息会交给 Agent。", + ); + 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, + })), + ], + }, + ], + }); + store.put("feishu_inbound", { ...row, state: "submitting", event }); + await core( + `/v1/agents/sessions/${run.core_session_id}/events`, + "POST", + { events: [event] }, + row.id, + ); + if (row.text === "/cancel") + queueReply( + stableId(`${row.id}:cancel`), + thread, + "已请求取消,等待 Agent 确认停止。", + ); + } + } + store.transaction(() => { + store.put("feishu_inbound", { + ...row, + state: "delivered", + media: undefined, + context: undefined, + error: undefined, + }); + if (!store.get("activity", row.id)) + store.put("activity", { + id: row.id, + task_id: task.id, + type: "feishu", + message: row.text, + actor: row.sender_id, + created_at: Math.floor(row.created_at / 1000), + }); + }); + }; + 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?.connection_wait && !run.deleted_at) { + const login = run.connection_wait; + const text = + login.status === "pending" + ? `请用发起任务的飞书账号完成授权,授权后任务自动继续。\n${login.verification_url}\n${login.user_code ? `验证码:${login.user_code}\n` : ""}链接过期可发送 /connect 重新连接。` + : `${login.error || "授权已过期或被取消。"}\n发送 /connect 重新连接,原任务仍保留。`; + queueReply( + stableId(`${thread.id}:connection:${login.id}:${login.status}`), + thread, + text, + ); + return; + } + 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. */ + } + } + } + for (const action of session.required_actions || []) { + if (action.type !== "function_call" || action.name !== "ask_user") + continue; + let args = action.arguments; + try { + if (typeof args === "string") args = JSON.parse(args); + } catch { + args = {}; + } + queueReply( + stableId(`${thread.id}:question:${action.call_id}`), + thread, + `Agent 需要补充信息\n\n${typeof args?.question === "string" ? args.question : "请补充任务信息"}\n${Array.isArray(args?.options) ? args.options.filter((value) => typeof value === "string").join("\n") : ""}\n\n回复本话题即可回答,或发送 /answer ${action.call_id} <回答>`, + true, + ); + } + const unseen = turns.filter( + (turn) => + ["completed", "failed", "cancelled"].includes(turn.status) && + !store.get("feishu_outbound", stableId(`${thread.id}:${turn.id}`)), + ); + if (session.status === "failed" && !turns.length && !thread.turn_cursor) + queueReply( + stableId(`${thread.id}:initial-failed`), + thread, + "执行环境准备失败或初始输入超时,请在 Web 任务中检查并重新执行。", + ); + const progress = store.get( + "feishu_outbound", + stableId(`${thread.first_message_id}:accepted`), + ); + if (progress?.sent_message_id && session.status !== "in_progress") { + const latest = turns.at(-1); + const text = + session.status === "requires_action" + ? "Agent 等待补充信息,请回复提问卡片。" + : session.status === "failed" || latest?.status === "failed" + ? "Agent 执行失败,请查看后续结果卡片。" + : latest?.status === "cancelled" + ? "Agent 已停止执行。" + : latest?.status === "completed" + ? "Agent 执行结束,结果见后续卡片。" + : null; + if (text && thread.progress_text !== text) { + try { + await channel.edit(progress.sent_message_id, text, thread); + thread = { ...thread, progress_text: text }; + store.put("feishu_threads", thread); + } catch { + // A deleted or uneditable progress card must not block result delivery. + } + } + } + if (progress?.sent_message_id && session.status === "in_progress") { + const items = await history(sid, "items", thread.item_cursor); + const output = items + .filter((item) => item.type === "message" && item.role === "assistant") + .flatMap((item) => item.content || []) + .filter((part) => part.type === "output_text") + .map((part) => part.text) + .join("\n"); + const text = `Agent 正在执行\n\n${output.slice(-4000)}\n\n已记录 ${items.filter((item) => item.type !== "message").length} 个执行步骤`; + 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); + } catch { + // A deleted or uneditable progress card must not block result delivery. + } + } + } + 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), + ]); + for (const turn of unseen) { + const output = items + .filter( + (item) => + item.turn_id === turn.id && + item.type === "message" && + item.role === "assistant", + ) + .flatMap((item) => item.content || []) + .filter((part) => part.type === "output_text") + .map((part) => part.text) + .join("\n\n"); + const files = artifacts + .filter((file) => file.turn_id === turn.id) + .map((file) => file.name || file.path || file.id) + .join("、"); + const summary = + turn.status === "completed" + ? "Agent 执行结束,请验收结果。" + : turn.status === "cancelled" + ? "Agent 已停止执行。" + : "Agent 执行失败,请在 Web 会话中检查。"; + queueReply( + stableId(`${thread.id}:${turn.id}`), + thread, + `${summary}\n\n${output.slice(0, 5000)}${output.length > 5000 ? "\n…完整结果请在 Web 查看。" : ""}${files ? `\n\n产物:${files}(在 Web 会话下载)` : ""}\n\n任务编号:${thread.task_id}(在 Parsar「任务」中搜索)`, + true, + ); + } + // 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, + 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, + "这条消息尚未成功处理。请在 Parsar 的飞书页面检查投递记录,并用原标识重试;不要重复创建任务。", + ); + } + }; + const deliver = async (current) => { + for (const row of store.list("feishu_outbound").reverse()) { + if (!current()) return; + if ( + !allowed(row.app_id, row.chat_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 ( + row.first_attempt_at && + stamp() - row.first_attempt_at >= 55 * 60000 + ) { + 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; + const result = await channel.send({ + ...row, + reply_to: root, + context_data: thread?.context_data, + sender_id: thread?.sender_id, + }); + if (thread && !thread.root_id) + store.put("feishu_threads", { + ...thread, + 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, + }); + if (row.notify && thread?.sender_id) { + const id = stableId(`${row.id}:ping`); + if (!store.get("feishu_outbound", id)) + store.put("feishu_outbound", { + ...row, + id, + notify: false, + plain_text: true, + payload: row.payload.split("\n")[0], + reply_to: result.id, + state: "queued", + attempts: 0, + first_attempt_at: undefined, + next_at: 0, + }); + } + }); + } catch (error) { + retry("feishu_outbound", row, error); + } + } + }; + const tick = async () => { + const config = store.get("feishu_bots", configId); + for (const occurrence of store.list("schedule_occurrences")) { + if ( + occurrence.delivery?.bot_id !== configId || + occurrence.delivery_thread_id || + occurrence.delivery_error + ) + continue; + if ( + !config?.enabled || + occurrence.delivery.app_id !== config.app_id || + !config.routes.some( + (route) => route.chat_id === occurrence.delivery.chat_id, + ) + ) + store.put("schedule_occurrences", { + ...occurrence, + delivery_error: + "飞书机器人已停用或投递路由已变更,结果保留在任务中。", + }); + } + 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); + for (const occurrence of store.list("schedule_occurrences")) { + if ( + occurrence.delivery_thread_id || + occurrence.delivery_error || + occurrence.delivery?.bot_id !== configId || + occurrence.delivery.app_id !== config.app_id || + !allowed(config.app_id, occurrence.delivery.chat_id) + ) + continue; + const id = stableId(`schedule:${occurrence.id}`); + if (store.get("feishu_threads", id)) continue; + const task = store.get("tasks", occurrence.task_id); + const thread = { + id, + app_id: config.app_id, + chat_id: occurrence.delivery.chat_id, + chat_type: "group", + task_id: occurrence.task_id, + session_id: occurrence.session_id, + agent_id: task?.agent_id, + runtime_id: task?.runtime_id, + first_message_id: id, + created_at: stamp(), + }; + store.transaction(() => { + store.put("feishu_threads", thread); + store.put("schedule_occurrences", { + ...occurrence, + delivery_thread_id: thread.id, + }); + queueReply( + stableId(`${id}:accepted`), + thread, + `定时任务已开始\n\n${task?.name || occurrence.task_id}`, + ); + }); + } + 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_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_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_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 1b3df5f29..1e5371d11 100644 --- a/example/parsar/server/product.mjs +++ b/example/parsar/server/product.mjs @@ -1,5 +1,16 @@ +import { userConnections } from "./connections.mjs"; +import { feishuConnection } from "./feishu-connection.mjs"; +import { agentMCPTokenAPI } from "./agent-mcp.mjs"; +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 { scheduleAPI } from "./schedules.mjs"; +import { feishuAPI } from "./feishu.mjs"; +import { workAPI } from "./work.mjs"; import { sessionAPI } from "./sessions.mjs"; import { AppError, text, uuid } from "./store.mjs"; @@ -11,7 +22,7 @@ const kinds = new Set([ "agents", "sessions", ]); -export function productAPI(store, core, fetchImpl = fetch) { +export function productAPI(store, core, fetchImpl = fetch, origin) { // Preserve earlier example configurations without fabricating execution records. for (const kind of ["templates", "instances"]) { for (const row of store.list(kind)) { @@ -40,15 +51,27 @@ export function productAPI(store, core, fetchImpl = fetch) { store.remove(kind, row.id); } } - const sessions = sessionAPI(store, core); + const connections = userConnections( + store, + feishuConnection(store, fetchImpl), + ); + const sessions = sessionAPI(store, core, connections); 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 row; }; + const capabilities = capabilityAPI(store); + const mcpTokens = agentMCPTokenAPI(store); + const oauth = mcpOAuthAPI(store, core, origin, fetchImpl); + const schedules = scheduleAPI(store); + const feishu = feishuAPI(store); + const registration = feishuRegistrationAPI(store, fetchImpl); + const work = workAPI(store, core, sessions); const providers = providerAPI(store, fetchImpl); const touchProviders = (...ids) => { for (const id of new Set(ids.filter(Boolean))) { @@ -70,7 +93,71 @@ export function productAPI(store, core, fetchImpl = fetch) { throw new AppError(400, "最多选择 8 个资源。"); return [...new Set(ids.map((id) => requireReference(kind, id)))]; }; - return async (method, path, body) => { + const handle = async (method, path, body) => { + if (path === "/app/connections" || path.startsWith("/app/connections/")) + return connections.api(method, path, body); + const retryId = path.match( + /^\/app\/sessions\/([a-f0-9-]{36})\/connection$/, + )?.[1]; + if (retryId && method === "POST") { + const row = store.get("sessions", retryId); + if (!row?.connection_requirement || row.core_session_id || row.deleted_at) + throw new AppError(409, "此会话无法重新发起连接,请开启新会话。"); + const login = await connections.begin( + row.connection_requirement, + row.connection_owner, + true, + ); + const current = store.get("sessions", retryId); + if ( + !current || + current.deleted_at || + current.core_session_id || + current.connection_dispatch_pending + ) + throw new AppError(409, "会话状态已改变,请刷新页面。"); + store.put("sessions", { + ...current, + connection_wait: login, + connection_error: undefined, + }); + return login; + } + if (/^\/app\/agents\/[^/]+\/mcp-token$/.test(path)) + return mcpTokens(method, path); + 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; + } + if (path === "/app/audit" && method === "GET") + return store.list("audit").sort((a, b) => b.created_at - a.created_at); + if (path === "/app/schedules" || path.startsWith("/app/schedules/")) + return schedules(method, path, body); + 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 (/^\/app\/(projects|tasks)(?:\/|$)/.test(path)) + return work(method, path, body); if (path === "/app/providers/discover" && method === "POST") return providers.discover(body); const match = path.match(/^\/app\/([a-z]+)(?:\/([a-f0-9-]{36}))?$/); @@ -78,7 +165,31 @@ export function productAPI(store, core, fetchImpl = fetch) { 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) { + const sources = + kind === "sessions" + ? new Map([ + ...store + .list("feishu_threads") + .map((row) => [row.session_id, "feishu"]), + ...store.list("agent_mcp_runs").map((row) => [row.id, "mcp"]), + ...store + .list("schedule_occurrences") + .map((row) => [row.session_id, "schedule"]), + ]) + : null; + return store + .list(kind) + .filter((row) => kind !== "sessions" || !row.deleted_at) + .map((row) => ({ + ...publicRow(kind, row), + ...(sources + ? { + source: sources.get(row.id) || (row.task_id ? "task" : "web"), + } + : {}), + })); + } const value = store.get(kind, id); if (!value) throw new AppError(404, "记录不存在。"); return kind === "providers" @@ -92,22 +203,93 @@ 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 && + row.connection_wait && + method === "DELETE" + ) { + store.put("sessions", { + ...row, + deleted_at: Date.now(), + connection_wait: undefined, + }); + const shared = store + .list("sessions") + .some( + (r) => + r.id !== row.id && + !r.deleted_at && + r.connection_wait?.id === row.connection_wait.id, + ); + const login = store.get("connection_logins", row.connection_wait.id); + if (!shared && login?.status === "pending") + store.put("connection_logins", { + ...login, + status: "cancelled", + device_code: undefined, + pending_tokens: undefined, + }); + return { deleted: true }; + } + 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); } const previous = store.get(kind, id); if (method === "DELETE") { const used = + store + .list("feishu_bots") + .flatMap((bot) => bot.routes) + .some((row) => row.agent_id === id || row.runtime_id === id) || + store + .list("tasks") + .some((row) => row.agent_id === id || row.runtime_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); if (used) throw new AppError(409, "资源仍被引用,请先调整绑定。"); store.transaction(() => { store.remove(kind, id); + if (kind === "agents") + for (const selection of store.list("feishu_selections")) { + if (selection.agent_id === id) + store.remove("feishu_selections", selection.id); + } if (kind === "models") touchProviders(previous?.provider_id); }); return {}; @@ -122,6 +304,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 ( @@ -152,12 +352,21 @@ 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); @@ -165,6 +374,32 @@ export function productAPI(store, core, fetchImpl = fetch) { throw new AppError(400, "请选择执行引擎。"); value.harness = body.harness; value.instructions = text(body.instructions, "指令", 100000); + value.enabled = body.enabled !== false; + value.ask_user = body.ask_user === true; + value.connection_requirement = connections.requirement( + body.connection_requirement, + ); + if (value.ask_user && body.harness === "mcode") + throw new AppError(400, "MiniMax Code 尚不支持公开 Function 工具。"); + value.runtime_id = body.runtime_id + ? requireReference("runtimes", body.runtime_id) + : ""; + value.capability_ids = references( + "capabilities", + body.capability_ids || [], + ); + 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) || @@ -177,12 +412,67 @@ 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.tickConnections = async () => { + await connections.tick(); + for (const row of store + .list("sessions") + .filter( + (r) => + (r.connection_wait || r.connection_dispatch_pending) && + !r.core_session_id && + !r.deleted_at, + )) { + try { + await sessions(row.id, {}); + } catch (error) { + const current = store.get("sessions", row.id); + if (current) + store.put("sessions", { + ...current, + connection_error: + error instanceof AppError + ? error.message + : "连接恢复暂时失败,将自动重试。", + }); + } + } }; + return handler; } diff --git a/example/parsar/server/providers.mjs b/example/parsar/server/providers.mjs index fcd64477a..39f9ecb7a 100644 --- a/example/parsar/server/providers.mjs +++ b/example/parsar/server/providers.mjs @@ -134,6 +134,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..861c605f6 100644 --- a/example/parsar/server/runtime-profile.mjs +++ b/example/parsar/server/runtime-profile.mjs @@ -1,46 +1,129 @@ +import { + connectionFields, + environmentVariableName, +} from "../shared/connection-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 connection_env = body.connection_env ?? {}; + if ( + !connection_env || + typeof connection_env !== "object" || + Array.isArray(connection_env) || + Object.keys(connection_env).length > 20 + ) + throw new AppError(400, "连接变量映射格式无效。"); + for (const [name, source] of Object.entries(connection_env)) { + if ( + !environmentVariableName(name) || + Object.hasOwn(env, name) || + typeof source !== "string" || + !Object.hasOwn(connectionFields, 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, + connection_env, + files: normalized, + }; +} + +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/schedules.mjs b/example/parsar/server/schedules.mjs new file mode 100644 index 000000000..5cc6a2102 --- /dev/null +++ b/example/parsar/server/schedules.mjs @@ -0,0 +1,323 @@ +import { CronExpressionParser } from "cron-parser"; +import { requireSessionSettled, requireAutomaticRuntime } from "./sessions.mjs"; +import { randomUUID } from "node:crypto"; +import { AppError, text, uuid, transientError } from "./store.mjs"; + +export function nextOccurrence(schedule, time = Date.now()) { + if (!schedule.cron) return time + schedule.interval_minutes * 60000; + try { + if ( + schedule.cron.trim().split(/\s+/).length !== 5 || + /[H?]/.test(schedule.cron) + ) + throw new Error(); + new Intl.DateTimeFormat("en", { timeZone: schedule.timezone }).format(time); + return CronExpressionParser.parse(schedule.cron, { + currentDate: time, + tz: schedule.timezone, + }) + .next() + .getTime(); + } catch { + throw new AppError(400, "请填写有效的五段 Cron 表达式和 IANA 时区。"); + } +} + +export function scheduleAPI(store) { + return async (method, path, body) => { + const match = path.match( + /^\/app\/schedules(?:\/([a-f0-9-]{36}))?(?:\/(run|history))?$/, + ); + if (!match || (match[1] && !uuid.test(match[1]))) + throw new AppError(404, "Not found."); + const id = match[1], + old = id && store.get("schedules", id); + if (match[2]) { + if (!old) throw new AppError(404, "计划不存在。"); + if (match[2] === "history" && method === "GET") + return store + .list("schedule_occurrences") + .filter((row) => row.schedule_id === id); + if (match[2] === "run" && method === "POST") { + if (!uuid.test(body.request_id)) + throw new AppError(400, "执行请求标识无效。"); + const receiptId = `${id}:${body.request_id}`; + if (store.get("schedule_requests", receiptId)) + return { accepted: true }; + if (old.pending || old.run_now) + throw new AppError(409, "计划仍有待处理的执行。"); + const template = store.get("tasks", old.task_id); + requireAutomaticRuntime(store, template?.runtime_id); + store.transaction(() => { + store.put("schedules", { + ...old, + run_now: true, + next_at: Date.now(), + revision: old.revision + 1, + }); + store.put("schedule_requests", { + id: receiptId, + schedule_id: id, + created_at: Date.now(), + }); + }); + return { accepted: true }; + } + throw new AppError(405, "Method not allowed."); + } + if (method === "GET") { + if (id && !old) throw new AppError(404, "计划不存在。"); + const publicRow = ({ pending, ...row }) => ({ + ...row, + pending: pending ? { task_id: pending.task_id } : undefined, + }); + return id ? publicRow(old) : store.list("schedules").map(publicRow); + } + if (!id || !["PUT", "DELETE"].includes(method)) + throw new AppError(405, "Method not allowed."); + if (old && body.revision !== old.revision) + throw new AppError(409, "计划已更新,请重新加载。"); + if (method === "DELETE") { + if (old?.pending && store.get("tasks", old.pending.task_id)) + throw new AppError( + 409, + "计划仍有未确认的执行,请先处理任务中的执行记录。", + ); + store.remove("schedules", id); + return {}; + } + const template = store.get( + "tasks", + text(body.task_id, "任务模板", 36, true), + ); + if (!template) throw new AppError(400, "请选择用作模板的任务。"); + if (body.enabled) requireAutomaticRuntime(store, template.runtime_id); + if (body.enabled && !template.agent_id) + throw new AppError(400, "任务模板需要 Agent 和可自动启动的运行时。"); + const cron = body.cron ? text(body.cron, "Cron", 100, true).trim() : ""; + const timezone = text(body.timezone || "UTC", "时区", 100, true); + if (cron) nextOccurrence({ cron, timezone }); + if ( + typeof body.enabled !== "boolean" || + (!cron && + (!Number.isInteger(body.interval_minutes) || + body.interval_minutes < 5 || + body.interval_minutes > 525600)) + ) + throw new AppError(400, "间隔必须为 5 至 525600 分钟。"); + let delivery; + if (body.delivery) { + const bot = store.get("feishu_bots", body.delivery.bot_id); + if ( + !bot?.enabled || + !bot.routes.some( + (route) => + route.chat_id === body.delivery.chat_id && route.chat_id !== "p2p", + ) + ) + throw new AppError(400, "请选择已启用飞书机器人的允许群。"); + delivery = { + bot_id: bot.id, + app_id: bot.app_id, + chat_id: body.delivery.chat_id, + }; + } + const value = { + id, + delivery, + name: text(body.name, "计划名称", 80, true), + task_id: template.id, + enabled: body.enabled, + consecutive_failures: body.enabled && !old?.enabled ? 0 : old?.consecutive_failures || 0, + interval_minutes: cron ? undefined : body.interval_minutes, + cron, + timezone, + run_now: old?.run_now, + revision: (old?.revision || 0) + 1, + next_at: + old?.pending && body.enabled + ? Date.now() + : old && + old.enabled === body.enabled && + old.interval_minutes === body.interval_minutes && + old.cron === cron && + old.timezone === timezone + ? old.next_at + : nextOccurrence({ ...body, cron, timezone }), + pending: old?.pending, + last_task_id: old?.last_task_id, + last_error: old?.last_error, + }; + store.put("schedules", value); + return value; + }; +} + +// Missed intervals are coalesced into one occurrence; pending work never overlaps. +export function scheduleWorker(store, product, core) { + let timer, + running, + stopping = false; + const tick = async (time = Date.now()) => { + // Outcomes are delivery receipts for failure accounting, not a second + // execution history. Read authoritative Session/Turn state until terminal. + for (const occurrence of store.list("schedule_occurrences").reverse()) { + if (occurrence.outcome) continue; + const record = store.get("sessions", occurrence.session_id); + if (!record?.core_session_id || record.deleted_at) { + if (record?.deleted_at) store.put("schedule_occurrences", { ...occurrence, outcome: "unknown" }); + continue; + } + try { + const prefix = `/v1/agents/sessions/${record.core_session_id}`; + const [session, turns] = await Promise.all([core(prefix, "GET"), core(`${prefix}/turns?order=desc&limit=1`, "GET")]); + const turn = turns.data[0]; + if (session.status !== "failed" && !(session.status === "idle" && ["completed", "failed", "cancelled"].includes(turn?.status))) continue; + const outcome = session.status === "failed" ? "failed" : turn.status; + store.transaction(() => { + store.put("schedule_occurrences", { ...occurrence, outcome, observed_at: time }); + const schedule = store.get("schedules", occurrence.schedule_id); + if (schedule && !occurrence.manual) { + const failures = outcome === "completed" ? 0 : (schedule.consecutive_failures || 0) + 1; + store.put("schedules", { ...schedule, consecutive_failures: failures, ...(failures >= 5 ? { enabled: false, last_error: "连续 5 次执行失败或取消,计划已暂停。检查最近执行后可重新启用。" } : {}), revision: schedule.revision + 1 }); + } + }); + } catch { /* An unavailable Core is not an execution failure. Retry observation. */ } + } + for (const schedule of store.list("schedules")) { + if ((!schedule.enabled && !schedule.run_now) || schedule.next_at > time) + continue; + let occurrence = schedule.pending; + try { + if (!occurrence) { + const latest = + schedule.last_task_id && + store + .list("sessions") + .find((row) => row.task_id === schedule.last_task_id); + if (latest) { + try { + await requireSessionSettled(core, latest); + } catch (error) { + if (!(error instanceof AppError) || error.status !== 409) + throw error; + const current = store.get("schedules", schedule.id); + if (current) + store.put("schedules", { + ...current, + next_at: nextOccurrence(current, time), + run_now: false, + last_error: "上次执行尚未结束,已跳过本次间隔。", + revision: current.revision + 1, + }); + continue; + } + } + const current = store.get("schedules", schedule.id); + if ( + !current || + (!current.enabled && !current.run_now) || + current.revision !== schedule.revision + ) + continue; + const template = store.get("tasks", schedule.task_id); + if (!template) throw new AppError(400, "任务模板不存在。"); + requireAutomaticRuntime(store, template.runtime_id); + if (!store.get("agents", template.agent_id || "")) + throw new AppError( + 400, + "任务模板需要 Agent 和可自动启动的运行时,请编辑模板后重新启用。", + ); + occurrence = { + task_id: randomUUID(), + run_id: randomUUID(), + template: { ...template }, + delivery: schedule.delivery, + manual: !!schedule.run_now, + created_at: time, + }; + store.put("schedules", { ...schedule, pending: occurrence }); + } + let task = store.get("tasks", occurrence.task_id); + if (!task) + task = await product("PUT", `/app/tasks/${occurrence.task_id}`, { + ...occurrence.template, + revision: undefined, + status: "backlog", + }); + // A definite rejection leaves no Session. If the operator recovered + // this occurrence from its task, adopt that run instead of dispatching again. + const recovered = + store.get("sessions", occurrence.run_id) || + store.list("sessions").find((row) => row.task_id === task.id); + if (!recovered) requireAutomaticRuntime(store, task.runtime_id); + await product( + "PUT", + `/app/tasks/${task.id}/runs/${recovered?.id || occurrence.run_id}`, + { revision: task.revision }, + ); + store.put("schedule_occurrences", { + id: occurrence.task_id, + schedule_id: schedule.id, + task_id: task.id, + session_id: recovered?.id || occurrence.run_id, + created_at: occurrence.created_at, + delivery: occurrence.delivery, + manual: occurrence.manual, + }); + const current = store.get("schedules", schedule.id); + if (current) + store.put("schedules", { + ...current, + run_now: false, + pending: undefined, + last_task_id: task.id, + last_error: undefined, + next_at: nextOccurrence(current, time), + revision: current.revision + 1, + }); + } catch (error) { + const current = store.get("schedules", schedule.id); + const transient = transientError(error); + if (current) + store.put("schedules", { + ...current, + enabled: transient ? current.enabled : false, + run_now: transient ? current.run_now : false, + next_at: transient ? time + 15000 : current.next_at, + last_task_id: occurrence?.task_id || current.last_task_id, + last_error: transient + ? "连接或执行结果暂未确认,将自动恢复原请求;不会创建另一轮执行。" + : error instanceof AppError + ? error.message + : "计划执行失败,请检查最近任务。", + revision: current.revision + 1, + }); + } + } + }; + const cycle = async () => { + if (stopping) return; + running = tick(); + try { + await running; + } catch { + console.error( + "Parsar schedule worker could not access local state; retrying.", + ); + } finally { + if (!stopping) timer = setTimeout(cycle, 15000); + } + }; + return { + tick, + start() { + void cycle(); + }, + async stop() { + stopping = true; + clearTimeout(timer); + await running; + }, + }; +} diff --git a/example/parsar/server/sessions.mjs b/example/parsar/server/sessions.mjs index 2cfdd72e6..3d6f35065 100644 --- a/example/parsar/server/sessions.mjs +++ b/example/parsar/server/sessions.mjs @@ -1,24 +1,38 @@ +import { runtimeEnvironment } from "./runtime-profile.mjs"; import { sessionRestriction } from "../shared/session-profile.mjs"; -import { zipSync, strToU8 } from "fflate"; import { AppError, text, uuid } from "./store.mjs"; -function requestFor(store, id, body) { +export function prepareSession(store, id, body, owner = { kind: "local" }) { 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.enabled === false) + 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"; + if ( + Object.keys(runtime.connection_env || {}).length && + !agent.connection_requirement + ) + throw new AppError( + 400, + "环境模板需要账号连接,请在 Agent 模板中选择所需用户连接。", + ); + if ( + agent.connection_requirement && + !Object.keys(runtime.connection_env || {}).length + ) + throw new AppError(400, "请在环境模板中配置连接变量映射。"); + const environment = runtimeEnvironment(runtime); const restriction = sessionRestriction(agent, runtime); if (restriction) throw new AppError(400, restriction); let modelProvider; - if (environment.type !== "none" && agent.harness !== "mcode") { + if (agent.harness !== "mcode") { const provider = store.get("providers", model.provider_id); if (!provider?.base_url?.startsWith("https://") || !provider.api_key) throw new AppError( @@ -31,83 +45,199 @@ function requestFor(store, id, body) { 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, - })), - ); - } else { + 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", + required: true, + ...(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 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 = text(body.name, "会话名称", 80, true); - const input = selfHosted ? undefined : text(body.input, "消息", 100000, true); + const input = text(body.input, "消息", 100000, true); return { id, name, agent_id: agent.id, runtime_id: runtime.id, + pricing: model.pricing, + configuration: { + agent_name: agent.name, + 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 || {}), + ...Object.keys(runtime.connection_env || {}), + ], + 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, + })), + }, + connection_env: runtime.connection_env || {}, + connection_requirement: agent.connection_requirement, + connection_owner: agent.connection_requirement ? owner : undefined, created_at: Math.floor(Date.now() / 1000), - ...(selfHosted - ? { - self_hosted: { - platform: runtime.platform, - workspace_directory: runtime.workspace_directory, - }, - } - : {}), request: { agent: { model: model.model, - instructions: agent.instructions, + instructions, x_agents_core: { harness: agent.harness }, multi_agent: { enabled: false }, text: { verbosity: "medium" }, tools, }, environment, - ...(input ? { input } : {}), + ...(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, + } + : {}), ...(modelProvider ? { x_agents_core: { model_provider: modelProvider } } : {}), @@ -121,16 +251,31 @@ function requestFor(store, id, body) { }; } -export function sessionAPI(store, core) { +export function sessionAPI(store, core, connections) { const running = new Map(); const create = async (id, body) => { let row = store.get("sessions", id); + if (row?.deleted_at) throw new AppError(410, "会话已取消。"); if (row?.core_session_id) return row; if (!row) { - row = requestFor(store, id, body); + row = prepareSession(store, id, body, connections?.owner(id)); store.put("sessions", row); } try { + if (connections && row.connection_requirement) { + row = await connections.prepare(row); + if (store.get("sessions", id)?.deleted_at) + throw new AppError(410, "会话已取消。"); + store.put("sessions", row); + if (row.connection_wait) { + const { request, ...record } = row; + return record; + } + } + if (row.connection) { + row = { ...row, connection_dispatch_pending: true }; + store.put("sessions", row); + } // Freeze the request before sending. Retries, including after restart, reuse the same key and bytes. const session = await core( "/v1/agents/sessions", @@ -142,6 +287,8 @@ export function sessionAPI(store, core) { throw new AppError(502, "Core 返回了无效的会话。"); const { request, ...record } = row; record.core_session_id = session.id; + delete record.connection_dispatch_pending; + delete record.connection_error; store.put("sessions", record); return record; } catch (error) { @@ -162,3 +309,34 @@ export function sessionAPI(store, core) { return running.get(id); }; } + +export async function requireSessionSettled(core, record) { + if (record.deleted_at) return; + if (record.connection_wait) + throw new AppError(409, "任务正在等待连接,请先完成授权或取消等待。"); + if (!record.core_session_id) + throw new AppError(409, "上次执行尚未确认,请先恢复创建。"); + const prefix = `/v1/agents/sessions/${record.core_session_id}`; + const [session, turns] = await Promise.all([ + core(prefix, "GET"), + core(`${prefix}/turns?order=desc&limit=1`, "GET"), + ]); + if ( + session.status !== "failed" && + !( + session.status === "idle" && + ["completed", "failed", "cancelled"].includes(turns.data?.[0]?.status) + ) + ) + throw new AppError( + 409, + "Agent 仍在准备、执行或等待处理,请先打开会话继续或取消。", + ); +} + +export function requireAutomaticRuntime(store, id) { + const runtime = store.get("runtimes", id || ""); + if (!runtime || runtime.environment !== "openai_hosted") + throw new AppError(400, "请选择有效的托管环境模板。"); + return runtime; +} 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/work.mjs b/example/parsar/server/work.mjs new file mode 100644 index 000000000..bf30b1ff4 --- /dev/null +++ b/example/parsar/server/work.mjs @@ -0,0 +1,327 @@ +import { randomUUID } from "node:crypto"; +import { AppError, text, uuid } from "./store.mjs"; +import { + prepareSession, + requireSessionSettled, + requireAutomaticRuntime, +} from "./sessions.mjs"; + +import { taskStatuses, taskPriorities } from "../shared/work-profile.mjs"; +const statuses = Object.keys(taskStatuses); +const priorities = Object.keys(taskPriorities); +const now = () => Math.floor(Date.now() / 1000); +const publicSession = ({ request, ...row }) => row; + +// Product planning and decisions live here. Core remains the execution authority. +export function workAPI(store, core, sessions) { + const dispatching = new Set(); + const requireRow = (kind, id) => { + const row = typeof id === "string" && store.get(kind, id); + if (!row) throw new AppError(404, "记录不存在。"); + return row; + }; + const revision = (row, body) => { + if (row && row.revision !== body.revision) + throw new AppError(409, "内容已更新,请刷新后重试。"); + }; + const runs = (id) => + store.list("sessions").filter((row) => row.task_id === id); + const activity = (task_id, type, message, extra = {}) => { + store.put("activity", { + id: randomUUID(), + task_id, + type, + message, + created_at: now(), + ...extra, + }); + }; + const update = (row, patch) => { + const value = { + ...row, + ...patch, + revision: row.revision + 1, + updated_at: now(), + }; + store.put("tasks", value); + return value; + }; + const requireSettled = async (id) => { + const latest = runs(id)[0]; + if (!latest) return; + await requireSessionSettled(core, latest); + }; + const dispatch = async (taskId, runId, body) => { + const existing = store.get("sessions", runId); + if (existing) { + if (existing.task_id !== taskId) + throw new AppError(409, "执行标识已被使用。"); + return sessions(runId, {}); + } + if (dispatching.has(taskId)) + throw new AppError(409, "正在分配任务,请稍后刷新。"); + dispatching.add(taskId); + try { + const before = requireRow("tasks", taskId); + revision(before, body); + if (before.status === "done") + throw new AppError(409, "请先重新打开已完成的任务。"); + await requireSettled(taskId); + const task = requireRow("tasks", taskId); + revision(task, body); + const project = task.project_id + ? requireRow("projects", task.project_id) + : null; + const input = [ + `Task: ${task.name}`, + task.description, + body.comment ? `Task comment: ${body.comment}` : "", + project ? `Project: ${project.name}\n${project.description}` : "", + project?.repository ? `Repository: ${project.repository}` : "", + "Deliver the requested work, describe verification and unresolved blockers. Save deliverable files under ./outputs when a workspace is available. Human review decides whether this task is complete.", + ] + .filter(Boolean) + .join("\n\n"); + const row = prepareSession(store, runId, { + name: task.name, + agent_id: task.agent_id, + runtime_id: task.runtime_id, + input, + images: body.images, + files: body.files, + }); + row.task_id = taskId; + // This is a frozen product brief, not a duplicate of execution history. + row.brief = input; + row.request.metadata.task_id = taskId; + store.transaction(() => { + store.put("sessions", row); + update(task, { status: "in_progress" }); + activity(taskId, "dispatch", "已分配给 Agent", { session_id: runId }); + }); + try { + return await sessions(runId, {}); + } catch (error) { + if (!store.get("sessions", runId)) { + store.transaction(() => { + activity( + taskId, + "dispatch_rejected", + "执行请求被拒绝,请检查配置后重新分配。", + ); + }); + } + throw error; + } + } finally { + dispatching.delete(taskId); + } + }; + return async (method, path, body) => { + const match = path.match( + /^\/app\/(projects|tasks)(?:\/([a-f0-9-]{36}))?(?:\/(activity|runs)(?:\/([a-f0-9-]{36}))?)?$/, + ); + if (!match || [match[2], match[4]].some((id) => id && !uuid.test(id))) + throw new AppError(404, "Not found."); + const [, kind, id, action, operationId] = match; + if (action) { + if (kind !== "tasks" || !id) throw new AppError(404, "Not found."); + requireRow("tasks", id); + if (method === "GET" && !operationId) + return action === "runs" + ? runs(id).map(publicSession) + : store + .list("activity") + .filter((row) => row.task_id === id) + .reverse(); + if (method !== "PUT" || !operationId) + throw new AppError(405, "Method not allowed."); + if (action === "runs") return dispatch(id, operationId, body); + const old = store.get("activity", operationId); + const message = text(body.message, "评论", 10000, true).trim(); + if (old) { + if ( + old.task_id !== id || + old.type !== "comment" || + old.message !== message || + !!old.notify_agent !== !!body.notify_agent + ) + throw new AppError(409, "评论标识已被使用。"); + if (!old.notify_agent || old.delivery === "sent") return old; + } + let comment = old || { + id: operationId, + task_id: id, + type: "comment", + notify_agent: body.notify_agent === true, + delivery: body.notify_agent === true ? "pending" : undefined, + message, + created_at: now(), + }; + store.put("activity", comment); + if (comment.notify_agent && comment.delivery !== "sent") { + try { + const task = requireRow("tasks", id); + const agent = store.get("agents", task.agent_id); + if (!agent || agent.enabled === false) + throw new AppError(400, "请先分配一个已启用的 Agent。"); + let run = comment.session_id + ? store.get("sessions", comment.session_id) + : runs(id).find( + (row) => !row.deleted_at && row.agent_id === task.agent_id, + ); + if (!run) { + requireAutomaticRuntime(store, task.runtime_id); + // The comment UUID is also the creation key. A retry recovers the + // frozen task+comment request instead of starting another Session. + comment = { ...comment, session_id: operationId, initial: true }; + store.put("activity", comment); + run = await dispatch(id, operationId, { + revision: task.revision, + comment: message, + }); + } else { + comment = { ...comment, session_id: run.id }; + store.put("activity", comment); + if (!run.core_session_id) run = await sessions(run.id, {}); + if (!comment.initial && !run.core_session_id) + throw new AppError( + 428, + "任务正在等待个人连接,授权后请重试发送评论。", + ); + if (!comment.initial) + await core( + `/v1/agents/sessions/${run.core_session_id}/events`, + "POST", + { + events: [ + { + type: "agent.session.input.message", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: message }], + }, + ], + }, + ], + }, + operationId, + ); + } + comment = { ...comment, delivery: "sent", delivery_error: undefined }; + store.put("activity", comment); + } catch (error) { + store.put("activity", { + ...comment, + delivery_error: + error instanceof AppError + ? error.message + : "发送尚未确认,可重试原评论。", + }); + throw error; + } + } + return comment; + } + if (method === "GET") return id ? requireRow(kind, id) : store.list(kind); + if (!id || !["PUT", "DELETE"].includes(method)) + throw new AppError(405, "Method not allowed."); + const previous = store.get(kind, id); + revision(previous, body); + if (method === "DELETE") { + if (!previous) throw new AppError(404, "记录不存在。"); + if ( + kind === "projects" && + (store.list("tasks").some((row) => row.project_id === id) || + store + .list("feishu_bots") + .flatMap((bot) => bot.routes) + .some((row) => row.project_id === id)) + ) + throw new AppError(409, "项目仍有任务,请先移动或删除任务。"); + if ( + kind === "tasks" && + (runs(id).length || + store + .list("schedules") + .some((row) => row.task_id === id || row.pending?.task_id === id)) + ) + throw new AppError(409, "此任务已有执行记录,请保留任务及其历史。"); + store.transaction(() => { + store.remove(kind, id); + if (kind === "tasks") + for (const row of store.list("activity")) + if (row.task_id === id) store.remove("activity", row.id); + }); + return {}; + } + const value = { + id, + name: text(body.name, "标题", 80, true).trim(), + description: text(body.description, "说明", 40000), + revision: (previous?.revision || 0) + 1, + created_at: previous?.created_at || now(), + updated_at: now(), + }; + if (kind === "projects") { + value.repository = text(body.repository || "", "仓库地址", 2000).trim(); + if (value.repository) { + let url; + try { + url = new URL(value.repository); + } catch { + throw new AppError(400, "请填写有效的 HTTPS 仓库地址。"); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash + ) + throw new AppError(400, "仓库地址必须是无凭据的 HTTPS 地址。"); + } + } else { + if ( + !statuses.includes(body.status) || + !priorities.includes(body.priority) + ) + throw new AppError(400, "任务状态或优先级无效。"); + for (const [field, resource] of [ + ["project_id", "projects"], + ["agent_id", "agents"], + ["runtime_id", "runtimes"], + ]) { + value[field] = body[field] || ""; + if (value[field]) requireRow(resource, value[field]); + } + value.status = body.status; + value.priority = body.priority; + if ( + previous && + previous.status !== value.status && + ["review", "done"].includes(value.status) + ) { + await requireSettled(id); + revision(requireRow(kind, id), body); + } + // Prevent stale edits from overwriting the dispatch status after an async Core read. + revision(store.get(kind, id), body); + } + store.transaction(() => { + store.put(kind, value); + if (kind === "tasks") { + if (!previous) activity(id, "created", "创建了任务"); + else if (previous.status !== value.status) + activity(id, "status", "更新了任务状态", { + from: previous.status, + to: value.status, + }); + else activity(id, "updated", "更新了任务说明或分配"); + } + }); + return value; + }; +} diff --git a/example/parsar/shared/connection-bindings.mjs b/example/parsar/shared/connection-bindings.mjs new file mode 100644 index 000000000..f485ec533 --- /dev/null +++ b/example/parsar/shared/connection-bindings.mjs @@ -0,0 +1,10 @@ +// Fields a connection may expose to a tool environment. Refresh tokens and app secrets are never bindable. +export const connectionFields = { + access_token: "访问令牌", + client_id: "应用 ID", + region: "区域", +}; +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/session-profile.mjs b/example/parsar/shared/session-profile.mjs index 96ea469fd..77baf1684 100644 --- a/example/parsar/shared/session-profile.mjs +++ b/example/parsar/shared/session-profile.mjs @@ -1,16 +1,8 @@ // 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 (agent.harness === "mcode") - return "当前示例的用户机器支持 Codex 和 Claude Code,请先编辑 Agent 的执行引擎。"; - } - if (runtime.environment === "none" && agent.skill_ids.length) - return "Skills 需要托管运行环境,请选择托管沙箱或移除 Skill 绑定。"; + if (runtime.environment !== "openai_hosted") + return "Parsar 仅支持托管工作环境,请重新配置环境模板。"; if (agent.harness === "mcode" && agent.mcp_ids.length) return "MiniMax Code 暂不支持此处的 HTTP MCP,请选择 Claude Code 或 Codex。"; } diff --git a/example/parsar/shared/work-profile.mjs b/example/parsar/shared/work-profile.mjs new file mode 100644 index 000000000..785e6850f --- /dev/null +++ b/example/parsar/shared/work-profile.mjs @@ -0,0 +1,13 @@ +export const taskStatuses = Object.freeze({ + backlog: "待办", + in_progress: "进行中", + blocked: "受阻", + review: "待验收", + done: "已完成", +}); +export const taskPriorities = Object.freeze({ + urgent: "紧急", + high: "高", + normal: "普通", + low: "低", +}); diff --git a/example/parsar/src/AgentDetail.tsx b/example/parsar/src/AgentDetail.tsx index a0a340e5e..4778754b8 100644 --- a/example/parsar/src/AgentDetail.tsx +++ b/example/parsar/src/AgentDetail.tsx @@ -1,3 +1,8 @@ +import { useSkills } from "./Skills"; +import { useMCPs } from "./lib/product"; +import { useCapabilities } from "./Capabilities"; +import { AgentMCP } from "./AgentMCP"; +import { tr } from "./lib/i18n"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { @@ -7,15 +12,30 @@ import { MessageSquare, ChevronRight, } from "lucide-react"; -import { product, useSessions, type AgentProfile } from "./lib/product"; +import { + product, + useSessions, + useModels, + useRuntimes, + harnessNames, + type AgentProfile, +} from "./lib/product"; import { dateTime } from "./lib/api"; -import { AgentEditor } from "./Agents"; +import { AgentEditor, readAgentDraft } from "./Agents"; import { NewSession } from "./NewSession"; import { Button } from "./components/ui/button"; import { EmptyState } from "./components/ui/empty-state"; +import { Property, PropertyList } from "./components/ui/property-list"; +import { Audit } from "./Records"; import { ErrorNotice, Help, PageHeader } from "./components/shared"; export function AgentDetail({ id }: { id: string }) { - const [editing, setEditing] = useState(false); + const [tab, setTab] = useState("config"); + const skills = useSkills(), + mcps = useMCPs(), + capabilities = useCapabilities(); + const models = useModels(), + runtimes = useRuntimes(); + const [editing, setEditing] = useState(() => !!readAgentDraft(id)); const [creating, setCreating] = useState(false); const query = useQuery({ queryKey: ["agent", id], @@ -27,49 +47,138 @@ export function AgentDetail({ id }: { id: string }) { <> - 每个会话独立保存工作区和历史。修改 Agent 配置只影响新会话。 + + {tr("每个会话独立保存工作区和历史。修改 Agent 配置只影响新会话。")} + +
+ {[ + ["activity", tr("动态")], + ["config", tr("配置")], + ["audit", tr("审计")], + ["exposure", tr("对外提供")], + ].map(([key, label]) => ( + + ))} + + {tr("展开")} + +
-
-

会话

-
- {rows.map((row) => ( - - - - {row.name} - - - {row.core_session_id ? dateTime(row.created_at) : "待恢复"} - - - - ))} + {tab === "exposure" ? ( + + ) : tab === "audit" ? ( + + ) : tab === "config" && query.data ? ( +
+ + + {query.data.enabled === false ? tr("已停用") : tr("已启用")} + + + {harnessNames[query.data.harness]} + + + {models.data?.find((row) => row.id === query.data?.model_id) + ?.name || "—"} + + + {runtimes.data?.find((row) => row.id === query.data?.runtime_id) + ?.name || tr("开始会话时选择")} + + + {query.data.skill_ids + .map( + (id) => + `${skills.data?.find((row) => row.id === id)?.name || id} · ${query.data?.skill_versions?.[id] || tr("默认版本")}`, + ) + .join(", ") || "—"} + + + {query.data.mcp_ids + .map( + (id) => mcps.data?.find((row) => row.id === id)?.name || id, + ) + .join(", ") || "—"} + + + {query.data.capability_ids + ?.map( + (id) => + `${capabilities.data?.find((row) => row.id === id)?.name || id} · ${query.data?.capability_versions?.[id] || tr("默认版本")}`, + ) + .join(", ") || "—"} + + + {query.data.ask_user ? tr("已启用") : tr("已停用")} + + +

{tr("主指令")}

+

+ {query.data.instructions || tr("尚未设置指令")} +

- {!sessions.isPending && !rows.length && ( - - )} -
+ ) : ( +
+

+ {tr("会话")} +

+ + {!sessions.isPending && !rows.length && ( + + )} +
+ )} {editing && query.data && ( setEditing(false)} /> )} diff --git a/example/parsar/src/AgentMCP.tsx b/example/parsar/src/AgentMCP.tsx new file mode 100644 index 000000000..c1a65613e --- /dev/null +++ b/example/parsar/src/AgentMCP.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { product } from "./lib/product"; +import { tr } from "./lib/i18n"; +import { dateTime } from "./lib/api"; +import { Button } from "./components/ui/button"; +import { ErrorNotice } from "./components/shared"; +export function AgentMCP({ id }: { id: string }) { + const cache = useQueryClient(); + const [token, setToken] = useState(""); + const path = `agents/${id}/mcp-token`; + const status = useQuery({ + queryKey: ["mcp-token", id], + queryFn: () => product<{ credential: { expires_at: number } | null }>(path), + }); + const save = useMutation({ + mutationFn: (method: "POST" | "DELETE") => + product<{ token?: string }>(path, method, {}), + onSuccess: (result) => { + setToken(result.token || ""); + void cache.invalidateQueries({ queryKey: ["mcp-token", id] }); + }, + }); + const configuration = { + mcpServers: { + parsar: { + type: "http", + url: `${location.origin}/mcp/agents/${id}`, + headers: { Authorization: `Bearer ${token || ""}` }, + }, + }, + }; + return ( +
+

{tr("通过 MCP 调用 Agent")}

+

+ {tr( + "提供 ask_agent 和 get_run。使用 Agent 的默认运行时,业务提问在 Parsar 中回答。地址仅对本机客户端开放。", + )} +

+ + {status.data?.credential && ( +

+ {tr("到期时间")}: {dateTime(status.data.credential.expires_at / 1000)} +

+ )} +
+ + {status.data?.credential && ( + + )} +
+ {token && ( +

{tr("密钥仅显示一次,请复制到客户端配置。")}

+ )} +
+        {JSON.stringify(configuration, null, 2)}
+      
+ +
+ ); +} diff --git a/example/parsar/src/Agents.tsx b/example/parsar/src/Agents.tsx index acb8b7d5c..ce45c0064 100644 --- a/example/parsar/src/Agents.tsx +++ b/example/parsar/src/Agents.tsx @@ -1,4 +1,9 @@ -import { useState } from "react"; +import { ListSlot } from "./components/plugin/SlotRenderer"; +import { tr } from "./lib/i18n"; +import { AgentDetail } from "./AgentDetail"; +import { ResizeHandle } from "./components/ui/resize-handle"; +import { useResizableWidth } from "./lib/layout-width"; +import { useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, Copy, Trash2, Layers } from "lucide-react"; import { @@ -6,9 +11,9 @@ import { useAgents, useModels, type AgentProfile, + harnessNames, } from "./lib/product"; import { ConfigurationFields, emptyConfiguration } from "./ConfigurationFields"; - import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import { Select, SelectOption } from "./components/ui/select"; @@ -21,23 +26,67 @@ import { } from "./components/ui/dialog"; import { ErrorNotice, Field, Help, PageHeader } from "./components/shared"; import { EmptyState } from "./components/ui/empty-state"; -export function Agents() { +import { Ledger, LedgerHeader, LedgerRow } from "./components/ui/ledger"; +const draftKey = "parsar-agent-drafts"; +function readAgentDrafts(): Record { + try { + const value = JSON.parse(sessionStorage.getItem(draftKey) || "{}"); + return value && typeof value === "object" && !Array.isArray(value) + ? value + : {}; + } catch { + return {}; + } +} +function writeAgentDraft(form: AgentProfile) { + try { + const drafts = readAgentDrafts(); + delete drafts[form.id]; + drafts[form.id] = form; + sessionStorage.setItem(draftKey, JSON.stringify(drafts)); + } catch { + /* Editing remains available without browser storage. */ + } +} +function clearAgentDraft(id: string) { + try { + const drafts = readAgentDrafts(); + delete drafts[id]; + sessionStorage.setItem(draftKey, JSON.stringify(drafts)); + } catch { + /* Browser storage may be unavailable. */ + } +} +export function readAgentDraft(id?: string): AgentProfile | null { + const drafts = readAgentDrafts(); + return (id ? drafts[id] : Object.values(drafts).at(-1)) || null; +} +export function Agents({ selectedId }: { selectedId?: string }) { + const rail = useResizableWidth({ + storageKey: "agent-rail", + defaultWidth: 384, + min: 320, + max: 720, + edge: "left", + }); const query = useAgents(); const models = useModels(); const cache = useQueryClient(); const [search, setSearch] = useState(""); - const [editing, setEditing] = useState(null); - + const [editing, setEditing] = useState(() => + selectedId ? null : readAgentDraft(), + ); const remove = useMutation({ mutationFn: (id: string) => product(`agents/${id}`, "DELETE"), onSuccess: () => cache.invalidateQueries({ queryKey: ["agents"] }), }); return ( <> - + - Agent 保存模型、指令、Skills 和 - MCP。开始会话时选择运行时,每个会话有独立的工作区和历史。 + {tr( + "Agent 模板定义职责,绑定模型、Skills 和 MCP,并选择默认环境模板。通过网页或飞书开始任务。", + )} -
+ {Object.values(readAgentDrafts()).length > 0 && ( +
+ {tr("未保存的草稿")} + {Object.values(readAgentDrafts()).map((draft) => ( + + ))} +
+ )} +
setSearch(e.target.value)} className="max-w-sm" />
-
-
+
+ + + + Agent + {tr("引擎")} + {tr("模型")} + {tr("能力")} + + {query.data - ?.filter((t) => t.name.toLowerCase().includes(search.toLowerCase())) + ?.filter((agent) => + `${agent.name} ${agent.instructions}` + .toLowerCase() + .includes(search.toLowerCase()), + ) .map((agent) => ( -
-
- -

- {agent.name} -

+ + + + {agent.name} + + {agent.instructions} + + + + {harnessNames[agent.harness]} + + + {models.data?.find((m) => m.id === agent.model_id)?.name || + tr("模型不可用")} + + + {agent.skill_ids.length} Skills · {agent.mcp_ids.length} MCP + +
-
-

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

-
- - {models.data?.find((m) => m.id === agent.model_id)?.name || - "模型不可用"} - - {!!agent.skill_ids.length && ( - - {agent.skill_ids.length} Skills - - )} - {!!agent.mcp_ids.length && ( - - {agent.mcp_ids.length} MCP - - )} -
-
-
-
+ ))} -
- {!query.isPending && !query.data?.length && ( - + {!query.isPending && !query.data?.length && ( + + )} + + {selectedId && ( +
+ + + {rail.dirty && ( +
+ + +
+ )} +
)}
{editing && ( @@ -150,13 +249,20 @@ export function AgentEditor({ close: () => void; }) { const cache = useQueryClient(); - const [form, setForm] = useState(value); + const [form, setForm] = useState(() => { + const draft = readAgentDraft(value.id); + return draft?.id === value.id ? draft : value; + }); + useEffect(() => { + writeAgentDraft(form); + }, [form]); const [preset, setPreset] = useState(""); const save = useMutation({ mutationFn: () => product(`agents/${form.id}`, "PUT", form), onSuccess: () => { void cache.invalidateQueries({ queryKey: ["agents"] }); void cache.invalidateQueries({ queryKey: ["agent", form.id] }); + clearAgentDraft(form.id); close(); }, }); @@ -169,23 +275,23 @@ export function AgentEditor({ > - + - {value.revision ? "编辑 Agent" : "新建 Agent"} + {value.revision ? tr("编辑 Agent") : tr("新建 Agent")}
{ e.preventDefault(); save.mutate(); }} > {!value.revision && ( - + )} - + setForm({ ...form, ...configuration })} /> + - + +
diff --git a/example/parsar/src/App.tsx b/example/parsar/src/App.tsx index c88138b90..b0301e659 100644 --- a/example/parsar/src/App.tsx +++ b/example/parsar/src/App.tsx @@ -1,110 +1,288 @@ +import { Connections } from "./Connections"; +import { ListSlot } from "./components/plugin/SlotRenderer"; +import { Inbox } from "./Questions"; +import { tr } from "./lib/i18n"; import { useEffect, useState } from "react"; -import { Bot, Cpu, BookOpen, Plug, Server, Moon, Sun } from "lucide-react"; -import { motion, useReducedMotion } from "motion/react"; -import { Models } from "./Models"; -import { Skills } from "./Skills"; -import { MCPs, Runtimes } from "./Resources"; +import { + Bot, + BookOpen, + Server, + Moon, + Sun, + Columns3, + Folder, + MessageSquare, + Clock3, + Play, + ChartBar, + ScrollText, + Settings2, + Monitor, +} from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { Capabilities } from "./Capabilities"; +import { Records, Audit } from "./Records"; +import { Settings, type WorkspaceSettings } from "./Settings"; +import { product } from "./lib/product"; +import { useResizableWidth } from "./lib/layout-width"; +import { ResizeHandle } from "./components/ui/resize-handle"; +import { Schedules } from "./Schedules"; +import { Feishu } from "./Feishu"; +import { Tasks } from "./Tasks"; +import { TaskDetail } from "./TaskDetail"; +import { Projects } from "./Projects"; +import { Runtimes } from "./Resources"; import { Agents } from "./Agents"; import { SessionPage } from "./SessionPage"; import { AgentDetail } from "./AgentDetail"; import { Button } from "./components/ui/button"; - export function App() { - const [route, setRoute] = useState(location.hash.slice(1) || "/agents"); - const [dark, setDark] = useState(() => { + const [route, setRoute] = useState(location.hash.slice(1) || "/tasks"); + const workspace = useQuery({ + queryKey: ["settings"], + queryFn: () => product("settings"), + }); + const sidebar = useResizableWidth({ + storageKey: "sidebar", + defaultWidth: 232, + min: 200, + max: 360, + edge: "right", + }); + const [theme, setTheme] = useState(() => { try { - return localStorage.getItem("oac-example-theme") === "dark"; + return localStorage.getItem("oac-example-theme") || "system"; } catch { - return false; + return "system"; } }); - const reduce = useReducedMotion(); + const [systemDark, setSystemDark] = useState( + matchMedia("(prefers-color-scheme: dark)").matches, + ); + const dark = theme === "dark" || (theme === "system" && systemDark); useEffect(() => { - const listener = () => setRoute(location.hash.slice(1) || "/agents"); + const media = matchMedia("(prefers-color-scheme: dark)"); + const change = () => setSystemDark(media.matches); + media.addEventListener("change", change); + return () => media.removeEventListener("change", change); + }, []); + useEffect(() => { + const listener = () => setRoute(location.hash.slice(1) || "/tasks"); window.addEventListener("hashchange", listener); return () => window.removeEventListener("hashchange", listener); }, []); useEffect(() => { document.documentElement.dataset.theme = dark ? "dark" : "light"; try { - localStorage.setItem("oac-example-theme", dark ? "dark" : "light"); + localStorage.setItem("oac-example-theme", theme); } catch { /* Theme remains usable without storage. */ } - }, [dark]); - const agentId = route.match(/^\/agents\/([a-f0-9-]{36})$/)?.[1]; + }, [dark, theme]); + const agentId = route + .split("?")[0]! + .match(/^\/agents\/([a-f0-9-]{36})$/)?.[1]; const sessionId = route.match(/^\/sessions\/([a-f0-9-]{36})$/)?.[1]; + const taskId = route.match(/^\/tasks\/([a-f0-9-]{36})$/)?.[1]; + const projectId = + new URLSearchParams(route.split("?")[1]).get("project") || ""; const navigation = [ - { href: "/agents", label: "Agents", icon: Bot }, - { href: "/models", label: "模型", icon: Cpu }, - { href: "/skills", label: "Skills", icon: BookOpen }, - { href: "/mcps", label: "MCP", icon: Plug }, - { href: "/runtimes", label: "运行时", icon: Server }, + { href: "/tasks", label: tr("任务"), icon: Columns3, group: tr("工作") }, + { href: "/projects", label: tr("项目"), icon: Folder, group: tr("工作") }, + { + href: "/conversations", + label: tr("对话"), + icon: MessageSquare, + group: tr("活动"), + }, + { + href: "/inbox", + label: tr("收件箱"), + icon: MessageSquare, + group: tr("活动"), + }, + { href: "/runs", label: tr("运行记录"), icon: Play, group: tr("活动") }, + { + href: "/schedules", + label: tr("定时任务"), + icon: Clock3, + group: tr("活动"), + }, + { href: "/usage", label: tr("用量"), icon: ChartBar, group: tr("活动") }, + { + href: "/audit", + label: tr("审计日志"), + icon: ScrollText, + group: tr("活动"), + }, + { href: "/agents", label: tr("Agent 模板"), icon: Bot, group: tr("构建") }, + { + href: "/runtimes", + label: tr("环境模板"), + icon: Server, + group: tr("构建"), + }, + { + href: "/capabilities", + label: tr("资源库"), + icon: BookOpen, + group: tr("构建"), + }, + { + href: "/connections", + label: tr("我的连接"), + icon: Settings2, + group: tr("接入与设置"), + }, + { + href: "/feishu", + label: tr("飞书接入"), + icon: MessageSquare, + group: tr("接入与设置"), + }, + { + href: "/settings", + label: tr("设置"), + icon: Settings2, + group: tr("接入与设置"), + }, ]; return (
-
- {sessionId ? ( + + {["/conversations", "/runs", "/usage"].includes(route) ? ( + + ) : route === "/inbox" ? ( + + ) : route === "/audit" ? ( + + ) : route === "/credentials" ? ( + + ) : route === "/settings" ? ( + + ) : taskId ? ( + + ) : route.split("?")[0] === "/tasks" ? ( + + ) : route === "/projects" ? ( + + ) : sessionId ? ( ) : agentId ? ( - + new URLSearchParams(route.split("?")[1]).get("view") === "full" ? ( + + ) : ( + + ) ) : route === "/models" ? ( - + + ) : route === "/capabilities" ? ( + ) : route === "/skills" ? ( - + ) : route === "/mcps" ? ( - + + ) : route === "/schedules" ? ( + + ) : route === "/connections" ? ( + + ) : route === "/feishu" ? ( + ) : route === "/runtimes" ? ( ) : ( diff --git a/example/parsar/src/Capabilities.tsx b/example/parsar/src/Capabilities.tsx new file mode 100644 index 000000000..1cb3bacdc --- /dev/null +++ b/example/parsar/src/Capabilities.tsx @@ -0,0 +1,371 @@ +import { tr } from "./lib/i18n"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Package, BookOpen, FileText } from "lucide-react"; +import { product } from "./lib/product"; +import { Models } from "./Models"; +import { Credentials } from "./Credentials"; +import { Skills } from "./Skills"; +import { MCPs } from "./Resources"; +import { PageHeader, ErrorNotice, Field } from "./components/shared"; +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { Textarea } from "./components/ui/textarea"; +import { Select, SelectOption } from "./components/ui/select"; +import { Ledger, LedgerHeader, LedgerRow } from "./components/ui/ledger"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "./components/ui/dialog"; +import { EmptyState } from "./components/ui/empty-state"; +export interface Capability { + id: string; + name: string; + description: string; + kind: "prompt" | "knowledge" | "plugin"; + revision: number; + deprecated: boolean; + default_version: string; + versions: { + version: string; + content?: string; + files?: string[]; + has_archive?: boolean; + has_client_bundle?: boolean; + mcp_servers?: { name: string; transport: string }[]; + }[]; +} +export const useCapabilities = () => + useQuery({ + queryKey: ["capabilities"], + queryFn: () => product("capabilities"), + }); +export function Capabilities({ + initialTab = "models", +}: { + initialTab?: string; +}) { + const [tab, setTab] = useState(initialTab); + return ( + <> + +
+ {[ + ["models", tr("模型")], + ["skills", "Skills"], + ["mcps", "MCP"], + ["plugin", "Plugins"], + ["prompt", tr("指令片段")], + ["knowledge", tr("知识")], + ["credentials", tr("凭据")], + ].map(([key, label]) => ( + + ))} +
+ {tab === "models" ? ( + + ) : tab === "credentials" ? ( + + ) : tab === "skills" ? ( + + ) : tab === "mcps" ? ( + + ) : ( + + )} + + ); +} +const labels = { + prompt: tr("指令片段"), + knowledge: tr("知识"), + plugin: "Plugins", +}; +function CapabilityList({ kind }: { kind: Capability["kind"] }) { + const query = useCapabilities(); + const [editing, setEditing] = useState(null); + const rows = query.data?.filter((row) => row.kind === kind); + const Icon = + kind === "plugin" ? Package : kind === "knowledge" ? BookOpen : FileText; + return ( + <> + + + + + + + + {tr("名称")} + {tr("说明")} + {tr("默认版本")} + {tr("状态")} + + {rows?.map((row) => ( + + + + {row.description} + v{row.default_version} + {row.deprecated ? tr("已停用") : tr("可用")} + + ))} + {!rows?.length && } + + {editing && ( + setEditing(null)} /> + )} + + ); +} +export function CapabilityEditor({ + value, + close, + onSaved, +}: { + value: Capability; + close: () => void; + onSaved?: (id: string) => void; +}) { + const cache = useQueryClient(), + [form, setForm] = useState(value), + [content, setContent] = useState( + value.revision ? undefined : "", + ), + [archive, setArchive] = useState(""), + [clientBundle, setClientBundle] = useState(), + [error, setError] = useState(""); + const selected = form.versions.find( + (version) => version.version === form.default_version, + ); + const finish = () => { + void cache.invalidateQueries({ queryKey: ["capabilities"] }); + close(); + }; + const save = useMutation({ + mutationFn: () => + product(`capabilities/${form.id}`, "PUT", { + ...form, + ...(content !== undefined && form.kind !== "plugin" + ? { content, default_version: "" } + : {}), + ...(archive ? { archive, default_version: "" } : {}), + ...(clientBundle !== undefined + ? { client_bundle: clientBundle, default_version: "" } + : {}), + }), + onSuccess: () => { + onSaved?.(form.id); + finish(); + }, + }); + const remove = useMutation({ + mutationFn: () => + product(`capabilities/${form.id}`, "DELETE", { revision: form.revision }), + onSuccess: finish, + }); + return ( + { + if (!open && !save.isPending) close(); + }} + > + + + + {form.revision ? tr("编辑能力") : tr("添加能力")} + + +
{ + event.preventDefault(); + event.stopPropagation(); + save.mutate(); + }} + > + + setForm({ ...form, name: e.target.value })} + /> + + + + setForm({ ...form, description: e.target.value }) + } + /> + + {form.versions.length > 0 && ( + + + + )} + {form.kind === "plugin" ? ( + <> + + { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 32 * 1024 * 1024) + return setError(tr("文件不能超过 32 MiB。")); + const reader = new FileReader(); + reader.onload = () => { + setArchive(String(reader.result).split(",")[1] || ""); + setError(""); + }; + reader.onerror = () => setError(tr("无法读取文件。")); + reader.readAsDataURL(file); + }} + /> + + + { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 1024 * 1024) + return setError(tr("前端扩展不能超过 1 MiB。")); + setClientBundle(await file.text()); + }} + /> + +

+ {tr( + "仅安装你信任的前端扩展。它以当前页面的权限运行,可访问 Parsar 数据和公开 API。", + )} +

+ {selected?.has_client_bundle && ( + + )} + {selected?.mcp_servers?.map((server) => ( +

+ {server.name} · {server.transport} +

+ ))} + {selected?.files && ( +
+                  {selected.files.join("\n")}
+                
+ )} + + ) : ( + +