From 598f34498ac946a97b2427edccd6846b4db49ce9 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 14 Oct 2026 06:23:06 +0000 Subject: [PATCH 01/12] Build Parsar collaborative workspace --- CONTRIBUTING.md | 6 +- docs/api/index.md | 2 + docs/examples.md | 6 +- docs/zh/api/index.md | 4 +- docs/zh/examples.md | 8 +- example/parsar/README.md | 50 +- example/parsar/package.json | 9 +- example/parsar/playwright.config.ts | 4 +- example/parsar/pnpm-lock.yaml | 400 +++++++++++ example/parsar/server.mjs | 94 ++- example/parsar/server/feishu.mjs | 831 +++++++++++++++++++++ example/parsar/server/product.mjs | 25 + example/parsar/server/schedules.mjs | 199 ++++++ example/parsar/server/sessions.mjs | 35 +- example/parsar/server/store.mjs | 17 +- example/parsar/server/work.mjs | 252 +++++++ example/parsar/shared/work-profile.mjs | 13 + example/parsar/src/App.tsx | 32 +- example/parsar/src/Composer.tsx | 3 + example/parsar/src/Feishu.tsx | 362 ++++++++++ example/parsar/src/Projects.tsx | 217 ++++++ example/parsar/src/Schedules.tsx | 229 ++++++ example/parsar/src/SessionDetail.tsx | 20 +- example/parsar/src/SessionPage.tsx | 12 +- example/parsar/src/TaskDetail.tsx | 389 ++++++++++ example/parsar/src/Tasks.tsx | 393 ++++++++++ example/parsar/src/lib/product.ts | 33 + example/parsar/tests/fixture.mjs | 2 + example/parsar/tests/server.test.mjs | 8 +- example/parsar/tests/work.test.mjs | 952 +++++++++++++++++++++++++ example/parsar/tests/workflow.spec.ts | 78 ++ example/parsar/tsconfig.json | 3 +- 32 files changed, 4612 insertions(+), 76 deletions(-) create mode 100644 example/parsar/server/feishu.mjs create mode 100644 example/parsar/server/schedules.mjs create mode 100644 example/parsar/server/work.mjs create mode 100644 example/parsar/shared/work-profile.mjs create mode 100644 example/parsar/src/Feishu.tsx create mode 100644 example/parsar/src/Projects.tsx create mode 100644 example/parsar/src/Schedules.tsx create mode 100644 example/parsar/src/TaskDetail.tsx create mode 100644 example/parsar/src/Tasks.tsx create mode 100644 example/parsar/tests/work.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7973a5e9d..e42da95a6 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 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 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 and configured Feishu groups 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..d39123ee4 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -23,3 +23,5 @@ 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. diff --git a/docs/examples.md b/docs/examples.md index 2b2f5f97c..a23d9e0e9 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 6837204f5..4b65dafd2 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: 5664f0d9cdcf581bbd8f4cc2f6447285b8134a18b072a56dbf867a3f302136fe --- Core 提供三个命名空间。每个命名空间都有一种调用方及其独立凭据,凭据只能在其所属命名空间中使用。 @@ -25,3 +25,5 @@ Core 提供三个命名空间。每个命名空间都有一种调用方及其独 运维脚本通过经 Core key 认证的 `GET` 和 `PUT /core/v1/workspace-storage` 配置独立文件系统存储;参见[工作区存储](../../../contracts/agents-api/zh/admin-api.md#workspace-storage)。 Web 和运维脚本通过 Core key 认证的 `PATCH /core/v1/sandbox/deployment` 更新沙箱空闲策略;参见[沙箱部署](../../../contracts/agents-api/zh/sandbox-deployment.md)。 + +可选的 [应用示例](../../../CONTRIBUTING.md#optional-application-example)拥有自己的回环 `/app/` 产品路由,调用方是同源本地 UI;进程内工作器复用产品操作。其服务器使用 Project API 密钥调用公开 `/v1`,不接受 Core 管理员凭据。 diff --git a/docs/zh/examples.md b/docs/zh/examples.md index 8730c4a63..0a45534f1 100644 --- a/docs/zh/examples.md +++ b/docs/zh/examples.md @@ -1,18 +1,18 @@ --- title: "示例" source: docs/examples.md -source_hash: 3c10e50ae47479688cdfac708e82efd73bd5b5f77a2e8b16497a4e02cb14573d +source_hash: fea7d5cf57c60b9d55214180805f9f111d1137926734e968319d64afff5fba96 --- 基于 [Agents API](api/public-agent-api.md)构建的完整应用。每个示例都使用 Project 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: 3c10e50ae47479688cdfac708e82efd73bd5b5f77a2e8b16497a4e02cb14573d | 功能 | 使用的 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..3b217376d 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -1,21 +1,51 @@ -# 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. +An independently started task workspace on OpenAgentCore, bringing Parsar’s task dispatch and run history into an optional public-API example. Organize projects and tasks, assign reusable Agents, continue execution conversations, inspect files, record delivery decisions, run recurring work and dispatch from Feishu groups. Agent execution uses bypass mode without approval gates. Core owns execution; Parsar owns product planning and channel delivery. ## Run Use Node 22.13+ (for built-in SQLite) and pnpm 10.30.3. Configure [Core](../../docs/getting-started/install.md), a deployment model/provider, and a Project API key. Store secrets in a private environment file outside the checkout: ```sh -export OAC_EXAMPLE_CORE_URL='http://127.0.0.1:8091' -export OAC_EXAMPLE_PROJECT_KEY='' +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 without sending credentials over plaintext networks. `OAC_EXAMPLE_PORT` changes the local port. For a built version, run `pnpm --filter @oac/parsar-example build` and then `pnpm --filter @oac/parsar-example start`. -## Main flow +## Tasks and projects + +**项目** stores shared context and an optional HTTPS repository URL. Context is included in each new task dispatch; a repository URL is an instruction, not automatic credential provisioning or checkout. **任务** offers board and list views, search by title/body/ID, and project/Agent filters. Tasks have a brief, priority, project, Agent and runtime. Tasks may be planned before those resources are configured. + +**分配执行** creates a Session with a frozen task brief, project context and Agent configuration. **重新执行** creates another independent Session; use the original Session for follow-ups that need its history and workspace. Agent/runtime edits affect subsequent dispatches. An unresolved creation, a provisioning Session with no Turn, or an active Turn blocks another dispatch. **恢复执行** retries the original request and key after a lost response or server restart. Repeated concurrent dispatches cannot allocate two Sessions for one task revision. + +The task detail shows durable comments, activity and run links. Comments are product records; they are not automatically sent to the Agent. Open a run to stream output, send a follow-up, cancel, connect a user machine, upload inputs or download Artifacts. Task navigation remains available from the Session. On a user-machine run, **填入任务说明,连接后发送** inserts the frozen brief into the composer for explicit sending once connected. + +Business states (**待办**, **进行中**, **受阻**, **待验收**, **已完成**) remain distinct from Core’s execution states. Dispatch sets **进行中**; completion is recorded by the operator. Marking a task ready for review or done checks that its latest execution has settled. **待验收** is an optional progress state, not an execution permission or approval gate. Reopen a done task before dispatching again. Tasks with execution history cannot be deleted; referenced projects, Agents and runtimes cannot be deleted. + +## Recurring work + +**定时任务** uses an existing task as a template and an interval of 5–525600 minutes. Each occurrence freezes the current template and creates an independent task and Session. Execution must use a hosted or text-only runtime. The server must stay running; after downtime, missed intervals coalesce into one occurrence. A still-unsettled previous occurrence causes that interval to be skipped, preventing overlap. + +Transient failures automatically retry the original occurrence without creating another task or Session. A definite rejection pauses the plan with its error and a task link; correct the configuration and enable the plan. A manually recovered task is reused when the plan resumes. Pending occurrences retain their original task/run IDs. Pausing stops future dispatches and does not cancel admitted work. Deleting a plan keeps created tasks and is blocked while an occurrence is unconfirmed. + +## Feishu + +**飞书** configures one enterprise self-built bot per installation, with up to 20 group routes. Each route selects a group ID, optional project, Agent and hosted/text-only runtime. Only explicit mentions of this bot in configured groups trigger work. Private messages, other bots and `@all` do not. Group membership is the authorization boundary: trusted members can run the selected Agent with its configured capabilities. Enable **允许群成员切换 Agent(共享机器人)** to let trusted members choose any Agent configured in this installation with `/list` and `/select `. The preference is per sender and group, applies to new tasks, and survives restarts; existing task threads keep their Session. The group’s configured runtime still applies, and unsupported combinations are rejected. With this switch off, the route’s Agent is fixed. User accounts and fine-grained permissions are not part of this example. + +1. Create a self-built application in the [Feishu developer console](https://open.feishu.cn/app), enable its bot, and grant `im:message.group_at_msg:readonly` and `im:message:send_as_bot`. +2. Select long-connection event delivery and subscribe to `im.message.receive_v1`; publish the application and add the bot to your group. +3. Enter **App ID** and **App Secret**, add each allowed group and its execution configuration, enable **启用飞书机器人**, and save. Secrets stay in restricted server-side SQLite; blank secret edits preserve the saved value. No public callback route is needed. +4. Mention the bot with a text task. Parsar creates a task and Session and replies in the same thread. Reply in that thread with another mention to continue the same Session. Each new top-level message starts an independent task. Route changes affect new threads; existing threads keep their execution binding. +5. Use `/cancel`, `/review`, `/done` or `/reopen` in the same thread with a bot mention. Cancellation takes priority over queued follow-ups: earlier unconfirmed messages in that thread are withdrawn so they cannot restart work after cancellation. An uncertain Session creation is recovered before cancelling the actual Session. Cancellation acknowledgement is separate from confirmed stop. Execution has no approval workflow. + +The official Feishu SDK owns authentication, normalization and WebSocket lifecycle. Its awaited event dispatcher saves accepted ingress in SQLite before acknowledging; a product worker submits the public Agents API request using a stable idempotency key. It reconciles durable Turns/Items after restarts and queues one final text reply per completed/failed/cancelled Turn. Summaries are bounded and include task IDs for Web search; complete history and file downloads remain available in the Web Session. Feishu media upload/download, streaming cards, personal bots, OAuth sign-in and other IM platforms are outside this example. + +**最近投递** shows the latest 100 inbound/outbound receipts plus every unresolved receipt, with errors and **重试此投递**. Transient failures, including a busy Session, retry automatically with backoff capped at five minutes; definite rejections remain visible for correction. Failed ingress pauses subsequent messages in the same thread until the original receipt is retried. Feishu replies reuse a persisted UUID; uncertain outbound sends are never automatically replayed beyond 55 minutes because the vendor’s deduplication window is one hour. An expired uncertain receipt requires checking the original thread manually. Disabling the bot pauses ingestion and delivery without cancelling Core work. Removing a group or changing App ID never sends saved messages through another identity. + +## Agent and Session configuration 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. @@ -28,11 +58,11 @@ Agent edits and catalog updates apply to future Sessions. Core freezes existing ## Ownership and storage -The loopback Node server has two small responsibilities: a fixed allowlist proxy for public Core resources, and `/app/` CRUD for product-owned configuration. One SQLite table stores typed JSON records. There is no ORM, background scheduler, execution database. +The loopback Node server exposes a fixed allowlist proxy for public Core resources and `/app/` operations for product-owned configuration, projects, tasks, comments, plans and channel receipts. One SQLite table stores typed JSON records. There is no ORM or product execution database. In-process workers handle interval plans and durable Feishu delivery; they call the same Session and task flows as the Web UI. -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}`. +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 and the Feishu App Secret are stored there. Builds and caches use `${OAC_DEV_HOME:-$HOME/.oac}`. -Use one server process and a dedicated Project for this local single-user example. The server rejects cross-origin writes and keeps the Project key out of browser responses. Session creation freezes a pending request in SQLite before calling Core, and uses the local Session ID as its idempotency key. An uncertain response can be recovered from the Agent's Session list, including after a server restart. Successful creation leaves only the Core Session reference and local title/Agent/runtime relationship; Core owns messages, turns and execution status. The browser consumes public SSE text deltas through a non-buffering proxy, reconnects after interruption, and reconciles with durable history to avoid duplicate final replies. Each send records request-return latency and first nonempty text-delta latency from browser request start. These independent observations remain in sessionStorage across reloads; missing observations are shown as a dash rather than inferred from history. Definite validation rejection removes the pending request so it can be corrected. Use one server process. Deleting a local resource is blocked while another local record references it; Session deletion/archival is outside this example. Skill deletion is a separate explicit Core operation. Earlier templates and instances become Agent configs on startup; they are never presented as actual Sessions. +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. Task-linked Sessions additionally retain the frozen product brief and task reference. Feishu stores message delivery receipts and reply payloads for restart recovery, not a second authoritative execution history. 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. 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. A few more implementation details: @@ -48,7 +78,7 @@ 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 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, projects/tasks, plans, Feishu configuration and mobile/help behavior. Durable worker tests cover duplicate delivery, frozen requests, safe retry windows and non-overlapping scheduled occurrences. Synthetic fixtures are not model execution. The opt-in live browser probe requires an already started example backed by an isolated real Project: diff --git a/example/parsar/package.json b/example/parsar/package.json index 45500e2f6..7fb089e90 100644 --- a/example/parsar/package.json +++ b/example/parsar/package.json @@ -8,11 +8,12 @@ "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", "test:e2e": "playwright test --config playwright.config.ts", "test:live": "node tests/live.mjs" }, "dependencies": { + "@larksuiteoapi/node-sdk": "^1.75.0", "@oac/agents-client": "workspace:*", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-select": "2.3.2", @@ -31,17 +32,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..c62c7ab2f 100644 --- a/example/parsar/pnpm-lock.yaml +++ b/example/parsar/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@larksuiteoapi/node-sdk': + specifier: ^1.75.0 + version: 1.75.0 '@oac/agents-client': specifier: workspace:* version: link:../../packages/agents-client @@ -280,6 +283,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@larksuiteoapi/node-sdk@1.75.0': + resolution: {integrity: sha512-HVHamfS/skiYsKTCpk6z7oIux3eo0DYQR6HZXGMHsNFZ4rYkhcKKSD9aU7iY26grupl9AP+cg3fisXNcMrqyLA==} + '@oxc-project/types@0.150.0': resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} @@ -288,6 +294,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 +1110,10 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1085,9 +1122,23 @@ 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==} + 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,6 +1165,10 @@ 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==} @@ -1140,6 +1195,10 @@ 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'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1154,13 +1213,33 @@ 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'} + 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'} @@ -1195,6 +1274,19 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + 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'} + framer-motion@13.4.3: resolution: {integrity: sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ==} peerDependencies: @@ -1211,13 +1303,40 @@ packages: 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==} @@ -1227,6 +1346,10 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1398,6 +1521,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==} @@ -1412,6 +1547,10 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -1541,6 +1680,14 @@ 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-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + motion-dom@13.4.2: resolution: {integrity: sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w==} @@ -1566,6 +1713,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + 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'} @@ -1604,6 +1755,18 @@ 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-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: @@ -1669,6 +1832,22 @@ packages: scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + 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==} @@ -1872,6 +2051,18 @@ packages: engines: {node: '>=8'} hasBin: true + 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'} @@ -1996,12 +2187,47 @@ 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 + '@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 +2879,42 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + 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: {} + 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,6 +2933,10 @@ snapshots: clsx@2.1.1: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} convert-source-map@2.0.0: {} @@ -2695,6 +2953,8 @@ snapshots: dependencies: character-entities: 2.0.2 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -2705,13 +2965,34 @@ 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 + 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 +3021,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + optional: true escape-string-regexp@5.0.0: {} @@ -2759,6 +3041,16 @@ snapshots: fflate@0.8.3: {} + 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 + framer-motion@13.4.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: motion-dom: 13.4.2 @@ -2771,10 +3063,42 @@ snapshots: 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 @@ -2801,6 +3125,13 @@ snapshots: html-url-attributes@3.0.1: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -2916,6 +3247,14 @@ 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): @@ -2928,6 +3267,8 @@ snapshots: markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -3272,6 +3613,12 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + motion-dom@13.4.2: dependencies: motion-utils: 13.3.0 @@ -3290,6 +3637,8 @@ snapshots: nanoid@3.3.19: {} + object-inspect@1.13.4: {} + obug@2.2.1: {} parse-entities@4.0.2: @@ -3327,6 +3676,27 @@ 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-from-env@2.1.0: {} + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 @@ -3436,6 +3806,34 @@ snapshots: scheduler@0.28.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: {} @@ -3592,6 +3990,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.22.0: {} + yaml@2.8.0: optional: true diff --git a/example/parsar/server.mjs b/example/parsar/server.mjs index bf81f97e6..c2341305b 100644 --- a/example/parsar/server.mjs +++ b/example/parsar/server.mjs @@ -7,6 +7,8 @@ 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 { feishuWorker, createFeishuTransport } from "./server/feishu.mjs"; const root = fileURLToPath(new URL(".", import.meta.url)); const routes = [ @@ -43,13 +45,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 +59,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,39 +79,43 @@ 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)); return async (req, res) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); @@ -339,15 +345,31 @@ if ( }) : undefined; const store = openStore(dataPath(config)); + const core = coreClient(config); + const productHandler = productAPI(store, core); + const feishu = feishuWorker( + store, + core, + productHandler, + createFeishuTransport, + ); + 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 () => { + await Promise.all([feishu.stop(), schedules.stop()]); server.close(() => store.close()); void vite?.close(); }); diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs new file mode 100644 index 000000000..ea16e0cef --- /dev/null +++ b/example/parsar/server/feishu.mjs @@ -0,0 +1,831 @@ +import { createHash, randomUUID } from "node:crypto"; +import { AppError, text, uuid, transientError } from "./store.mjs"; +import { requireAutomaticRuntime } from "./sessions.mjs"; +import { taskStatuses } from "../shared/work-profile.mjs"; +import { sessionRestriction } from "../shared/session-profile.mjs"; + +const stamp = () => Date.now(); +const selectionId = (app, chat, sender) => stableId(`${app}:${chat}:${sender}`); +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)}`; +}; +export const publicFeishu = ({ app_secret, ...record }) => ({ + ...record, + has_app_secret: !!app_secret, +}); +export function feishuAPI(store) { + return async (method, path, body) => { + const old = store.get("feishu", "settings") || { + id: "settings", + revision: 0, + enabled: false, + app_id: "", + app_secret: "", + routes: [], + }; + 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/status" && method === "GET") + return store.get("feishu_health", "connection") || { 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, ...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, + }; + }); + if (path !== "/app/feishu") throw new AppError(404, "Not found."); + if (method === "GET") return publicFeishu(old); + if (method !== "PUT") throw new AppError(405, "Method not allowed."); + if (old.revision !== body.revision) + throw new AppError(409, "配置已更新,请重新加载。"); + const app_id = text(body.app_id, "App ID", 100).trim(); + 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 (!/^oc_[a-zA-Z0-9_-]+$/.test(chat_id)) + throw new AppError(400, "群 ID 应以 oc_ 开头。"); + const agent = store.get( + "agents", + text(route.agent_id, "Agent", 36, true), + ), + runtime = store.get( + "runtimes", + text(route.runtime_id, "运行时", 36, true), + ); + if (!agent || !runtime) + throw new AppError(400, "请选择群对应的 Agent 和运行时。"); + if (body.enabled) { + 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: 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: "settings", + app_id, + app_secret: secret, + enabled: body.enabled, + routes, + revision: old.revision + 1, + }; + store.put("feishu", value); + return publicFeishu(value); + }; +} + +// 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) { + let channel, + active, + timer, + stopping = false, + running; + const health = (state) => + store.put("feishu_health", { + id: "connection", + state, + updated_at: stamp(), + }); + const allowed = (appId, chatId) => { + const config = store.get("feishu", "settings"); + return ( + config?.enabled && + config.app_id === appId && + config.routes.some((r) => 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" || + !message.mentionedBot || + !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", "settings") + .routes.find((r) => r.chat_id === message.chatId); + let thread = threadFor(appId, message); + if ( + thread && + !store.get("tasks", thread.task_id) && + !message.content.trim().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?.first_message_id === id) store.put("feishu_threads", thread); + if (!thread) + store.put("feishu_threads", { + id: threadId, + app_id: appId, + chat_id: message.chatId, + root_id: root, + thread_id: message.threadId, + 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, + created_at: stamp(), + }); + store.put("feishu_inbound", { + id, + app_id: appId, + chat_id: message.chatId, + thread_id: threadId, + message_id: message.messageId, + sender_id: message.senderId, + text: message.content.trim(), + state: "queued", + attempts: 0, + created_at: stamp(), + next_at: 0, + }); + }); + }; + const queueReply = (id, thread, payload) => { + if (!store.get("feishu_outbound", id)) + store.put("feishu_outbound", { + id, + app_id: thread.app_id, + chat_id: thread.chat_id, + thread_id: thread.id, + task_id: thread.task_id, + reply_to: thread.root_id, + payload, + state: "queued", + attempts: 0, + created_at: stamp(), + next_at: 0, + }); + }; + const reject = (row, reason) => { + store.put("feishu_inbound", { ...row, state: "rejected", 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); + // 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 inbound = async (row) => { + let thread = store.get("feishu_threads", row.thread_id); + if (!thread) return reject(row, "话题关联不可用,请在 Web 中检查。"); + const route = store + .get("feishu", "settings") + .routes.find((value) => value.chat_id === row.chat_id); + 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); + 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 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.shared && route?.allow_agent_selection) { + const selected = store.get( + "feishu_selections", + selectionId(row.app_id, row.chat_id, row.sender_id), + ); + if (selected) { + thread = { ...thread, agent_id: selected.agent_id }; + store.put("feishu_threads", thread); + } + } + task = await product("PUT", `/app/tasks/${thread.task_id}`, { + name: row.text.split("\n")[0].slice(0, 80), + description: row.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); + await product("PUT", `/app/tasks/${task.id}/runs/${thread.session_id}`, { + revision: task.revision, + }); + queueReply( + stableId(`${row.id}:accepted`), + thread, + `已接收任务「${task.name}」。正在交给 Agent 执行。\n请在本话题中 @机器人 追问;/cancel 取消执行,/review 提交验收,/done 确认完成,/reopen 重新打开。\n任务编号:${task.id}(在 Parsar「任务」中搜索)`, + ); + } else { + const run = store.get("sessions", thread.session_id); + 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") + return reject( + row, + "支持的命令:/list、/select、/cancel、/review、/done、/reopen。其他消息会交给 Agent。", + ); + const event = + row.event || + (row.text === "/cancel" + ? { type: "agent.session.input.cancel" } + : { + type: "agent.session.input.message", + input: [ + { + role: "user", + content: [{ type: "input_text", text: row.text }], + }, + ], + }); + 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", + 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 = []; + for (let n = 0; n < 100; n++) { + 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 || page.last_id === after) + throw new Error("Invalid history cursor"); + after = page.last_id; + } + throw new Error("History limit exceeded"); + }; + const observe = async (thread) => { + const run = store.get("sessions", thread.session_id); + if (!run?.core_session_id) 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), + ]); + 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 任务中检查并重新执行。", + ); + if (!unseen.length) 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「任务」中搜索)`, + ); + } + // 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() + 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.state !== "queued" || + row.next_at > stamp() + ) + continue; + // Feishu deduplicates UUIDs for one hour. Never automatically replay an uncertain send beyond that 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 result = await channel.rawClient.im.message.reply({ + path: { message_id: row.reply_to }, + data: { + msg_type: "text", + content: JSON.stringify({ text: row.payload }), + reply_in_thread: true, + uuid: row.id, + }, + }); + if (result.code !== 0 || !result.data?.message_id) + throw new Error("Feishu rejected reply"); + store.put("feishu_outbound", { + ...store.get("feishu_outbound", row.id), + state: "delivered", + sent_message_id: result.data.message_id, + error: undefined, + }); + } catch (error) { + retry("feishu_outbound", row, error); + } + } + }; + const tick = async () => { + const config = store.get("feishu", "settings"); + if (!config?.enabled) { + if (channel) await channel.disconnect(); + channel = undefined; + active = undefined; + health("stopped"); + return; + } + if ( + !active || + active.app_id !== config.app_id || + active.app_secret !== config.app_secret || + 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", "settings")?.revision === active.revision; + if (!current()) return; + health(channel.getConnectionStatus?.()?.state || "connected"); + 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)) 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; + const row = store.get("feishu_inbound", original.id); + if ( + !allowed(row.app_id, row.chat_id) || + row.text === "/cancel" || + ["delivered", "failed", "rejected"].includes(row.state) || + blocked.has(row.thread_id) + ) + continue; + if (row.next_at <= stamp()) { + try { + 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)) 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 async function createFeishuTransport(settings, receive) { + const { Client, WSClient, EventDispatcher, normalize } = 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, + logger, + }; + const client = new Client(options); + const ws = new WSClient({ ...options, handshakeTimeoutMs: 15000 }); + return { + rawClient: client, + 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"); + const dispatcher = new EventDispatcher({ logger }).register({ + "im.message.receive_v1": async (event) => { + if ( + event.sender?.sender_type !== "user" || + event.message?.message_type !== "text" + ) + 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(); + }, + }; +} diff --git a/example/parsar/server/product.mjs b/example/parsar/server/product.mjs index 1b3df5f29..1b005ecd6 100644 --- a/example/parsar/server/product.mjs +++ b/example/parsar/server/product.mjs @@ -1,5 +1,8 @@ import { providerAPI, publicProvider } from "./providers.mjs"; import { runtimeProfile } 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"; @@ -49,6 +52,9 @@ export function productAPI(store, core, fetchImpl = fetch) { } return row; }; + const schedules = scheduleAPI(store); + const feishu = feishuAPI(store); + const work = workAPI(store, core, sessions); const providers = providerAPI(store, fetchImpl); const touchProviders = (...ids) => { for (const id of new Set(ids.filter(Boolean))) { @@ -71,6 +77,12 @@ export function productAPI(store, core, fetchImpl = fetch) { return [...new Set(ids.map((id) => requireReference(kind, id)))]; }; return async (method, path, body) => { + if (path === "/app/schedules" || path.startsWith("/app/schedules/")) + return schedules(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}))?$/); @@ -98,6 +110,14 @@ export function productAPI(store, core, fetchImpl = fetch) { const previous = store.get(kind, id); if (method === "DELETE") { const used = + store + .get("feishu", "settings") + ?.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") @@ -108,6 +128,11 @@ export function productAPI(store, core, fetchImpl = fetch) { 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 {}; diff --git a/example/parsar/server/schedules.mjs b/example/parsar/server/schedules.mjs new file mode 100644 index 000000000..11bcbbec4 --- /dev/null +++ b/example/parsar/server/schedules.mjs @@ -0,0 +1,199 @@ +import { requireSessionSettled, requireAutomaticRuntime } from "./sessions.mjs"; +import { randomUUID } from "node:crypto"; +import { AppError, text, uuid, transientError } from "./store.mjs"; + +export function scheduleAPI(store) { + return async (method, path, body) => { + const match = path.match(/^\/app\/schedules(?:\/([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("schedules", id); + 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 和可自动启动的运行时。"); + if ( + typeof body.enabled !== "boolean" || + !Number.isInteger(body.interval_minutes) || + body.interval_minutes < 5 || + body.interval_minutes > 525600 + ) + throw new AppError(400, "间隔必须为 5 至 525600 分钟。"); + const value = { + id, + name: text(body.name, "计划名称", 80, true), + task_id: template.id, + enabled: body.enabled, + interval_minutes: body.interval_minutes, + 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.next_at + : Date.now() + body.interval_minutes * 60000, + 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()) => { + for (const schedule of store.list("schedules")) { + if (!schedule.enabled || 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: time + current.interval_minutes * 60000, + last_error: "上次执行尚未结束,已跳过本次间隔。", + revision: current.revision + 1, + }); + continue; + } + } + const current = store.get("schedules", schedule.id); + if ( + !current || + !current.enabled || + 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 }, + 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 }, + ); + const current = store.get("schedules", schedule.id); + if (current) + store.put("schedules", { + ...current, + pending: undefined, + last_task_id: task.id, + last_error: undefined, + next_at: time + current.interval_minutes * 60000, + 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, + 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..37ecb33f2 100644 --- a/example/parsar/server/sessions.mjs +++ b/example/parsar/server/sessions.mjs @@ -2,7 +2,7 @@ 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) { const agent = store.get("agents", body.agent_id); const runtime = store.get("runtimes", body.runtime_id); if (!agent || !runtime) @@ -127,7 +127,7 @@ export function sessionAPI(store, core) { let row = store.get("sessions", id); if (row?.core_session_id) return row; if (!row) { - row = requestFor(store, id, body); + row = prepareSession(store, id, body); store.put("sessions", row); } try { @@ -162,3 +162,34 @@ export function sessionAPI(store, core) { return running.get(id); }; } + +export async function requireSessionSettled(core, record) { + 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 === "self_hosted") + throw new AppError( + 400, + "自动执行需要托管或纯文本运行时;用户机器需在 Web 中连接后发送。", + ); + return runtime; +} 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..a0eaa4ab7 --- /dev/null +++ b/example/parsar/server/work.mjs @@ -0,0 +1,252 @@ +import { randomUUID } from "node:crypto"; +import { AppError, text, uuid } from "./store.mjs"; +import { prepareSession, requireSessionSettled } 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, + 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, + }); + 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 + ) + throw new AppError(409, "评论标识已被使用。"); + return old; + } + const comment = { + id: operationId, + task_id: id, + type: "comment", + message, + created_at: now(), + }; + store.put("activity", comment); + 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 + .get("feishu", "settings") + ?.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/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/App.tsx b/example/parsar/src/App.tsx index c88138b90..df6f6926d 100644 --- a/example/parsar/src/App.tsx +++ b/example/parsar/src/App.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from "react"; -import { Bot, Cpu, BookOpen, Plug, Server, Moon, Sun } from "lucide-react"; +import { Bot, Cpu, BookOpen, Plug, Server, Moon, Sun, Columns3, Folder, MessageSquare, Clock3 } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; +import { Schedules } from "./Schedules"; +import { Feishu } from "./Feishu"; +import { Tasks } from "./Tasks"; +import { TaskDetail } from "./TaskDetail"; +import { Projects } from "./Projects"; import { Models } from "./Models"; import { Skills } from "./Skills"; import { MCPs, Runtimes } from "./Resources"; @@ -10,7 +15,7 @@ import { AgentDetail } from "./AgentDetail"; import { Button } from "./components/ui/button"; export function App() { - const [route, setRoute] = useState(location.hash.slice(1) || "/agents"); + const [route, setRoute] = useState(location.hash.slice(1) || "/tasks"); const [dark, setDark] = useState(() => { try { return localStorage.getItem("oac-example-theme") === "dark"; @@ -20,7 +25,7 @@ export function App() { }); const reduce = useReducedMotion(); useEffect(() => { - const listener = () => setRoute(location.hash.slice(1) || "/agents"); + const listener = () => setRoute(location.hash.slice(1) || "/tasks"); window.addEventListener("hashchange", listener); return () => window.removeEventListener("hashchange", listener); }, []); @@ -34,11 +39,17 @@ export function App() { }, [dark]); const agentId = route.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: "/tasks", label: "任务", icon: Columns3 }, + { href: "/projects", label: "项目", icon: Folder }, { href: "/agents", label: "Agents", icon: Bot }, { href: "/models", label: "模型", icon: Cpu }, { href: "/skills", label: "Skills", icon: BookOpen }, { href: "/mcps", label: "MCP", icon: Plug }, + { href: "/schedules", label: "定时任务", icon: Clock3 }, + { href: "/feishu", label: "飞书", icon: MessageSquare }, { href: "/runtimes", label: "运行时", icon: Server }, ]; return ( @@ -59,7 +70,8 @@ export function App() { > {navigation.map(({ href, label, icon: Icon }) => { const selected = - route === href || + route.split("?")[0] === href || + (href === "/tasks" && !!taskId) || (href === "/agents" && (!!agentId || !!sessionId)); return (
- {sessionId ? ( + {taskId ? ( + + ) : route.split("?")[0] === "/tasks" ? ( + + ) : route === "/projects" ? ( + + ) : sessionId ? ( ) : agentId ? ( @@ -105,6 +123,10 @@ export function App() { ) : route === "/mcps" ? ( + ) : route === "/schedules" ? ( + + ) : route === "/feishu" ? ( + ) : route === "/runtimes" ? ( ) : ( diff --git a/example/parsar/src/Composer.tsx b/example/parsar/src/Composer.tsx index 9f80c9ea5..3b66597d5 100644 --- a/example/parsar/src/Composer.tsx +++ b/example/parsar/src/Composer.tsx @@ -12,12 +12,14 @@ import { ErrorNotice } from "./components/shared"; export function Composer({ session, + taskBrief, turnId, connectionPending = false, attachment = "", onPendingChange, }: { session: AgentSession; + taskBrief?: string; turnId?: string; connectionPending?: boolean; attachment?: string; @@ -114,6 +116,7 @@ export function Composer({ 已请求取消,等待执行结束。

)} + {taskBrief && }
{ diff --git a/example/parsar/src/Feishu.tsx b/example/parsar/src/Feishu.tsx new file mode 100644 index 000000000..8bc13ef99 --- /dev/null +++ b/example/parsar/src/Feishu.tsx @@ -0,0 +1,362 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2 } from "lucide-react"; +import { product, useAgents, useRuntimes, useProjects } from "./lib/product"; +import { PageHeader, Field, ErrorNotice } from "./components/shared"; +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { Select, SelectOption } from "./components/ui/select"; +import { EmptyState } from "./components/ui/empty-state"; +import { dateTime } from "./lib/api"; +interface FeishuConfig { + revision: number; + app_id: string; + app_secret?: string; + has_app_secret: boolean; + enabled: boolean; + routes: { + chat_id: string; + project_id: string; + agent_id: string; + runtime_id: string; + allow_agent_selection?: boolean; + }[]; +} +interface Delivery { + id: string; + direction: string; + chat_id: string; + state: string; + error?: string; + created_at: number; + attempts: number; + task_id?: string; +} +const states: Record = { + stopped: "已停用", + connecting: "连接中", + connected: "已连接", + reconnecting: "重连中", + error: "连接失败,请检查机器人配置", + core_unavailable: "Core 暂不可用", + queued: "等待处理", + submitting: "正在确认", + delivered: "已送达", + failed: "需要处理", + rejected: "已拒绝", +}; +export function Feishu() { + const cache = useQueryClient(); + const retry = useMutation({ + mutationFn: (row: Delivery) => + product(`feishu/deliveries/${row.direction}/${row.id}`, "PUT", {}), + onSettled: () => + void cache.invalidateQueries({ queryKey: ["feishu-deliveries"] }), + }); + const config = useQuery({ + queryKey: ["feishu"], + queryFn: () => product("feishu"), + }); + const status = useQuery({ + queryKey: ["feishu-status"], + queryFn: () => + product<{ state: string; updated_at?: number }>("feishu/status"), + refetchInterval: 5000, + }); + const deliveries = useQuery({ + queryKey: ["feishu-deliveries"], + queryFn: () => product("feishu/deliveries"), + refetchInterval: 5000, + }); + return ( + <> + + + {states[status.data?.state || "stopped"] || status.data?.state} + + + void config.refetch()} + /> +
+
+
+

让任务从团队的对话开始

+

+ 在允许的飞书群中 @机器人 描述工作,Parsar 会创建任务并交给 + Agent。结果回到原话题;在话题中继续 @机器人 即可追问。 +

+
+
+ + 配置机器人 · 三步接入 + +
    +
  1. + 在飞书开放平台创建企业自建应用,开启机器人能力。申请接收群内 + @机器人 消息和以应用身份发送消息的权限。 +
  2. +
  3. + 事件订阅选择「使用长连接接收事件」,订阅 + im.message.receive_v1。发布应用并把机器人加入测试群。 +
  4. +
  5. + 填写应用凭据,添加允许的群及其 + Agent、运行时,然后启用。只接收这些群的 @机器人 消息;私聊和 + @所有人 不触发执行。 +
  6. +
+

+ 无需公网回调。App Secret 仅存于服务器。飞书群成员可触发对应 + Agent;请只开放给可信群。 +

+
+ {config.data ? ( + + ) : ( + + )} +
+

最近投递

+

+ 连接中断会按相同请求标识重试。超过重试上限或飞书去重窗口的记录会保留为「需要处理」,避免重复发送。 +

+ {!deliveries.data?.length ? ( + + ) : ( +
+ )} +
+
+
+ + ); +} +function FeishuEditor({ value }: { value: FeishuConfig }) { + const cache = useQueryClient(), + agents = useAgents(), + runtimes = useRuntimes(), + projects = useProjects(); + const [form, setForm] = useState({ ...value, app_secret: "" }); + const [saved, setSaved] = useState(false); + const save = useMutation({ + mutationFn: () => product("feishu", "PUT", form), + onSuccess: (config) => { + setForm({ ...config, app_secret: "" }); + setSaved(true); + cache.setQueryData(["feishu"], config); + void cache.invalidateQueries({ queryKey: ["feishu-status"] }); + }, + }); + const updateRoute = (index: number, field: string, value: string | boolean) => + setForm({ + ...form, + routes: form.routes.map((row, i) => + i === index ? { ...row, [field]: value } : row, + ), + }); + return ( + { + e.preventDefault(); + save.mutate(); + }} + > +
+ + setForm({ ...form, app_id: e.target.value })} + placeholder="cli_…" + /> + + + setForm({ ...form, app_secret: e.target.value })} + placeholder={ + form.has_app_secret ? "已保存,留空保留" : "输入应用密钥" + } + /> + +
+
+

允许的群与分配规则

+ +
+ {form.routes.map((route, index) => ( +
+
+ updateRoute(index, "chat_id", e.target.value)} + /> + +
+ +
+ + + +
+
+ ))} + +

+ 同一话题继续同一个执行会话。配置更改作用于新话题。支持 + /cancel、/review、/done、/reopen;结果回传文本摘要,文件可在 Web + 会话中下载。 +

+ +
+ + {saved && 配置已保存} +
+ + ); +} diff --git a/example/parsar/src/Projects.tsx b/example/parsar/src/Projects.tsx new file mode 100644 index 000000000..f0ba3db80 --- /dev/null +++ b/example/parsar/src/Projects.tsx @@ -0,0 +1,217 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Folder, Plus, Pencil } from "lucide-react"; +import { + product, + useProjects, + useTasks, + type ProjectRecord, +} from "./lib/product"; +import { PageHeader, Field, ErrorNotice } from "./components/shared"; +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { Textarea } from "./components/ui/textarea"; +import { EmptyState } from "./components/ui/empty-state"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "./components/ui/dialog"; + +export function Projects() { + const projects = useProjects(), + tasks = useTasks(); + const [editing, setEditing] = useState | null>(null); + return ( + <> + + + + void projects.refetch()} + /> +
+

+ 把目标、仓库和背景放在一起,让每次任务从同一份上下文开始。 +

+ {projects.isPending ? ( + + ) : !projects.data?.length ? ( + + ) : ( +
+ {projects.data.map((project) => { + const related = + tasks.data?.filter((task) => task.project_id === project.id) || + []; + const done = related.filter( + (task) => task.status === "done", + ).length; + return ( +
+
+ + +
+ + {project.name} + +

+ {project.description || "尚未添加项目背景"} +

+
+ {related.length} 个任务 + {done} 已完成 +
+ +
+ ); + })} +
+ )} +
+ {editing && ( + setEditing(null)} + /> + )} + + ); +} +function ProjectEditor({ + value, + close, +}: { + value: Partial; + close: () => void; +}) { + const cache = useQueryClient(); + const [form, setForm] = useState(() => ({ + id: crypto.randomUUID(), + name: "", + description: "", + repository: "", + ...value, + })); + const save = useMutation({ + mutationFn: () => product(`projects/${form.id}`, "PUT", form), + onSuccess: () => { + void cache.invalidateQueries({ queryKey: ["projects"] }); + close(); + }, + }); + const remove = useMutation({ + mutationFn: () => product(`projects/${form.id}`, "DELETE", form), + onSuccess: () => { + void cache.invalidateQueries({ queryKey: ["projects"] }); + close(); + }, + }); + return ( + { + if (!open && !save.isPending && !remove.isPending) close(); + }} + > + + + {value.id ? "编辑项目" : "新建项目"} + +
{ + event.preventDefault(); + save.mutate(); + }} + > + + setForm({ ...form, name: e.target.value })} + /> + + +