diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a4b73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.turbo/ +.DS_Store +*.log +*.tsbuildinfo diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000..044636a Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a7cdb1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# DataRecipe Agent Architecture + +DataRecipe is an **Agent Tool Layer**, not a fixed-flow Skill generator. + +The primary user experience is the browser side panel's real conversational Agent. Users configure an OpenAI-compatible Provider and state goals in natural language; the side-panel Agent plans through `/v1/chat/completions` tool calls and uses the same Agent Tool Layer that future external Agents can reuse. + +## Responsibility boundaries + +- The browser extension captures authorized page/API activity and exposes bounded tools. +- The side-panel Agent (or a future external Agent) decides which captures to inspect, how much content to read, how to refine a recipe, and how to write `SKILL.md` for the user's actual goal. +- The user authorizes browser access and confirms sensitive execution/export actions. +- The final artifact is a ZIP **Data Skill Package** containing `SKILL.md`, `workflow.json`, one or more `recipes/*.json`, examples, and human-readable documentation. `.data-skill.json` is development/import interchange only. +- Provider credentials stay in `chrome.storage.local`; never pass them to a tool, model message, capture, recipe, or package. + +## Agent-facing tools + +Keep these names and responsibilities stable: + +- `listCapturedSources`: discover captured sources without loading every response. +- `readCapture`: read request/response content using preview, page, range, or full mode. +- `inspectResponse`: inspect response structure and selected content using the same bounded read modes. +- `buildRecipe`: produce a rule-derived recipe draft in RecipeStore, optionally applying Agent edits by `recipeId`. +- `runRecipe`: execute or dry-run a stored recipe by `recipeId` through the Browser RPC Bridge. +- `exportSkillPackage`: package Agent-authored `SKILL.md`, all referenced `recipeIds`, and `workflow.json` as a real ZIP. + +Never silently expand a bounded read to a full read. Never expose Cookie, Authorization headers, or equivalent browser credentials to the Agent. + +`readCapture` / `inspectResponse` full reads, every `runRecipe`, and every `exportSkillPackage` require action-time user confirmation. Model-provided flags do not count as user confirmation. + +## Runtime durability + +- Prefer streaming Chat Completions and aggregate incremental text and `tool_calls`; fall back to non-streaming only when the Provider explicitly rejects streaming. +- Checkpoint conversation messages immediately after every assistant message and tool result. +- Store active conversation history and RecipeStore snapshots in `chrome.storage.session`, scoped by `tabId + conversationId`. +- Never clear history on model changes or failures. Only the explicit “新建对话” action clears the active conversation. +- The model never sends a complete recipe back to the plugin. `buildRecipe` creates a `recipeId`; patch, run, and export operations reference that ID. +- Validate every tool input and return structured errors with `code`, `message`, `retryable`, and `expectedInput`. +- Stop repeated identical failed calls, enforce per-tool budgets, and request a `tool_choice: none` failure summary instead of discarding history at the iteration limit. +- Persist UI history as ordered `ConversationEvent`s: user/assistant messages, tool calls, tool results, statuses, artifacts, and outcomes. Create a tool-call event before execution and update that event in place. +- Treat `AgentRunOutcome` as authoritative. Carry it into the next model turn; never let prose contradict structured tool errors. Budget pauses preserve pending steps and offer continuation. + +## Artifact policy + +Files under `recipes/` are machine-oriented execution configurations. Rules and tools generate initial drafts; the Agent may inspect and modify them. `workflow.json` expresses ordered input/output dependencies across recipes, including list → filter → detail → optional diff flows. Never describe multiple interfaces in `SKILL.md` while exporting only one recipe. `SKILL.md` is authored by the configured model from tool results and user intent, and must not be constrained to a deterministic template. Mock generation is test/development-only and must never appear in the user-facing path. diff --git a/PR_TESTING.md b/PR_TESTING.md new file mode 100644 index 0000000..b87052b --- /dev/null +++ b/PR_TESTING.md @@ -0,0 +1,82 @@ +# PR Testing — Real side-panel Agent Runtime + +This round makes the real OpenAI-compatible Agent the only user-facing chat path. + +## Automated checks + +```bash +pnpm typecheck +pnpm test:agent-runtime +pnpm build:extension +``` + +The following packages and surfaces must compile: + +- `@data-recipe/agent-runtime` +- `@data-recipe/agent-tools-core` +- `@data-recipe/skill-builder` +- the Chrome side panel and its six tool handlers + +## Manual verification + +### Provider settings + +1. Open the side panel with no saved configuration. +2. Confirm the chat input is disabled and asks the user to configure a model. +3. Open “模型设置” and save `baseUrl`, `apiKey`, and a tool-capable `model`. +4. Reopen the side panel and confirm the configuration was restored from `chrome.storage.local`. + +### Agent tool loop + +1. Open `docs/test-page.html` and click “开始学习页面”. +2. Trigger a fetch or XHR request. +3. Ask the Agent to create a data skill. +4. Confirm the configured provider receives streaming `/v1/chat/completions` requests with messages and function tools. +5. Confirm SSE text appears incrementally and incremental tool call arguments are aggregated before execution. +6. Confirm returned `tool_calls` execute locally and collapsed tool results are sent back as `role: tool` messages with matching `tool_call_id`. +7. Confirm the loop ends only when the model returns final assistant text or a protected termination generates a `tool_choice: none` summary. +8. Confirm each call appears in the timeline immediately, updates in place on completion, and its result follows it in actual occurrence order. +9. Confirm streamed Markdown headings, lists, tables, code blocks, quotes, and links refresh incrementally and unsafe HTML is removed. + +### Conversation recovery + +- Close and reopen the side panel after at least one tool result; history and recipe IDs should remain usable. +- Simulate a Provider failure after a tool result; the partial history must remain in the next request. +- Changing model settings must not clear history. +- Clicking “新建对话” must create a new conversation ID and clear the active history. + +### Automated regressions + +- streaming text delta aggregation; +- streaming `tool_calls` name/arguments aggregation; +- explicit streaming rejection fallback; +- partial recipe validation without undefined-property errors; +- `runRecipe` requiring `recipeId`; +- failed tool results present in the next model turn; +- `AgentRunError.partialMessages` after a later Provider failure; +- repeated identical failure termination; +- max-iteration fault summary with history retained. +- `AgentRunOutcome` fields and budget-pause continuation state; +- multi-recipe weekly-report workflow and valid standalone ZIP structure. + +### Safety confirmations + +- `readCapture` and `inspectResponse` preview/page/range modes do not silently expand to full reads. +- Full capture/response reads require a visible user confirmation. +- Every `runRecipe` requires a visible user confirmation. +- Every `exportSkillPackage` requires a visible user confirmation. +- A recipe whose API URL is not present in current captures is rejected. +- Cookie, Authorization, API key, password, secret, and token-shaped captured fields are redacted before model exposure. +- Provider credentials are never included in tool inputs, tool results, recipes, or exported packages. + +### Skill package path + +- `buildDataSkillPackage` rejects empty Agent-authored `skillMarkdown`. +- The side panel does not call the Mock development helper. +- Export uses the `SKILL.md` supplied by the model to `exportSkillPackage`. +- Formal export has MIME `application/zip` and contains `workflow.json` plus every referenced `recipes/*.json` file. +- A weekly-report goal containing only “池佳 W25” resolves person/year/week dynamically, obtains `empCode`, `uid`, and `weekId` from prior step outputs, then reads detail and optional diff without hardcoded identities. + +## Known limitation + +Non-dry-run Browser RPC execution remains a typed placeholder and returns `blocked` until the real bridge transport is connected. diff --git a/README.en.md b/README.en.md index fafa4dd..34c5d90 100644 --- a/README.en.md +++ b/README.en.md @@ -4,8 +4,7 @@ Operate once. Generate reusable data recipes for AI agents. -DataRecipe turns web page queries, API responses, query parameters, pagination rules, and field meanings into reusable data recipes. -Any AI agent can later use these recipes to collect, organize, and analyze data. +DataRecipe is a browser-resident tool layer for AI agents. It turns authorized page queries, API responses, parameters, pagination rules, and field meanings into reusable recipes while letting the Agent decide what to read and how to build the final package. ## Why DataRecipe @@ -129,10 +128,14 @@ data-recipe/ ├── apps/ │ └── extension/ # Browser sidebar extension ├── packages/ +│ ├── browser-rpc-core/ # Browser RPC Bridge protocol types +│ ├── agent-runtime/ # OpenAI-compatible Agent Runtime +│ ├── agent-tools-core/ # Agent-facing tool contracts │ ├── detector/ # API request detection │ ├── recipe-core/ # Core Data Recipe model │ ├── recipe-runner/ # Recipe runtime -│ └── recipe-exporter/ # MCP / OpenAPI / JSON exporter +│ ├── skill-builder/ # Data Skill Package generation +│ └── skill-exporter/ # Data Skill Package export ├── docs/ │ ├── vision.md │ ├── data-recipe-spec.md @@ -145,3 +148,140 @@ data-recipe/ ## License TBD + +## Run the MVP locally + +### Install dependencies + +```bash +pnpm install +``` + +### Build the browser extension + +```bash +pnpm build:extension +``` + +The extension output is: + +```text +apps/extension/dist +``` + +### Load it in Chrome + +1. Open `chrome://extensions`; +2. Enable Developer mode; +3. Click "Load unpacked"; +4. Select `apps/extension/dist`; +5. Click the "AI 有数" extension icon to open the side panel. + +### Manual verification + +1. Open `docs/test-page.html`; +2. Click "开始学习页面" in the conversational side panel; +3. Click the fetch or XHR test button on the test page; +4. State a goal in natural language; the side-panel Agent should create a plan and call the local tools; +5. Tool results remain collapsed, while "高级信息" contains data-source details and the recipe draft. + +The side panel is the primary workspace for ordinary users: conversation is the main UI, while sources, recipes, test output, and tool traces are secondary. Users first configure an OpenAI-compatible Provider (`baseUrl`, `apiKey`, and a tool-capable `model`). The side panel then runs a real Agent through `/v1/chat/completions`; future external Agents can reuse the same Agent Tool Layer. + +You can also start discovery on any page you are authorized to access and then trigger a page query. This MVP only performs low-frequency local detection. It does not bypass login, captchas, risk controls, or dynamic signatures. + +### Development mode + +```bash +pnpm dev:extension +``` + +This watches and rebuilds `apps/extension/dist`. Refresh the unpacked extension in Chrome before testing updated code. + +### Export a Data Skill Package + +After one discovery flow, use the side panel to: + +1. Fill in the data skill name and purpose; +2. Confirm or rename returned fields; +3. Check "试运行结果" to confirm data can be read; +4. Check "技能包预览" to confirm required files are present; +5. Click "导出技能包". + +Production export downloads a real ZIP with MIME type `application/zip` and real paths: + +```text +SKILL.md +workflow.json +recipes/ + people-list.json + week-list.json + week-detail.json +examples.md +README.md +``` + +`.data-skill.json` remains available only as a development/import interchange format, not the primary user export. + +### Data Skill Package acceptance checks + +An exported package should at least: + +* Include `SKILL.md`, `workflow.json`, one or more `recipes/*.json`, `examples.md`, and `README.md`; +* Use `SKILL.md` to explain what the data skill is useful for; +* Use each recipe to hold its data source, query inputs, returned fields, and test-run information; +* Use `workflow.json` to express list → filter → detail → optional diff dependencies, with one packaged recipe for every interface used; +* Show a data preview in the side panel test-run result; +* Show no missing required files before export. + +### Validate an exported skill package + +After exporting a ZIP, run: + +```bash +pnpm validate:skill-package path/to/your-data-skill.zip +``` + +The command checks required files, the recipes directory, and workflow references. It exits with a non-zero status when the package is invalid. + +## Artifact Hierarchy + +* **The plugin provides tools:** `listCapturedSources`, `readCapture`, `inspectResponse`, `buildRecipe`, `runRecipe`, and `exportSkillPackage`. Read tools support preview, page, range, and explicit full modes. +* **Streaming and recovery:** the Runtime aggregates SSE text/tool deltas, checkpoints every assistant/tool message, and stores conversations in `chrome.storage.session` by tab and conversation ID. +* **A real conversation timeline:** user messages, AI messages, tool calls, tool results, statuses, outcomes, and ZIP artifacts are ordered `ConversationEvent`s. Markdown refreshes incrementally and is sanitized with DOMPurify. +* **recipeId protocol:** complete recipes stay inside the plugin RecipeStore; the model patches, runs, and exports by `recipeId` instead of retransmitting full recipes. +* **The Agent makes judgments:** it selects sources, decides how much to read, reviews and edits the recipe, and freely authors `SKILL.md` from tool evidence and the user's goal. +* **The user authorizes and confirms:** browser-session access, origin permissions, recipe runs, and protected export actions remain user-controlled. +* **Multi-recipe workflow:** rules and tools generate `recipes/*.json`; the Agent reviews them, while `workflow.json` defines cross-recipe dependencies. Production export is ZIP. +* **Structured stopping:** `AgentRunOutcome` distinguishes completion, budget pause, tool failure, and user/safety blocking. Paused progress can be continued. +* **Mock is not a production path:** the configured model must author `SKILL.md` in the user flow. Mock helpers are restricted to tests and development support. +* **The final deliverable is a Data Skill Package:** Agent-authored `SKILL.md`, `workflow.json`, reviewed `recipes/`, examples, and documentation. + +## Long-term Runtime Architecture + +DataRecipe's long-term execution path is: + +```text +User ↔ AI Agent → Agent-facing tools → Browser RPC Bridge → authorized Chrome session + ↓ + Data Skill Package +``` + +Meaning: + +* `AI Agent` is the controller: it plans tool calls, reads progressively, edits the recipe, and authors SKILL.md for the user goal; +* `Agent-facing tools` expose capture discovery, bounded reads, response inspection, recipe build/run, and package export; +* `Browser RPC Bridge` is the runtime behind `runRecipe`, forwarding only validated recipe runs to the extension; +* `Chrome Extension` executes requests inside the user's authorized browser session; +* `authorized browser session` means a page session the user has already logged into and is allowed to access. + +This architecture does not expose Cookie, Authorization header, or other sensitive credentials to AI agents, and it is not an arbitrary request proxy. The project will not implement bypasses for login, captchas, risk controls, dynamic signatures, or anti-bot systems. + +See [`docs/agent-tool-architecture.md`](./docs/agent-tool-architecture.md) and [`AGENTS.md`](./AGENTS.md) for the detailed responsibility, tool, and phase design. + +## Current MVP Status + +* Done: API detection / Agent tool contracts / OpenAI-compatible Agent Runtime / side-panel model settings / six local tool handlers. +* Done: the Agent uses tool calls to read selectively, inspect/edit recipes, test runs, and author SKILL.md. +* Done: `recipe-runner.runRecipeWithBrowserRpc` is defined, with its types wired to `BrowserRunRecipeResult` from `@data-recipe/browser-rpc-core`. +* Not yet implemented: real browser RPC execution. The MVP only completes the wire protocol and the skill package draft; `runRecipeWithBrowserRpc` currently returns `blocked` until the bridge transport is connected in a follow-up PR. + diff --git a/README.md b/README.md index 098ab72..4374a5e 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,57 @@ **中文名:AI 有数** -Turn web page queries and API responses into reusable data recipes for AI agents. +Provide a browser-resident Agent Tool Layer that turns authorized page/API activity into reusable data capabilities. -将网页查询和 API 响应沉淀为 AI Agent 可复用的数据配方。 +为 AI Agent 提供浏览器驻留工具层,将用户授权的网页查询和 API 响应沉淀为可复用的数据能力。 ## Languages * [中文说明](./README.zh-CN.md) * [English README](./README.en.md) + +## MVP quick start + +```bash +pnpm install +pnpm build:extension +``` + +Load `apps/extension/dist` as an unpacked extension in Chrome, open the side-panel “模型设置”, and configure an OpenAI-compatible `baseUrl`, `apiKey`, and tool-capable `model`. Then open `docs/test-page.html`, click “开始学习页面”, trigger a fetch/XHR query, and describe your goal in natural language. The real Agent will call the local tools, test the recipe, author `SKILL.md`, and ask before exporting a Data Skill Package. + + +## Validate an exported skill package + +```bash +pnpm validate:skill-package path/to/your-data-skill.zip +``` + +## Long-term architecture + +```text +User ↔ Side-panel Agent → OpenAI-compatible /v1/chat/completions + ↓ tool_calls + Agent-facing tools → Browser RPC Bridge → authorized Chrome session + ↓ + Data Skill Package +``` + +The plugin provides tools; the AI Agent decides what to inspect, how much to read, how to edit the rule-generated recipe, and how to author `SKILL.md`; the user grants permission and confirms protected actions. The final deliverable is a Data Skill Package. + +The side panel is the primary conversational Agent workspace. Tool calls and data-source details are secondary, collapsed information; the same Agent Tool Layer remains reusable by future external Agents. + +Provider configuration is stored in `chrome.storage.local`. The API key is used only for the configured model request and is never exposed to Agent tools or included in a Data Skill Package. + +- **Agent-facing tools:** `listCapturedSources`, `readCapture`, `inspectResponse`, `buildRecipe`, `runRecipe`, and `exportSkillPackage`. Reads support preview, page, range, and explicit full modes. +- **Streaming and durable history:** the runtime parses SSE text/tool deltas, checkpoints after every assistant/tool message, and stores the active conversation in `chrome.storage.session` by tab and conversation ID. +- **Real conversation timeline:** user messages, model messages, tool calls, tool results, statuses, outcomes, and ZIP artifacts are independent `ConversationEvent`s rendered in occurrence order. Markdown is rendered with `marked` and sanitized with DOMPurify. +- **Stable recipe protocol:** complete recipes stay in RecipeStore; the model uses `recipeId` for patch, run, and export operations. +- **Multi-recipe packages:** production export is `application/zip`, with `SKILL.md`, `workflow.json`, `recipes/*.json`, examples, and README. `.data-skill.json` remains development/import interchange only. +- **Structured stopping:** `AgentRunOutcome` distinguishes completed, budget-paused, failed-tool, and user/safety-blocked runs. A paused run preserves pending work and exposes “继续任务”. +- **Recipe JSON is rule/tool generated and Agent-reviewable.** Each file under `recipes/` remains low-level execution config; the Agent may inspect and modify it before running or exporting. +- **SKILL.md is Agent-authored.** It is based on tool evidence and the user's goal. The user-facing flow never auto-generates it from a Mock template; Mock helpers are test/development only. +- **Browser RPC Bridge is the runtime tool.** It executes a reviewed recipe inside the user's authorized Chrome session without exposing browser credentials to the Agent. The real round-trip is not wired up yet. +- **Current MVP status:** discovery + recipe-draft + local sample preview + skill package export all work end-to-end. Real browser RPC execution (the Chrome extension actually running `browser.runRecipe` inside an authorized session) is NOT implemented yet. `recipe-runner.runRecipeWithBrowserRpc` returns `blocked` until the bridge transport is connected in a follow-up PR. + +See [Agent Tool Layer architecture](./docs/agent-tool-architecture.md) and [AGENTS.md](./AGENTS.md) for the detailed responsibility and type boundaries. + diff --git a/README.zh-CN.md b/README.zh-CN.md index 03a60ce..c7cc669 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,8 +2,7 @@ **操作一次页面,生成 AI Agent 可复用的数据配方。** -AI 有数可以将网页查询、API 响应、查询条件、分页规则和字段含义沉淀为一份可复用的数据配方。 -以后无论在哪个 AI Agent 中,都可以复用这份配方来自动取数、整理和分析。 +AI 有数是供 AI Agent 使用的浏览器驻留工具层。它将网页查询、API 响应、查询条件、分页规则和字段含义沉淀为可复用的数据配方,并允许 Agent 按需读取、判断和生成最终技能包。 ## 为什么做这个项目 @@ -122,10 +121,14 @@ data-recipe/ ├── apps/ │ └── extension/ # 浏览器侧边栏插件 ├── packages/ +│ ├── browser-rpc-core/ # Browser RPC Bridge 协议类型 +│ ├── agent-runtime/ # OpenAI-compatible Agent Runtime +│ ├── agent-tools-core/ # Agent-facing tools 类型契约 │ ├── detector/ # API 请求探测 │ ├── recipe-core/ # 数据配方核心结构 │ ├── recipe-runner/ # 数据配方执行器 -│ └── recipe-exporter/ # MCP / OpenAPI / JSON 导出 +│ ├── skill-builder/ # Data Skill Package 生成 +│ └── skill-exporter/ # Data Skill Package 导出 ├── docs/ │ ├── vision.md │ ├── data-recipe-spec.md @@ -138,3 +141,140 @@ data-recipe/ ## License TBD + +## 本地运行 MVP + +### 安装依赖 + +```bash +pnpm install +``` + +### 构建浏览器插件 + +```bash +pnpm build:extension +``` + +构建产物在: + +```text +apps/extension/dist +``` + +### 在 Chrome 中加载插件 + +1. 打开 `chrome://extensions`; +2. 开启「开发者模式」; +3. 点击「加载已解压的扩展程序」; +4. 选择 `apps/extension/dist`; +5. 点击浏览器工具栏里的「AI 有数」图标打开侧边栏。 + +### 手动验证 + +1. 打开 `docs/test-page.html`; +2. 在 AI 有数对话侧边栏点击「开始学习页面」; +3. 在测试页点击「触发 fetch 查询」或「触发 XHR 查询」; +4. 在输入框中用自然语言说明目标,侧边栏 Agent 应生成执行计划并调用本地工具; +5. 工具结果默认折叠,展开「高级信息」可以查看数据来源和 recipe 草稿。 + +侧边栏是普通用户的主要工作台:对话是主界面,数据源列表、recipe、测试结果和工具调用记录都是辅助信息。用户需要先在「模型设置」中配置 OpenAI-compatible Provider 的 `baseUrl`、`apiKey` 和支持 tools 的 `model`。侧边栏随后通过 `/v1/chat/completions` 运行真实 Agent,Agent Tool Layer 同时可供未来外部 Agent 复用。 + +也可以在任意你有权访问的网页上点击「开始发现」,然后触发页面查询。当前 MVP 只做低频本地探测,不会绕过登录、验证码、风控或动态签名。 + +### 开发模式 + +```bash +pnpm dev:extension +``` + +该命令会监听并重新构建 `apps/extension/dist`。Chrome 扩展页面中需要手动点击刷新插件后再验证最新代码。 + +### 导出 Data Skill Package + +完成一次发现后,可以在侧边栏中: + +1. 为数据技能填写名称和用途说明; +2. 确认或修改返回字段名称; +3. 查看「试运行结果」确认能读取数据; +4. 在「技能包预览」中确认已包含必需文件; +5. 点击「导出技能包」。 + +正式导出会下载一个 MIME 为 `application/zip` 的 ZIP,包含真实目录和文件: + +```text +SKILL.md +workflow.json +recipes/ + people-list.json + week-list.json + week-detail.json +examples.md +README.md +``` + +`.data-skill.json` 只保留为开发和导入交换格式,不是用户主导出格式。 + +### Data Skill Package 验收点 + +导出的技能包至少应满足: + +* 包含 `SKILL.md`、`workflow.json`、一个或多个 `recipes/*.json`、`examples.md`、`README.md`; +* `SKILL.md` 能说明这个数据技能适合完成什么任务; +* 每个 recipe 包含数据来源、查询条件、返回字段和测试运行所需信息; +* `workflow.json` 明确 list → filter → detail → 可选 diff 的输入输出依赖,涉及多个接口时必须包含对应的多个 recipe; +* 侧边栏「试运行结果」可以展示数据预览; +* 导出前没有提示缺少必需文件。 + +### 校验导出的技能包 + +导出 ZIP 后,可以用下面的命令检查必需文件、recipe 目录和 workflow 引用: + +```bash +pnpm validate:skill-package path/to/your-data-skill.zip +``` + +校验通过时会输出技能包名称和文件列表;缺少文件或文件为空时会返回非零退出码,便于后续接入自动化验收。 + +## 产物层级 + +* **插件提供工具**:`listCapturedSources`、`readCapture`、`inspectResponse`、`buildRecipe`、`runRecipe`、`exportSkillPackage`。读取工具支持预览、分页、范围和明确的完整读取。 +* **支持流式与故障恢复**:Runtime 聚合 SSE 文本和工具参数增量,每条 assistant/tool 消息立即 checkpoint,并按 tab 与 conversation 保存到 `chrome.storage.session`。 +* **真实对话时间线**:用户消息、AI 消息、tool call、tool result、状态、运行结果和 ZIP 产物统一存为 `ConversationEvent`,按发生顺序显示;Markdown 增量渲染并用 DOMPurify 清洗。 +* **recipeId 协议**:完整 recipe 只保存在插件 RecipeStore;模型通过 `recipeId` 修改、测试和导出,不再重传完整 recipe。 +* **Agent 做判断**:选择数据来源、决定读取多少、检查并修改配方,并结合工具结果和用户目标自由编写 `SKILL.md`,不受固定模板策略限制。 +* **用户做授权和确认**:浏览器会话访问、来源权限、recipe 执行和导出等受保护动作由用户控制。 +* **多 recipe 与 workflow**:规则和工具生成 `recipes/*.json`,Agent 检查并修改;`workflow.json` 描述跨 recipe 的依赖。正式导出是 ZIP。 +* **结构化停止状态**:`AgentRunOutcome` 区分完成、预算暂停、工具失败和用户/安全阻止;暂停时保留进度并提供“继续任务”。 +* **Mock 不在正式主路径**:用户流程必须由已配置模型编写 `SKILL.md`;Mock helper 只允许用于测试和开发辅助。 +* **最终产物是 Data Skill Package**:包含 Agent 编写的 `SKILL.md`、`workflow.json`、审核后的 `recipes/`、示例和说明文档。 + +## 长期 Runtime 架构 + +DataRecipe 的长期执行链路是: + +```text +用户 ↔ AI Agent → Agent-facing tools → Browser RPC Bridge → 用户授权的 Chrome 会话 + ↓ + Data Skill Package +``` + +含义: + +* `AI Agent` 是主控,按用户目标规划工具调用、渐进读取、修改 recipe 并生成 SKILL.md; +* `Agent-facing tools` 提供捕获列表、受限读取、响应检查、recipe 构建/执行和技能包导出; +* `Browser RPC Bridge` 是 `runRecipe` 背后的运行时工具,只把经过校验的 recipe 运行请求转交给浏览器插件; +* `Chrome Extension` 在用户授权并确认的当前浏览器会话中执行请求; +* `authorized browser session` 指用户已经正常登录并有权访问的页面会话。 + +这个架构不会把 Cookie、Authorization header 或其它敏感凭证暴露给 AI Agent,也不会提供任意请求代理。项目不会实现绕过登录、验证码、风控、动态签名或反爬的能力。 + +更完整的职责、工具和分期设计见 [`docs/agent-tool-architecture.md`](./docs/agent-tool-architecture.md) 与 [`AGENTS.md`](./AGENTS.md)。 + +## 当前 MVP 状态 + +* 已完成:API 探测 / Agent 工具类型契约 / OpenAI-compatible Agent Runtime / 侧边栏模型配置 / 六个本地工具 handler; +* 已完成:Agent 通过 tool calls 自主选择读取范围、检查/修改 recipe、测试运行并编写 SKILL.md; +* 已完成:`recipe-runner.runRecipeWithBrowserRpc` 接口定形,类型已接上 `@data-recipe/browser-rpc-core` 的 `BrowserRunRecipeResult`; +* 尚未实现:真实浏览器 RPC 执行。当前 MVP 只完成协议和技能包草稿,`runRecipeWithBrowserRpc` 暂时返回 `blocked`,真实执行会在后续 PR 接入。 + diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 0000000..a9211cf --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,26 @@ +{ + "name": "@data-recipe/extension", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "clean": "rimraf dist", + "build": "tsup src/background.ts src/content.ts src/page-hook.ts src/sidepanel.ts --format iife --target es2020 --splitting false --clean --out-dir dist && node scripts/copy-assets.mjs", + "dev": "tsup src/background.ts src/content.ts src/page-hook.ts src/sidepanel.ts --format iife --target es2020 --splitting false --watch --out-dir dist --onSuccess \"node scripts/copy-assets.mjs\"", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@data-recipe/agent-runtime": "workspace:*", + "@data-recipe/agent-tools-core": "workspace:*", + "@data-recipe/detector": "workspace:*", + "@data-recipe/recipe-core": "workspace:*", + "@data-recipe/recipe-runner": "workspace:*", + "@data-recipe/skill-builder": "workspace:*", + "@data-recipe/skill-exporter": "workspace:*", + "dompurify": "^3.4.11", + "marked": "^18.0.5" + }, + "devDependencies": { + "rimraf": "^6.0.1" + } +} diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json new file mode 100644 index 0000000..1df2fa3 --- /dev/null +++ b/apps/extension/public/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "DataRecipe / AI 有数", + "description": "内置真实 AI Agent,通过用户配置的 OpenAI-compatible 模型学习页面并生成数据技能。", + "version": "0.1.0", + "action": { + "default_title": "AI 有数" + }, + "background": { + "service_worker": "background.global.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.global.js"], + "run_at": "document_start" + } + ], + "side_panel": { + "default_path": "sidepanel.html" + }, + "permissions": ["activeTab", "sidePanel", "tabs", "scripting", "storage"], + "host_permissions": [""], + "web_accessible_resources": [ + { + "resources": ["page-hook.global.js"], + "matches": [""] + } + ] +} + diff --git a/apps/extension/public/sidepanel.css b/apps/extension/public/sidepanel.css new file mode 100644 index 0000000..c476065 --- /dev/null +++ b/apps/extension/public/sidepanel.css @@ -0,0 +1,267 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #1f2937; + background: #f7f7f5; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 340px; } +button, textarea { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .55; } + +.app { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 100vh; + padding: 14px; +} + +.header, .brand, .header-actions, .learn-actions, .message-head, .artifact-actions, .workspace summary, .dialog-head { + align-items: center; + display: flex; +} + +.header { justify-content: space-between; } +.header-actions { gap: 7px; } +.brand { gap: 10px; } +.brand-mark { + align-items: center; + background: linear-gradient(145deg, #0f766e, #115e59); + border-radius: 10px; + color: white; + display: flex; + font-size: 15px; + font-weight: 700; + height: 34px; + justify-content: center; + width: 34px; +} +h1 { font-size: 16px; line-height: 1.2; margin: 0; } +.brand p { color: #7c827f; font-size: 11px; margin: 2px 0 0; } +.status { + align-items: center; + background: #eeefec; + border-radius: 999px; + color: #6b706d; + display: inline-flex; + font-size: 11px; + gap: 5px; + max-width: 128px; + overflow: hidden; + padding: 5px 8px; + text-overflow: ellipsis; + white-space: nowrap; +} +.status span { background: #9ca3af; border-radius: 50%; height: 6px; width: 6px; } +.status.active { background: #e3f4ef; color: #0f766e; } +.status.active span { background: #10b981; box-shadow: 0 0 0 3px #c8eee2; } +.settings-button, .icon-button { + background: transparent; + border: 0; + color: #66706c; + font-size: 11px; + padding: 5px; +} + +.learn-card { + background: linear-gradient(135deg, #ecf7f4, #f3f8f4); + border: 1px solid #cde7df; + border-radius: 12px; + display: grid; + gap: 12px; + padding: 13px; +} +.learn-card strong { color: #164e47; font-size: 13px; } +.learn-card p { color: #52706a; font-size: 12px; line-height: 1.45; margin: 4px 0 0; } +.learn-actions { gap: 8px; } +.primary, .quiet, .artifact-actions button { + border: 1px solid #b8d8cf; + border-radius: 8px; + min-height: 34px; + padding: 0 11px; +} +.primary { background: #0f766e; border-color: #0f766e; color: #fff; } +.quiet, .artifact-actions button { background: rgba(255,255,255,.7); color: #315e58; } + +.messages { display: flex; flex: 1; flex-direction: column; gap: 14px; min-height: 220px; } +.message { display: flex; gap: 8px; max-width: 94%; } +.message.user { align-self: flex-end; flex-direction: row-reverse; } +.avatar { + align-items: center; + background: #dceee9; + border-radius: 8px; + color: #0f766e; + display: flex; + flex: 0 0 auto; + font-size: 11px; + font-weight: 700; + height: 26px; + justify-content: center; + margin-top: 2px; + width: 26px; +} +.user .avatar { background: #e5e7eb; color: #4b5563; } +.bubble { + background: #fff; + border: 1px solid #e5e7e2; + border-radius: 4px 12px 12px 12px; + box-shadow: 0 1px 2px rgba(25, 40, 35, .04); + min-width: 0; + padding: 10px 11px; +} +.user .bubble { background: #0f766e; border-color: #0f766e; border-radius: 12px 4px 12px 12px; color: #fff; } +.message-head { color: #6b7280; font-size: 10px; justify-content: space-between; margin-bottom: 5px; } +.message-text { font-size: 13px; line-height: 1.55; white-space: pre-wrap; } + +.markdown-body { overflow-wrap: anywhere; white-space: normal; } +.markdown-body > :first-child { margin-top: 0; } +.markdown-body > :last-child { margin-bottom: 0; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { line-height: 1.3; margin: 14px 0 7px; } +.markdown-body h1 { font-size: 18px; } +.markdown-body h2 { font-size: 16px; } +.markdown-body h3 { font-size: 14px; } +.markdown-body p, .markdown-body ul, .markdown-body ol { margin: 7px 0; } +.markdown-body ul, .markdown-body ol { padding-left: 20px; } +.markdown-body blockquote { border-left: 3px solid #84b8ad; color: #5e6965; margin: 9px 0; padding: 2px 0 2px 10px; } +.markdown-body code { background: #edf1ee; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9em; padding: 1px 4px; } +.markdown-body pre { margin: 9px 0; } +.markdown-body pre code { background: transparent; padding: 0; } +.markdown-body a { color: #0f766e; text-decoration: underline; } +.markdown-body table { border-collapse: collapse; display: block; margin: 9px 0; max-width: 100%; overflow-x: auto; } +.markdown-body th, .markdown-body td { border: 1px solid #dce2de; font-size: 11px; padding: 5px 7px; text-align: left; } +.markdown-body th { background: #f0f4f1; } + +.plan { border-top: 1px solid #eceeea; margin: 9px 0 0; padding: 8px 0 0 20px; } +.plan li { color: #58615e; font-size: 12px; line-height: 1.5; padding: 2px 0 2px 2px; } +.plan li.done::marker { color: #0f9f78; content: "✓ "; } +.plan li.running::marker { color: #d58a18; content: "● "; } + +.tool-call { border-top: 1px solid #eceeea; margin-top: 8px; padding-top: 8px; } +.tool-call summary { color: #6b7280; cursor: pointer; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; } +.tool-call pre, .source-detail pre { + background: #202522; + border-radius: 7px; + color: #e6eee9; + font-size: 10px; + line-height: 1.45; + margin: 8px 0 0; + max-height: 220px; + overflow: auto; + padding: 9px; + white-space: pre-wrap; +} +.artifact-actions { flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.artifact-actions button { font-size: 11px; min-height: 30px; } + +.timeline-status { align-self: center; color: #858d89; font-size: 10px; padding: 1px 8px; } +.timeline-tool, .timeline-result { + align-self: stretch; + background: #f1f3f0; + border: 1px solid #e0e4df; + border-radius: 8px; + margin-left: 34px; + padding: 8px 9px; +} +.timeline-tool summary, .timeline-result summary { color: #626b67; cursor: pointer; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; } +.timeline-tool.running { border-color: #d8c38d; } +.timeline-tool.success, .timeline-result.success { border-color: #bcded4; } +.timeline-tool.error, .timeline-result.error { border-color: #efc1bc; background: #fff5f4; } +.timeline-tool pre, .timeline-result pre { margin-top: 7px; } +.artifact-card { + align-self: stretch; + background: linear-gradient(135deg, #e6f4ef, #f2f8f5); + border: 1px solid #b9ddd2; + border-radius: 10px; + color: #195e54; + font-size: 12px; + font-weight: 650; + margin-left: 34px; + padding: 11px; +} +.outcome-card { + align-self: stretch; + background: #f0f2ef; + border: 1px solid #dde1dc; + border-radius: 10px; + display: grid; + gap: 5px; + margin-left: 34px; + padding: 10px; +} +.outcome-card strong { font-size: 12px; } +.outcome-card span { color: #707874; font-size: 10px; } +.outcome-card.paused_budget { background: #fff8e8; border-color: #ead59a; } +.outcome-card.failed_tool, .outcome-card.blocked_user { background: #fff3f1; border-color: #e9bbb5; } +.outcome-card button { background: #0f766e; border: 0; border-radius: 7px; color: white; font-size: 11px; justify-self: start; min-height: 30px; padding: 0 10px; } + +.suggestions { display: flex; gap: 7px; overflow-x: auto; } +.suggestions button { + background: #fff; + border: 1px solid #dde1dc; + border-radius: 999px; + color: #59625f; + font-size: 11px; + padding: 7px 10px; + white-space: nowrap; +} +.composer { + align-items: flex-end; + background: #fff; + border: 1px solid #d9ddd8; + border-radius: 12px; + box-shadow: 0 4px 16px rgba(24, 45, 38, .07); + display: flex; + gap: 8px; + padding: 8px; + position: sticky; + bottom: 8px; +} +.composer:focus-within { border-color: #68aa9d; box-shadow: 0 0 0 3px #dceee9; } +.composer textarea { border: 0; color: #1f2937; line-height: 1.45; max-height: 120px; outline: 0; padding: 5px; resize: none; width: 100%; } +.send { background: #0f766e; border: 0; border-radius: 8px; color: #fff; flex: 0 0 auto; font-size: 19px; height: 34px; width: 34px; } + +.workspace { border-top: 1px solid #dcdfda; margin-top: 2px; padding-top: 10px; } +.workspace summary { color: #686f6c; cursor: pointer; font-size: 12px; justify-content: space-between; } +.count { background: #e9ebe8; border-radius: 999px; font-size: 10px; padding: 3px 7px; } +.workspace-body { display: grid; gap: 10px; padding-top: 10px; } +.source-list, .source-detail { background: #fff; border: 1px solid #e3e5e1; border-radius: 9px; padding: 9px; } +.empty { color: #8a918e; font-size: 12px; } +.source-item { background: #fff; border: 1px solid #e5e7e3; border-radius: 7px; display: block; margin-bottom: 6px; padding: 8px; text-align: left; width: 100%; } +.source-item.active { border-color: #4f9a8c; } +.source-title { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.source-meta { color: #858b88; font-size: 10px; margin-top: 3px; } +.source-detail dl { display: grid; gap: 7px; margin: 0; } +.source-detail dt { color: #858b88; font-size: 10px; } +.source-detail dd { font-size: 11px; margin: 1px 0 0; overflow-wrap: anywhere; } + +.model-dialog { + border: 0; + border-radius: 14px; + box-shadow: 0 18px 60px rgba(15, 35, 29, .22); + max-width: 390px; + padding: 0; + width: calc(100% - 28px); +} +.model-dialog::backdrop { background: rgba(20, 30, 27, .38); } +.model-form { display: grid; gap: 13px; padding: 18px; } +.dialog-head { align-items: flex-start; justify-content: space-between; } +.dialog-head h2 { font-size: 16px; margin: 0; } +.dialog-head p { color: #737b78; font-size: 11px; line-height: 1.45; margin: 4px 0 0; } +.icon-button { font-size: 22px; line-height: 1; } +.model-form label { display: grid; gap: 5px; } +.model-form label span { color: #59625f; font-size: 11px; font-weight: 600; } +.model-form input { + border: 1px solid #d4d9d4; + border-radius: 8px; + min-height: 38px; + outline: 0; + padding: 7px 9px; + width: 100%; +} +.model-form input:focus { border-color: #55988b; box-shadow: 0 0 0 3px #e1f0ec; } +.storage-note { color: #7b827f; font-size: 10px; line-height: 1.5; margin: 0; } +.model-error { color: #b42318; font-size: 11px; margin: 0; min-height: 16px; } +.save-model { width: 100%; } diff --git a/apps/extension/public/sidepanel.html b/apps/extension/public/sidepanel.html new file mode 100644 index 0000000..98f2648 --- /dev/null +++ b/apps/extension/public/sidepanel.html @@ -0,0 +1,89 @@ + + + + + + AI 有数 + + + +
+
+
+ +
+

AI 有数

+

页面数据 Agent

+
+
+
+ + + 待命 +
+
+ +
+
+ 让 AI 先认识这个页面 +

开启后,请在页面里完成一次查询或翻页。

+
+
+ + +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + 高级信息 + 0 个数据源 + +
+
学习页面后,数据源会显示在这里。
+
尚未选择数据源。
+
+
+
+ + +
+
+
+

模型设置

+

配置支持 Chat Completions 与 function tools 的 OpenAI-compatible Provider。

+
+ +
+ + + +

配置仅保存到浏览器扩展的本地存储。API Key 不会发送给 Agent 工具或写入技能包。

+ + +
+
+ + + diff --git a/apps/extension/scripts/copy-assets.mjs b/apps/extension/scripts/copy-assets.mjs new file mode 100644 index 0000000..63ec88a --- /dev/null +++ b/apps/extension/scripts/copy-assets.mjs @@ -0,0 +1,10 @@ +import { cp, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const publicDir = join(root, "public"); +const distDir = join(root, "dist"); + +await mkdir(distDir, { recursive: true }); +await cp(publicDir, distDir, { recursive: true }); diff --git a/apps/extension/src/agent-tool-definitions.ts b/apps/extension/src/agent-tool-definitions.ts new file mode 100644 index 0000000..adc8a12 --- /dev/null +++ b/apps/extension/src/agent-tool-definitions.ts @@ -0,0 +1,149 @@ +import type { AgentFunctionTool } from "@data-recipe/agent-runtime"; + +const selectionSchema = { + type: "object", + oneOf: [ + { properties: { mode: { const: "preview" }, maxChars: { type: "integer" }, maxItems: { type: "integer" } }, required: ["mode"], additionalProperties: false }, + { properties: { mode: { const: "page" }, cursor: { type: "string" }, pageSize: { type: "integer" } }, required: ["mode"], additionalProperties: false }, + { properties: { mode: { const: "range" }, offset: { type: "integer" }, length: { type: "integer" } }, required: ["mode", "offset", "length"], additionalProperties: false }, + { properties: { mode: { const: "full" } }, required: ["mode"], additionalProperties: false } + ] +}; + +const workflowValueSchema = { + oneOf: [ + { + type: "object", + properties: { type: { const: "user_input" }, name: { type: "string" }, path: { type: "string" } }, + required: ["type", "name"], + additionalProperties: false + }, + { + type: "object", + properties: { type: { const: "step_output" }, stepId: { type: "string" }, path: { type: "string" } }, + required: ["type", "stepId", "path"], + additionalProperties: false + }, + { + type: "object", + properties: { type: { const: "literal" }, value: {} }, + required: ["type", "value"], + additionalProperties: false + } + ] +}; + +const workflowSchema = { + type: "object", + properties: { + version: { const: 1 }, + description: { type: "string" }, + userInputs: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + type: { enum: ["string", "number", "boolean", "object"] }, + required: { type: "boolean" }, + description: { type: "string" }, + examples: { type: "array" }, + parsing: { type: "string" } + }, + required: ["name", "type", "required", "description"], + additionalProperties: false + } + }, + steps: { + type: "array", + minItems: 1, + items: { + type: "object", + properties: { + id: { type: "string" }, + operation: { enum: ["list", "filter", "detail", "diff"] }, + recipeId: { type: "string" }, + optional: { type: "boolean" }, + dependsOn: { type: "array", items: { type: "string" } }, + inputs: { type: "object", additionalProperties: workflowValueSchema }, + outputs: { + type: "object", + additionalProperties: { + type: "object", + properties: { path: { type: "string" }, description: { type: "string" } }, + required: ["path"], + additionalProperties: false + } + } + }, + required: ["id", "operation", "inputs", "outputs"], + additionalProperties: false + } + }, + finalOutputs: { type: "object", additionalProperties: workflowValueSchema } + }, + required: ["version", "description", "userInputs", "steps", "finalOutputs"], + additionalProperties: false +}; + +export const AGENT_TOOL_DEFINITIONS: AgentFunctionTool[] = [ + tool("listCapturedSources", "分页列出插件已捕获的数据来源元信息,不读取完整响应。", { + type: "object", + properties: { cursor: { type: "string" }, pageSize: { type: "integer" }, pageUrl: { type: "string" } } + }), + tool("readCapture", "按预览、分页、范围或完整模式读取某个捕获。full 模式会触发用户确认。", { + type: "object", + properties: { + captureId: { type: "string" }, + section: { enum: ["request", "response", "both"] }, + selection: selectionSchema + }, + required: ["captureId", "section", "selection"] + }), + tool("inspectResponse", "检查响应结构、候选列表路径和字段,并按 bounded read 返回内容。", { + type: "object", + properties: { + captureId: { type: "string" }, + selection: selectionSchema, + jsonPath: { type: "string" }, + maxDepth: { type: "integer" } + }, + required: ["captureId", "selection"] + }), + tool("buildRecipe", "首次用 captureId 生成 recipeId;后续只用 recipeId + patch 修改,不要传完整 recipe。", { + type: "object", + properties: { + captureId: { type: "string" }, + recipeId: { type: "string" }, + patch: { type: "object" } + }, + oneOf: [{ required: ["captureId"] }, { required: ["recipeId", "patch"] }] + }), + tool("runRecipe", "测试或运行已检查的 recipe。每次调用都会触发用户确认。", { + type: "object", + properties: { + recipeId: { type: "string" }, + variables: { type: "object" }, + dryRun: { type: "boolean" }, + previewLimit: { type: "integer" } + }, + required: ["recipeId"] + }), + tool("exportSkillPackage", "导出 ZIP Data Skill Package。提供全部 recipeIds、workflow 和 Agent 编写的完整 SKILL.md;多接口技能必须包含多个 recipe。", { + type: "object", + properties: { + recipeIds: { type: "array", minItems: 1, items: { type: "string" } }, + workflow: workflowSchema, + packageName: { type: "string" }, + skillMarkdown: { type: "string" }, + userGoal: { type: "string" }, + examplesMarkdown: { type: "string" }, + readmeMarkdown: { type: "string" } + }, + required: ["recipeIds", "workflow", "skillMarkdown", "userGoal"] + }) +]; + +function tool(name: string, description: string, parameters: Record): AgentFunctionTool { + return { type: "function", function: { name, description, parameters: { additionalProperties: false, ...parameters } } }; +} diff --git a/apps/extension/src/agent-tools.ts b/apps/extension/src/agent-tools.ts new file mode 100644 index 0000000..dc68415 --- /dev/null +++ b/apps/extension/src/agent-tools.ts @@ -0,0 +1,404 @@ +import { + RecipeStore, + RecipeStoreError, + summarizeRecipe, + type AgentReadSelection, + type AgentToolDataMap, + type AgentToolError, + type AgentToolInputMap, + type AgentToolLayer, + type AgentToolName, + type AgentToolOutputMap, + type DataRecipePatch, + type ReadResult +} from "@data-recipe/agent-tools-core"; +import { buildRecipeFromCapture, summarizeResponse, type CapturedRequest } from "@data-recipe/detector"; +import type { DataRecipe, JsonObject } from "@data-recipe/recipe-core"; +import { runRecipeOnSample, runRecipeWithBrowserRpc } from "@data-recipe/recipe-runner"; +import { buildDataSkillPackage } from "@data-recipe/skill-builder"; +import { exportDataSkillPackageAsZip } from "@data-recipe/skill-exporter"; + +export interface SidepanelAgentToolsOptions { + getCaptures(): CapturedRequest[]; + confirmAction(message: string): Promise; + download(file: { fileName: string; mimeType: string; content: string | Uint8Array }): void; + recipeStore?: RecipeStore; +} + +const EXPECTED: Record = { + listCapturedSources: { cursor: "string?", pageSize: "integer 1..50?", pageUrl: "string?" }, + readCapture: { captureId: "string", section: "request|response|both", selection: "preview|page|range|full object" }, + inspectResponse: { captureId: "string", selection: "preview|page|range|full object", jsonPath: "string?", maxDepth: "integer 1..20?" }, + buildRecipe: { firstCall: { captureId: "string", patch: "object?" }, update: { recipeId: "string", patch: "object" } }, + runRecipe: { recipeId: "string", variables: "object?", dryRun: "boolean?", previewLimit: "integer 1..20?" }, + exportSkillPackage: { recipeIds: "non-empty string[]", workflow: "workflow object", packageName: "string?", skillMarkdown: "non-empty string", userGoal: "non-empty string", examplesMarkdown: "string?", readmeMarkdown: "string?" } +}; + +class ToolInputError extends Error { + constructor(readonly code: string, message: string, readonly retryable = true) { + super(message); + this.name = "ToolInputError"; + } +} + +export class SidepanelAgentToolLayer implements AgentToolLayer { + readonly recipeStore: RecipeStore; + + constructor(private readonly options: SidepanelAgentToolsOptions) { + this.recipeStore = options.recipeStore ?? new RecipeStore(); + } + + async invoke( + name: TName, + input: AgentToolInputMap[TName] + ): Promise { + try { + let data: AgentToolDataMap[AgentToolName]; + switch (name) { + case "listCapturedSources": data = this.listCapturedSources(input); break; + case "readCapture": data = await this.readCapture(input); break; + case "inspectResponse": data = await this.inspectResponse(input); break; + case "buildRecipe": data = this.buildRecipe(input); break; + case "runRecipe": data = await this.runRecipe(input); break; + case "exportSkillPackage": data = await this.exportSkillPackage(input); break; + default: throw new ToolInputError("UNKNOWN_TOOL", `不支持的工具:${String(name)}`, false); + } + return { ok: true, data } as AgentToolOutputMap[TName]; + } catch (caught) { + return { ok: false, error: toToolError(name, caught) } as AgentToolOutputMap[TName]; + } + } + + private listCapturedSources(input: unknown): AgentToolDataMap["listCapturedSources"] { + const record = requireRecord(input); + rejectUnknownKeys(record, ["cursor", "pageSize", "pageUrl"]); + optionalString(record.cursor, "cursor"); + optionalString(record.pageUrl, "pageUrl"); + optionalInteger(record.pageSize, "pageSize", 1, 50); + const pageSize = clampInteger(record.pageSize as number | undefined, 1, 50, 20); + const offset = parseCursor(record.cursor as string | undefined); + const pageUrl = record.pageUrl as string | undefined; + const filtered = this.options.getCaptures().filter((capture) => !pageUrl || capture.pageUrl === pageUrl); + const page = filtered.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + return { + sources: page.map((capture) => ({ + captureId: capture.id, + pageUrl: redactUrl(capture.pageUrl), + apiUrl: redactUrl(capture.url), + method: capture.method, + status: capture.status, + capturedAt: capture.capturedAt, + transport: capture.transport, + responseSize: safeJson(capture.responseBody).length, + preview: selectReadContent(redactSecrets(capture.responseBody), { mode: "preview", maxChars: 500 }).value + })), + nextCursor: nextOffset < filtered.length ? String(nextOffset) : undefined + }; + } + + private async readCapture(input: unknown): Promise { + const record = requireRecord(input); + rejectUnknownKeys(record, ["captureId", "section", "selection"]); + const captureId = requiredString(record.captureId, "captureId"); + const section = requiredEnum(record.section, "section", ["request", "response", "both"] as const); + const selection = parseSelection(record.selection); + const capture = this.requireCapture(captureId); + if (selection.mode === "full") await this.requireConfirmation("Agent 请求完整读取当前捕获内容。完整内容可能较大,是否允许?"); + const request = { url: redactUrl(capture.url), method: capture.method, query: capture.query, body: capture.requestBody }; + const raw = section === "request" ? request : section === "response" ? capture.responseBody : { request, response: capture.responseBody }; + return selectReadContent(redactSecrets(raw), selection); + } + + private async inspectResponse(input: unknown): Promise { + const record = requireRecord(input); + rejectUnknownKeys(record, ["captureId", "selection", "jsonPath", "maxDepth"]); + const captureId = requiredString(record.captureId, "captureId"); + const selection = parseSelection(record.selection); + optionalString(record.jsonPath, "jsonPath"); + optionalInteger(record.maxDepth, "maxDepth", 1, 20); + const capture = this.requireCapture(captureId); + if (selection.mode === "full") await this.requireConfirmation("Agent 请求完整读取响应内容以检查结构,是否允许?"); + const detection = summarizeResponse(capture.responseBody); + const jsonPath = record.jsonPath as string | undefined; + const focused = jsonPath ? getByPath(capture.responseBody, jsonPath) : capture.responseBody; + const shape = Array.isArray(focused) ? "list" : focused && typeof focused === "object" ? "object" : focused === undefined ? "unknown" : "scalar"; + return { + shape, + listPaths: detection.arrayPaths, + candidateTotalPaths: detection.totalPath ? [detection.totalPath] : [], + candidateFields: detection.fields.map((field) => ({ + path: field.path, + type: field.type, + sample: isSensitiveName(field.path) ? "[REDACTED]" : redactSecrets(field.sample) + })), + content: selectReadContent(redactSecrets(limitDepth(focused, (record.maxDepth as number | undefined) ?? 8)), selection) + }; + } + + private buildRecipe(input: unknown): AgentToolDataMap["buildRecipe"] { + const record = requireRecord(input); + rejectUnknownKeys(record, ["captureId", "recipeId", "patch"]); + optionalString(record.captureId, "captureId"); + optionalString(record.recipeId, "recipeId"); + if (record.patch !== undefined && !isRecord(record.patch)) throw new ToolInputError("INVALID_PATCH", "patch 必须是 object。"); + if (isRecord(record.patch)) { + rejectUnknownKeys(record.patch, ["name", "displayName", "description", "useCases", "request", "response", "pagination", "fields"]); + } + const patch = redactSecrets(record.patch) as DataRecipePatch | undefined; + const existingId = record.recipeId as string | undefined; + + if (existingId) { + if (record.captureId !== undefined) throw new ToolInputError("AMBIGUOUS_RECIPE_REFERENCE", "recipeId 更新模式不能同时提供 captureId。"); + if (!patch) throw new ToolInputError("PATCH_REQUIRED", "使用 recipeId 修改配方时必须提供 patch。"); + const updated = sanitizeRecipe(this.recipeStore.update(existingId, patch)); + this.recipeStore.update(existingId, recipeToPatch(updated)); + return { recipeId: existingId, summary: summarizeRecipe(existingId, updated), warnings: recipeWarnings(updated), inferredByRules: false }; + } + + const captureId = requiredString(record.captureId, "captureId"); + let recipe = sanitizeRecipe(buildRecipeFromCapture(this.requireCapture(captureId))); + const recipeId = this.recipeStore.create(recipe); + if (patch) recipe = sanitizeRecipe(this.recipeStore.update(recipeId, patch)); + return { recipeId, summary: summarizeRecipe(recipeId, recipe), warnings: recipeWarnings(recipe), inferredByRules: true }; + } + + private async runRecipe(input: unknown): Promise { + const record = requireRecord(input); + rejectUnknownKeys(record, ["recipeId", "variables", "dryRun", "previewLimit"]); + const recipeId = requiredString(record.recipeId, "recipeId"); + if (record.variables !== undefined && !isRecord(record.variables)) throw new ToolInputError("INVALID_VARIABLES", "variables 必须是 object。"); + optionalBoolean(record.dryRun, "dryRun"); + optionalInteger(record.previewLimit, "previewLimit", 1, 20); + const recipe = sanitizeRecipe(this.recipeStore.get(recipeId)); + const capture = this.requireRecipeCapture(recipe); + await this.requireConfirmation(`Agent 请求运行配方「${recipe.displayName}」,是否允许?`); + if (record.dryRun !== false) { + const result = runRecipeOnSample(recipe, capture.responseBody, { previewLimit: clampInteger(record.previewLimit as number | undefined, 1, 20, 5) }); + return { + recipeName: recipe.name, + status: result.status === "unsupported" ? "error" : result.status, + rows: result.previewRows, + rowCount: result.rowCount, + responsePreview: result.previewRows, + message: result.message + }; + } + return runRecipeWithBrowserRpc({ recipe, variables: redactSecrets(record.variables) as JsonObject | undefined }); + } + + private async exportSkillPackage(input: unknown): Promise { + const record = requireRecord(input); + rejectUnknownKeys(record, ["recipeIds", "workflow", "packageName", "skillMarkdown", "userGoal", "examplesMarkdown", "readmeMarkdown"]); + if (!Array.isArray(record.recipeIds) || record.recipeIds.length === 0 || !record.recipeIds.every((id) => typeof id === "string" && id.trim())) { + throw new ToolInputError("INVALID_INPUT", "recipeIds 必须是非空字符串数组。"); + } + if (!isRecord(record.workflow)) throw new ToolInputError("INVALID_INPUT", "workflow 必须是 object。"); + optionalString(record.packageName, "packageName"); + const skillMarkdown = requiredString(record.skillMarkdown, "skillMarkdown"); + const userGoal = requiredString(record.userGoal, "userGoal"); + optionalString(record.examplesMarkdown, "examplesMarkdown"); + optionalString(record.readmeMarkdown, "readmeMarkdown"); + const recipeIds = [...new Set((record.recipeIds as string[]).map((id) => id.trim()))]; + const recipes = recipeIds.map((recipeId) => ({ recipeId, recipe: sanitizeRecipe(this.recipeStore.get(recipeId)) })); + recipes.forEach(({ recipe }) => this.requireRecipeCapture(recipe)); + let skillPackage; + try { + skillPackage = buildDataSkillPackage({ + packageName: record.packageName as string | undefined, + recipes, + workflow: record.workflow as never, + userGoal, + skillMarkdown, + examplesMarkdown: record.examplesMarkdown as string | undefined, + readmeMarkdown: record.readmeMarkdown as string | undefined + }); + } catch (caught) { + throw new ToolInputError( + "INVALID_WORKFLOW", + caught instanceof Error ? caught.message : "workflow 或技能包结构无效。", + true + ); + } + await this.requireConfirmation(`Agent 已准备好包含 ${recipes.length} 个 recipe 的 Data Skill Package,是否导出 ZIP?`); + this.options.download(exportDataSkillPackageAsZip(skillPackage)); + return { package: skillPackage, warnings: [] }; + } + + private requireCapture(id: string): CapturedRequest { + const capture = this.options.getCaptures().find((item) => item.id === id); + if (!capture) throw new ToolInputError("CAPTURE_NOT_FOUND", `找不到 capture:${id}`, false); + return capture; + } + + private requireRecipeCapture(recipe: DataRecipe): CapturedRequest { + const capture = this.options.getCaptures().find((item) => redactUrl(item.url) === recipe.source.apiUrl); + if (!capture) throw new ToolInputError("RECIPE_SOURCE_NOT_CAPTURED", "recipe 的数据来源不在当前已捕获列表中,拒绝执行。", false); + return capture; + } + + private async requireConfirmation(message: string): Promise { + if (!await this.options.confirmAction(message)) throw new ToolInputError("USER_DENIED", "用户拒绝了本次操作。", false); + } +} + +function toToolError(name: AgentToolName, caught: unknown): AgentToolError { + if (caught instanceof ToolInputError) { + return { code: caught.code, message: caught.message, retryable: caught.retryable, expectedInput: EXPECTED[name] }; + } + if (caught instanceof RecipeStoreError) { + return { code: caught.code, message: caught.message, retryable: caught.code !== "RECIPE_NOT_FOUND", expectedInput: EXPECTED[name] }; + } + return { code: "INTERNAL_TOOL_ERROR", message: "工具内部执行失败,请检查输入或重新开始该步骤。", retryable: false, expectedInput: EXPECTED[name] }; +} + +function requireRecord(value: unknown): Record { + if (!isRecord(value)) throw new ToolInputError("INVALID_INPUT", "工具参数必须是 object。"); + return value; +} + +function rejectUnknownKeys(record: Record, allowed: string[]): void { + const unknown = Object.keys(record).filter((key) => !allowed.includes(key)); + if (unknown.length) throw new ToolInputError("UNKNOWN_INPUT_FIELDS", `不支持的参数字段:${unknown.join("、")}。`); +} + +function requiredString(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim()) throw new ToolInputError("INVALID_INPUT", `${field} 必须是非空字符串。`); + return value.trim(); +} + +function optionalString(value: unknown, field: string): void { + if (value !== undefined && typeof value !== "string") throw new ToolInputError("INVALID_INPUT", `${field} 必须是字符串。`); +} + +function optionalBoolean(value: unknown, field: string): void { + if (value !== undefined && typeof value !== "boolean") throw new ToolInputError("INVALID_INPUT", `${field} 必须是 boolean。`); +} + +function optionalInteger(value: unknown, field: string, min: number, max: number): void { + if (value !== undefined && (!Number.isInteger(value) || Number(value) < min || Number(value) > max)) { + throw new ToolInputError("INVALID_INPUT", `${field} 必须是 ${min}..${max} 的整数。`); + } +} + +function requiredEnum(value: unknown, field: string, allowed: T): T[number] { + if (typeof value !== "string" || !allowed.includes(value)) throw new ToolInputError("INVALID_INPUT", `${field} 必须是 ${allowed.join("、")}。`); + return value as T[number]; +} + +function parseSelection(value: unknown): AgentReadSelection { + const record = requireRecord(value); + const mode = requiredEnum(record.mode, "selection.mode", ["preview", "page", "range", "full"] as const); + if (mode === "preview") { + rejectUnknownKeys(record, ["mode", "maxChars", "maxItems"]); + optionalInteger(record.maxChars, "selection.maxChars", 100, 8000); + optionalInteger(record.maxItems, "selection.maxItems", 1, 100); + return { mode, maxChars: record.maxChars as number | undefined, maxItems: record.maxItems as number | undefined }; + } + if (mode === "page") { + rejectUnknownKeys(record, ["mode", "cursor", "pageSize"]); + optionalString(record.cursor, "selection.cursor"); + optionalInteger(record.pageSize, "selection.pageSize", 100, 8000); + return { mode, cursor: record.cursor as string | undefined, pageSize: record.pageSize as number | undefined }; + } + if (mode === "range") { + rejectUnknownKeys(record, ["mode", "offset", "length"]); + optionalInteger(record.offset, "selection.offset", 0, Number.MAX_SAFE_INTEGER); + optionalInteger(record.length, "selection.length", 1, 8000); + if (record.offset === undefined || record.length === undefined) throw new ToolInputError("INVALID_INPUT", "range 模式必须提供 offset 和 length。"); + return { mode, offset: record.offset as number, length: record.length as number }; + } + rejectUnknownKeys(record, ["mode"]); + return { mode }; +} + +function selectReadContent(value: unknown, selection: AgentReadSelection): ReadResult { + const text = safeJson(value); + if (selection.mode === "full") return { value, selection, truncated: false, totalChars: text.length }; + if (selection.mode === "preview") { + const maxChars = clampInteger(selection.maxChars, 100, 8000, 1800); + const previewValue = Array.isArray(value) ? value.slice(0, clampInteger(selection.maxItems, 1, 100, 10)) : text.slice(0, maxChars); + return { value: previewValue, selection, truncated: Array.isArray(value) ? previewValue.length < value.length : text.length > maxChars, totalItems: Array.isArray(value) ? value.length : undefined, totalChars: text.length }; + } + if (selection.mode === "range") { + const offset = clampInteger(selection.offset, 0, text.length, 0); + const length = clampInteger(selection.length, 1, 8000, 1800); + return { value: text.slice(offset, offset + length), selection, truncated: offset > 0 || offset + length < text.length, totalChars: text.length }; + } + const offset = parseCursor(selection.cursor); + const pageSize = clampInteger(selection.pageSize, 100, 8000, 1800); + const end = Math.min(text.length, offset + pageSize); + return { value: text.slice(offset, end), selection, truncated: end < text.length, nextCursor: end < text.length ? String(end) : undefined, totalChars: text.length }; +} + +function sanitizeRecipe(recipe: DataRecipe): DataRecipe { + return { + ...recipe, + source: { ...recipe.source, pageUrl: redactUrl(recipe.source.pageUrl), apiUrl: redactUrl(recipe.source.apiUrl) }, + request: { + ...recipe.request, + query: redactSecrets(recipe.request.query) as JsonObject, + body: redactSecrets(recipe.request.body), + queryFields: recipe.request.queryFields.map((field) => ({ ...field, sample: isSensitiveName(`${field.name}.${field.path}`) ? "[REDACTED]" : redactSecrets(field.sample) })), + bodyFields: recipe.request.bodyFields.map((field) => ({ ...field, sample: isSensitiveName(`${field.name}.${field.path}`) ? "[REDACTED]" : redactSecrets(field.sample) })) + }, + fields: recipe.fields.map((field) => ({ ...field, sample: isSensitiveName(`${field.name}.${field.path}`) ? "[REDACTED]" : redactSecrets(field.sample) })) + }; +} + +function recipeToPatch(recipe: DataRecipe): DataRecipePatch { + return { name: recipe.name, displayName: recipe.displayName, description: recipe.description, useCases: recipe.useCases, request: recipe.request, response: recipe.response, pagination: recipe.pagination, fields: recipe.fields }; +} + +function recipeWarnings(recipe: DataRecipe): string[] { + return recipe.fields.length ? [] : ["尚未识别出稳定返回字段,请先 inspectResponse。"]; +} + +function redactSecrets(value: unknown, seen = new WeakSet()): unknown { + if (!value || typeof value !== "object") return value; + if (seen.has(value)) return "[Circular]"; + seen.add(value); + if (Array.isArray(value)) return value.map((item) => redactSecrets(item, seen)); + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, isSensitiveName(key) ? "[REDACTED]" : redactSecrets(child, seen)])); +} + +function redactUrl(value: string): string { + try { + const url = new URL(value); + Array.from(url.searchParams.keys()).forEach((key) => { if (isSensitiveName(key)) url.searchParams.set(key, "[REDACTED]"); }); + return url.toString(); + } catch { return value; } +} + +function isSensitiveName(value: string): boolean { + return /authorization|cookie|set-cookie|api[-_]?key|access[-_]?token|refresh[-_]?token|password|secret/i.test(value); +} + +function limitDepth(value: unknown, depth: number): unknown { + if (depth <= 0 && value && typeof value === "object") return "[Max depth reached]"; + if (Array.isArray(value)) return value.slice(0, 100).map((item) => limitDepth(item, depth - 1)); + if (isRecord(value)) return Object.fromEntries(Object.entries(value).slice(0, 100).map(([key, child]) => [key, limitDepth(child, depth - 1)])); + return value; +} + +function getByPath(value: unknown, path: string): unknown { + if (!path || path === "$") return value; + return path.replace(/^\$\.?/, "").split(".").filter(Boolean).reduce((current, part) => isRecord(current) ? current[part] : undefined, value); +} + +function clampInteger(value: number | undefined, min: number, max: number, fallback: number): number { + return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.floor(value!))) : fallback; +} + +function parseCursor(cursor: string | undefined): number { + const value = Number(cursor ?? 0); + return Number.isInteger(value) && value >= 0 ? value : 0; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function safeJson(value: unknown): string { + try { return JSON.stringify(value, null, 2) ?? "null"; } catch { return "[Unserializable]"; } +} diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts new file mode 100644 index 0000000..11e95c6 --- /dev/null +++ b/apps/extension/src/background.ts @@ -0,0 +1,124 @@ +import type { CapturedRequest } from "@data-recipe/detector"; +import type { PanelEvent, PanelMessage, PanelSnapshot, RuntimeMessage, TabControlMessage } from "./messages"; + +const tabCaptures = new Map(); +const activeTabs = new Set(); +const panelPorts = new Set(); + +chrome.runtime.onInstalled.addListener(() => { + if (chrome.sidePanel?.setPanelBehavior) { + void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + } +}); + +chrome.action.onClicked.addListener(async (tab) => { + if (tab.id && chrome.sidePanel?.open) { + await chrome.sidePanel.open({ tabId: tab.id }); + } +}); + +chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender) => { + if (message.type !== "captured-request" || !sender.tab?.id) { + return; + } + + const tabId = sender.tab.id; + const captures = tabCaptures.get(tabId) ?? []; + captures.unshift(message.capture); + tabCaptures.set(tabId, captures.slice(0, 100)); + + broadcast({ + type: "capture-added", + capture: message.capture, + snapshot: createSnapshot(tabId) + }); +}); + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== "data-recipe-sidepanel") { + return; + } + + panelPorts.add(port); + port.onDisconnect.addListener(() => panelPorts.delete(port)); + port.onMessage.addListener((message: PanelMessage) => { + void handlePanelMessage(port, message); + }); + + void sendSnapshot(port); +}); + +async function handlePanelMessage(port: chrome.runtime.Port, message: PanelMessage): Promise { + const tabId = await getActiveTabId(); + + if (message.type === "get-snapshot") { + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + return; + } + + if (!tabId) { + send(port, { type: "snapshot", snapshot: createSnapshot(null) }); + return; + } + + if (message.type === "start-discovery") { + activeTabs.add(tabId); + await sendControlMessage(tabId, { type: "start-discovery" }); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + return; + } + + if (message.type === "stop-discovery") { + activeTabs.delete(tabId); + await sendControlMessage(tabId, { type: "stop-discovery" }); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + return; + } + + if (message.type === "clear-captures") { + tabCaptures.set(tabId, []); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + } +} + +async function sendSnapshot(port: chrome.runtime.Port): Promise { + const tabId = await getActiveTabId(); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); +} + +async function getActiveTabId(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab?.id ?? null; +} + +async function sendControlMessage(tabId: number, message: TabControlMessage): Promise { + try { + await chrome.tabs.sendMessage(tabId, message); + } catch { + await chrome.scripting.executeScript({ + target: { tabId }, + files: ["content.global.js"] + }); + await chrome.tabs.sendMessage(tabId, message); + } +} + +function createSnapshot(tabId: number | null): PanelSnapshot { + return { + tabId, + state: tabId && activeTabs.has(tabId) ? "active" : "idle", + captures: tabId ? tabCaptures.get(tabId) ?? [] : [] + }; +} + +function broadcast(event: PanelEvent): void { + panelPorts.forEach((port) => send(port, event)); +} + +function send(port: chrome.runtime.Port, event: PanelEvent): void { + try { + port.postMessage(event); + } catch { + panelPorts.delete(port); + } +} diff --git a/apps/extension/src/content.ts b/apps/extension/src/content.ts new file mode 100644 index 0000000..72dfaf6 --- /dev/null +++ b/apps/extension/src/content.ts @@ -0,0 +1,59 @@ +import { formatPreview, parseQueryFromUrl, tryParseJson, type CapturedRequest } from "@data-recipe/detector"; +import type { PageCapturedMessage, PageControlMessage, TabControlMessage } from "./messages"; + +const scriptId = "data-recipe-page-hook"; + +injectPageHook(); + +chrome.runtime.onMessage.addListener((message: TabControlMessage) => { + if (message.type === "start-discovery" || message.type === "stop-discovery") { + postControl(message.type); + } +}); + +window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window || event.data?.source !== "data-recipe-page-hook") { + return; + } + + if (event.data.type !== "captured-request") { + return; + } + + const payload = event.data.payload; + const responseBody = + typeof payload.responseBody === "string" ? tryParseJson(payload.responseBody) : payload.responseBody; + + const capture: CapturedRequest = { + ...payload, + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + pageUrl: window.location.href, + query: parseQueryFromUrl(payload.url), + responseBody, + responsePreview: formatPreview(responseBody), + capturedAt: Date.now() + }; + + void chrome.runtime.sendMessage({ type: "captured-request", capture }); +}); + +function injectPageHook(): void { + if (document.getElementById(scriptId)) { + return; + } + + const script = document.createElement("script"); + script.id = scriptId; + script.src = chrome.runtime.getURL("page-hook.global.js"); + script.async = false; + (document.documentElement || document.head).appendChild(script); + script.remove(); +} + +function postControl(type: PageControlMessage["type"]): void { + const message: PageControlMessage = { + source: "data-recipe-content", + type + }; + window.postMessage(message, "*"); +} diff --git a/apps/extension/src/messages.ts b/apps/extension/src/messages.ts new file mode 100644 index 0000000..633d65a --- /dev/null +++ b/apps/extension/src/messages.ts @@ -0,0 +1,38 @@ +import type { CapturedRequest } from "@data-recipe/detector"; + +export type DiscoveryState = "idle" | "active"; + +export interface PanelSnapshot { + tabId: number | null; + state: DiscoveryState; + captures: CapturedRequest[]; +} + +export type RuntimeMessage = + | { type: "captured-request"; capture: CapturedRequest } + | { type: "content-ready" }; + +export type TabControlMessage = + | { type: "start-discovery" } + | { type: "stop-discovery" }; + +export type PanelMessage = + | { type: "get-snapshot" } + | { type: "start-discovery" } + | { type: "stop-discovery" } + | { type: "clear-captures" }; + +export type PanelEvent = + | { type: "snapshot"; snapshot: PanelSnapshot } + | { type: "capture-added"; capture: CapturedRequest; snapshot: PanelSnapshot }; + +export interface PageCapturedMessage { + source: "data-recipe-page-hook"; + type: "captured-request"; + payload: Omit; +} + +export interface PageControlMessage { + source: "data-recipe-content"; + type: "start-discovery" | "stop-discovery"; +} diff --git a/apps/extension/src/page-hook.ts b/apps/extension/src/page-hook.ts new file mode 100644 index 0000000..cec3135 --- /dev/null +++ b/apps/extension/src/page-hook.ts @@ -0,0 +1,203 @@ +import type { PageControlMessage } from "./messages"; + +type CapturePayload = { + url: string; + method: string; + status: number; + requestBody: unknown; + responseBody: unknown; + responsePreview: string; + transport: "fetch" | "xhr"; +}; + +const state = { + enabled: false, + fetchPatched: false, + xhrPatched: false +}; + +patchFetch(); +patchXhr(); + +window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window || event.data?.source !== "data-recipe-content") { + return; + } + + state.enabled = event.data.type === "start-discovery"; +}); + +function patchFetch(): void { + if (state.fetchPatched || !window.fetch) { + return; + } + + state.fetchPatched = true; + const originalFetch = window.fetch.bind(window); + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const requestInfo = await readFetchRequest(input, init); + const response = await originalFetch(input, init); + + if (state.enabled) { + void readResponseBody(response.clone()).then((responseBody) => { + emitCapture({ + ...requestInfo, + status: response.status, + responseBody, + responsePreview: makePreview(responseBody), + transport: "fetch" + }); + }); + } + + return response; + }; +} + +function patchXhr(): void { + if (state.xhrPatched || !window.XMLHttpRequest) { + return; + } + + state.xhrPatched = true; + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function open(method: string, url: string | URL): void { + this.__dataRecipe = { + method, + url: String(url), + requestBody: null + }; + return originalOpen.apply(this, arguments as unknown as Parameters); + }; + + XMLHttpRequest.prototype.send = function send(body?: Document | XMLHttpRequestBodyInit | null): void { + if (this.__dataRecipe) { + this.__dataRecipe.requestBody = serializeBody(body); + } + + this.addEventListener("loadend", () => { + if (!state.enabled || !this.__dataRecipe) { + return; + } + + const responseBody = parseJsonLike(this.responseText); + emitCapture({ + url: toAbsoluteUrl(this.__dataRecipe.url), + method: this.__dataRecipe.method, + status: this.status, + requestBody: this.__dataRecipe.requestBody, + responseBody, + responsePreview: makePreview(responseBody), + transport: "xhr" + }); + }); + + return originalSend.apply(this, arguments as unknown as Parameters); + }; +} + +async function readFetchRequest(input: RequestInfo | URL, init?: RequestInit): Promise> { + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const url = input instanceof Request ? input.url : String(input); + const requestBody = init?.body !== undefined ? serializeBody(init.body) : await readRequestClone(input); + + return { + url: toAbsoluteUrl(url), + method: method.toUpperCase(), + requestBody + }; +} + +async function readRequestClone(input: RequestInfo | URL): Promise { + if (!(input instanceof Request)) { + return null; + } + + try { + return parseJsonLike(await input.clone().text()); + } catch { + return null; + } +} + +async function readResponseBody(response: Response): Promise { + try { + return parseJsonLike(await response.text()); + } catch { + return null; + } +} + +function serializeBody(body: unknown): unknown { + if (body === undefined || body === null) { + return null; + } + + if (typeof body === "string") { + return parseJsonLike(body); + } + + if (body instanceof URLSearchParams) { + return Object.fromEntries(body.entries()); + } + + if (body instanceof FormData) { + return Object.fromEntries(Array.from(body.entries()).map(([key, value]) => [key, String(value)])); + } + + if (body instanceof Blob || body instanceof ArrayBuffer) { + return "[binary body]"; + } + + return String(body); +} + +function parseJsonLike(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +function makePreview(value: unknown): string { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + return text.length > 1800 ? `${text.slice(0, 1800)}...` : text; +} + +function toAbsoluteUrl(url: string): string { + try { + return new URL(url, window.location.href).href; + } catch { + return url; + } +} + +function emitCapture(payload: CapturePayload): void { + window.postMessage( + { + source: "data-recipe-page-hook", + type: "captured-request", + payload + }, + "*" + ); +} + +declare global { + interface XMLHttpRequest { + __dataRecipe?: { + method: string; + url: string; + requestBody: unknown; + }; + } +} diff --git a/apps/extension/src/sidepanel.ts b/apps/extension/src/sidepanel.ts new file mode 100644 index 0000000..7e46fbf --- /dev/null +++ b/apps/extension/src/sidepanel.ts @@ -0,0 +1,680 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; +import { + AgentRunError, + OpenAICompatibleProvider, + ToolCallingAgentRuntime, + type AgentChatMessage, + type AgentRunOutcome, + type AgentStatus, + type AgentToolCallStartedEvent, + type AgentToolExecutionEvent, + type OpenAICompatibleProviderConfig +} from "@data-recipe/agent-runtime"; +import type { AgentToolName } from "@data-recipe/agent-tools-core"; +import { buildRecipeFromCapture, summarizeResponse, type CapturedRequest } from "@data-recipe/detector"; +import type { PanelEvent, PanelSnapshot } from "./messages"; +import { AGENT_TOOL_DEFINITIONS } from "./agent-tool-definitions"; +import { SidepanelAgentToolLayer } from "./agent-tools"; + +type ConversationEvent = + | { id: string; at: number; type: "user_message"; content: string } + | { id: string; at: number; type: "assistant_message"; content: string; state: "streaming" | "complete" | "error" } + | { id: string; at: number; type: "tool_call"; toolCallId: string; name: string; arguments: unknown; status: "running" | "success" | "error" } + | { id: string; at: number; type: "tool_result"; toolCallId: string; name: string; result: unknown; success: boolean } + | { id: string; at: number; type: "status"; status: AgentStatus | "restored"; label: string } + | { id: string; at: number; type: "artifact"; name: string; mimeType: string; details: unknown } + | { id: string; at: number; type: "outcome"; outcome: AgentRunOutcome }; + +interface ConversationCheckpoint { + version: 2; + conversationId: string; + agentMessages: AgentChatMessage[]; + events: ConversationEvent[]; + recipes: unknown; + lastOutcome: AgentRunOutcome | null; +} + +const MODEL_CONFIG_STORAGE_KEY = "dataRecipeOpenAICompatibleProvider"; +const CONVERSATION_INDEX_PREFIX = "dataRecipeConversationIndex"; +const CONVERSATION_PREFIX = "dataRecipeConversation"; +const SYSTEM_PROMPT = `你是 AI 有数的数据技能生成 Agent。 + +你可以调用插件工具观察当前页面捕获的数据来源,并根据用户目标生成可独立运行的 Data Skill Package ZIP。 + +工作规则: +- 先用 listCapturedSources 了解来源,再按需调用 readCapture/inspectResponse;优先 bounded preview/page/range,full 只在必要时请求。 +- 不要要求 Cookie、Authorization header、API token;不要绕过登录、验证码、风控、动态签名、反爬或来源权限。 +- 首次 buildRecipe 使用 captureId 获取 recipeId;后续只能用 recipeId + patch。runRecipe 只接收 recipeId。 +- 多接口技能必须为每个接口创建 recipe。exportSkillPackage 必须提交所有 recipeIds 和 workflow;workflow 要描述 list → filter → detail → diff 的输入输出依赖。 +- SKILL.md 由你基于工具结果和用户目标编写,包含 YAML frontmatter、用途、输入、输出、流程和安全约束。 +- 在导出前测试必要 recipe。exportSkillPackage 输出正式 ZIP。 +- 如果上一轮提供 AgentRunOutcome,必须继续其 pendingSteps,并如实承认 failedToolCalls;结构化状态存在错误时不得声称“没有报错”。 +- 用户输入如“池佳 W25”时,应解析人员、当前/明确年份与 ISO 周数;通过列表匹配动态获得 empCode、uid、weekId,禁止硬编码具体人员标识,再获取详情并按需 diff。`; + +const port = chrome.runtime.connect({ name: "data-recipe-sidepanel" }); +const state: { + snapshot: PanelSnapshot; + selectedId: string | null; + running: boolean; + configLoaded: boolean; + providerConfig: OpenAICompatibleProviderConfig | null; + events: ConversationEvent[]; + agentMessages: AgentChatMessage[]; + conversationId: string; + loadedConversationTabId: number | null; + lastOutcome: AgentRunOutcome | null; +} = { + snapshot: { tabId: null, state: "idle", captures: [] }, + selectedId: null, + running: false, + configLoaded: false, + providerConfig: null, + events: [assistantEvent("你好,我是 AI 有数。配置模型后,告诉我你想从当前页面完成什么数据技能。", "complete")], + agentMessages: [], + conversationId: createId(), + loadedConversationTabId: null, + lastOutcome: null +}; + +const elements = { + statusBadge: getElement("statusBadge"), + newConversationButton: getElement("newConversationButton"), + settingsButton: getElement("settingsButton"), + startButton: getElement("startButton"), + stopButton: getElement("stopButton"), + learnHint: getElement("learnHint"), + messageList: getElement("messageList"), + composer: getElement("composer"), + chatInput: getElement("chatInput"), + sendButton: getElement("sendButton"), + suggestions: getElement("suggestions"), + captureCount: getElement("captureCount"), + requestList: getElement("requestList"), + requestDetail: getElement("requestDetail"), + modelDialog: getElement("modelDialog"), + modelForm: getElement("modelForm"), + closeSettingsButton: getElement("closeSettingsButton"), + baseUrlInput: getElement("baseUrlInput"), + apiKeyInput: getElement("apiKeyInput"), + modelInput: getElement("modelInput"), + modelError: getElement("modelError") +}; + +const toolLayer = new SidepanelAgentToolLayer({ + getCaptures: () => state.snapshot.captures, + confirmAction: async (message) => window.confirm(message), + download: downloadFile +}); + +elements.settingsButton.addEventListener("click", openModelSettings); +elements.newConversationButton.addEventListener("click", () => void startNewConversation()); +elements.closeSettingsButton.addEventListener("click", () => elements.modelDialog.close()); +elements.modelForm.addEventListener("submit", (event) => { event.preventDefault(); void saveModelSettings(); }); +elements.startButton.addEventListener("click", startLearning); +elements.stopButton.addEventListener("click", () => port.postMessage({ type: "stop-discovery" })); +elements.composer.addEventListener("submit", (event) => { event.preventDefault(); void submitGoal(elements.chatInput.value); }); +elements.chatInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); elements.composer.requestSubmit(); } +}); +elements.chatInput.addEventListener("input", resizeComposer); +elements.suggestions.querySelectorAll("[data-prompt]").forEach((button) => { + button.addEventListener("click", () => void submitGoal(button.dataset.prompt ?? "")); +}); + +port.onMessage.addListener((event: PanelEvent) => { + if (event.type === "snapshot") return applySnapshot(event.snapshot); + if (event.type === "capture-added") { + const wasEmpty = state.snapshot.captures.length === 0; + state.snapshot = event.snapshot; + state.selectedId = event.capture.id; + if (wasEmpty) appendEvent(assistantEvent("已捕获到第一个数据来源。现在可以告诉我你想生成什么技能。", "complete")); + render(); + } +}); + +marked.setOptions({ gfm: true, breaks: true }); +void loadModelSettings(); +port.postMessage({ type: "get-snapshot" }); +render(); + +function startLearning(): void { + port.postMessage({ type: "start-discovery" }); + appendEvent(statusEvent("thinking", "已开始学习页面,请在页面中执行查询、筛选或翻页")); +} + +async function submitGoal(rawGoal: string): Promise { + const goal = rawGoal.trim(); + if (!goal || state.running) return; + if (!state.providerConfig) { + appendEvent(assistantEvent("请先在“模型设置”中配置 OpenAI-compatible Provider。", "complete")); + openModelSettings(); + return; + } + + elements.chatInput.value = ""; + resizeComposer(); + appendEvent(userEvent(goal)); + let currentAssistant: Extract | null = null; + const previousOutcome = state.lastOutcome; + state.lastOutcome = null; + const outcomeContext: AgentChatMessage[] = previousOutcome + ? [{ role: "user", content: `[Previous AgentRunOutcome]\n${JSON.stringify(previousOutcome)}` }] + : []; + const runMessages: AgentChatMessage[] = [...state.agentMessages, ...outcomeContext, { role: "user", content: goal }]; + state.agentMessages = runMessages; + state.running = true; + await saveConversation(); + render(); + + try { + const runtime = new ToolCallingAgentRuntime(new OpenAICompatibleProvider(state.providerConfig)); + const result = await runtime.run({ + systemPrompt: SYSTEM_PROMPT, + messages: runMessages, + tools: AGENT_TOOL_DEFINITIONS, + executeTool: invokeAgentTool, + onTextDelta: (delta) => { + if (!currentAssistant) { + currentAssistant = assistantEvent("", "streaming"); + appendEvent(currentAssistant); + } + currentAssistant.content += delta; + renderTimeline(); + scheduleConversationSave(); + scrollToLatest(); + }, + onStatusChange: (status) => { + if (status === "thinking") { + finishAssistantEvent(currentAssistant); + currentAssistant = null; + } + appendEvent(statusEvent(status, statusLabel(status))); + }, + onToolCall: (event) => { + discardEmptyAssistantEvent(currentAssistant); + currentAssistant = null; + showToolCall(event); + }, + onToolExecution: (event) => showToolResult(event), + onMessagesUpdated: async (messages) => { + state.agentMessages = withoutSystem(messages); + await saveConversation(); + } + }); + state.agentMessages = withoutSystem(result.messages); + const completedAssistant = currentAssistant as Extract | null; + if (!completedAssistant) { + currentAssistant = assistantEvent(result.finalText, "complete"); + appendEvent(currentAssistant); + } else { + completedAssistant.state = "complete"; + if (!completedAssistant.content.trim()) completedAssistant.content = result.finalText; + } + state.lastOutcome = result.outcome; + appendEvent(outcomeEvent(result.outcome)); + } catch (caught) { + const outcome = caught instanceof AgentRunError ? caught.outcome : failedOutcome(caught); + if (caught instanceof AgentRunError) state.agentMessages = withoutSystem(caught.partialMessages); + let failedAssistant = currentAssistant as Extract | null; + if (!failedAssistant) { + currentAssistant = assistantEvent("", "error"); + appendEvent(currentAssistant); + failedAssistant = currentAssistant; + } + failedAssistant.state = "error"; + const failure = caught instanceof Error ? caught.message : "Agent 运行失败。"; + failedAssistant.content += `${failedAssistant.content ? "\n\n" : ""}**运行中断:** ${failure}`; + state.lastOutcome = outcome; + appendEvent(outcomeEvent(outcome)); + } finally { + state.running = false; + await saveConversation(); + render(); + scrollToLatest(); + } +} + +async function invokeAgentTool(name: string, input: unknown): Promise { + if (!isAgentToolName(name)) { + return { ok: false, error: { code: "UNKNOWN_TOOL", message: `未知工具:${name}`, retryable: false, expectedInput: { availableTools: AGENT_TOOL_DEFINITIONS.map((item) => item.function.name) } } }; + } + return toolLayer.invoke(name, input as never); +} + +function showToolCall(event: AgentToolCallStartedEvent): void { + appendEvent({ id: createId(), at: Date.now(), type: "tool_call", toolCallId: event.toolCall.id, name: event.toolCall.function.name, arguments: event.arguments, status: "running" }); +} + +function showToolResult(event: AgentToolExecutionEvent): void { + const call = state.events.find((item): item is Extract => item.type === "tool_call" && item.toolCallId === event.toolCall.id); + if (call) call.status = event.failed ? "error" : "success"; + appendEvent({ id: createId(), at: Date.now(), type: "tool_result", toolCallId: event.toolCall.id, name: event.toolCall.function.name, result: event.result, success: !event.failed }); + if (event.toolCall.function.name === "exportSkillPackage" && !event.failed) { + const packageInfo = getSuccessfulToolData(event.result)?.package as { packageName?: string; files?: unknown[] } | undefined; + appendEvent({ + id: createId(), + at: Date.now(), + type: "artifact", + name: `${packageInfo?.packageName ?? "data-skill"}.zip`, + mimeType: "application/zip", + details: { files: packageInfo?.files?.length ?? 0 } + }); + } +} + +function applySnapshot(snapshot: PanelSnapshot): void { + state.snapshot = snapshot; + if (!state.selectedId && snapshot.captures[0]) state.selectedId = snapshot.captures[0].id; + if (snapshot.tabId !== null && snapshot.tabId !== state.loadedConversationTabId) void loadConversation(snapshot.tabId); + render(); +} + +async function loadModelSettings(): Promise { + try { + const stored = await chrome.storage.local.get(MODEL_CONFIG_STORAGE_KEY); + if (isProviderConfig(stored[MODEL_CONFIG_STORAGE_KEY])) state.providerConfig = stored[MODEL_CONFIG_STORAGE_KEY]; + } finally { + state.configLoaded = true; + render(); + } +} + +function openModelSettings(): void { + elements.baseUrlInput.value = state.providerConfig?.baseUrl ?? "https://api.openai.com"; + elements.apiKeyInput.value = state.providerConfig?.apiKey ?? ""; + elements.modelInput.value = state.providerConfig?.model ?? ""; + elements.modelError.textContent = ""; + elements.modelDialog.showModal(); +} + +async function saveModelSettings(): Promise { + try { + const config: OpenAICompatibleProviderConfig = { + baseUrl: elements.baseUrlInput.value.trim(), + apiKey: elements.apiKeyInput.value.trim(), + model: elements.modelInput.value.trim() + }; + new OpenAICompatibleProvider(config); + if (!/^https?:$/.test(new URL(config.baseUrl).protocol)) throw new Error("Base URL 必须使用 http 或 https。"); + await chrome.storage.local.set({ [MODEL_CONFIG_STORAGE_KEY]: config }); + state.providerConfig = config; + elements.modelDialog.close(); + appendEvent(assistantEvent(`模型已配置为 **${config.model}**。当前对话历史保持不变。`, "complete")); + } catch (caught) { + elements.modelError.textContent = caught instanceof Error ? caught.message : "模型配置无效。"; + } +} + +function render(): void { + const active = state.snapshot.state === "active"; + elements.statusBadge.classList.toggle("active", active || !!state.providerConfig); + elements.statusBadge.lastChild!.textContent = active ? "学习中" : state.providerConfig ? state.providerConfig.model : "未配置"; + elements.startButton.disabled = active; + elements.startButton.textContent = active ? "正在学习页面…" : "开始学习页面"; + elements.stopButton.hidden = !active; + elements.learnHint.textContent = active ? `正在观察页面操作,已发现 ${state.snapshot.captures.length} 个数据来源。` : "开启后,请在页面里完成一次查询或翻页。"; + const configured = state.configLoaded && !!state.providerConfig; + elements.chatInput.disabled = state.running || !configured; + elements.chatInput.placeholder = !state.configLoaded ? "正在读取模型配置…" : configured ? "告诉 AI 你想用页面数据完成什么…" : "请先点击“模型设置”配置 Provider"; + elements.sendButton.disabled = state.running || !configured; + elements.newConversationButton.disabled = state.running; + elements.captureCount.textContent = `${state.snapshot.captures.length} 个数据源`; + renderTimeline(); + renderWorkspace(); +} + +function renderTimeline(): void { + elements.messageList.innerHTML = ""; + state.events.forEach((event) => elements.messageList.append(renderConversationEvent(event))); +} + +function renderConversationEvent(event: ConversationEvent): HTMLElement { + if (event.type === "user_message" || event.type === "assistant_message") { + const article = document.createElement("article"); + article.className = `message ${event.type === "user_message" ? "user" : "assistant"}`; + const avatar = document.createElement("div"); + avatar.className = "avatar"; + avatar.textContent = event.type === "user_message" ? "我" : "AI"; + const bubble = document.createElement("div"); + bubble.className = "bubble"; + if (event.type === "assistant_message") { + const head = document.createElement("div"); + head.className = "message-head"; + head.textContent = event.state === "streaming" ? "AI 有数 · 生成中" : event.state === "error" ? "AI 有数 · 已中断" : "AI 有数"; + bubble.append(head); + const markdown = document.createElement("div"); + markdown.className = "message-text markdown-body"; + renderMarkdown(markdown, event.content || "_正在思考…_"); + bubble.append(markdown); + } else { + const text = document.createElement("div"); + text.className = "message-text"; + text.textContent = event.content; + bubble.append(text); + } + article.append(avatar, bubble); + return article; + } + + if (event.type === "status") { + const row = document.createElement("div"); + row.className = "timeline-status"; + row.textContent = event.label; + return row; + } + + if (event.type === "tool_call") { + const details = document.createElement("details"); + details.className = `timeline-tool ${event.status}`; + const summary = document.createElement("summary"); + summary.textContent = `${event.status === "running" ? "●" : event.status === "success" ? "✓" : "!"} ${event.name} · ${event.status === "running" ? "执行中" : event.status === "success" ? "已完成" : "失败"}`; + details.append(summary, jsonPre(event.arguments)); + return details; + } + + if (event.type === "tool_result") { + const details = document.createElement("details"); + details.className = `timeline-result ${event.success ? "success" : "error"}`; + const summary = document.createElement("summary"); + summary.textContent = `${event.success ? "↳" : "↳ !"} ${event.name} result`; + details.append(summary, jsonPre(event.result)); + return details; + } + + if (event.type === "artifact") { + const card = document.createElement("div"); + card.className = "artifact-card"; + card.textContent = `ZIP 产物 · ${event.name}`; + return card; + } + + const card = document.createElement("div"); + card.className = `outcome-card ${event.outcome.status}`; + const title = document.createElement("strong"); + title.textContent = outcomeTitle(event.outcome); + const detail = document.createElement("span"); + detail.textContent = `已完成 ${event.outcome.completedSteps.length} 步 · 待处理 ${event.outcome.pendingSteps.length} 步 · recipe ${event.outcome.recipeIds.length} 个`; + card.append(title, detail); + if (event.outcome.status === "paused_budget") { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "继续任务"; + button.disabled = state.running; + button.addEventListener("click", () => void submitGoal("继续任务")); + card.append(button); + } + return card; +} + +function renderMarkdown(target: HTMLElement, markdown: string): void { + const rendered = marked.parse(markdown, { async: false }) as string; + target.innerHTML = DOMPurify.sanitize(rendered, { USE_PROFILES: { html: true } }); + target.querySelectorAll("a").forEach((link) => { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + }); +} + +function renderWorkspace(): void { + const captures = state.snapshot.captures; + elements.requestList.innerHTML = ""; + if (!captures.length) { + elements.requestList.className = "source-list empty"; + elements.requestList.textContent = "学习页面后,数据源会显示在这里。"; + } else { + elements.requestList.className = "source-list"; + captures.forEach((capture) => elements.requestList.append(sourceButton(capture))); + } + const capture = getSelectedCapture(); + elements.requestDetail.innerHTML = ""; + if (!capture) { + elements.requestDetail.className = "source-detail empty"; + elements.requestDetail.textContent = "尚未选择数据源。"; + return; + } + elements.requestDetail.className = "source-detail"; + const recipe = buildRecipeFromCapture(capture); + const info = document.createElement("dl"); + const facts: Array<[string, string]> = [["接口", capture.url], ["页面", capture.pageUrl], ["请求", `${capture.method} · ${capture.status}`], ["传输", capture.transport]]; + facts.forEach(([key, value]) => { + const row = document.createElement("div"); + const dt = document.createElement("dt"); + const dd = document.createElement("dd"); + dt.textContent = key; + dd.textContent = value; + row.append(dt, dd); + info.append(row); + }); + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "查看 recipe 草稿与响应预览"; + details.append(summary, jsonPre({ recipe, responsePreview: capture.responsePreview })); + elements.requestDetail.append(info, details); +} + +function sourceButton(capture: CapturedRequest): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = `source-item${capture.id === state.selectedId ? " active" : ""}`; + const title = document.createElement("div"); + title.className = "source-title"; + title.textContent = readableUrl(capture.url); + const meta = document.createElement("div"); + meta.className = "source-meta"; + meta.textContent = `${capture.method} · ${capture.status} · ${summarizeResponse(capture.responseBody).looksLikeList ? "列表" : "普通响应"}`; + button.append(title, meta); + button.addEventListener("click", () => { state.selectedId = capture.id; renderWorkspace(); }); + return button; +} + +async function loadConversation(tabId: number): Promise { + state.loadedConversationTabId = tabId; + toolLayer.recipeStore.clear(); + try { + const indexKey = conversationIndexKey(tabId); + const index = await chrome.storage.session.get(indexKey); + const storedId = index[indexKey]; + state.conversationId = typeof storedId === "string" && storedId ? storedId : createId(); + let restored = false; + if (typeof storedId === "string" && storedId) { + const key = conversationStorageKey(tabId, storedId); + const checkpoint = (await chrome.storage.session.get(key))[key]; + if (isConversationCheckpoint(checkpoint)) { + state.agentMessages = checkpoint.agentMessages; + state.events = checkpoint.events; + state.lastOutcome = checkpoint.lastOutcome; + try { toolLayer.recipeStore.restore(checkpoint.recipes); } catch { toolLayer.recipeStore.clear(); } + restored = true; + } + } + if (!restored) { + state.agentMessages = []; + state.events = [assistantEvent("你好,我是 AI 有数。告诉我你想从当前页面完成什么数据技能。", "complete")]; + state.lastOutcome = null; + } else { + state.events.push(statusEvent("restored", "已恢复当前标签页的对话进度")); + } + await chrome.storage.session.set({ [indexKey]: state.conversationId }); + await saveConversation(); + } catch { /* in-memory history remains available */ } + render(); +} + +let saveTimer: number | undefined; +function scheduleConversationSave(): void { + if (saveTimer !== undefined) window.clearTimeout(saveTimer); + saveTimer = window.setTimeout(() => { saveTimer = undefined; void saveConversation(); }, 250); +} + +async function saveConversation(): Promise { + const tabId = state.snapshot.tabId; + if (tabId === null || state.loadedConversationTabId !== tabId) return; + const checkpoint: ConversationCheckpoint = { + version: 2, + conversationId: state.conversationId, + agentMessages: state.agentMessages, + events: state.events, + recipes: toolLayer.recipeStore.snapshot(), + lastOutcome: state.lastOutcome + }; + try { + await chrome.storage.session.set({ + [conversationIndexKey(tabId)]: state.conversationId, + [conversationStorageKey(tabId, state.conversationId)]: checkpoint + }); + } catch { /* best effort */ } +} + +async function startNewConversation(): Promise { + if (state.running || state.snapshot.tabId === null) return; + const tabId = state.snapshot.tabId; + const oldKey = conversationStorageKey(tabId, state.conversationId); + state.conversationId = createId(); + state.agentMessages = []; + state.events = [assistantEvent("已新建对话。告诉我这次要完成什么数据技能。", "complete")]; + state.lastOutcome = null; + toolLayer.recipeStore.clear(); + try { await chrome.storage.session.remove(oldKey); } catch { /* no-op */ } + await saveConversation(); + render(); +} + +function appendEvent(event: ConversationEvent): void { + state.events.push(event); + scheduleConversationSave(); + renderTimeline(); + scrollToLatest(); +} + +function finishAssistantEvent(event: Extract | null): void { + if (event) event.state = "complete"; +} + +function discardEmptyAssistantEvent(event: Extract | null): void { + if (!event || event.content.trim()) { + finishAssistantEvent(event); + return; + } + const index = state.events.findIndex((item) => item.id === event.id); + if (index >= 0) state.events.splice(index, 1); +} + +function userEvent(content: string): Extract { + return { id: createId(), at: Date.now(), type: "user_message", content }; +} + +function assistantEvent(content: string, eventState: "streaming" | "complete" | "error"): Extract { + return { id: createId(), at: Date.now(), type: "assistant_message", content, state: eventState }; +} + +function statusEvent(status: AgentStatus | "restored", label: string): Extract { + return { id: createId(), at: Date.now(), type: "status", status, label }; +} + +function outcomeEvent(outcome: AgentRunOutcome): Extract { + return { id: createId(), at: Date.now(), type: "outcome", outcome }; +} + +function failedOutcome(caught: unknown): AgentRunOutcome { + return { + status: "failed_tool", + message: caught instanceof Error ? caught.message : "Agent 运行失败。", + failedToolCalls: [], + completedSteps: [], + pendingSteps: ["retry_or_adjust_task"], + recipeIds: [] + }; +} + +function outcomeTitle(outcome: AgentRunOutcome): string { + if (outcome.status === "paused_budget") return "本轮达到执行上限,进度已保存"; + if (outcome.status === "blocked_user") return "任务已因用户决定或安全限制停止"; + if (outcome.status === "failed_tool") return "任务因工具连续失败停止"; + return outcome.failedToolCalls.length ? "任务完成,但存在工具错误" : "任务完成"; +} + +function statusLabel(status: AgentStatus): string { + const labels: Record = { + connecting: "正在连接模型", + streaming: "正在接收流式回复", + fallback: "Provider 不支持流式,已回退非流式", + thinking: "模型正在规划", + executing_tool: "正在执行工具", + checkpointing: "进度已保存", + summarizing: "正在生成故障总结", + completed: "本轮运行完成" + }; + return labels[status]; +} + +function getSuccessfulToolData(result: unknown): Record | undefined { + if (!result || typeof result !== "object") return undefined; + const record = result as Record; + return record.ok === true && record.data && typeof record.data === "object" ? record.data as Record : undefined; +} + +function jsonPre(value: unknown): HTMLPreElement { + const pre = document.createElement("pre"); + try { pre.textContent = JSON.stringify(value, null, 2); } catch { pre.textContent = "[Unserializable]"; } + return pre; +} + +function getSelectedCapture(): CapturedRequest | undefined { + return state.snapshot.captures.find((capture) => capture.id === state.selectedId) ?? state.snapshot.captures[0]; +} + +function isAgentToolName(name: string): name is AgentToolName { + return AGENT_TOOL_DEFINITIONS.some((tool) => tool.function.name === name); +} + +function isProviderConfig(value: unknown): value is OpenAICompatibleProviderConfig { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return typeof record.baseUrl === "string" && !!record.baseUrl && typeof record.apiKey === "string" && !!record.apiKey && typeof record.model === "string" && !!record.model; +} + +function isConversationCheckpoint(value: unknown): value is ConversationCheckpoint { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return record.version === 2 && typeof record.conversationId === "string" && Array.isArray(record.agentMessages) + && Array.isArray(record.events) && !!record.recipes && typeof record.recipes === "object"; +} + +function withoutSystem(messages: AgentChatMessage[]): AgentChatMessage[] { + return messages.filter((message) => message.role !== "system"); +} + +function conversationIndexKey(tabId: number): string { return `${CONVERSATION_INDEX_PREFIX}:${tabId}`; } +function conversationStorageKey(tabId: number, conversationId: string): string { return `${CONVERSATION_PREFIX}:${tabId}:${conversationId}`; } + +function resizeComposer(): void { + elements.chatInput.style.height = "auto"; + elements.chatInput.style.height = `${Math.min(elements.chatInput.scrollHeight, 120)}px`; +} + +function scrollToLatest(): void { + window.requestAnimationFrame(() => window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" })); +} + +function readableUrl(urlValue: string): string { + try { const url = new URL(urlValue); return `${url.hostname}${url.pathname}`; } catch { return urlValue; } +} + +function downloadFile(file: { fileName: string; mimeType: string; content: string | Uint8Array }): void { + const content = file.content instanceof Uint8Array ? file.content.slice().buffer : file.content; + const url = URL.createObjectURL(new Blob([content], { type: file.mimeType })); + const link = document.createElement("a"); + link.href = url; + link.download = file.fileName; + link.click(); + URL.revokeObjectURL(url); +} + +function createId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } + +function getElement(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing element: ${id}`); + return element as T; +} diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 0000000..7dd6ed9 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["chrome"], + "noEmit": true + }, + "include": ["src", "scripts"] +} diff --git a/docs/agent-tool-architecture.md b/docs/agent-tool-architecture.md new file mode 100644 index 0000000..361ec9c --- /dev/null +++ b/docs/agent-tool-architecture.md @@ -0,0 +1,73 @@ +# Agent Tool Layer architecture + +## Direction + +DataRecipe is a browser-resident tool layer used by an AI Agent. Its primary UI is a conversational side-panel Agent; future external Agents reuse the same tools. The extension does not own a fixed pipeline that turns one capture into one templated Skill. It exposes capabilities and constrained runtime access; the active Agent owns planning and judgment. + +```text +User goal in side-panel chat + ↓ +Built-in Agent Runtime → user-configured OpenAI-compatible Provider + ↓ /v1/chat/completions + tool_calls +Agent (plans, reads selectively, edits, writes SKILL.md) + ↓ tool calls +DataRecipe Agent Tool Layer + ├─ capture catalog and bounded readers + ├─ rule-based response inspection / recipe drafting + ├─ Browser RPC Bridge runtime + └─ Data Skill Package exporter + ↓ authorization / confirmation +User-authorized Chrome session +``` + +The responsibility split is: + +1. **Plugin provides tools.** It captures eligible traffic, stores it locally, provides bounded reads, applies deterministic inference rules, validates recipes, runs approved recipes, and exports packages. +2. **Agent makes judgments.** It chooses sources, decides whether a preview is enough, requests further pages/ranges only when needed, checks and edits stored recipes, designs `workflow.json`, and writes `SKILL.md` based on evidence and the user's goal. +3. **User authorizes and confirms.** Browser-session access, origin permissions, recipe runs, and export confirmation remain visible user decisions where required. +4. **Data Skill Package is the deliverable.** It is a real ZIP containing Agent-authored `SKILL.md`, `workflow.json`, reviewed `recipes/*.json`, examples, and README. + +The side panel runs a streaming Agent Loop. Users configure `baseUrl`, `apiKey`, and `model`; the Runtime sends the system prompt, messages, and function tools to `/v1/chat/completions` with `stream: true`, parses SSE text/tool deltas, executes aggregated `tool_calls`, appends `role: tool` results, and continues until the model returns final text. Providers that explicitly reject streaming fall back to a non-streaming request. The UI stores user messages, AI messages, tool calls, tool results, statuses, outcomes, and artifacts as ordered `ConversationEvent`s. Tool calls appear before execution, update in place, and keep their results collapsed as secondary detail. Model Markdown is rendered incrementally, then sanitized with DOMPurify. + +Every completed assistant message and tool result is checkpointed immediately. The side panel stores model history, UI history, and RecipeStore state in `chrome.storage.session` under `tabId + conversationId`. Failures carry partial history through `AgentRunError`; only “新建对话” clears it. + +## Agent-facing tools + +The canonical TypeScript contracts live in `@data-recipe/agent-tools-core`. + +| Tool | Purpose | +| --- | --- | +| `listCapturedSources` | Paginate lightweight capture metadata so the Agent can select candidates without reading all bodies. | +| `readCapture` | Read request, response, or both with explicit `preview`, `page`, `range`, or `full` selection. | +| `inspectResponse` | Apply structural rules and return selected response content; supports JSON-path focus and bounded depth. | +| `buildRecipe` | Create a rule-generated recipe and return a `recipeId`; later edits use `recipeId + patch`. | +| `runRecipe` | Dry-run or execute a reviewed stored recipe by `recipeId`. | +| `exportSkillPackage` | Export Agent-authored Skill content with all referenced `recipeIds` and a checked workflow as ZIP. | + +Bounded reads are a protocol guarantee. A tool must report truncation and continuation metadata and must not silently turn a preview, page, or range request into a full read. The Agent may explicitly request `full` when justified. + +## Recipe and Skill lifecycle + +Each file under `recipes/` remains deterministic and machine-oriented. Detection rules can infer request parameters, response paths, fields, and pagination. The Agent reviews warnings and evidence, then may change recipes before dry-run or export. `workflow.json` defines ordered inputs, step-output dependencies, and final outputs across list, filter, detail, and optional diff steps. Runtime validation remains authoritative even after Agent edits, and a multi-interface Skill cannot export only one recipe. + +Complete recipes live only inside RecipeStore. Models receive `recipeId` plus a bounded summary and cannot submit a partial object as a complete recipe. All tool arguments are validated before execution; failures use the structured `{ code, message, retryable, expectedInput }` contract. + +`SKILL.md` is not generated through a fixed template call. The configured model writes it freely after reading enough evidence and understanding the user's goal. `buildDataSkillPackage` requires non-empty Agent-authored Markdown. Any Mock builder is restricted to tests and development support. + +Production export uses `application/zip` with real paths. `.data-skill.json` is retained only as a development/import interchange format. + +## Outcomes and continuation + +Every run returns an `AgentRunOutcome`: `completed`, `paused_budget`, `failed_tool`, or `blocked_user`. It records failed tool calls, completed and pending steps, and recipe IDs. The outcome is persisted and injected into the next model turn, so narrative text cannot erase structured failures. A budget pause displays “本轮达到执行上限,进度已保存” and offers “继续任务”; repeated identical failures, user refusal, and safety limits are true stops. + +## Runtime and trust boundary + +`runRecipe` delegates execution to the Browser RPC Bridge. The Bridge is a constrained runtime tool operating inside a Chrome session the user has already authorized. It validates origin and confirmation requirements and returns data or bounded previews. It does not expose Cookie, Authorization headers, or comparable credentials to the Agent and must not become an arbitrary request proxy. + +The runtime does not bypass login, captchas, risk controls, dynamic signatures, anti-bot systems, or source authorization. + +## Delivery phases + +1. **Now:** real OpenAI-compatible streaming Agent Runtime, ordered conversation events, durable outcomes, local provider settings, capture/read/inspect/build/run/export handlers, and multi-recipe ZIP export. +2. **Next:** connect non-dry-run `runRecipe` to the real Browser RPC Bridge with origin checks, user confirmation, pagination limits, and response bounding. +3. **Then:** add richer confirmation UI and external Agent transports without changing tool contracts. diff --git a/docs/test-page.html b/docs/test-page.html new file mode 100644 index 0000000..9086569 --- /dev/null +++ b/docs/test-page.html @@ -0,0 +1,86 @@ + + + + + + DataRecipe 测试页 + + + +
+

DataRecipe 测试页

+

加载插件后打开侧边栏,点击「开始发现」,再点击下面的按钮触发 fetch 或 XHR 请求。

+ + +
等待请求...
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..c96f7ec --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "data-recipe", + "version": "0.1.0", + "private": true, + "description": "Turn web page queries and API responses into reusable data recipes for AI agents.", + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "dev:extension": "pnpm --filter @data-recipe/extension dev", + "build:extension": "pnpm --filter @data-recipe/extension build", + "test:agent-runtime": "node --experimental-transform-types scripts/test-agent-runtime.mjs", + "validate:skill-package": "node scripts/validate-data-skill-package.mjs" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json new file mode 100644 index 0000000..99e4d74 --- /dev/null +++ b/packages/agent-runtime/package.json @@ -0,0 +1,18 @@ +{ + "name": "@data-recipe/agent-runtime", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + } +} diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts new file mode 100644 index 0000000..2a127c7 --- /dev/null +++ b/packages/agent-runtime/src/index.ts @@ -0,0 +1,609 @@ +export interface OpenAICompatibleProviderConfig { + baseUrl: string; + apiKey: string; + model: string; + allowNonStreamingFallback?: boolean; +} + +export interface AgentFunctionTool { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +export interface AgentToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +export type AgentChatMessage = + | { role: "system" | "user"; content: string } + | { role: "assistant"; content: string | null; tool_calls?: AgentToolCall[] } + | { role: "tool"; content: string; tool_call_id: string }; + +export type AgentStatus = + | "connecting" + | "streaming" + | "fallback" + | "thinking" + | "executing_tool" + | "checkpointing" + | "summarizing" + | "completed"; + +export interface AgentCompletionRequest { + messages: AgentChatMessage[]; + tools?: AgentFunctionTool[]; + toolChoice?: "auto" | "none" | "required"; + stream?: boolean; +} + +export interface AgentCompletionCallbacks { + onTextDelta?: (delta: string, accumulated: string) => void | Promise; + onStatusChange?: (status: AgentStatus) => void | Promise; +} + +export interface AgentCompletion { + message: Extract; + finishReason?: string; + streamed: boolean; +} + +export interface AgentModelProvider { + complete(input: AgentCompletionRequest, callbacks?: AgentCompletionCallbacks): Promise; +} + +export class OpenAICompatibleProvider implements AgentModelProvider { + readonly config: Required; + + constructor(config: OpenAICompatibleProviderConfig) { + const baseUrl = config.baseUrl.trim().replace(/\/+$/, ""); + const model = config.model.trim(); + const apiKey = config.apiKey.trim(); + if (!baseUrl || !model || !apiKey) throw new Error("baseUrl、apiKey 和 model 都必须配置。"); + this.config = { baseUrl, model, apiKey, allowNonStreamingFallback: config.allowNonStreamingFallback ?? true }; + } + + async complete(input: AgentCompletionRequest, callbacks: AgentCompletionCallbacks = {}): Promise { + const wantsStream = input.stream !== false; + await callbacks.onStatusChange?.("connecting"); + const response = await this.request(input, wantsStream); + + if (!response.ok) { + const body = await readResponseBody(response); + if (wantsStream && this.config.allowNonStreamingFallback && isStreamingUnsupported(response.status, body)) { + await callbacks.onStatusChange?.("fallback"); + return this.completeNonStreaming(input, callbacks); + } + throw createProviderError(response.status, body); + } + + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (wantsStream && response.body && contentType.includes("text/event-stream")) { + await callbacks.onStatusChange?.("streaming"); + return parseChatCompletionStream(response.body, callbacks); + } + + return parseNonStreamingResponse(response, callbacks, false); + } + + private async completeNonStreaming( + input: AgentCompletionRequest, + callbacks: AgentCompletionCallbacks + ): Promise { + const response = await this.request(input, false); + if (!response.ok) throw createProviderError(response.status, await readResponseBody(response)); + return parseNonStreamingResponse(response, callbacks, false); + } + + private request(input: AgentCompletionRequest, stream: boolean): Promise { + return fetch(buildChatCompletionsUrl(this.config.baseUrl), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${this.config.apiKey}` }, + body: JSON.stringify({ + model: this.config.model, + messages: input.messages, + tools: input.tools?.length ? input.tools : undefined, + tool_choice: input.tools?.length ? (input.toolChoice ?? "auto") : undefined, + stream + }) + }); + } +} + +export interface AgentToolExecutionEvent { + toolCall: AgentToolCall; + arguments: unknown; + result: unknown; + failed: boolean; +} + +export interface AgentToolCallStartedEvent { + toolCall: AgentToolCall; + arguments: unknown; +} + +export interface AgentRunOutcome { + status: "completed" | "paused_budget" | "failed_tool" | "blocked_user"; + message: string; + failedToolCalls: Array<{ + name: string; + arguments: unknown; + error: { code: string; message: string; retryable: boolean; expectedInput?: unknown }; + }>; + completedSteps: string[]; + pendingSteps: string[]; + recipeIds: string[]; +} + +export interface AgentRunInput extends AgentCompletionCallbacks { + systemPrompt: string; + messages: AgentChatMessage[]; + tools: AgentFunctionTool[]; + executeTool(name: string, input: unknown): Promise; + maxIterations?: number; + toolBudgets?: Partial>; + onToolCall?: (event: AgentToolCallStartedEvent) => void | Promise; + onToolExecution?: (event: AgentToolExecutionEvent) => void | Promise; + onMessagesUpdated?: (messages: AgentChatMessage[], iterations: number) => void | Promise; +} + +export interface AgentRunResult { + finalText: string; + messages: AgentChatMessage[]; + iterations: number; + terminationReason: "completed" | "max_iterations" | "repeated_failure" | "blocked_user"; + outcome: AgentRunOutcome; +} + +export interface AgentRuntime { + run(input: AgentRunInput): Promise; +} + +export class AgentRunError extends Error { + readonly partialMessages: AgentChatMessage[]; + readonly iterations: number; + readonly outcome: AgentRunOutcome; + + constructor(message: string, partialMessages: AgentChatMessage[], iterations: number, outcome: AgentRunOutcome, options?: ErrorOptions) { + super(message, options); + this.name = "AgentRunError"; + this.partialMessages = cloneMessages(partialMessages); + this.iterations = iterations; + this.outcome = outcome; + } +} + +const DEFAULT_TOOL_BUDGETS: Record = { + buildRecipe: 6, + runRecipe: 3, + exportSkillPackage: 2 +}; + +export class ToolCallingAgentRuntime implements AgentRuntime { + constructor(private readonly provider: AgentModelProvider) {} + + async run(input: AgentRunInput): Promise { + const maxIterations = input.maxIterations ?? 12; + const messages: AgentChatMessage[] = [ + { role: "system", content: input.systemPrompt }, + ...input.messages.filter((message) => message.role !== "system") + ]; + const budgets = { ...DEFAULT_TOOL_BUDGETS, ...input.toolBudgets }; + const callCounts = new Map(); + let lastFailureSignature = ""; + let consecutiveFailureCount = 0; + let iterations = 0; + const failedToolCalls: AgentRunOutcome["failedToolCalls"] = []; + const completedSteps: string[] = []; + const recipeIds = new Set(); + + try { + for (iterations = 1; iterations <= maxIterations; iterations += 1) { + await input.onStatusChange?.("thinking"); + const completion = await this.provider.complete( + { messages, tools: input.tools, toolChoice: "auto", stream: true }, + { onTextDelta: input.onTextDelta, onStatusChange: input.onStatusChange } + ); + const assistantMessage = completion.message; + messages.push(assistantMessage); + await checkpoint(input, messages, iterations); + + const toolCalls = assistantMessage.tool_calls ?? []; + if (toolCalls.length === 0) { + let finalText = assistantMessage.content?.trim(); + if (!finalText) throw new Error("模型没有返回最终文本或工具调用。"); + const consistentText = enforceOutcomeConsistency(finalText, failedToolCalls); + if (consistentText !== finalText) { + const suffix = consistentText.slice(finalText.length); + assistantMessage.content = consistentText; + messages[messages.length - 1] = assistantMessage; + await input.onTextDelta?.(suffix, consistentText); + await checkpoint(input, messages, iterations); + } + finalText = consistentText; + await input.onStatusChange?.("completed"); + const outcome = createOutcome("completed", failedToolCalls, completedSteps, [], recipeIds); + return { finalText, messages, iterations, terminationReason: "completed", outcome }; + } + + const preparedToolCalls = toolCalls.map((toolCall) => { + try { + return { toolCall, arguments: parseToolArguments(toolCall.function.arguments), argumentError: undefined }; + } catch (caught) { + return { + toolCall, + arguments: { invalidArguments: true }, + argumentError: caught instanceof Error ? caught : new ToolArgumentsError("模型返回了无效的工具参数 JSON。") + }; + } + }); + for (const prepared of preparedToolCalls) { + await input.onToolCall?.({ toolCall: prepared.toolCall, arguments: prepared.arguments }); + } + + for (let toolIndex = 0; toolIndex < preparedToolCalls.length; toolIndex += 1) { + const prepared = preparedToolCalls[toolIndex]!; + const toolCall = prepared.toolCall; + await input.onStatusChange?.("executing_tool"); + const args = prepared.arguments; + let result: unknown; + try { + if (prepared.argumentError) { + result = toolFailure("INVALID_TOOL_ARGUMENTS", prepared.argumentError.message, true, { type: "object", tool: toolCall.function.name }); + } else { + const count = (callCounts.get(toolCall.function.name) ?? 0) + 1; + callCounts.set(toolCall.function.name, count); + const budget = budgets[toolCall.function.name]; + result = budget !== undefined && count > budget + ? toolFailure("TOOL_BUDGET_EXCEEDED", `工具 ${toolCall.function.name} 已达到本轮调用预算 ${budget}。`, false, { budget }) + : await input.executeTool(toolCall.function.name, args); + } + } catch (caught) { + result = toolFailure( + "TOOL_EXECUTION_FAILED", + caught instanceof Error ? caught.message : "工具执行失败。", + false + ); + } + + const failed = isFailedToolResult(result); + collectRecipeIds(args, recipeIds); + collectRecipeIds(result, recipeIds); + const structuredError = getStructuredToolError(result); + if (structuredError) { + failedToolCalls.push({ name: toolCall.function.name, arguments: args, error: structuredError }); + } else { + completedSteps.push(`${toolCall.function.name}:${toolCall.id}`); + } + const signature = `${toolCall.function.name}:${stableArguments(toolCall.function.arguments)}`; + if (failed) { + consecutiveFailureCount = signature === lastFailureSignature ? consecutiveFailureCount + 1 : 1; + lastFailureSignature = signature; + } else { + consecutiveFailureCount = 0; + lastFailureSignature = ""; + } + + await input.onToolExecution?.({ toolCall, arguments: args, result, failed }); + messages.push({ role: "tool", tool_call_id: toolCall.id, content: safeStringify(result) }); + await checkpoint(input, messages, iterations); + + if (structuredError && isBlockingToolError(structuredError)) { + const pending = preparedToolCalls.slice(toolIndex + 1).map(({ toolCall: call }) => `${call.function.name}:${call.id}`); + return summarizeTermination(this.provider, input, messages, iterations, "blocked_user", failedToolCalls, completedSteps, pending, recipeIds); + } + + if (failed && consecutiveFailureCount >= 2) { + const pending = preparedToolCalls.slice(toolIndex + 1).map(({ toolCall: call }) => `${call.function.name}:${call.id}`); + return summarizeTermination(this.provider, input, messages, iterations, "repeated_failure", failedToolCalls, completedSteps, pending, recipeIds); + } + } + } + + return summarizeTermination(this.provider, input, messages, maxIterations, "max_iterations", failedToolCalls, completedSteps, ["continue_agent_loop"], recipeIds); + } catch (caught) { + if (caught instanceof AgentRunError) throw caught; + throw new AgentRunError( + caught instanceof Error ? caught.message : "Agent 运行失败。", + messages, + Math.min(iterations, maxIterations), + createOutcome("failed_tool", failedToolCalls, completedSteps, ["retry_or_adjust_task"], recipeIds, caught instanceof Error ? caught.message : "Agent 运行失败。"), + caught instanceof Error ? { cause: caught } : undefined + ); + } + } +} + +async function summarizeTermination( + provider: AgentModelProvider, + input: AgentRunInput, + messages: AgentChatMessage[], + iterations: number, + reason: "max_iterations" | "repeated_failure" | "blocked_user", + failedToolCalls: AgentRunOutcome["failedToolCalls"], + completedSteps: string[], + pendingSteps: string[], + recipeIds: Set +): Promise { + await input.onStatusChange?.("summarizing"); + const instruction = reason === "max_iterations" + ? "工具循环已达到最大轮数。请不要再调用工具;总结已完成的工作、当前故障、保留的上下文和用户下一步可采取的操作。" + : reason === "blocked_user" + ? "用户拒绝了操作或安全策略阻止继续。请不要再调用工具;说明已完成进度、阻止原因和用户可以选择的下一步。" + : "相同工具调用已连续失败两次。请不要再调用工具;总结失败原因、已保留的上下文和可行的下一步建议。"; + const completion = await provider.complete( + { + messages: [...messages, { role: "user", content: instruction }], + tools: input.tools, + toolChoice: "none", + stream: true + }, + { onTextDelta: input.onTextDelta, onStatusChange: input.onStatusChange } + ); + const summary = completion.message.content?.trim(); + if (!summary) throw new Error("模型未能生成故障总结。"); + const consistentSummary = enforceOutcomeConsistency(summary, failedToolCalls); + if (consistentSummary !== summary) { + await input.onTextDelta?.(consistentSummary.slice(summary.length), consistentSummary); + } + messages.push({ role: "assistant", content: consistentSummary }); + await checkpoint(input, messages, iterations); + await input.onStatusChange?.("completed"); + const status = reason === "max_iterations" ? "paused_budget" : reason === "blocked_user" ? "blocked_user" : "failed_tool"; + const outcome = createOutcome(status, failedToolCalls, completedSteps, pendingSteps, recipeIds); + return { finalText: consistentSummary, messages, iterations, terminationReason: reason, outcome }; +} + +async function checkpoint(input: AgentRunInput, messages: AgentChatMessage[], iterations: number): Promise { + await input.onStatusChange?.("checkpointing"); + await input.onMessagesUpdated?.(cloneMessages(messages), iterations); +} + +function toolFailure(code: string, message: string, retryable: boolean, expectedInput?: unknown): unknown { + return { ok: false, error: { code, message, retryable, expectedInput } }; +} + +function isFailedToolResult(value: unknown): boolean { + return !!value && typeof value === "object" && (value as Record).ok === false; +} + +function getStructuredToolError(value: unknown): AgentRunOutcome["failedToolCalls"][number]["error"] | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (record.ok !== false || !record.error || typeof record.error !== "object") return undefined; + const error = record.error as Record; + return { + code: typeof error.code === "string" ? error.code : "TOOL_FAILED", + message: typeof error.message === "string" ? error.message : "工具执行失败。", + retryable: error.retryable === true, + expectedInput: error.expectedInput + }; +} + +function isBlockingToolError(error: AgentRunOutcome["failedToolCalls"][number]["error"]): boolean { + return error.code === "USER_DENIED" || error.code === "PERMISSION_DENIED" + || error.code === "RECIPE_SOURCE_NOT_CAPTURED" || error.code.startsWith("SAFETY_"); +} + +function collectRecipeIds(value: unknown, target: Set, depth = 0): void { + if (depth > 5 || !value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.slice(0, 50).forEach((item) => collectRecipeIds(item, target, depth + 1)); + return; + } + Object.entries(value as Record).forEach(([key, child]) => { + if (key === "recipeId" && typeof child === "string" && child) target.add(child); + if (key === "recipeIds" && Array.isArray(child)) child.forEach((id) => { if (typeof id === "string" && id) target.add(id); }); + collectRecipeIds(child, target, depth + 1); + }); +} + +function createOutcome( + status: AgentRunOutcome["status"], + failedToolCalls: AgentRunOutcome["failedToolCalls"], + completedSteps: string[], + pendingSteps: string[], + recipeIds: Set, + message?: string +): AgentRunOutcome { + const defaultMessages: Record = { + completed: failedToolCalls.length ? "任务已完成,但本轮存在已记录的工具错误。" : "任务已完成。", + paused_budget: "本轮达到执行上限,进度已保存。", + failed_tool: "工具连续失败,任务已停止并保留进度。", + blocked_user: "用户拒绝或安全限制阻止继续,进度已保存。" + }; + return { + status, + message: message ?? defaultMessages[status], + failedToolCalls: JSON.parse(JSON.stringify(failedToolCalls)) as AgentRunOutcome["failedToolCalls"], + completedSteps: [...completedSteps], + pendingSteps: [...pendingSteps], + recipeIds: [...recipeIds] + }; +} + +function enforceOutcomeConsistency(text: string, failedToolCalls: AgentRunOutcome["failedToolCalls"]): string { + if (!failedToolCalls.length) return text; + const codes = [...new Set(failedToolCalls.map((call) => call.error.code))].join("、"); + return `${text}\n\n> 运行状态:本轮记录到 ${failedToolCalls.length} 个工具错误(${codes}),请以上述结构化状态为准。`; +} + +function stableArguments(value: string): string { + try { + return JSON.stringify(JSON.parse(value)); + } catch { + return value.trim(); + } +} + +function cloneMessages(messages: AgentChatMessage[]): AgentChatMessage[] { + return JSON.parse(JSON.stringify(messages)) as AgentChatMessage[]; +} + +function buildChatCompletionsUrl(baseUrl: string): string { + if (/\/v1\/chat\/completions$/i.test(baseUrl)) return baseUrl; + return /\/v1$/i.test(baseUrl) ? `${baseUrl}/chat/completions` : `${baseUrl}/v1/chat/completions`; +} + +function parseToolArguments(value: string): unknown { + if (!value.trim()) return {}; + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ToolArgumentsError("工具参数必须是 JSON object。"); + } + return parsed; + } catch (caught) { + throw new ToolArgumentsError(caught instanceof Error && caught.message === "工具参数必须是 JSON object。" + ? caught.message + : "模型返回了无效的工具参数 JSON。"); + } +} + +class ToolArgumentsError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolArgumentsError"; + } +} + +async function parseChatCompletionStream( + stream: ReadableStream, + callbacks: AgentCompletionCallbacks +): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let text = ""; + let finishReason: string | undefined; + const toolCalls = new Map(); + + const processFrame = async (frame: string): Promise => { + const data = frame.split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) return false; + if (data.trim() === "[DONE]") return true; + const chunk = JSON.parse(data) as ChatCompletionChunk; + const choice = chunk.choices?.[0]; + if (!choice) return false; + if (typeof choice.delta?.content === "string") { + text += choice.delta.content; + await callbacks.onTextDelta?.(choice.delta.content, text); + } + choice.delta?.tool_calls?.forEach((delta) => { + if (!Number.isInteger(delta.index)) return; + const current = toolCalls.get(delta.index) ?? { id: "", name: "", arguments: "" }; + if (typeof delta.id === "string") current.id += delta.id; + if (typeof delta.function?.name === "string") current.name += delta.function.name; + if (typeof delta.function?.arguments === "string") current.arguments += delta.function.arguments; + toolCalls.set(delta.index, current); + }); + if (typeof choice.finish_reason === "string") finishReason = choice.finish_reason; + return false; + }; + + let done = false; + while (!done) { + const read = await reader.read(); + buffer += decoder.decode(read.value, { stream: !read.done }); + const frames = buffer.split(/\r?\n\r?\n/); + buffer = frames.pop() ?? ""; + for (const frame of frames) { + if (await processFrame(frame)) { done = true; break; } + } + if (read.done) { + if (buffer.trim()) await processFrame(buffer); + break; + } + } + + const aggregatedCalls = [...toolCalls.entries()] + .sort(([a], [b]) => a - b) + .map(([, call]) => ({ id: call.id, type: "function" as const, function: { name: call.name, arguments: call.arguments } })) + .filter((call) => call.id && call.function.name); + return { + message: { role: "assistant", content: text || null, tool_calls: aggregatedCalls.length ? aggregatedCalls : undefined }, + finishReason, + streamed: true + }; +} + +async function parseNonStreamingResponse( + response: Response, + callbacks: AgentCompletionCallbacks, + streamed: boolean +): Promise { + const body = await readResponseBody(response) as ChatCompletionsResponse; + const choice = body.choices?.[0]; + if (!choice?.message || choice.message.role !== "assistant") throw new Error("模型响应中缺少 assistant message。"); + const content = typeof choice.message.content === "string" ? choice.message.content : null; + if (content) await callbacks.onTextDelta?.(content, content); + return { + message: { role: "assistant", content, tool_calls: normalizeToolCalls(choice.message.tool_calls) }, + finishReason: choice.finish_reason, + streamed + }; +} + +function normalizeToolCalls(value: unknown): AgentToolCall[] | undefined { + if (!Array.isArray(value)) return undefined; + const calls = value.filter(isAgentToolCall); + return calls.length ? calls : undefined; +} + +function isAgentToolCall(value: unknown): value is AgentToolCall { + if (!value || typeof value !== "object") return false; + const call = value as Partial; + return call.type === "function" && typeof call.id === "string" && !!call.function + && typeof call.function.name === "string" && typeof call.function.arguments === "string"; +} + +function isStreamingUnsupported(status: number, body: unknown): boolean { + if (![400, 404, 405, 415, 422, 501].includes(status)) return false; + return /stream|streaming|unsupported|not supported/i.test(extractErrorMessage(body)); +} + +function createProviderError(status: number, body: unknown): Error { + return new Error(`模型请求失败(HTTP ${status}):${extractErrorMessage(body)}`); +} + +async function readResponseBody(response: Response): Promise { + const text = await response.text(); + if (!text) return {}; + try { return JSON.parse(text); } catch { return { message: text.slice(0, 1000) }; } +} + +function extractErrorMessage(body: unknown): string { + if (!body || typeof body !== "object") return "未知错误"; + const record = body as Record; + const error = record.error; + if (error && typeof error === "object" && typeof (error as Record).message === "string") { + return String((error as Record).message); + } + return typeof record.message === "string" ? record.message : "未知错误"; +} + +function safeStringify(value: unknown): string { + try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(toolFailure("UNSERIALIZABLE_RESULT", "工具结果无法序列化。", false)); } +} + +interface ChatCompletionsResponse { + choices?: Array<{ finish_reason?: string; message?: { role?: string; content?: unknown; tool_calls?: unknown } }>; +} + +interface ChatCompletionChunk { + choices?: Array<{ + finish_reason?: string | null; + delta?: { + content?: string | null; + tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }>; + }; + }>; +} diff --git a/packages/agent-runtime/tsconfig.json b/packages/agent-runtime/tsconfig.json new file mode 100644 index 0000000..46f5ba2 --- /dev/null +++ b/packages/agent-runtime/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": ["src"] +} diff --git a/packages/agent-tools-core/package.json b/packages/agent-tools-core/package.json new file mode 100644 index 0000000..437f055 --- /dev/null +++ b/packages/agent-tools-core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@data-recipe/agent-tools-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/browser-rpc-core": "workspace:*", + "@data-recipe/recipe-core": "workspace:*", + "@data-recipe/skill-builder": "workspace:*" + } +} diff --git a/packages/agent-tools-core/src/index.ts b/packages/agent-tools-core/src/index.ts new file mode 100644 index 0000000..ac4ac23 --- /dev/null +++ b/packages/agent-tools-core/src/index.ts @@ -0,0 +1,237 @@ +import type { BrowserRunRecipeResult } from "@data-recipe/browser-rpc-core"; +import type { + DataRecipe, + DataRecipeField, + DataRecipePagination, + DataRecipeParameter, + DataRecipeRequest, + DataRecipeResponse, + HttpMethod, + JsonObject +} from "@data-recipe/recipe-core"; +import type { DataSkillPackage, DataSkillWorkflow } from "@data-recipe/skill-builder"; + +export type AgentReadSelection = + | { mode: "preview"; maxChars?: number; maxItems?: number } + | { mode: "page"; cursor?: string; pageSize?: number } + | { mode: "range"; offset: number; length: number } + | { mode: "full" }; + +export interface CapturedSourceSummary { + captureId: string; + pageUrl: string; + apiUrl: string; + method: HttpMethod; + status: number; + capturedAt: number; + transport: "fetch" | "xhr"; + responseContentType?: string; + responseSize?: number; + preview?: unknown; +} + +export interface ReadResult { + value: T; + selection: AgentReadSelection; + truncated: boolean; + nextCursor?: string; + totalItems?: number; + totalChars?: number; +} + +export interface DataRecipePatch { + name?: string; + displayName?: string; + description?: string; + useCases?: string[]; + request?: Partial; + response?: Partial; + pagination?: Partial; + fields?: DataRecipeField[]; +} + +export interface DataRecipeSummary { + recipeId: string; + name: string; + displayName: string; + description: string; + source: { pageUrl: string; apiUrl: string; method: HttpMethod }; + parameters: Array>; + response: DataRecipeResponse; + pagination: DataRecipePagination; + fields: Array>; +} + +export interface AgentToolInputMap { + listCapturedSources: { cursor?: string; pageSize?: number; pageUrl?: string }; + readCapture: { captureId: string; section: "request" | "response" | "both"; selection: AgentReadSelection }; + inspectResponse: { captureId: string; selection: AgentReadSelection; jsonPath?: string; maxDepth?: number }; + buildRecipe: { captureId?: string; recipeId?: string; patch?: DataRecipePatch }; + runRecipe: { recipeId: string; variables?: JsonObject; dryRun?: boolean; previewLimit?: number }; + exportSkillPackage: { + recipeIds: string[]; + workflow: DataSkillWorkflow; + packageName?: string; + skillMarkdown: string; + userGoal: string; + examplesMarkdown?: string; + readmeMarkdown?: string; + }; +} + +export interface ResponseInspection { + shape: "list" | "object" | "scalar" | "unknown"; + listPaths: string[]; + candidateTotalPaths: string[]; + candidateFields: Array<{ path: string; type: string; sample?: unknown }>; + content: ReadResult; +} + +export interface AgentToolDataMap { + listCapturedSources: { sources: CapturedSourceSummary[]; nextCursor?: string }; + readCapture: ReadResult; + inspectResponse: ResponseInspection; + buildRecipe: { recipeId: string; summary: DataRecipeSummary; warnings: string[]; inferredByRules: boolean }; + runRecipe: BrowserRunRecipeResult; + exportSkillPackage: { package: DataSkillPackage; warnings: string[] }; +} + +export interface AgentToolError { + code: string; + message: string; + retryable: boolean; + expectedInput: unknown; +} + +export type AgentToolResult = { ok: true; data: T } | { ok: false; error: AgentToolError }; +export type AgentToolOutputMap = { [TName in keyof AgentToolDataMap]: AgentToolResult }; +export type AgentToolName = keyof AgentToolInputMap; + +export interface AgentToolLayer { + invoke( + name: TName, + input: AgentToolInputMap[TName] + ): Promise; +} + +export class RecipeStoreError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.name = "RecipeStoreError"; + this.code = code; + } +} + +export class RecipeStore { + private readonly recipes = new Map(); + + create(recipe: DataRecipe): string { + assertDataRecipe(recipe); + const recipeId = createRecipeId(); + this.recipes.set(recipeId, cloneRecipe(recipe)); + return recipeId; + } + + get(recipeId: string): DataRecipe { + if (!recipeId || typeof recipeId !== "string") throw new RecipeStoreError("INVALID_RECIPE_ID", "recipeId 必须是非空字符串。"); + const recipe = this.recipes.get(recipeId); + if (!recipe) throw new RecipeStoreError("RECIPE_NOT_FOUND", `找不到 recipeId:${recipeId}`); + return cloneRecipe(recipe); + } + + update(recipeId: string, patch: DataRecipePatch): DataRecipe { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + throw new RecipeStoreError("INVALID_RECIPE_PATCH", "patch 必须是 object。"); + } + const current = this.get(recipeId); + const next: DataRecipe = { + ...current, + name: patch.name ?? current.name, + displayName: patch.displayName ?? current.displayName, + description: patch.description ?? current.description, + useCases: patch.useCases ?? current.useCases, + fields: patch.fields ?? current.fields, + request: patch.request ? { ...current.request, ...patch.request } : current.request, + response: patch.response ? { ...current.response, ...patch.response } : current.response, + pagination: patch.pagination ? { ...current.pagination, ...patch.pagination } : current.pagination + }; + assertDataRecipe(next); + this.recipes.set(recipeId, cloneRecipe(next)); + return cloneRecipe(next); + } + + clear(): void { + this.recipes.clear(); + } + + snapshot(): Record { + return Object.fromEntries([...this.recipes.entries()].map(([id, recipe]) => [id, cloneRecipe(recipe)])); + } + + restore(snapshot: unknown): void { + if (!isObject(snapshot)) throw new RecipeStoreError("INVALID_RECIPE_SNAPSHOT", "recipe snapshot 必须是 object。"); + const restored = new Map(); + Object.entries(snapshot).forEach(([id, recipe]) => { + if (!id) throw new RecipeStoreError("INVALID_RECIPE_SNAPSHOT", "recipe snapshot 包含空 id。"); + assertDataRecipe(recipe); + restored.set(id, cloneRecipe(recipe)); + }); + this.recipes.clear(); + restored.forEach((recipe, id) => this.recipes.set(id, recipe)); + } +} + +export function summarizeRecipe(recipeId: string, recipe: DataRecipe): DataRecipeSummary { + assertDataRecipe(recipe); + return { + recipeId, + name: recipe.name, + displayName: recipe.displayName, + description: recipe.description, + source: { ...recipe.source }, + parameters: [...recipe.request.queryFields, ...recipe.request.bodyFields].map(({ name, displayName, path, type, required }) => ({ + name, displayName, path, type, required + })), + response: { ...recipe.response }, + pagination: { ...recipe.pagination }, + fields: recipe.fields.map(({ name, displayName, path, type, description }) => ({ name, displayName, path, type, description })) + }; +} + +export function assertDataRecipe(value: unknown): asserts value is DataRecipe { + if (!isObject(value)) throw new RecipeStoreError("INVALID_RECIPE", "recipe 必须是完整 object。"); + const recipe = value as Partial; + if (!nonEmpty(recipe.name) || !nonEmpty(recipe.displayName) || typeof recipe.description !== "string") { + throw new RecipeStoreError("INVALID_RECIPE", "recipe 缺少 name、displayName 或 description。"); + } + if (!Array.isArray(recipe.useCases) || !isObject(recipe.source) || !isObject(recipe.runtime) + || !isObject(recipe.request) || !isObject(recipe.response) || !isObject(recipe.pagination) || !Array.isArray(recipe.fields)) { + throw new RecipeStoreError("INVALID_RECIPE", "recipe 缺少完整的 source/runtime/request/response/pagination/fields。"); + } + if (!nonEmpty(recipe.source.apiUrl) || !nonEmpty(recipe.source.pageUrl) || !nonEmpty(recipe.source.method)) { + throw new RecipeStoreError("INVALID_RECIPE", "recipe.source 不完整。"); + } + if (!Array.isArray(recipe.request.queryFields) || !Array.isArray(recipe.request.bodyFields) || !isObject(recipe.request.query)) { + throw new RecipeStoreError("INVALID_RECIPE", "recipe.request 不完整。"); + } + if (!nonEmpty(recipe.response.type) || typeof recipe.response.listPath !== "string" || typeof recipe.response.totalPath !== "string") { + throw new RecipeStoreError("INVALID_RECIPE", "recipe.response 不完整。"); + } +} + +function cloneRecipe(recipe: DataRecipe): DataRecipe { + return JSON.parse(JSON.stringify(recipe)) as DataRecipe; +} + +function createRecipeId(): string { + return globalThis.crypto?.randomUUID?.() ?? `recipe-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function nonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/packages/agent-tools-core/tsconfig.json b/packages/agent-tools-core/tsconfig.json new file mode 100644 index 0000000..03c2b5a --- /dev/null +++ b/packages/agent-tools-core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": ["src"], + "references": [ + { "path": "../browser-rpc-core" }, + { "path": "../recipe-core" }, + { "path": "../skill-builder" } + ] +} diff --git a/packages/browser-rpc-core/package.json b/packages/browser-rpc-core/package.json new file mode 100644 index 0000000..3973c2b --- /dev/null +++ b/packages/browser-rpc-core/package.json @@ -0,0 +1,21 @@ +{ + "name": "@data-recipe/browser-rpc-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/recipe-core": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + } +} diff --git a/packages/browser-rpc-core/src/index.ts b/packages/browser-rpc-core/src/index.ts new file mode 100644 index 0000000..f7e373c --- /dev/null +++ b/packages/browser-rpc-core/src/index.ts @@ -0,0 +1,96 @@ +import type { DataRecipe, HttpMethod, JsonObject } from "@data-recipe/recipe-core"; + +export type BrowserRpcMethod = + | "browser.getBridgeStatus" + | "browser.getActiveTab" + | "browser.runRecipe" + | "browser.fetchWithSession"; + +export type BrowserRpcBridgeState = "unavailable" | "disconnected" | "permission_required" | "ready"; + +export interface BrowserRpcBridgeStatus { + state: BrowserRpcBridgeState; + extensionVersion?: string; + activeTab?: BrowserRpcTabSummary; + message?: string; +} + +export interface BrowserRpcTabSummary { + tabId: number; + url: string; + title?: string; + origin: string; + active: boolean; +} + +export interface BrowserRpcRequestMap { + "browser.getBridgeStatus": undefined; + "browser.getActiveTab": undefined; + "browser.runRecipe": BrowserRunRecipeParams; + "browser.fetchWithSession": BrowserFetchWithSessionParams; +} + +export interface BrowserRpcResponseMap { + "browser.getBridgeStatus": BrowserRpcBridgeStatus; + "browser.getActiveTab": BrowserRpcTabSummary | null; + "browser.runRecipe": BrowserRunRecipeResult; + "browser.fetchWithSession": BrowserFetchWithSessionResult; +} + +export interface BrowserRunRecipeParams { + recipe: DataRecipe; + variables?: JsonObject; + requireUserConfirmation?: boolean; +} + +export interface BrowserRunRecipeResult { + recipeName: string; + status: "ok" | "empty" | "blocked" | "error"; + rows?: unknown[]; + rowCount?: number; + responsePreview?: unknown; + message?: string; +} + +export interface BrowserFetchWithSessionParams { + url: string; + method: HttpMethod; + query?: JsonObject; + body?: unknown; + requiredOrigin: string; + requireUserConfirmation?: boolean; +} + +export interface BrowserFetchWithSessionResult { + url: string; + status: number; + ok: boolean; + contentType?: string; + bodyPreview?: unknown; +} + +export type BrowserRpcRequest = { + id: string; + method: TMethod; + params: BrowserRpcRequestMap[TMethod]; +}; + +export type BrowserRpcResponse = + | { + id: string; + method: TMethod; + ok: true; + result: BrowserRpcResponseMap[TMethod]; + } + | { + id: string; + method: TMethod; + ok: false; + error: BrowserRpcError; + }; + +export interface BrowserRpcError { + code: "BRIDGE_UNAVAILABLE" | "PERMISSION_DENIED" | "ORIGIN_MISMATCH" | "ACTIVE_SESSION_REQUIRED" | "REQUEST_FAILED" | "INVALID_REQUEST"; + message: string; + details?: JsonObject; +} diff --git a/packages/browser-rpc-core/tsconfig.json b/packages/browser-rpc-core/tsconfig.json new file mode 100644 index 0000000..21fc57b --- /dev/null +++ b/packages/browser-rpc-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ], + "references": [{ "path": "../recipe-core" }] +} diff --git a/packages/detector/package.json b/packages/detector/package.json new file mode 100644 index 0000000..4c9ded6 --- /dev/null +++ b/packages/detector/package.json @@ -0,0 +1,21 @@ +{ + "name": "@data-recipe/detector", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/recipe-core": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + } +} diff --git a/packages/detector/src/index.ts b/packages/detector/src/index.ts new file mode 100644 index 0000000..f976603 --- /dev/null +++ b/packages/detector/src/index.ts @@ -0,0 +1,237 @@ +import { + createDataRecipeDraft, + createUnknownPagination, + type DataRecipe, + type DataRecipePagination, + inferDataValueType, + inferParameters, + type DataRecipeField, + type DataRecipeParameter, + type JsonObject +} from "@data-recipe/recipe-core"; + +export interface CapturedRequest { + id: string; + pageUrl: string; + url: string; + method: string; + status: number; + requestBody: unknown; + query: JsonObject; + responseBody: unknown; + responsePreview: string; + capturedAt: number; + transport: "fetch" | "xhr"; +} + +export interface ResponseDetection { + isJson: boolean; + looksLikeList: boolean; + hasArray: boolean; + arrayPaths: string[]; + listPath: string; + totalPath: string; + keywordPaths: string[]; + fields: DataRecipeField[]; +} + +const TOTAL_KEYS = new Set(["total", "count", "totalCount", "total_count", "recordCount", "record_count"]); +const LIST_KEYS = new Set(["records", "list", "items", "rows", "data", "result"]); +const PAGE_KEYS = new Set(["page", "pageNo", "pageNum", "page_number", "current", "currentPage"]); +const PAGE_SIZE_KEYS = new Set(["pageSize", "page_size", "size", "limit", "perPage", "per_page"]); + +export function parseQueryFromUrl(urlValue: string): JsonObject { + try { + const url = new URL(urlValue); + const query: JsonObject = {}; + url.searchParams.forEach((value, key) => { + if (query[key] === undefined) { + query[key] = value; + } else if (Array.isArray(query[key])) { + (query[key] as string[]).push(value); + } else { + query[key] = [query[key], value]; + } + }); + return query; + } catch { + return {}; + } +} + +export function tryParseJson(text: string): unknown { + if (!text.trim()) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export function summarizeResponse(responseBody: unknown): ResponseDetection { + const isJson = responseBody !== null && typeof responseBody === "object"; + const arrayPaths: string[] = []; + const keywordPaths: string[] = []; + const totalPaths: string[] = []; + + if (isJson) { + walk(responseBody, "$", (path, value, key) => { + if (Array.isArray(value)) { + arrayPaths.push(path); + } + + if (key && (TOTAL_KEYS.has(key) || LIST_KEYS.has(key) || PAGE_KEYS.has(key) || PAGE_SIZE_KEYS.has(key))) { + keywordPaths.push(path); + } + + if (key && TOTAL_KEYS.has(key) && typeof value !== "object") { + totalPaths.push(path); + } + }); + } + + const listPath = pickListPath(responseBody, arrayPaths); + const fields = inferFieldsAtPath(responseBody, listPath); + + return { + isJson, + looksLikeList: arrayPaths.length > 0 && fields.length > 0, + hasArray: arrayPaths.length > 0, + arrayPaths, + listPath, + totalPath: totalPaths[0] ?? "", + keywordPaths, + fields + }; +} + +export function buildRecipeFromCapture(capture: CapturedRequest): DataRecipe { + const detection = summarizeResponse(capture.responseBody); + const queryFields = inferParameters(capture.query, "query"); + const bodyFields = inferParameters(capture.requestBody, "body"); + + return createDataRecipeDraft({ + pageUrl: capture.pageUrl, + apiUrl: capture.url, + method: capture.method, + query: capture.query, + body: capture.requestBody ?? {}, + queryFields, + bodyFields, + responseType: detection.looksLikeList ? "list" : detection.isJson ? "object" : "unknown", + listPath: detection.listPath, + totalPath: detection.totalPath, + pagination: detectPageNumberPagination([...queryFields, ...bodyFields], detection.totalPath), + fields: detection.fields + }); +} + +export function formatPreview(value: unknown, maxLength = 1800): string { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + + if (!text) { + return ""; + } + + return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; +} + +function detectPageNumberPagination(parameters: DataRecipeParameter[], totalPath: string): DataRecipePagination { + const pageParam = parameters.find((field) => PAGE_KEYS.has(field.name))?.path ?? ""; + const pageSizeParam = parameters.find((field) => PAGE_SIZE_KEYS.has(field.name))?.path ?? ""; + + // Only mark page_number when an explicit page parameter is detected. + // Detecting only a page size / limit parameter is not enough — many + // single-page APIs take `limit` or `size` without supporting pagination. + if (!pageParam) { + return createUnknownPagination(totalPath); + } + + return { + type: "page_number", + pageParam, + pageSizeParam, + totalPath + }; +} + +function pickListPath(responseBody: unknown, arrayPaths: string[]): string { + if (arrayPaths.length === 0) { + return ""; + } + + const preferred = arrayPaths.find((path) => { + const key = path.split(".").at(-1) ?? ""; + return LIST_KEYS.has(key); + }); + + return preferred ?? arrayPaths[0] ?? ""; +} + +function inferFieldsAtPath(responseBody: unknown, listPath: string): DataRecipeField[] { + const target = getByPath(responseBody, listPath); + const sample = Array.isArray(target) ? target.find((item) => item && typeof item === "object") : null; + + if (!sample || typeof sample !== "object" || Array.isArray(sample)) { + return []; + } + + return Object.entries(sample).map(([key, value]) => ({ + name: key, + displayName: key, + path: `${listPath}.${key}`, + type: inferDataValueType(value), + sample: simplifySample(value), + description: "待确认" + })); +} + +function getByPath(value: unknown, path: string): unknown { + if (!path || path === "$") { + return value; + } + + return path + .replace(/^\$\./, "") + .split(".") + .filter(Boolean) + .reduce((current, part) => { + if (current && typeof current === "object" && part in current) { + return (current as Record)[part]; + } + return undefined; + }, value); +} + +function walk(value: unknown, path: string, visit: (path: string, value: unknown, key?: string) => void): void { + visit(path, value, path.split(".").at(-1)); + + if (!value || typeof value !== "object") { + return; + } + + if (Array.isArray(value)) { + value.slice(0, 3).forEach((item, index) => walk(item, `${path}[${index}]`, visit)); + return; + } + + Object.entries(value).forEach(([key, child]) => { + const childPath = path === "$" ? `$.${key}` : `${path}.${key}`; + walk(child, childPath, visit); + }); +} + +function simplifySample(value: unknown): unknown { + if (Array.isArray(value)) { + return value.slice(0, 2); + } + + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).slice(0, 5)); + } + + return value; +} diff --git a/packages/detector/tsconfig.json b/packages/detector/tsconfig.json new file mode 100644 index 0000000..97fb707 --- /dev/null +++ b/packages/detector/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../recipe-core" + } + ] +} diff --git a/packages/recipe-core/package.json b/packages/recipe-core/package.json new file mode 100644 index 0000000..a70786b --- /dev/null +++ b/packages/recipe-core/package.json @@ -0,0 +1,18 @@ +{ + "name": "@data-recipe/recipe-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + } +} diff --git a/packages/recipe-core/src/index.ts b/packages/recipe-core/src/index.ts new file mode 100644 index 0000000..b586c2a --- /dev/null +++ b/packages/recipe-core/src/index.ts @@ -0,0 +1,205 @@ +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | string; + +export type JsonObject = Record; + +export type DataValueType = "string" | "number" | "boolean" | "object" | "array" | "null" | "unknown"; + +export interface DataRecipe { + name: string; + displayName: string; + description: string; + useCases: string[]; + source: DataRecipeSource; + runtime: DataRecipeRuntime; + request: DataRecipeRequest; + response: DataRecipeResponse; + pagination: DataRecipePagination; + fields: DataRecipeField[]; +} + +export interface DataRecipeSource { + type: "web_api"; + pageUrl: string; + apiUrl: string; + method: HttpMethod; +} + +export interface DataRecipeRuntime { + type: "browser_rpc" | "direct_fetch" | "manual"; + requiredOrigin: string; + requiresActiveSession: boolean; + requiresUserConfirmation: boolean; + permissionNote: string; +} + +export interface DataRecipeRequest { + query: JsonObject; + body: unknown; + queryFields: DataRecipeParameter[]; + bodyFields: DataRecipeParameter[]; +} + +export interface DataRecipeParameter { + name: string; + displayName: string; + path: string; + type: DataValueType; + required: boolean; + sample?: unknown; +} + +export interface DataRecipeResponse { + type: "list" | "object" | "unknown"; + listPath: string; + totalPath: string; +} + +export interface DataRecipePagination { + type: "page_number" | "cursor" | "unknown"; + pageParam: string; + pageSizeParam: string; + totalPath: string; +} + +export interface DataRecipeField { + name: string; + displayName: string; + path: string; + type: DataValueType; + sample?: unknown; + description: string; +} + +export interface CreateRecipeInput { + pageUrl: string; + apiUrl: string; + method: HttpMethod; + query: JsonObject; + body: unknown; + queryFields?: DataRecipeParameter[]; + bodyFields?: DataRecipeParameter[]; + responseType: DataRecipeResponse["type"]; + listPath: string; + totalPath: string; + pagination?: DataRecipePagination; + runtime?: DataRecipeRuntime; + fields: DataRecipeField[]; +} + +export function createDataRecipeDraft(input: CreateRecipeInput): DataRecipe { + const name = buildRecipeName(input.apiUrl); + + return { + name, + displayName: "数据来源", + description: "用于获取和预览这个数据来源中的列表数据。", + useCases: ["查看数据明细", "汇总和分析列表数据"], + source: { + type: "web_api", + pageUrl: input.pageUrl, + apiUrl: input.apiUrl, + method: input.method + }, + runtime: input.runtime ?? createDefaultRuntime(input.pageUrl, input.apiUrl), + request: { + query: input.query, + body: input.body ?? {}, + queryFields: input.queryFields ?? inferParameters(input.query, "query"), + bodyFields: input.bodyFields ?? inferParameters(input.body, "body") + }, + response: { + type: input.responseType, + listPath: input.listPath, + totalPath: input.totalPath + }, + pagination: input.pagination ?? createUnknownPagination(input.totalPath), + fields: input.fields + }; +} + +export function createUnknownPagination(totalPath = ""): DataRecipePagination { + return { + type: "unknown", + pageParam: "", + pageSizeParam: "", + totalPath + }; +} + +export function inferParameters(value: unknown, rootPath: "query" | "body"): DataRecipeParameter[] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return []; + } + + return Object.entries(value).slice(0, 50).map(([name, sample]) => ({ + name, + displayName: name, + path: `${rootPath}.${name}`, + type: inferDataValueType(sample), + required: false, + sample: simplifySample(sample) + })); +} + +export function inferDataValueType(value: unknown): DataValueType { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "string") return "string"; + if (typeof value === "number") return "number"; + if (typeof value === "boolean") return "boolean"; + if (typeof value === "object") return "object"; + return "unknown"; +} + +function createDefaultRuntime(pageUrl: string, apiUrl: string): DataRecipeRuntime { + return { + type: "browser_rpc", + requiredOrigin: inferOrigin(pageUrl) || inferOrigin(apiUrl), + requiresActiveSession: true, + requiresUserConfirmation: true, + permissionNote: "需要在用户已授权访问的浏览器会话中运行,不直接暴露 Cookie 或 Authorization header。" + }; +} + +function buildRecipeName(apiUrl: string): string { + try { + const url = new URL(apiUrl); + const pathPart = url.pathname + .split("/") + .filter(Boolean) + .slice(-2) + .join("_"); + return normalizeName(pathPart || url.hostname); + } catch { + return normalizeName(apiUrl || "data_source"); + } +} + +function normalizeName(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + + return normalized || "data_source"; +} + +function inferOrigin(urlValue: string): string { + try { + return new URL(urlValue).origin; + } catch { + return ""; + } +} + +function simplifySample(value: unknown): unknown { + if (Array.isArray(value)) { + return value.slice(0, 2); + } + + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).slice(0, 5)); + } + + return value; +} diff --git a/packages/recipe-core/tsconfig.json b/packages/recipe-core/tsconfig.json new file mode 100644 index 0000000..8a1cac6 --- /dev/null +++ b/packages/recipe-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ] +} diff --git a/packages/recipe-runner/package.json b/packages/recipe-runner/package.json new file mode 100644 index 0000000..751feed --- /dev/null +++ b/packages/recipe-runner/package.json @@ -0,0 +1,22 @@ +{ + "name": "@data-recipe/recipe-runner", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/browser-rpc-core": "workspace:*", + "@data-recipe/recipe-core": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + } +} diff --git a/packages/recipe-runner/src/index.ts b/packages/recipe-runner/src/index.ts new file mode 100644 index 0000000..9c8de01 --- /dev/null +++ b/packages/recipe-runner/src/index.ts @@ -0,0 +1,130 @@ +import type { DataRecipe, JsonObject } from "@data-recipe/recipe-core"; +import type { + BrowserRunRecipeResult, + BrowserRpcError +} from "@data-recipe/browser-rpc-core"; + +export interface RecipeRunResult { + recipe: DataRecipe; + rows: unknown[]; + rowCount: number; + previewRows: unknown[]; + status: "ok" | "empty" | "unsupported"; + message: string; +} + +export interface RunRecipeOnSampleOptions { + previewLimit?: number; +} + +export function runRecipeOnSample( + recipe: DataRecipe, + responseBody: unknown, + options: RunRecipeOnSampleOptions = {} +): RecipeRunResult { + const previewLimit = options.previewLimit ?? 5; + const listValue = getByPath(responseBody, recipe.response.listPath); + + if (!Array.isArray(listValue)) { + return { + recipe, + rows: [], + rowCount: 0, + previewRows: [], + status: recipe.response.type === "list" ? "empty" : "unsupported", + message: recipe.response.type === "list" ? "没有从当前响应中读取到列表数据。" : "当前数据来源暂不适合按列表预览。" + }; + } + + const rows = listValue; + + return { + recipe, + rows, + rowCount: rows.length, + previewRows: rows.slice(0, previewLimit), + status: rows.length > 0 ? "ok" : "empty", + message: rows.length > 0 ? `已读取 ${rows.length} 条数据,下面展示前 ${Math.min(rows.length, previewLimit)} 条。` : "当前列表没有数据。" + }; +} + +/** + * Input for running a recipe through the Browser RPC Bridge. The contract + * mirrors `BrowserRunRecipeParams` in `@data-recipe/browser-rpc-core` but + * keeps recipe-runner free of a hard dependency on any particular transport. + */ +export interface BrowserRunRecipeInput { + recipe: DataRecipe; + variables?: JsonObject; +} + +/** + * Run a recipe through the Browser RPC Bridge inside the user's authorized + * Chrome session. + * + * Current status (MVP): the bridge executor is not implemented yet. The + * browser extension only exposes the `browser-rpc-core` wire types; the + * actual `browser.runRecipe` round-trip lands in a future PR. Until then + * this returns a `blocked` result rather than throwing, so callers can be + * wired end-to-end against the real `BrowserRunRecipeResult` shape and swap + * in a transport later without changing the surface. + */ +export function runRecipeWithBrowserRpc(input: BrowserRunRecipeInput): BrowserRunRecipeResult { + if (input.recipe.runtime.type !== "browser_rpc") { + return { + recipeName: input.recipe.name, + status: "blocked", + message: `运行方式 「${input.recipe.runtime.type}」 不通过 Browser RPC Bridge 执行。` + }; + } + + // MVP placeholder: the bridge transport is not connected yet. + return blockedResult( + input.recipe.name, + "Browser RPC Bridge 执行器尚未实现。当前 MVP 只完成协议和技能包草稿,尚未实现真实浏览器 RPC 执行。" + ); +} + +/** + * Build a `BrowserRpcError` for a failed bridge call. Handy once a real + * transport is wired up; kept here so the error vocabulary lives next to + * the runner that produces it. + */ +export function createBrowserRpcError( + code: BrowserRpcError["code"], + message: string, + details?: JsonObject +): BrowserRpcError { + return { code, message, details }; +} + +function blockedResult(recipeName: string, message: string): BrowserRunRecipeResult { + return { recipeName, status: "blocked", message }; +} + +function getByPath(value: unknown, path: string): unknown { + if (!path || path === "$") { + return value; + } + + const parts = path + .replace(/^\$\.?/, "") + .split(".") + .filter(Boolean); + + return parts.reduce((current, part) => { + if (!current || typeof current !== "object") { + return undefined; + } + + const arrayMatch = part.match(/^(.*)\[(\d+)\]$/); + if (arrayMatch) { + const [, key, indexText] = arrayMatch; + const container = key ? (current as Record)[key] : current; + const index = Number(indexText); + return Array.isArray(container) ? container[index] : undefined; + } + + return (current as Record)[part]; + }, value); +} diff --git a/packages/recipe-runner/tsconfig.json b/packages/recipe-runner/tsconfig.json new file mode 100644 index 0000000..23412fb --- /dev/null +++ b/packages/recipe-runner/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../browser-rpc-core" + }, + { + "path": "../recipe-core" + } + ] +} diff --git a/packages/skill-builder/package.json b/packages/skill-builder/package.json new file mode 100644 index 0000000..d4bd030 --- /dev/null +++ b/packages/skill-builder/package.json @@ -0,0 +1,21 @@ +{ + "name": "@data-recipe/skill-builder", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/recipe-core": "workspace:*" + } +} diff --git a/packages/skill-builder/src/index.ts b/packages/skill-builder/src/index.ts new file mode 100644 index 0000000..c401a6d --- /dev/null +++ b/packages/skill-builder/src/index.ts @@ -0,0 +1,433 @@ +import type { DataRecipe } from "@data-recipe/recipe-core"; + +export interface DataSkillPackage { + packageName: string; + files: DataSkillPackageFile[]; +} + +export interface DataSkillPackageFile { + path: string; + content: string; +} + +export type WorkflowOperation = "list" | "filter" | "detail" | "diff"; + +export type WorkflowValueRef = + | { type: "user_input"; name: string; path?: string } + | { type: "step_output"; stepId: string; path: string } + | { type: "literal"; value: unknown }; + +export interface DataSkillWorkflowStep { + id: string; + operation: WorkflowOperation; + recipeId?: string; + optional?: boolean; + dependsOn?: string[]; + inputs: Record; + outputs: Record; +} + +export interface DataSkillWorkflow { + version: 1; + description: string; + userInputs: Array<{ + name: string; + type: "string" | "number" | "boolean" | "object"; + required: boolean; + description: string; + examples?: unknown[]; + parsing?: string; + }>; + steps: DataSkillWorkflowStep[]; + finalOutputs: Record; +} + +export interface DataSkillRecipeInput { + recipeId: string; + recipe: DataRecipe; +} + +export interface BuildDataSkillPackageInput { + packageName?: string; + recipes: DataSkillRecipeInput[]; + workflow: DataSkillWorkflow; + userGoal?: string; + /** + * SKILL.md authored by the controlling AI Agent after it has inspected the + * captures and recipe through the Agent Tool Layer. + */ + skillMarkdown: string; + examplesMarkdown?: string; + readmeMarkdown?: string; +} + +/** Test/development-only deterministic Markdown helper. */ +export interface SkillMarkdownFallback { + buildFallbackSkillMarkdown(recipe: DataRecipe, goal: string): string; +} + +export class MockSkillMarkdownFallback implements SkillMarkdownFallback { + buildFallbackSkillMarkdown(recipe: DataRecipe, goal: string): string { + return buildSkillMarkdown(recipe, goal); + } +} + +export function buildDataSkillPackage(input: BuildDataSkillPackageInput): DataSkillPackage { + if (!input.recipes.length) throw new Error("At least one recipe is required."); + assertWorkflow(input.workflow, input.recipes); + const packageName = input.packageName?.trim() || input.recipes[0]?.recipe.name || "data-skill"; + const goal = input.userGoal?.trim() || input.workflow.description; + if (!input.skillMarkdown.trim()) { + throw new Error("skillMarkdown must be authored by the Agent before export."); + } + const skillMarkdown = ensureTrailingNewline(input.skillMarkdown); + const recipeFiles = input.recipes.map(({ recipeId, recipe }) => ({ + path: `recipes/${normalizeRecipeId(recipeId)}.json`, + content: `${JSON.stringify(recipe, null, 2)}\n` + })); + + return { + packageName, + files: [ + { path: "SKILL.md", content: skillMarkdown }, + { path: "workflow.json", content: `${JSON.stringify(input.workflow, null, 2)}\n` }, + ...recipeFiles, + { + path: "examples.md", + content: input.examplesMarkdown?.trim() + ? ensureTrailingNewline(input.examplesMarkdown) + : buildExamplesMarkdown(input.recipes[0]!.recipe, goal) + }, + { + path: "README.md", + content: input.readmeMarkdown?.trim() + ? ensureTrailingNewline(input.readmeMarkdown) + : buildReadmeMarkdown(input.recipes, input.workflow, goal) + } + ] + }; +} + +/** Test/development helper only. Never use this in the user-facing flow. */ +export function buildMockDataSkillPackageForDevelopment( + input: Omit +): DataSkillPackage { + const firstRecipe = input.recipes[0]?.recipe; + if (!firstRecipe) throw new Error("At least one recipe is required."); + const goal = input.userGoal?.trim() || input.workflow.description; + return buildDataSkillPackage({ + ...input, + skillMarkdown: new MockSkillMarkdownFallback().buildFallbackSkillMarkdown(firstRecipe, goal) + }); +} + +function assertWorkflow(workflow: DataSkillWorkflow, recipes: DataSkillRecipeInput[]): void { + if (!isRecord(workflow) || workflow.version !== 1 || typeof workflow.description !== "string" + || !Array.isArray(workflow.userInputs) || !Array.isArray(workflow.steps) || workflow.steps.length === 0 + || !isRecord(workflow.finalOutputs)) { + throw new Error("workflow.json requires version, description, userInputs, steps, and finalOutputs."); + } + workflow.userInputs.forEach((input, index) => { + if (!isRecord(input) || typeof input.name !== "string" || !input.name + || !["string", "number", "boolean", "object"].includes(String(input.type)) + || typeof input.required !== "boolean" || typeof input.description !== "string") { + throw new Error(`Workflow user input ${index} is invalid.`); + } + }); + recipes.forEach((item, index) => { + if (!item || typeof item.recipeId !== "string" || !item.recipeId.trim() || !item.recipe) { + throw new Error(`Recipe entry ${index} is invalid.`); + } + }); + const recipeIds = new Set(recipes.map((item) => item.recipeId)); + const normalizedRecipeIds = new Set(recipes.map((item) => normalizeRecipeId(item.recipeId))); + if (recipeIds.size !== recipes.length || normalizedRecipeIds.size !== recipes.length) { + throw new Error("Recipe IDs must be unique after file-name normalization."); + } + const userInputNames = new Set(workflow.userInputs.map((input) => input.name)); + if (userInputNames.size !== workflow.userInputs.length) throw new Error("Workflow user input names must be unique."); + const stepIds = new Set(); + workflow.steps.forEach((step, index) => { + if (!isRecord(step) || typeof step.id !== "string" || !step.id + || !["list", "filter", "detail", "diff"].includes(String(step.operation)) + || !isRecord(step.inputs) || !isRecord(step.outputs) + || (step.dependsOn !== undefined && (!Array.isArray(step.dependsOn) || !step.dependsOn.every((id) => typeof id === "string")))) { + throw new Error(`Workflow step ${index} is invalid.`); + } + Object.entries(step.inputs).forEach(([name, value]) => assertWorkflowValueRef(value, `Workflow step ${step.id} input ${name}`)); + Object.entries(step.outputs).forEach(([name, value]) => { + if (!isRecord(value) || typeof value.path !== "string") throw new Error(`Workflow step ${step.id} output ${name} is invalid.`); + }); + if (stepIds.has(step.id)) throw new Error(`Workflow step IDs must be unique: ${step.id}`); + stepIds.add(step.id); + if ((step.operation === "list" || step.operation === "detail") && !step.recipeId) { + throw new Error(`Workflow step ${step.id} requires recipeId.`); + } + if (step.recipeId && !recipeIds.has(step.recipeId)) { + throw new Error(`Workflow step ${step.id} references missing recipe ${step.recipeId}.`); + } + }); + Object.entries(workflow.finalOutputs).forEach(([name, value]) => assertWorkflowValueRef(value, `Workflow final output ${name}`)); + const priorStepIds = new Set(); + workflow.steps.forEach((step) => { + step.dependsOn?.forEach((dependency) => { + if (!priorStepIds.has(dependency)) throw new Error(`Workflow step ${step.id} depends on missing or later step ${dependency}.`); + }); + Object.values(step.inputs).forEach((input) => { + if (input.type === "user_input" && !userInputNames.has(input.name)) { + throw new Error(`Workflow step ${step.id} reads missing user input ${input.name}.`); + } + if (input.type === "step_output" && !priorStepIds.has(input.stepId)) { + throw new Error(`Workflow step ${step.id} reads missing or later step ${input.stepId}.`); + } + if (input.type === "step_output" && !step.dependsOn?.includes(input.stepId)) { + throw new Error(`Workflow step ${step.id} must declare dependency ${input.stepId}.`); + } + }); + priorStepIds.add(step.id); + }); + Object.values(workflow.finalOutputs).forEach((output) => { + if (output.type === "user_input" && !userInputNames.has(output.name)) { + throw new Error(`Workflow final output reads missing user input ${output.name}.`); + } + if (output.type === "step_output" && !stepIds.has(output.stepId)) { + throw new Error(`Workflow final output reads missing step ${output.stepId}.`); + } + }); + const referenced = new Set(workflow.steps.flatMap((step) => step.recipeId ? [step.recipeId] : [])); + recipes.forEach(({ recipeId }) => { + if (!referenced.has(recipeId)) throw new Error(`Recipe ${recipeId} is not referenced by workflow.json.`); + }); +} + +function assertWorkflowValueRef(value: unknown, context: string): asserts value is WorkflowValueRef { + if (!isRecord(value) || typeof value.type !== "string") throw new Error(`${context} is invalid.`); + if (value.type === "user_input" && typeof value.name === "string" && (!value.path || typeof value.path === "string")) return; + if (value.type === "step_output" && typeof value.stepId === "string" && typeof value.path === "string") return; + if (value.type === "literal" && Object.prototype.hasOwnProperty.call(value, "value")) return; + throw new Error(`${context} is invalid.`); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function normalizeRecipeId(value: string): string { + const normalized = value.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); + if (!normalized) throw new Error("recipeId must contain a safe file name."); + return normalized; +} + +function ensureTrailingNewline(value: string): string { + return value.endsWith("\n") ? value : `${value}\n`; +} + +function buildSkillMarkdown(recipe: DataRecipe, goal: string): string { + const frontmatterName = recipe.name || "data-skill"; + const frontmatterDescription = escapeYamlValue(goal || recipe.description); + + const inputs = buildInputsMarkdown(recipe); + const outputs = buildOutputsMarkdown(recipe); + const execution = buildExecutionMarkdown(recipe); + const runtime = buildRuntimeMarkdown(recipe); + const examples = buildSkillExamplesMarkdown(recipe, goal); + + return `--- +name: ${frontmatterName} +description: ${frontmatterDescription} +--- + +# ${recipe.displayName} + +${goal || recipe.description} + +## Runtime + +${runtime} + +## Inputs + +${inputs} + +## Outputs + +${outputs} + +## Execution + +${execution} + +## Safety + +- Only use this skill for data the user is authorized to access. +- Do not use it to bypass login, captchas, risk controls, dynamic signatures, or anti-bot measures. +- Do not call the underlying data source URL recorded under recipes/ directly. +- Do not ask the user for cookies, Authorization headers, or other credentials. +- Dry-run the recipe through the Browser RPC Bridge first to confirm the data preview is healthy before drawing conclusions. +- ${recipe.runtime.permissionNote || "Follow the runtime configuration recorded under recipes/."} + +## Examples + +${examples} +`; +} + +function escapeYamlValue(value: string): string { + // Quote the value so YAML special characters (colons, quotes, newlines) are safe. + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n+/g, " ").trim(); + return `"${escaped}"`; +} + +function buildInputsMarkdown(recipe: DataRecipe): string { + const queryFields = [...recipe.request.queryFields, ...recipe.request.bodyFields]; + + if (queryFields.length === 0) { + return "- This data source has no confirmed query conditions yet.\n- Inspect the selected file under `recipes/` and its `request.queryFields` / `request.bodyFields` before asking the user for parameters."; + } + + const items = queryFields + .map((field) => { + const required = field.required ? "required" : "optional"; + return `- \`${field.name}\` (${field.type}, ${required}) — ${field.displayName}. Path in source: \`${field.path}\`.`; + }) + .join("\n"); + + return `${items}\n- Only pass parameters the user explicitly provides or confirms; do not invent hidden query fields.`; +} + +function buildOutputsMarkdown(recipe: DataRecipe): string { + const listHint = recipe.response.type === "list" + ? `List payload root: \`${recipe.response.listPath || "$"}\`.` + : `Response shape: ${recipe.response.type}. List path: \`${recipe.response.listPath || "(none)"}\`.`; + + if (recipe.fields.length === 0) { + return `- No return fields confirmed yet.\n- ${listHint}`; + } + + const fields = recipe.fields + .map((field) => `- \`${field.name}\` (${field.type}) — ${field.description || field.displayName}. Path: \`${field.path}\`.`) + .join("\n"); + + return `${fields}\n- ${listHint}\n- Total count path (if any): \`${recipe.response.totalPath || "(none)"}\`.`; +} + +function buildExecutionMarkdown(recipe: DataRecipe): string { + if (recipe.runtime.type !== "browser_rpc") { + return `- Execution mode: \`${recipe.runtime.type}\`.\n- ${recipe.runtime.permissionNote || "使用即可。"}`; + } + + const confirmation = recipe.runtime.requiresUserConfirmation + ? "The agent MUST ask the user to confirm before each run." + : "No extra confirmation required, but still surface a preview first."; + const session = recipe.runtime.requiresActiveSession + ? "Requires an active, authorized browser session for the target origin." + : "Does not require a pre-existing session."; + const origin = recipe.runtime.requiredOrigin || "未限定,请在运行前确认来源页面"; + + const parts = [ + "1. Confirm the user's goal and which inputs they want to use.", + "2. Call the Browser RPC Bridge with the selected recipe recorded under recipes/.", + `3. Site scope: \`${origin}\`. +4. ${session} +5. ${confirmation}`, + "6. Do not call the data source URL directly. Do not surface cookies or Authorization headers to the agent.", + "7. Preview the returned rows before summarizing or analyzing." + ]; + + if (recipe.pagination.type === "page_number") { + parts.push( + `8. Pagination is page-number based. Page parameter: \`${recipe.pagination.pageParam || "(none)"}\`; page-size parameter: \`${recipe.pagination.pageSizeParam || "(none)"}\`.` + ); + } else if (recipe.pagination.type === "cursor") { + parts.push(`8. Pagination is cursor based. Pagination cursor path: \`${recipe.pagination.pageParam || "(none)"}\`.`); + } else { + parts.push("8. Pagination behavior is unknown; do not assume the data can be paginated unless verified."); + } + + return parts.join("\n"); +} + +function buildSkillExamplesMarkdown(recipe: DataRecipe, goal: string): string { + const firstUseCase = recipe.useCases[0] ?? goal; + const fieldList = recipe.fields.map((f) => `\`${f.name}\``).join(", ") || "(fields pending confirmation)"; + + return `### Example queries + +- "Use '${recipe.displayName}' to pull the latest data." +- "Based on this data, help me with: ${firstUseCase}." + +### Expected flow + +The agent first asks the Browser RPC Bridge for a dry-run preview of the selected recipe under recipes/, confirms the data looks healthy, then organizes and analyzes the returned fields: ${fieldList}.`; +} + +function buildRuntimeMarkdown(recipe: DataRecipe): string { + if (recipe.runtime.type !== "browser_rpc") { + return `- Execution mode: \`${recipe.runtime.type}\`. +- Permission note: ${recipe.runtime.permissionNote || "使用即可。"}`; + } + + const confirmation = recipe.runtime.requiresUserConfirmation ? "需要用户确认" : "不需要额外确认"; + const session = recipe.runtime.requiresActiveSession ? "需要用户当前浏览器中已有授权会话" : "不要求已有授权会话"; + + return `- **Execution mode**: Browser RPC Bridge. Runs inside the user's authorized Chrome session via the browser extension. +- **Site scope**: ${recipe.runtime.requiredOrigin || "未限定,请在运行前确认来源页面"}. +- **Session requirement**: ${session};${confirmation}. +- The agent must NOT call the data source URL directly; hand execution to the Browser RPC Bridge. +- The Browser RPC Bridge does NOT expose cookies, Authorization headers, or other sensitive credentials to the agent. +- **Permission note**: ${recipe.runtime.permissionNote}. +- This is a draft skill package. The real browser RPC executor is not wired up yet — dry-run confirmations currently return \`blocked\`.`; +} + +function buildExamplesMarkdown(recipe: DataRecipe, goal: string): string { + const firstUseCase = recipe.useCases[0] ?? goal; + + return `# 示例 + +## 示例问题 + +- 请根据「${recipe.displayName}」查看最新数据。 +- 请基于这些数据完成:${firstUseCase} + +## 示例输出 + +系统会先通过 Browser RPC Bridge 在用户授权的浏览器会话中测试运行 recipes/ 下的配方,确认能读取数据,再基于返回字段进行整理和分析。 +`; +} + +function buildReadmeMarkdown( + recipes: DataSkillRecipeInput[], + workflow: DataSkillWorkflow, + goal: string +): string { + const primary = recipes[0]!.recipe; + const recipeList = recipes.map(({ recipeId }) => `- recipes/${normalizeRecipeId(recipeId)}.json`).join("\n"); + return `# ${primary.displayName} + +${goal} + +## 包含文件 + +- SKILL.md: 给 AI Agent 使用的技能说明。 +- workflow.json: 多步骤输入输出依赖。 +- recipes/: 可测试运行的数据配方目录。 +- examples.md: 示例问题和示例输出。 +- README.md: 给人看的说明。 + +## 数据配方 + +${recipeList} + +## 工作流 + +${workflow.steps.map((step) => `- ${step.id}: ${step.operation}${step.recipeId ? ` → ${step.recipeId}` : ""}`).join("\n")} + +## 运行方式 + +这个技能默认通过 Browser RPC Bridge 在用户授权的 Chrome 会话中运行,不直接暴露 Cookie 或 Authorization header。 + +## 验证方式 + +按 workflow.json 顺序,通过 Browser RPC Bridge 运行 recipes/ 中的配方并确认输入输出依赖有效。 +`; +} diff --git a/packages/skill-builder/tsconfig.json b/packages/skill-builder/tsconfig.json new file mode 100644 index 0000000..334c4e4 --- /dev/null +++ b/packages/skill-builder/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "references": [{ "path": "../recipe-core" }] +} diff --git a/packages/skill-exporter/package.json b/packages/skill-exporter/package.json new file mode 100644 index 0000000..6b0ae4a --- /dev/null +++ b/packages/skill-exporter/package.json @@ -0,0 +1,22 @@ +{ + "name": "@data-recipe/skill-exporter", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/skill-builder": "workspace:*", + "fflate": "^0.8.3" + } +} diff --git a/packages/skill-exporter/src/index.ts b/packages/skill-exporter/src/index.ts new file mode 100644 index 0000000..8c872a4 --- /dev/null +++ b/packages/skill-exporter/src/index.ts @@ -0,0 +1,116 @@ +import { strFromU8, strToU8, unzipSync, zipSync } from "fflate"; +import type { DataSkillPackage, DataSkillWorkflow } from "@data-recipe/skill-builder"; + +export interface DataSkillPackageValidation { + ok: boolean; + missingFiles: string[]; + errors: string[]; +} + +export interface ExportedDataSkillPackageZip { + fileName: string; + mimeType: "application/zip"; + content: Uint8Array; +} + +export interface ExportedDataSkillPackageJson { + fileName: string; + mimeType: "application/json"; + content: string; +} + +const REQUIRED_FILES = ["SKILL.md", "workflow.json", "examples.md", "README.md"] as const; + +export function validateDataSkillPackage(skillPackage: DataSkillPackage): DataSkillPackageValidation { + const existing = new Set(skillPackage.files.map((file) => file.path)); + const missingFiles = REQUIRED_FILES.filter((file) => !existing.has(file)); + const errors: string[] = []; + const recipePaths = skillPackage.files.filter((file) => /^recipes\/[^/]+\.json$/.test(file.path)); + + if (!skillPackage.packageName.trim()) errors.push("packageName is required"); + if (recipePaths.length === 0) errors.push("at least one recipes/*.json file is required"); + skillPackage.files.forEach((file) => { + if (!file.content.trim()) errors.push(`${file.path} is empty`); + if (file.path.startsWith("/") || file.path.includes("..")) errors.push(`${file.path} is not a safe relative path`); + }); + + const workflowFile = skillPackage.files.find((file) => file.path === "workflow.json"); + if (workflowFile) validateWorkflowReferences(workflowFile.content, recipePaths.map((file) => file.path), errors); + + return { ok: missingFiles.length === 0 && errors.length === 0, missingFiles, errors }; +} + +export function exportDataSkillPackageAsZip(skillPackage: DataSkillPackage): ExportedDataSkillPackageZip { + const validation = validateDataSkillPackage(skillPackage); + if (!validation.ok) throw new Error(`Invalid Data Skill Package: ${[...validation.missingFiles, ...validation.errors].join(", ")}`); + const entries = Object.fromEntries(skillPackage.files.map((file) => [file.path, strToU8(file.content)])); + return { + fileName: `${normalizeFileName(skillPackage.packageName || "data-skill")}.zip`, + mimeType: "application/zip", + content: zipSync(entries, { level: 6 }) + }; +} + +/** Development/import interchange only. ZIP is the production export format. */ +export function exportDataSkillPackageAsJson(skillPackage: DataSkillPackage): ExportedDataSkillPackageJson { + const safeName = normalizeFileName(skillPackage.packageName || "data-skill"); + return { + fileName: `${safeName}.data-skill.json`, + mimeType: "application/json", + content: `${JSON.stringify(skillPackage, null, 2)}\n` + }; +} + +export function validateExportedDataSkillPackageJson(content: string): DataSkillPackageValidation { + try { + const parsed = JSON.parse(content) as DataSkillPackage; + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.files)) { + return { ok: false, missingFiles: [...REQUIRED_FILES], errors: ["exported package must contain a files array"] }; + } + return validateDataSkillPackage(parsed); + } catch { + return { ok: false, missingFiles: [...REQUIRED_FILES], errors: ["exported package is not valid JSON"] }; + } +} + +export function inspectDataSkillPackageZip(content: Uint8Array): string[] { + return Object.keys(unzipSync(content)).sort(); +} + +export function validateDataSkillPackageZip(content: Uint8Array): DataSkillPackageValidation { + try { + const files = Object.entries(unzipSync(content)).map(([path, bytes]) => ({ path, content: strFromU8(bytes) })); + return validateDataSkillPackage({ packageName: "imported-data-skill", files }); + } catch { + return { ok: false, missingFiles: [...REQUIRED_FILES], errors: ["exported package is not a valid ZIP"] }; + } +} + +function validateWorkflowReferences(content: string, recipePaths: string[], errors: string[]): void { + try { + const workflow = JSON.parse(content) as DataSkillWorkflow; + if (workflow.version !== 1 || !Array.isArray(workflow.steps) || workflow.steps.length === 0) { + errors.push("workflow.json must contain version 1 steps"); + return; + } + const available = new Set(recipePaths.map((path) => path.slice("recipes/".length, -".json".length))); + const referenced = new Set(workflow.steps.flatMap((step) => step.recipeId ? [normalizeRecipeId(step.recipeId)] : [])); + referenced.forEach((recipeId) => { + if (!available.has(recipeId)) errors.push(`workflow.json references missing recipe ${recipeId}`); + }); + available.forEach((recipeId) => { + if (!referenced.has(recipeId)) errors.push(`recipes/${recipeId}.json is not referenced by workflow.json`); + }); + } catch { + errors.push("workflow.json is not valid JSON"); + } +} + +function normalizeRecipeId(value: string): string { + return value.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function normalizeFileName(value: string): string { + const normalized = value.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, ""); + return normalized || "data-skill"; +} diff --git a/packages/skill-exporter/tsconfig.json b/packages/skill-exporter/tsconfig.json new file mode 100644 index 0000000..ead34fc --- /dev/null +++ b/packages/skill-exporter/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "references": [{ "path": "../skill-builder" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a6fcbd0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1117 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/chrome': + specifier: ^0.0.268 + version: 0.0.268 + tsup: + specifier: ^8.5.1 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + apps/extension: + dependencies: + '@data-recipe/agent-runtime': + specifier: workspace:* + version: link:../../packages/agent-runtime + '@data-recipe/agent-tools-core': + specifier: workspace:* + version: link:../../packages/agent-tools-core + '@data-recipe/detector': + specifier: workspace:* + version: link:../../packages/detector + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../../packages/recipe-core + '@data-recipe/recipe-runner': + specifier: workspace:* + version: link:../../packages/recipe-runner + '@data-recipe/skill-builder': + specifier: workspace:* + version: link:../../packages/skill-builder + '@data-recipe/skill-exporter': + specifier: workspace:* + version: link:../../packages/skill-exporter + dompurify: + specifier: ^3.4.11 + version: 3.4.11 + marked: + specifier: ^18.0.5 + version: 18.0.5 + devDependencies: + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + + packages/agent-runtime: {} + + packages/agent-tools-core: + dependencies: + '@data-recipe/browser-rpc-core': + specifier: workspace:* + version: link:../browser-rpc-core + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + '@data-recipe/skill-builder': + specifier: workspace:* + version: link:../skill-builder + + packages/browser-rpc-core: + dependencies: + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + + packages/detector: + dependencies: + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + + packages/recipe-core: {} + + packages/recipe-runner: + dependencies: + '@data-recipe/browser-rpc-core': + specifier: workspace:* + version: link:../browser-rpc-core + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + + packages/skill-builder: + dependencies: + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + + packages/skill-exporter: + dependencies: + '@data-recipe/skill-builder': + specifier: workspace:* + version: link:../skill-builder + fflate: + specifier: ^0.8.3 + version: 0.8.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/chrome@0.0.268': + resolution: {integrity: sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/chrome@0.0.268': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@types/estree@1.0.9': {} + + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + + '@types/trusted-types@2.0.7': + optional: true + + acorn@8.17.0: {} + + any-promise@1.3.0: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + fsevents@2.3.3: + optional: true + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + joycon@3.1.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + lru-cache@11.5.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@18.0.5: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minipass@7.1.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + object-assign@4.1.1: {} + + package-json-from-dist@1.0.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1: + dependencies: + lilconfig: 3.1.3 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + source-map@0.7.6: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1 + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3baf9ff --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - "apps/*" + - "packages/*" + +allowBuilds: + esbuild: true + +onlyBuiltDependencies: + - esbuild diff --git a/scripts/test-agent-runtime.mjs b/scripts/test-agent-runtime.mjs new file mode 100644 index 0000000..3f9daf4 --- /dev/null +++ b/scripts/test-agent-runtime.mjs @@ -0,0 +1,434 @@ +import assert from "node:assert/strict"; +import { + AgentRunError, + OpenAICompatibleProvider, + ToolCallingAgentRuntime +} from "../packages/agent-runtime/dist/index.js"; +import { RecipeStore, RecipeStoreError } from "../packages/agent-tools-core/dist/index.js"; +import { createDataRecipeDraft } from "../packages/recipe-core/dist/index.js"; +import { buildDataSkillPackage } from "../packages/skill-builder/dist/index.js"; +import { + exportDataSkillPackageAsZip, + inspectDataSkillPackageZip, + validateDataSkillPackageZip +} from "../packages/skill-exporter/dist/index.js"; +import { SidepanelAgentToolLayer } from "../apps/extension/src/agent-tools.ts"; + +const tool = { type: "function", function: { name: "echo", description: "echo", parameters: { type: "object" } } }; + +await testStreamingText(); +await testStreamingToolCalls(); +await testStreamingFallback(); +await testFailureHistoryCheckpoint(); +await testToolCallsAppearBeforeExecution(); +await testAgentRunErrorPartialHistory(); +await testMaxIterationsSummary(); +await testRepeatedFailureStops(); +await testRecipeStoreAndRecipeIdTools(); +await testWeeklyReportMultiRecipeZip(); +console.log("agent-runtime and agent-tool tests passed"); + +async function testStreamingText() { + await withFetch(async () => sseResponse([ + `data: ${JSON.stringify({ choices: [{ delta: { content: "你" }, finish_reason: null }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: { content: "好" }, finish_reason: "stop" }] })}\n\n`, + "data: [DONE]\n\n" + ], [7, 13, 5]), async () => { + const deltas = []; + const provider = configuredProvider(); + const completion = await provider.complete({ messages: [{ role: "user", content: "hi" }] }, { + onTextDelta: (delta) => deltas.push(delta) + }); + assert.equal(completion.message.content, "你好"); + assert.deepEqual(deltas, ["你", "好"]); + assert.equal(completion.streamed, true); + }); +} + +async function testStreamingToolCalls() { + await withFetch(async () => sseResponse([ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: "call-1", type: "function", function: { name: "build", arguments: "{\"recipe" } }] }, finish_reason: null }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { name: "Recipe", arguments: "Id\":\"r1\"}" } }] }, finish_reason: "tool_calls" }] })}\n\n`, + "data: [DONE]\n\n" + ], [11, 3, 29]), async () => { + const completion = await configuredProvider().complete({ messages: [{ role: "user", content: "tool" }], tools: [tool] }); + assert.equal(completion.message.tool_calls?.[0]?.id, "call-1"); + assert.equal(completion.message.tool_calls?.[0]?.function.name, "buildRecipe"); + assert.equal(completion.message.tool_calls?.[0]?.function.arguments, "{\"recipeId\":\"r1\"}"); + }); +} + +async function testStreamingFallback() { + let turn = 0; + const streamFlags = []; + const statuses = []; + await withFetch(async (_url, init) => { + streamFlags.push(JSON.parse(String(init.body)).stream); + turn += 1; + if (turn === 1) { + return new Response(JSON.stringify({ error: { message: "streaming not supported" } }), { status: 400, headers: { "content-type": "application/json" } }); + } + return jsonResponse({ choices: [{ finish_reason: "stop", message: { role: "assistant", content: "fallback ok" } }] }); + }, async () => { + const completion = await configuredProvider().complete({ messages: [{ role: "user", content: "fallback" }] }, { + onStatusChange: (status) => statuses.push(status) + }); + assert.equal(completion.message.content, "fallback ok"); + assert.deepEqual(streamFlags, [true, false]); + assert.ok(statuses.includes("fallback")); + }); +} + +async function testFailureHistoryCheckpoint() { + let turn = 0; + const checkpoints = []; + const runtime = new ToolCallingAgentRuntime({ + async complete(input) { + turn += 1; + if (turn === 1) return toolCompletion("failure-1", "echo", "{\"value\":1}"); + assert.ok(input.messages.some((message) => message.role === "tool" && message.tool_call_id === "failure-1")); + return textCompletion("history preserved"); + } + }); + const result = await runtime.run({ + systemPrompt: "system", + messages: [{ role: "user", content: "run" }], + tools: [tool], + executeTool: async () => ({ ok: false, error: { code: "EXPECTED", message: "failed", retryable: true, expectedInput: {} } }), + onMessagesUpdated: (messages) => checkpoints.push(messages) + }); + assert.match(result.finalText, /^history preserved/); + assert.match(result.finalText, /1 个工具错误(EXPECTED)/); + assert.equal(result.outcome.failedToolCalls.length, 1); + assert.ok(checkpoints.some((messages) => messages.some((message) => message.role === "tool"))); +} + +async function testToolCallsAppearBeforeExecution() { + let turn = 0; + const timeline = []; + const runtime = new ToolCallingAgentRuntime({ + async complete() { + turn += 1; + if (turn === 1) { + return { + message: { + role: "assistant", + content: null, + tool_calls: [ + { id: "call-a", type: "function", function: { name: "echo", arguments: "{\"value\":\"a\"}" } }, + { id: "call-b", type: "function", function: { name: "echo", arguments: "{\"value\":\"b\"}" } } + ] + }, + streamed: false + }; + } + return textCompletion("done"); + } + }); + await runtime.run({ + systemPrompt: "system", + messages: [{ role: "user", content: "two calls" }], + tools: [tool], + onToolCall: ({ toolCall }) => timeline.push(`call:${toolCall.id}`), + executeTool: async (_name, args) => { + timeline.push(`execute:${args.value}`); + return { ok: true, data: args }; + } + }); + assert.deepEqual(timeline, ["call:call-a", "call:call-b", "execute:a", "execute:b"]); +} + +async function testAgentRunErrorPartialHistory() { + let turn = 0; + const runtime = new ToolCallingAgentRuntime({ + async complete() { + turn += 1; + if (turn === 1) return toolCompletion("partial-1", "echo", "{}"); + throw new Error("provider down"); + } + }); + await assert.rejects( + runtime.run({ systemPrompt: "system", messages: [{ role: "user", content: "run" }], tools: [tool], executeTool: async () => ({ ok: true, data: {} }) }), + (error) => { + assert.ok(error instanceof AgentRunError); + assert.ok(error.partialMessages.some((message) => message.role === "tool" && message.tool_call_id === "partial-1")); + assert.equal(error.iterations, 2); + assert.equal(error.outcome.status, "failed_tool"); + assert.ok(error.outcome.completedSteps.includes("echo:partial-1")); + return true; + } + ); +} + +async function testMaxIterationsSummary() { + let sawSummaryChoice = false; + const runtime = new ToolCallingAgentRuntime({ + async complete(input) { + if (input.toolChoice === "none") { + sawSummaryChoice = true; + return textCompletion("达到上限,建议新建对话后缩小目标"); + } + return toolCompletion("loop-1", "echo", "{}"); + } + }); + const result = await runtime.run({ + systemPrompt: "system", + messages: [{ role: "user", content: "loop" }], + tools: [tool], + maxIterations: 1, + executeTool: async () => ({ ok: true, data: {} }) + }); + assert.equal(result.terminationReason, "max_iterations"); + assert.equal(result.finalText, "达到上限,建议新建对话后缩小目标"); + assert.equal(sawSummaryChoice, true); + assert.ok(result.messages.some((message) => message.role === "tool")); + assert.equal(result.outcome.status, "paused_budget"); + assert.equal(result.outcome.message, "本轮达到执行上限,进度已保存。"); + assert.deepEqual(result.outcome.pendingSteps, ["continue_agent_loop"]); +} + +async function testRepeatedFailureStops() { + let normalTurns = 0; + const runtime = new ToolCallingAgentRuntime({ + async complete(input) { + if (input.toolChoice === "none") return textCompletion("重复失败,建议修正参数"); + normalTurns += 1; + return toolCompletion(`repeat-${normalTurns}`, "echo", "{\"same\":true}"); + } + }); + const result = await runtime.run({ + systemPrompt: "system", + messages: [{ role: "user", content: "repeat" }], + tools: [tool], + executeTool: async () => ({ ok: false, error: { code: "BAD", message: "bad", retryable: true, expectedInput: {} } }) + }); + assert.equal(result.terminationReason, "repeated_failure"); + assert.equal(normalTurns, 2); + assert.equal(result.outcome.status, "failed_tool"); + assert.equal(result.outcome.failedToolCalls.length, 2); +} + +async function testRecipeStoreAndRecipeIdTools() { + const store = new RecipeStore(); + assert.throws(() => store.create({ name: "partial" }), (error) => { + assert.ok(error instanceof RecipeStoreError); + assert.doesNotMatch(error.message, /Cannot read properties/); + return true; + }); + + const capture = { + id: "capture-1", + pageUrl: "https://example.test/orders", + url: "https://example.test/api/orders?page=1", + method: "GET", + status: 200, + requestBody: null, + query: { page: "1" }, + responseBody: { total: 1, records: [{ id: 1, amount: 10 }] }, + responsePreview: "preview", + capturedAt: Date.now(), + transport: "fetch" + }; + const layer = new SidepanelAgentToolLayer({ getCaptures: () => [capture], confirmAction: async () => true, download: () => {}, recipeStore: store }); + const built = await layer.invoke("buildRecipe", { captureId: "capture-1", patch: { description: "orders" } }); + assert.equal(built.ok, true); + const recipeId = built.ok ? built.data.recipeId : ""; + const patched = await layer.invoke("buildRecipe", { recipeId, patch: { response: { listPath: "$.records" } } }); + assert.equal(patched.ok, true); + const run = await layer.invoke("runRecipe", { recipeId, dryRun: true }); + assert.equal(run.ok, true); + const oldProtocol = await layer.invoke("runRecipe", { recipe: { name: "partial" } }); + assert.equal(oldProtocol.ok, false); + assert.doesNotMatch(oldProtocol.ok ? "" : oldProtocol.error.message, /Cannot read properties/); + const invalidExport = await layer.invoke("exportSkillPackage", { + recipeIds: [recipeId], + workflow: { version: 1, description: "broken", userInputs: [], steps: [], finalOutputs: {} }, + skillMarkdown: "# Skill", + userGoal: "test" + }); + assert.equal(invalidExport.ok, false); + assert.equal(invalidExport.ok ? "" : invalidExport.error.code, "INVALID_WORKFLOW"); + assert.ok(invalidExport.ok ? false : invalidExport.error.expectedInput); +} + +async function testWeeklyReportMultiRecipeZip() { + const recipes = [ + { recipeId: "people-list", recipe: weeklyRecipe("people", "/api/people", "list", "$.records", ["name", "empCode", "uid"]) }, + { recipeId: "week-list", recipe: weeklyRecipe("weeks", "/api/weeks", "list", "$.records", ["year", "week", "weekId"]) }, + { recipeId: "week-detail", recipe: weeklyRecipe("detail", "/api/week-detail", "object", "$", ["weekId", "content", "updatedAt"]) } + ]; + const workflow = { + version: 1, + description: "根据“人员 + 周数”动态获取周报详情,并可选比较上一周。", + userInputs: [{ + name: "query", + type: "string", + required: true, + description: "自然语言人员与 ISO 周,例如 池佳 W25;未提供年份时使用当前年份。", + examples: ["池佳 W25"], + parsing: "解析 personName、year(缺省为当前年份)和 isoWeek;不得推断或硬编码人员标识。" + }], + steps: [ + { + id: "list_people", + operation: "list", + recipeId: "people-list", + inputs: { query: { type: "user_input", name: "query", path: "personName" } }, + outputs: { records: { path: "$.records" } } + }, + { + id: "match_person", + operation: "filter", + dependsOn: ["list_people"], + inputs: { + records: { type: "step_output", stepId: "list_people", path: "records" }, + personName: { type: "user_input", name: "query", path: "personName" } + }, + outputs: { empCode: { path: "$.empCode" }, uid: { path: "$.uid" } } + }, + { + id: "list_weeks", + operation: "list", + recipeId: "week-list", + dependsOn: ["match_person"], + inputs: { uid: { type: "step_output", stepId: "match_person", path: "uid" } }, + outputs: { weeks: { path: "$.records" } } + }, + { + id: "match_week", + operation: "filter", + dependsOn: ["list_weeks"], + inputs: { + weeks: { type: "step_output", stepId: "list_weeks", path: "weeks" }, + year: { type: "user_input", name: "query", path: "year" }, + isoWeek: { type: "user_input", name: "query", path: "isoWeek" } + }, + outputs: { weekId: { path: "$.weekId" } } + }, + { + id: "get_detail", + operation: "detail", + recipeId: "week-detail", + dependsOn: ["match_person", "match_week"], + inputs: { + empCode: { type: "step_output", stepId: "match_person", path: "empCode" }, + uid: { type: "step_output", stepId: "match_person", path: "uid" }, + weekId: { type: "step_output", stepId: "match_week", path: "weekId" } + }, + outputs: { report: { path: "$" } } + }, + { + id: "optional_diff", + operation: "diff", + optional: true, + dependsOn: ["get_detail"], + inputs: { current: { type: "step_output", stepId: "get_detail", path: "report" } }, + outputs: { changes: { path: "$" } } + } + ], + finalOutputs: { + report: { type: "step_output", stepId: "get_detail", path: "report" }, + diff: { type: "step_output", stepId: "optional_diff", path: "changes" } + } + }; + const skillMarkdown = `--- +name: weekly-report +description: 动态查询指定人员和 ISO 周的周报 +--- + +# 周报查询 + +接受“人员 + 周数”,例如“池佳 W25”。解析人员、当前年份与 ISO 周,依次执行 workflow.json。 + +- 从人员列表按姓名匹配,动态取得 empCode 和 uid。 +- 从周列表按 year 和 isoWeek 匹配 weekId。 +- 用动态标识读取详情,并按需执行 diff。 +- 禁止硬编码任何人员、empCode、uid 或 weekId。 +`; + const skillPackage = buildDataSkillPackage({ + packageName: "weekly-report", + recipes, + workflow, + userGoal: "池佳 W25", + skillMarkdown + }); + const exported = exportDataSkillPackageAsZip(skillPackage); + assert.equal(exported.mimeType, "application/zip"); + assert.match(exported.fileName, /\.zip$/); + assert.deepEqual(inspectDataSkillPackageZip(exported.content), [ + "README.md", + "SKILL.md", + "examples.md", + "recipes/people-list.json", + "recipes/week-detail.json", + "recipes/week-list.json", + "workflow.json" + ]); + assert.equal(validateDataSkillPackageZip(exported.content).ok, true); + assert.doesNotMatch(skillMarkdown, /E\d{3,}|U\d{3,}/); + assert.equal(workflow.steps.find((step) => step.id === "get_detail").inputs.weekId.type, "step_output"); + assert.throws(() => buildDataSkillPackage({ + packageName: "broken-weekly-report", + recipes: recipes.slice(0, 1), + workflow, + skillMarkdown + }), /missing recipe/); +} + +function weeklyRecipe(name, path, responseType, listPath, fields) { + const parameterNames = name === "people" ? ["name"] : name === "weeks" ? ["uid"] : ["empCode", "uid", "weekId"]; + return createDataRecipeDraft({ + pageUrl: "https://weekly.example.test/reports", + apiUrl: `https://weekly.example.test${path}`, + method: "GET", + query: {}, + body: {}, + queryFields: parameterNames.map((field) => ({ + name: field, + displayName: field, + path: `query.${field}`, + type: "string", + required: true + })), + responseType, + listPath, + totalPath: responseType === "list" ? "$.total" : "", + fields: fields.map((field) => ({ name: field, displayName: field, path: `$.${field}`, type: "string", description: field })) + }); +} + +function configuredProvider() { + return new OpenAICompatibleProvider({ baseUrl: "https://provider.example", apiKey: "test-key", model: "tool-model" }); +} + +function toolCompletion(id, name, args) { + return { message: { role: "assistant", content: null, tool_calls: [{ id, type: "function", function: { name, arguments: args } }] }, streamed: false }; +} + +function textCompletion(content) { + return { message: { role: "assistant", content }, streamed: false }; +} + +function jsonResponse(body) { + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); +} + +function sseResponse(frames, chunkSizes) { + const bytes = new TextEncoder().encode(frames.join("")); + let offset = 0; + let sizeIndex = 0; + return new Response(new ReadableStream({ + pull(controller) { + if (offset >= bytes.length) return controller.close(); + const size = chunkSizes[sizeIndex++ % chunkSizes.length]; + controller.enqueue(bytes.slice(offset, Math.min(bytes.length, offset + size))); + offset += size; + } + }), { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +async function withFetch(replacement, run) { + const original = globalThis.fetch; + globalThis.fetch = replacement; + try { await run(); } finally { globalThis.fetch = original; } +} diff --git a/scripts/validate-data-skill-package.mjs b/scripts/validate-data-skill-package.mjs new file mode 100644 index 0000000..b4ce330 --- /dev/null +++ b/scripts/validate-data-skill-package.mjs @@ -0,0 +1,32 @@ +import { readFile } from "node:fs/promises"; +import { + validateDataSkillPackageZip, + validateExportedDataSkillPackageJson +} from "../packages/skill-exporter/dist/index.js"; + +const filePath = process.argv[2]; + +if (!filePath) { + console.error("Usage: pnpm validate:skill-package "); + process.exit(1); +} + +let validation; +try { + const content = await readFile(filePath); + validation = filePath.toLowerCase().endsWith(".zip") + ? validateDataSkillPackageZip(new Uint8Array(content)) + : validateExportedDataSkillPackageJson(content.toString("utf8")); +} catch (error) { + console.error(`Unable to read package: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} + +if (!validation.ok) { + if (validation.missingFiles.length) console.error(`Missing files: ${validation.missingFiles.join(", ")}`); + validation.errors.forEach((error) => console.error(error)); + process.exit(1); +} + +console.log("Valid Data Skill Package"); +console.log("Required structure: SKILL.md, workflow.json, recipes/*.json, examples.md, README.md"); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..5151f16 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@data-recipe/recipe-core": [ + "packages/recipe-core/src/index.ts" + ], + "@data-recipe/browser-rpc-core": [ + "packages/browser-rpc-core/src/index.ts" + ], + "@data-recipe/detector": [ + "packages/detector/src/index.ts" + ], + "@data-recipe/recipe-runner": [ + "packages/recipe-runner/src/index.ts" + ], + "@data-recipe/skill-builder": [ + "packages/skill-builder/src/index.ts" + ], + "@data-recipe/agent-tools-core": [ + "packages/agent-tools-core/src/index.ts" + ], + "@data-recipe/agent-runtime": [ + "packages/agent-runtime/src/index.ts" + ] + } + } +}