From 9cc92629459a2ae48432a621533934c9614e5dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 13 Mar 2026 23:16:05 +0800 Subject: [PATCH 001/150] =?UTF-8?q?=E2=9C=85=20=E4=BF=AE=E5=A4=8D=20e2e=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20service=20worker=20=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E7=AD=89=E5=BE=85=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gm-api.spec.ts: Phase 2 重启 context 后等待 service worker 注册完成 再交给 fixtures,避免 extensionId fixture 用 10s 全局超时等待失败 - gm-api.spec.ts: 用事件驱动 Promise 替换 500ms 轮询循环, console 结果一出现立即继续 - utils.ts: installScriptByCode 用 DOM 事件等待替代固定延迟: click 后等光标出现,粘贴后等 .view-lines 内容变化 Co-Authored-By: Claude Sonnet 4.6 --- e2e/gm-api.spec.ts | 38 ++++++++++++++++++++++---------------- e2e/utils.ts | 26 ++++++++++++++------------ 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 3918bf094..e70c9a190 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -20,12 +20,13 @@ const test = base.extend<{ args: ["--headless=new", ...chromeArgs], }); let [bg] = ctx1.serviceWorkers(); - if (!bg) bg = await ctx1.waitForEvent("serviceworker"); + if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 30_000 }); const extensionId = bg.url().split("/")[2]; const extPage = await ctx1.newPage(); await extPage.goto("chrome://extensions/"); await extPage.waitForLoadState("domcontentloaded"); - await extPage.waitForTimeout(1_000); + // Wait for developerPrivate API to be available instead of a fixed delay + await extPage.waitForFunction(() => !!(chrome as any).developerPrivate, { timeout: 10_000 }); await extPage.evaluate(async (id) => { await (chrome as any).developerPrivate.updateExtensionConfiguration({ extensionId: id, @@ -40,6 +41,10 @@ const test = base.extend<{ headless: false, args: ["--headless=new", ...chromeArgs], }); + // Ensure service worker is registered before handing context to fixtures, + // preventing extensionId fixture from timing out with the global 10s timeout. + let [sw] = context.serviceWorkers(); + if (!sw) await context.waitForEvent("serviceworker", { timeout: 30_000 }); await use(context); await context.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); @@ -117,24 +122,25 @@ async function runTestScript( const page = await context.newPage(); const logs: string[] = []; - page.on("console", (msg) => logs.push(msg.text())); - - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - - // Wait for test results to appear in console - const deadline = Date.now() + timeoutMs; let passed = -1; let failed = -1; - while (Date.now() < deadline) { - for (const log of logs) { - const passMatch = log.match(/通过[::]\s*(\d+)/); - const failMatch = log.match(/失败[::]\s*(\d+)/); + + // Resolve as soon as both pass and fail counts appear in console output + const resultReady = new Promise((resolve) => { + page.on("console", (msg) => { + const text = msg.text(); + logs.push(text); + const passMatch = text.match(/通过[::]\s*(\d+)/); + const failMatch = text.match(/失败[::]\s*(\d+)/); if (passMatch) passed = parseInt(passMatch[1], 10); if (failMatch) failed = parseInt(failMatch[1], 10); - } - if (passed >= 0 && failed >= 0) break; - await page.waitForTimeout(500); - } + if (passed >= 0 && failed >= 0) resolve(); + }); + }); + + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + // Race: resolve immediately when results arrive, or fall through after timeout + await Promise.race([resultReady, page.waitForTimeout(timeoutMs)]); await page.close(); return { passed, failed, logs }; diff --git a/e2e/utils.ts b/e2e/utils.ts index a3d1a8604..ff33c5134 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -36,18 +36,24 @@ export async function openEditorPage(context: BrowserContext, extensionId: strin /** Install a script by injecting code into the Monaco editor and saving */ export async function installScriptByCode(context: BrowserContext, extensionId: string, code: string): Promise { const page = await openEditorPage(context, extensionId); - // Wait for Monaco editor to be ready + // Wait for Monaco editor DOM and default template content to be ready await page.locator(".monaco-editor").waitFor({ timeout: 30_000 }); await page.locator(".view-lines").waitFor({ timeout: 15_000 }); - // Click into editor to ensure focus + // Click to focus and wait for the cursor to appear (confirms editor is interactive) await page.locator(".monaco-editor .view-lines").click(); - await page.waitForTimeout(500); - // Select all existing content and replace via clipboard + await page.locator(".cursors-layer .cursor").waitFor({ timeout: 5_000 }); + // Select all existing content await page.keyboard.press("ControlOrMeta+a"); - await page.waitForTimeout(500); + // Capture current content fingerprint, then paste replacement + const initialText = await page.locator(".view-lines").textContent(); await page.evaluate((text) => navigator.clipboard.writeText(text), code); await page.keyboard.press("ControlOrMeta+v"); - await page.waitForTimeout(2000); + // Wait for Monaco to finish rendering the pasted content (content will differ from template) + await page.waitForFunction( + (init) => document.querySelector(".view-lines")?.textContent !== init, + initialText, + { timeout: 10_000 } + ); // Save await page.keyboard.press("ControlOrMeta+s"); // Wait for save: try arco-message first, then verify via script list @@ -61,13 +67,9 @@ export async function installScriptByCode(context: BrowserContext, extensionId: // For scripts with @require/@resource, the message may not appear. // Verify save by checking the script list on the options page. const listPage = await openOptionsPage(context, extensionId); - await listPage.waitForTimeout(2_000); const emptyState = listPage.locator(".arco-empty"); - // Wait until at least one script appears (no empty state) - for (let i = 0; i < 30; i++) { - if ((await emptyState.count()) === 0) break; - await listPage.waitForTimeout(1_000); - } + // Wait until at least one script appears (no empty state), up to 30s + await emptyState.waitFor({ state: "detached", timeout: 30_000 }).catch(() => {}); await listPage.close(); } await page.close(); From c4b26ed7813ba5382989d5725929ad820e196d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 11 Mar 2026 17:53:29 +0800 Subject: [PATCH 002/150] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20Agent=20=E8=81=8A=E5=A4=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 AI Agent 聊天模块,包括聊天界面、服务层、数据存储和多语言支持 --- package.json | 8 +- pnpm-lock.yaml | 925 ++++++++++++++++++ src/app/repo/agent_chat.ts | 104 ++ src/app/service/agent/providers/anthropic.ts | 138 +++ src/app/service/agent/providers/openai.ts | 118 +++ src/app/service/agent/sse_parser.ts | 71 ++ src/app/service/agent/types.ts | 49 + src/app/service/service_worker/agent.ts | 90 ++ src/app/service/service_worker/index.ts | 3 + src/locales/ach-UG/translation.json | 45 +- src/locales/de-DE/translation.json | 45 +- src/locales/en-US/translation.json | 45 +- src/locales/ja-JP/translation.json | 45 +- src/locales/ru-RU/translation.json | 45 +- src/locales/vi-VN/translation.json | 45 +- src/locales/zh-CN/translation.json | 45 +- src/locales/zh-TW/translation.json | 45 +- src/pages/components/layout/Sider.tsx | 41 +- src/pages/options/routes/Agent.tsx | 30 + .../options/routes/AgentChat/ChatArea.tsx | 184 ++++ .../options/routes/AgentChat/ChatInput.tsx | 99 ++ .../routes/AgentChat/ConversationList.tsx | 112 +++ .../routes/AgentChat/MarkdownRenderer.tsx | 89 ++ .../options/routes/AgentChat/MessageItem.tsx | 62 ++ .../routes/AgentChat/ThinkingBlock.tsx | 32 + .../routes/AgentChat/ToolCallBlock.tsx | 52 + src/pages/options/routes/AgentChat/hooks.ts | 188 ++++ src/pages/options/routes/AgentChat/index.tsx | 57 ++ src/pages/options/routes/AgentProvider.tsx | 375 +++++++ src/pages/options/routes/script/index.css | 14 +- src/pkg/config/config.ts | 29 + 31 files changed, 3204 insertions(+), 26 deletions(-) create mode 100644 src/app/repo/agent_chat.ts create mode 100644 src/app/service/agent/providers/anthropic.ts create mode 100644 src/app/service/agent/providers/openai.ts create mode 100644 src/app/service/agent/sse_parser.ts create mode 100644 src/app/service/agent/types.ts create mode 100644 src/app/service/service_worker/agent.ts create mode 100644 src/pages/options/routes/Agent.tsx create mode 100644 src/pages/options/routes/AgentChat/ChatArea.tsx create mode 100644 src/pages/options/routes/AgentChat/ChatInput.tsx create mode 100644 src/pages/options/routes/AgentChat/ConversationList.tsx create mode 100644 src/pages/options/routes/AgentChat/MarkdownRenderer.tsx create mode 100644 src/pages/options/routes/AgentChat/MessageItem.tsx create mode 100644 src/pages/options/routes/AgentChat/ThinkingBlock.tsx create mode 100644 src/pages/options/routes/AgentChat/ToolCallBlock.tsx create mode 100644 src/pages/options/routes/AgentChat/hooks.ts create mode 100644 src/pages/options/routes/AgentChat/index.tsx create mode 100644 src/pages/options/routes/AgentProvider.tsx diff --git a/package.json b/package.json index 7f2b38c93..09cea68ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scriptcat", - "version": "1.3.1", + "version": "1.4.0.alpha", "description": "脚本猫,一个可以执行用户脚本的浏览器扩展,万物皆可脚本化,让你的浏览器可以做更多的事情!", "author": "CodFrm", "license": "GPLv3", @@ -42,6 +42,7 @@ "eslint-linter-browserify": "9.26.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^5.3.6", + "highlight.js": "^11.11.1", "i18next": "^23.16.4", "monaco-editor": "^0.52.2", "react": "^18.3.1", @@ -50,7 +51,10 @@ "react-i18next": "^15.6.0", "react-icons": "^5.5.0", "react-joyride": "^2.9.3", + "react-markdown": "^9.1.0", "react-router-dom": "^7.13.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", "string-similarity-js": "^2.1.4", "uuid": "^11.1.0", "webdav": "^5.9.0", @@ -104,4 +108,4 @@ "**/*.scss", "**/*.less" ] -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2dab0f5e..77779c61a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: fast-xml-parser: specifier: ^5.3.6 version: 5.3.6 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 i18next: specifier: ^23.16.4 version: 23.16.4 @@ -74,9 +77,18 @@ importers: react-joyride: specifier: ^2.9.3 version: 2.9.3(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-markdown: + specifier: ^9.1.0 + version: 9.1.0(@types/react@18.3.23)(react@18.3.1) react-router-dom: specifier: ^7.13.0 version: 7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 string-similarity-js: specifier: ^2.1.4 version: 2.1.4 @@ -1221,6 +1233,9 @@ packages: '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1230,6 +1245,9 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1248,6 +1266,9 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1260,9 +1281,15 @@ packages: '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-forge@1.3.14': resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} @@ -1316,6 +1343,12 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1378,6 +1411,9 @@ packages: resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@unocss/astro@66.5.4': resolution: {integrity: sha512-6KsilC1SiTBmEJRMuPl+Mg8KDWB1+DaVoirGZR7BAEtMf2NzrfQcR4+O/3DHtzb38pfb0K1aHCfWwCozHxLlfA==} peerDependencies: @@ -1716,6 +1752,9 @@ packages: b-validate@1.5.3: resolution: {integrity: sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1804,6 +1843,9 @@ packages: caniuse-lite@1.0.30001727: resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1812,6 +1854,18 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -1848,6 +1902,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2012,6 +2069,9 @@ packages: decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-diff@1.0.2: resolution: {integrity: sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==} @@ -2070,6 +2130,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.0.10: resolution: {integrity: sha512-eM2RzuR3i+M046r2Q0Optl3pS31qTWf8aFuA7H9wnsHTwl8EPvroVLwvQene/6paAs39Tbk6fWZcn2aZaHkc/w==} @@ -2195,6 +2258,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -2282,6 +2349,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2318,6 +2388,9 @@ packages: exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2548,6 +2621,22 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hot-patcher@2.0.1: resolution: {integrity: sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==} @@ -2564,6 +2653,9 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-deceiver@1.2.7: resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} @@ -2651,6 +2743,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2663,6 +2758,12 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2708,6 +2809,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2729,6 +2833,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -2760,6 +2867,10 @@ packages: resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} engines: {node: '>=10'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2941,10 +3052,16 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2969,6 +3086,9 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2976,6 +3096,51 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -3002,6 +3167,90 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3209,6 +3458,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -3329,6 +3581,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protocol-buffers-schema@3.6.0: resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} @@ -3440,6 +3695,12 @@ packages: react: 15 - 18 react-dom: 15 - 18 + react-markdown@9.1.0: + resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-router-dom@7.13.0: resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==} engines: {node: '>=20.0.0'} @@ -3490,6 +3751,21 @@ packages: resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} engines: {node: '>= 0.4'} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3693,6 +3969,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdy-transport@3.0.0: resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} @@ -3742,6 +4021,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -3753,6 +4035,12 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3867,6 +4155,12 @@ packages: peerDependencies: tslib: '2' + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -3951,6 +4245,27 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unocss@66.5.4: resolution: {integrity: sha512-yNajR8ADgvOzLhDkMKAXVE/SHM4sDrtVhhCnhBjiUMOR0LHIYO7cqunJJudbccrsfJbRTn/odSTBGu9f2IaXOg==} engines: {node: '>=14'} @@ -4029,6 +4344,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@7.0.2: resolution: {integrity: sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4279,6 +4600,9 @@ packages: resolution: {integrity: sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==} engines: {node: '>= 6'} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.4.3': {} @@ -5166,6 +5490,10 @@ snapshots: '@types/crypto-js@4.2.2': {} + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -5180,6 +5508,10 @@ snapshots: '@types/json-schema': 7.0.15 optional: true + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} '@types/express-serve-static-core@4.19.8': @@ -5204,6 +5536,10 @@ snapshots: '@types/har-format@1.2.16': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.17': @@ -5214,8 +5550,14 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/node-forge@1.3.14': dependencies: '@types/node': 22.16.2 @@ -5275,6 +5617,10 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.16.2 @@ -5372,6 +5718,8 @@ snapshots: '@typescript-eslint/types': 8.46.3 eslint-visitor-keys: 4.2.1 + '@ungap/structured-clone@1.3.0': {} + '@unocss/astro@66.5.4(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.1))': dependencies: '@unocss/core': 66.5.4 @@ -5865,6 +6213,8 @@ snapshots: b-validate@1.5.3: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} base-64@1.0.0: {} @@ -5969,6 +6319,8 @@ snapshots: caniuse-lite@1.0.30001727: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -5976,6 +6328,14 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} charenc@0.0.2: {} @@ -6019,6 +6379,8 @@ snapshots: colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} commander@7.2.0: {} @@ -6172,6 +6534,10 @@ snapshots: decimal.js@10.5.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-diff@1.0.2: {} deep-is@0.1.4: {} @@ -6215,6 +6581,10 @@ snapshots: detect-node@2.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dexie@4.0.10: {} diff@4.0.2: {} @@ -6440,6 +6810,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -6562,6 +6934,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -6619,6 +6993,8 @@ snapshots: exsolve@1.0.7: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -6850,6 +7226,43 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + highlight.js@11.11.1: {} + hot-patcher@2.0.1: {} hpack.js@2.1.6: @@ -6869,6 +7282,8 @@ snapshots: dependencies: void-elements: 3.1.0 + html-url-attributes@3.0.1: {} + http-deceiver@1.2.7: {} http-errors@1.8.1: @@ -6965,6 +7380,8 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6975,6 +7392,13 @@ snapshots: ipaddr.js@2.3.0: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -7021,6 +7445,8 @@ snapshots: call-bound: 1.0.3 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -7037,6 +7463,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -7058,6 +7486,8 @@ snapshots: is-plain-obj@3.0.0: {} + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} is-regex@1.2.1: @@ -7256,10 +7686,18 @@ snapshots: lodash@4.17.21: {} + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@10.4.3: {} luxon@3.7.2: {} @@ -7282,6 +7720,8 @@ snapshots: make-error@1.3.6: {} + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} md5@2.3.0: @@ -7290,6 +7730,159 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.12.2: {} media-typer@0.3.0: {} @@ -7320,6 +7913,197 @@ snapshots: methods@1.1.2: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.1 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7514,6 +8298,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.27.1 @@ -7620,6 +8414,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@7.1.0: {} + protocol-buffers-schema@3.6.0: {} proxy-addr@2.0.7: @@ -7738,6 +8534,24 @@ snapshots: transitivePeerDependencies: - '@types/react' + react-markdown@9.1.0(@types/react@18.3.23)(react@18.3.1): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 18.3.23 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-router-dom@7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -7808,6 +8622,48 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} requires-port@1.0.0: {} @@ -8078,6 +8934,8 @@ snapshots: source-map@0.6.1: optional: true + space-separated-tokens@2.0.2: {} + spdy-transport@3.0.0: dependencies: debug: 4.4.1 @@ -8161,6 +9019,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -8169,6 +9032,14 @@ snapshots: strnum@2.1.2: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -8270,6 +9141,10 @@ snapshots: dependencies: tslib: 2.8.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8379,6 +9254,44 @@ snapshots: undici-types@6.21.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unocss@66.5.4(postcss@8.5.6)(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.1)): dependencies: '@unocss/astro': 66.5.4(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.1)) @@ -8457,6 +9370,16 @@ snapshots: vary@1.1.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.1): dependencies: esbuild: 0.25.5 @@ -8749,3 +9672,5 @@ snapshots: archiver-utils: 2.1.0 compress-commons: 2.1.1 readable-stream: 3.6.2 + + zwitch@2.0.4: {} diff --git a/src/app/repo/agent_chat.ts b/src/app/repo/agent_chat.ts new file mode 100644 index 000000000..5452ba1d5 --- /dev/null +++ b/src/app/repo/agent_chat.ts @@ -0,0 +1,104 @@ +import type { Conversation, ChatMessage } from "@App/app/service/agent/types"; + +const AGENT_CHAT_DIR = "agent-chat"; +const CONVERSATIONS_FILE = "conversations.json"; +const MESSAGES_DIR = "messages"; + +// 获取 Agent 聊天的根目录 +async function getAgentChatDir(): Promise { + const root = await navigator.storage.getDirectory(); + return root.getDirectoryHandle(AGENT_CHAT_DIR, { create: true }); +} + +// 获取消息目录 +async function getMessagesDir(): Promise { + const chatDir = await getAgentChatDir(); + return chatDir.getDirectoryHandle(MESSAGES_DIR, { create: true }); +} + +// 读取 JSON 文件 +async function readJsonFile(dir: FileSystemDirectoryHandle, filename: string, defaultValue: T): Promise { + try { + const fileHandle = await dir.getFileHandle(filename); + const file = await fileHandle.getFile(); + const text = await file.text(); + return JSON.parse(text) as T; + } catch { + return defaultValue; + } +} + +// 写入 JSON 文件 +async function writeJsonFile(dir: FileSystemDirectoryHandle, filename: string, data: unknown): Promise { + const fileHandle = await dir.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(data)); + await writable.close(); +} + +export class AgentChatRepo { + // 获取所有会话列表 + async listConversations(): Promise { + const dir = await getAgentChatDir(); + return readJsonFile(dir, CONVERSATIONS_FILE, []); + } + + // 保存/更新会话 + async saveConversation(conversation: Conversation): Promise { + const dir = await getAgentChatDir(); + const conversations = await readJsonFile(dir, CONVERSATIONS_FILE, []); + const index = conversations.findIndex((c) => c.id === conversation.id); + if (index >= 0) { + conversations[index] = conversation; + } else { + conversations.unshift(conversation); + } + await writeJsonFile(dir, CONVERSATIONS_FILE, conversations); + } + + // 删除会话及其消息 + async deleteConversation(id: string): Promise { + const dir = await getAgentChatDir(); + const conversations = await readJsonFile(dir, CONVERSATIONS_FILE, []); + const filtered = conversations.filter((c) => c.id !== id); + await writeJsonFile(dir, CONVERSATIONS_FILE, filtered); + // 删除对应消息文件 + try { + const messagesDir = await getMessagesDir(); + await messagesDir.removeEntry(`${id}.json`); + } catch { + // 消息文件不存在则忽略 + } + } + + // 获取指定会话的所有消息 + async getMessages(conversationId: string): Promise { + const messagesDir = await getMessagesDir(); + return readJsonFile(messagesDir, `${conversationId}.json`, []); + } + + // 追加消息 + async appendMessage(message: ChatMessage): Promise { + const messagesDir = await getMessagesDir(); + const messages = await readJsonFile(messagesDir, `${message.conversationId}.json`, []); + messages.push(message); + await writeJsonFile(messagesDir, `${message.conversationId}.json`, messages); + } + + // 更新消息(按 id 匹配) + async updateMessage(message: ChatMessage): Promise { + const messagesDir = await getMessagesDir(); + const messages = await readJsonFile(messagesDir, `${message.conversationId}.json`, []); + const index = messages.findIndex((m) => m.id === message.id); + if (index >= 0) { + messages[index] = message; + await writeJsonFile(messagesDir, `${message.conversationId}.json`, messages); + } + } + + // 保存整个消息列表(用于批量更新) + async saveMessages(conversationId: string, messages: ChatMessage[]): Promise { + const messagesDir = await getMessagesDir(); + await writeJsonFile(messagesDir, `${conversationId}.json`, messages); + } +} diff --git a/src/app/service/agent/providers/anthropic.ts b/src/app/service/agent/providers/anthropic.ts new file mode 100644 index 000000000..952ecc8b6 --- /dev/null +++ b/src/app/service/agent/providers/anthropic.ts @@ -0,0 +1,138 @@ +import type { ChatStreamEvent, ChatRequest } from "../types"; +import type { AgentModelConfig } from "@App/pkg/config/config"; +import { SSEParser } from "../sse_parser"; + +// 构造 Anthropic 格式的请求 +export function buildAnthropicRequest( + config: AgentModelConfig, + request: ChatRequest +): { url: string; init: RequestInit } { + const baseUrl = config.apiBaseUrl || "https://api.anthropic.com"; + const url = `${baseUrl}/v1/messages`; + + // 分离 system 消息和其他消息 + const systemMessages = request.messages.filter((m) => m.role === "system"); + const otherMessages = request.messages.filter((m) => m.role !== "system"); + + const body: Record = { + model: config.model, + max_tokens: 8192, + messages: otherMessages.map((m) => ({ + role: m.role, + content: m.content, + })), + stream: true, + }; + + if (systemMessages.length > 0) { + body.system = systemMessages.map((m) => m.content).join("\n\n"); + } + + const headers: Record = { + "Content-Type": "application/json", + "x-api-key": config.apiKey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }; + + return { + url, + init: { + method: "POST", + headers, + body: JSON.stringify(body), + }, + }; +} + +// 解析 Anthropic SSE 流,生成 ChatStreamEvent +export function parseAnthropicStream( + reader: ReadableStreamDefaultReader, + onEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal +): Promise { + const parser = new SSEParser(); + const decoder = new TextDecoder(); + + return (async () => { + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const events = parser.parse(chunk); + + for (const sseEvent of events) { + try { + const json = JSON.parse(sseEvent.data); + + switch (sseEvent.event) { + case "content_block_start": { + const block = json.content_block; + if (block?.type === "thinking") { + // thinking block 开始,后续通过 content_block_delta 传输内容 + } else if (block?.type === "tool_use") { + onEvent({ + type: "tool_call_start", + toolCall: { + id: block.id, + name: block.name, + arguments: "", + }, + }); + } + break; + } + case "content_block_delta": { + const delta = json.delta; + if (delta?.type === "text_delta") { + onEvent({ type: "content_delta", delta: delta.text }); + } else if (delta?.type === "thinking_delta") { + onEvent({ type: "thinking_delta", delta: delta.thinking }); + } else if (delta?.type === "input_json_delta") { + onEvent({ + type: "tool_call_delta", + id: "", + delta: delta.partial_json, + }); + } + break; + } + case "message_delta": { + // 消息结束,可能包含 usage + if (json.usage) { + onEvent({ + type: "done", + usage: { + inputTokens: json.usage.input_tokens || 0, + outputTokens: json.usage.output_tokens || 0, + }, + }); + return; + } + break; + } + case "message_stop": { + onEvent({ type: "done" }); + return; + } + case "error": { + onEvent({ + type: "error", + message: json.error?.message || "Anthropic API error", + }); + return; + } + } + } catch { + // 解析失败忽略 + } + } + } + } catch (e: any) { + if (signal.aborted) return; + onEvent({ type: "error", message: e.message || "Stream read error" }); + } + })(); +} diff --git a/src/app/service/agent/providers/openai.ts b/src/app/service/agent/providers/openai.ts new file mode 100644 index 000000000..5256dbd5d --- /dev/null +++ b/src/app/service/agent/providers/openai.ts @@ -0,0 +1,118 @@ +import type { ChatStreamEvent, ChatRequest } from "../types"; +import type { AgentModelConfig } from "@App/pkg/config/config"; +import { SSEParser } from "../sse_parser"; + +// 构造 OpenAI 兼容格式的请求 +export function buildOpenAIRequest(config: AgentModelConfig, request: ChatRequest): { url: string; init: RequestInit } { + const baseUrl = config.apiBaseUrl || "https://api.openai.com/v1"; + const url = `${baseUrl}/chat/completions`; + + const headers: Record = { + "Content-Type": "application/json", + }; + if (config.apiKey) { + headers["Authorization"] = `Bearer ${config.apiKey}`; + } + + const body = JSON.stringify({ + model: config.model, + messages: request.messages.map((m) => ({ + role: m.role, + content: m.content, + })), + stream: true, + stream_options: { include_usage: true }, + }); + + return { + url, + init: { + method: "POST", + headers, + body, + }, + }; +} + +// 解析 OpenAI SSE 流,生成 ChatStreamEvent +export function parseOpenAIStream( + reader: ReadableStreamDefaultReader, + onEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal +): Promise { + const parser = new SSEParser(); + const decoder = new TextDecoder(); + + return (async () => { + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const events = parser.parse(chunk); + + for (const sseEvent of events) { + if (sseEvent.data === "[DONE]") { + onEvent({ type: "done" }); + return; + } + + try { + const json = JSON.parse(sseEvent.data); + + // 处理 usage(最后一个 chunk) + if (json.usage) { + onEvent({ + type: "done", + usage: { + inputTokens: json.usage.prompt_tokens || 0, + outputTokens: json.usage.completion_tokens || 0, + }, + }); + return; + } + + const choice = json.choices?.[0]; + if (!choice) continue; + + const delta = choice.delta; + if (!delta) continue; + + // 内容增量 + if (delta.content) { + onEvent({ type: "content_delta", delta: delta.content }); + } + + // 工具调用 + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + if (tc.function?.name) { + onEvent({ + type: "tool_call_start", + toolCall: { + id: tc.id || `tc_${Date.now()}`, + name: tc.function.name, + arguments: tc.function.arguments || "", + }, + }); + } else if (tc.function?.arguments) { + onEvent({ + type: "tool_call_delta", + id: tc.id || "", + delta: tc.function.arguments, + }); + } + } + } + } catch { + // 解析失败忽略 + } + } + } + } catch (e: any) { + if (signal.aborted) return; + onEvent({ type: "error", message: e.message || "Stream read error" }); + } + })(); +} diff --git a/src/app/service/agent/sse_parser.ts b/src/app/service/agent/sse_parser.ts new file mode 100644 index 000000000..62dfd30ad --- /dev/null +++ b/src/app/service/agent/sse_parser.ts @@ -0,0 +1,71 @@ +// SSE (Server-Sent Events) 文本流解析器 +export type SSEEvent = { + event: string; + data: string; +}; + +export class SSEParser { + private buffer = ""; + private currentEvent = ""; + private currentData: string[] = []; + + // 解析输入的文本块,返回完整的事件列表 + parse(chunk: string): SSEEvent[] { + this.buffer += chunk; + const events: SSEEvent[] = []; + const lines = this.buffer.split("\n"); + // 保留最后一个不完整的行 + this.buffer = lines.pop() || ""; + + for (const line of lines) { + if (line === "" || line === "\r") { + // 空行表示事件结束 + if (this.currentData.length > 0) { + events.push({ + event: this.currentEvent || "message", + data: this.currentData.join("\n"), + }); + this.currentEvent = ""; + this.currentData = []; + } + continue; + } + + const cleanLine = line.endsWith("\r") ? line.slice(0, -1) : line; + + if (cleanLine.startsWith(":")) { + // 注释行,忽略 + continue; + } + + const colonIndex = cleanLine.indexOf(":"); + if (colonIndex === -1) { + // 没有冒号,整行作为字段名 + continue; + } + + const field = cleanLine.slice(0, colonIndex); + // 冒号后如果有空格则跳过 + const value = + cleanLine[colonIndex + 1] === " " ? cleanLine.slice(colonIndex + 2) : cleanLine.slice(colonIndex + 1); + + switch (field) { + case "event": + this.currentEvent = value; + break; + case "data": + this.currentData.push(value); + break; + } + } + + return events; + } + + // 重置解析器状态 + reset(): void { + this.buffer = ""; + this.currentEvent = ""; + this.currentData = []; + } +} diff --git a/src/app/service/agent/types.ts b/src/app/service/agent/types.ts new file mode 100644 index 000000000..7a3530dd4 --- /dev/null +++ b/src/app/service/agent/types.ts @@ -0,0 +1,49 @@ +export type Conversation = { + id: string; + title: string; + modelId: string; + createdAt: number; + updatedAt: number; +}; + +export type MessageRole = "user" | "assistant" | "system"; + +export type ToolCall = { + id: string; + name: string; + arguments: string; + result?: string; + status?: "pending" | "running" | "completed" | "error"; +}; + +export type ThinkingBlock = { + content: string; +}; + +export type ChatMessage = { + id: string; + conversationId: string; + role: MessageRole; + content: string; + thinking?: ThinkingBlock; + toolCalls?: ToolCall[]; + error?: string; + modelId?: string; + createdAt: number; +}; + +// Service Worker -> UI 的流式事件(通过 MessageConnect 的 sendMessage 传输) +export type ChatStreamEvent = + | { type: "content_delta"; delta: string } + | { type: "thinking_delta"; delta: string } + | { type: "tool_call_start"; toolCall: Omit } + | { type: "tool_call_delta"; id: string; delta: string } + | { type: "done"; usage?: { inputTokens: number; outputTokens: number } } + | { type: "error"; message: string }; + +// UI -> Service Worker 的聊天请求 +export type ChatRequest = { + conversationId: string; + modelId: string; + messages: Array<{ role: MessageRole; content: string }>; +}; diff --git a/src/app/service/service_worker/agent.ts b/src/app/service/service_worker/agent.ts new file mode 100644 index 000000000..7a7fc2ee1 --- /dev/null +++ b/src/app/service/service_worker/agent.ts @@ -0,0 +1,90 @@ +import type { Group, IGetSender } from "@Packages/message/server"; +import { GetSenderType } from "@Packages/message/server"; +import type { SystemConfig, AgentModelConfig } from "@App/pkg/config/config"; +import type { ChatRequest, ChatStreamEvent } from "@App/app/service/agent/types"; +import { buildOpenAIRequest, parseOpenAIStream } from "@App/app/service/agent/providers/openai"; +import { buildAnthropicRequest, parseAnthropicStream } from "@App/app/service/agent/providers/anthropic"; + +export class AgentService { + constructor( + private systemConfig: SystemConfig, + private group: Group + ) {} + + init() { + // 通过 connect 建立流式聊天 + this.group.on("chat", this.handleChat.bind(this)); + } + + private async handleChat(params: ChatRequest, sender: IGetSender) { + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("AI chat requires connect mode"); + } + const msgConn = sender.getConnect()!; + + // 获取模型配置 + const agentConfig = await this.systemConfig.getAgentConfig(); + const model = agentConfig.models.find((m: AgentModelConfig) => m.id === params.modelId); + if (!model) { + msgConn.sendMessage({ + action: "event", + data: { type: "error", message: "Model not found" } as ChatStreamEvent, + }); + msgConn.disconnect(); + return; + } + + const abortController = new AbortController(); + let isDisconnected = false; + + msgConn.onDisconnect(() => { + isDisconnected = true; + abortController.abort(); + }); + + const sendEvent = (event: ChatStreamEvent) => { + if (!isDisconnected) { + msgConn.sendMessage({ action: "event", data: event }); + } + }; + + try { + // 根据 provider 构造请求 + const { url, init } = + model.provider === "anthropic" ? buildAnthropicRequest(model, params) : buildOpenAIRequest(model, params); + + const response = await fetch(url, { + ...init, + signal: abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + let errorMessage = `API error: ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error?.message || errorJson.message || errorMessage; + } catch { + if (errorText) errorMessage += ` - ${errorText.slice(0, 200)}`; + } + sendEvent({ type: "error", message: errorMessage }); + return; + } + + if (!response.body) { + sendEvent({ type: "error", message: "No response body" }); + return; + } + + const reader = response.body.getReader(); + + // 根据 provider 解析流 + const parseStream = model.provider === "anthropic" ? parseAnthropicStream : parseOpenAIStream; + + await parseStream(reader, sendEvent, abortController.signal); + } catch (e: any) { + if (abortController.signal.aborted) return; + sendEvent({ type: "error", message: e.message || "Unknown error" }); + } + } +} diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 51f381283..7f35f55d9 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -21,6 +21,7 @@ import { FaviconDAO } from "@App/app/repo/favicon"; import { onRegularUpdateCheckAlarm } from "./regular_updatecheck"; import { cacheInstance } from "@App/app/cache"; import { InfoNotification } from "./utils"; +import { AgentService } from "./agent"; // service worker的管理器 export default class ServiceWorkerManager { @@ -101,6 +102,8 @@ export default class ServiceWorkerManager { faviconDAO ); system.init(); + const agent = new AgentService(systemConfig, this.api.group("agent")); + agent.init(); const regularScriptUpdateCheck = async () => { const res = await onRegularUpdateCheckAlarm(systemConfig, script, subscribe); diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index e6f3850b5..d499e7321 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -557,5 +557,46 @@ "editor": { "show_script_list": "Show Script List", "hide_script_list": "Hide Script List" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Fetch Models", + "agent_model_fetch_failed": "Failed to fetch models", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_no_models": "No models configured", + "agent_coming_soon": "Coming soon...", + "agent_chat_new": "New Chat", + "agent_chat_delete": "Delete Chat", + "agent_chat_delete_confirm": "Delete this conversation?", + "agent_chat_no_conversations": "No conversations", + "agent_chat_input_placeholder": "Type a message...", + "agent_chat_send": "Send", + "agent_chat_stop": "Stop", + "agent_chat_thinking": "Thinking", + "agent_chat_tool_call": "Tool Call", + "agent_chat_error": "Error occurred", + "agent_chat_no_model": "No model configured. Please add one in Model Service first.", + "agent_chat_model_select": "Select Model", + "agent_chat_rename": "Rename", + "agent_chat_copy": "Copy", + "agent_chat_copy_success": "Copied", + "agent_chat_regenerate": "Regenerate", + "agent_chat_streaming": "Generating..." +} diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 9df67cfd8..6cc9ea4ba 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -584,5 +584,46 @@ "editor": { "show_script_list": "Skriptliste anzeigen", "hide_script_list": "Skriptliste ausblenden" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Modelle abrufen", + "agent_model_fetch_failed": "Modellliste konnte nicht abgerufen werden", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_no_models": "No models configured", + "agent_coming_soon": "Coming soon...", + "agent_chat_new": "New Chat", + "agent_chat_delete": "Delete Chat", + "agent_chat_delete_confirm": "Delete this conversation?", + "agent_chat_no_conversations": "No conversations", + "agent_chat_input_placeholder": "Type a message...", + "agent_chat_send": "Send", + "agent_chat_stop": "Stop", + "agent_chat_thinking": "Thinking", + "agent_chat_tool_call": "Tool Call", + "agent_chat_error": "Error occurred", + "agent_chat_no_model": "No model configured. Please add one in Model Service first.", + "agent_chat_model_select": "Select Model", + "agent_chat_rename": "Rename", + "agent_chat_copy": "Copy", + "agent_chat_copy_success": "Copied", + "agent_chat_regenerate": "Regenerate", + "agent_chat_streaming": "Generating..." +} diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 9769ef7f7..590a5f2a6 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -584,5 +584,46 @@ "editor": { "show_script_list": "Show Script List", "hide_script_list": "Hide Script List" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Fetch Models", + "agent_model_fetch_failed": "Failed to fetch models", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_no_models": "No models configured", + "agent_coming_soon": "Coming soon...", + "agent_chat_new": "New Chat", + "agent_chat_delete": "Delete Chat", + "agent_chat_delete_confirm": "Delete this conversation?", + "agent_chat_no_conversations": "No conversations", + "agent_chat_input_placeholder": "Type a message...", + "agent_chat_send": "Send", + "agent_chat_stop": "Stop", + "agent_chat_thinking": "Thinking", + "agent_chat_tool_call": "Tool Call", + "agent_chat_error": "Error occurred", + "agent_chat_no_model": "No model configured. Please add one in Model Service first.", + "agent_chat_model_select": "Select Model", + "agent_chat_rename": "Rename", + "agent_chat_copy": "Copy", + "agent_chat_copy_success": "Copied", + "agent_chat_regenerate": "Regenerate", + "agent_chat_streaming": "Generating..." +} diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index 823949c52..7ccdb88de 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -584,5 +584,46 @@ "editor": { "show_script_list": "スクリプトリストを表示", "hide_script_list": "スクリプトリストを非表示" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "チャット", + "agent_provider": "モデルサービス", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "モデルサービス", + "agent_provider_select": "AIサービスプロバイダー", + "agent_provider_api_base_url": "APIアドレス", + "agent_provider_api_key": "APIキー", + "agent_provider_model": "デフォルトモデル", + "agent_provider_test_connection": "接続テスト", + "agent_provider_test_success": "接続成功", + "agent_provider_test_failed": "接続失敗", + "agent_model_fetch": "モデルを取得", + "agent_model_fetch_failed": "モデルリストの取得に失敗しました", + "agent_model_name": "名前", + "agent_model_add": "モデルを追加", + "agent_model_edit": "編集", + "agent_model_delete": "削除", + "agent_model_set_default": "デフォルトに設定", + "agent_model_default_label": "デフォルト", + "agent_model_delete_confirm": "このモデル設定を削除してもよろしいですか?", + "agent_model_no_models": "モデル設定がありません", + "agent_coming_soon": "開発中...", + "agent_chat_new": "新しいチャット", + "agent_chat_delete": "チャットを削除", + "agent_chat_delete_confirm": "この会話を削除しますか?", + "agent_chat_no_conversations": "会話がありません", + "agent_chat_input_placeholder": "メッセージを入力...", + "agent_chat_send": "送信", + "agent_chat_stop": "停止", + "agent_chat_thinking": "思考中", + "agent_chat_tool_call": "ツール呼び出し", + "agent_chat_error": "エラーが発生しました", + "agent_chat_no_model": "モデルが設定されていません。先にモデルサービスで追加してください。", + "agent_chat_model_select": "モデルを選択", + "agent_chat_rename": "名前を変更", + "agent_chat_copy": "コピー", + "agent_chat_copy_success": "コピーしました", + "agent_chat_regenerate": "再生成", + "agent_chat_streaming": "生成中..." +} diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 1b6b72973..7155212ce 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -584,5 +584,46 @@ "editor": { "show_script_list": "Показать список скриптов", "hide_script_list": "Скрыть список скриптов" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Получить модели", + "agent_model_fetch_failed": "Не удалось получить список моделей", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_no_models": "No models configured", + "agent_coming_soon": "Coming soon...", + "agent_chat_new": "New Chat", + "agent_chat_delete": "Delete Chat", + "agent_chat_delete_confirm": "Delete this conversation?", + "agent_chat_no_conversations": "No conversations", + "agent_chat_input_placeholder": "Type a message...", + "agent_chat_send": "Send", + "agent_chat_stop": "Stop", + "agent_chat_thinking": "Thinking", + "agent_chat_tool_call": "Tool Call", + "agent_chat_error": "Error occurred", + "agent_chat_no_model": "No model configured. Please add one in Model Service first.", + "agent_chat_model_select": "Select Model", + "agent_chat_rename": "Rename", + "agent_chat_copy": "Copy", + "agent_chat_copy_success": "Copied", + "agent_chat_regenerate": "Regenerate", + "agent_chat_streaming": "Generating..." +} diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index c69cb10be..d568fd154 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -584,5 +584,46 @@ "editor": { "show_script_list": "Hiển thị danh sách script", "hide_script_list": "Ẩn danh sách script" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Fetch Models", + "agent_model_fetch_failed": "Failed to fetch models", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_no_models": "No models configured", + "agent_coming_soon": "Coming soon...", + "agent_chat_new": "New Chat", + "agent_chat_delete": "Delete Chat", + "agent_chat_delete_confirm": "Delete this conversation?", + "agent_chat_no_conversations": "No conversations", + "agent_chat_input_placeholder": "Type a message...", + "agent_chat_send": "Send", + "agent_chat_stop": "Stop", + "agent_chat_thinking": "Thinking", + "agent_chat_tool_call": "Tool Call", + "agent_chat_error": "Error occurred", + "agent_chat_no_model": "No model configured. Please add one in Model Service first.", + "agent_chat_model_select": "Select Model", + "agent_chat_rename": "Rename", + "agent_chat_copy": "Copy", + "agent_chat_copy_success": "Copied", + "agent_chat_regenerate": "Regenerate", + "agent_chat_streaming": "Generating..." +} diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 1267d3472..8bc57fc60 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -584,5 +584,46 @@ "editor": { "show_script_list": "显示脚本列表", "hide_script_list": "隐藏脚本列表" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "会话", + "agent_provider": "模型服务", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "模型服务", + "agent_provider_select": "AI 服务提供商", + "agent_provider_api_base_url": "API 地址", + "agent_provider_api_key": "API 密钥", + "agent_provider_model": "默认模型", + "agent_provider_test_connection": "测试连接", + "agent_provider_test_success": "连接成功", + "agent_provider_test_failed": "连接失败", + "agent_model_fetch": "获取模型", + "agent_model_fetch_failed": "获取模型列表失败", + "agent_model_name": "名称", + "agent_model_add": "添加模型", + "agent_model_edit": "编辑", + "agent_model_delete": "删除", + "agent_model_set_default": "设为默认", + "agent_model_default_label": "默认", + "agent_model_delete_confirm": "确定要删除此模型配置吗?", + "agent_model_no_models": "暂无模型配置", + "agent_coming_soon": "开发中...", + "agent_chat_new": "新建会话", + "agent_chat_delete": "删除会话", + "agent_chat_delete_confirm": "确定要删除此会话吗?", + "agent_chat_no_conversations": "暂无会话", + "agent_chat_input_placeholder": "输入消息...", + "agent_chat_send": "发送", + "agent_chat_stop": "停止", + "agent_chat_thinking": "思考过程", + "agent_chat_tool_call": "工具调用", + "agent_chat_error": "发生错误", + "agent_chat_no_model": "未配置模型,请先在模型服务中添加", + "agent_chat_model_select": "选择模型", + "agent_chat_rename": "重命名", + "agent_chat_copy": "复制", + "agent_chat_copy_success": "已复制", + "agent_chat_regenerate": "重新生成", + "agent_chat_streaming": "生成中..." +} diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index a56ddd57e..0fb6c1f08 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -584,5 +584,46 @@ "editor": { "show_script_list": "顯示腳本列表", "hide_script_list": "隱藏腳本列表" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "會話", + "agent_provider": "模型服務", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "模型服務", + "agent_provider_select": "AI 服務提供商", + "agent_provider_api_base_url": "API 地址", + "agent_provider_api_key": "API 密鑰", + "agent_provider_model": "預設模型", + "agent_provider_test_connection": "測試連接", + "agent_provider_test_success": "連接成功", + "agent_provider_test_failed": "連接失敗", + "agent_model_fetch": "取得模型", + "agent_model_fetch_failed": "取得模型列表失敗", + "agent_model_name": "名稱", + "agent_model_add": "新增模型", + "agent_model_edit": "編輯", + "agent_model_delete": "刪除", + "agent_model_set_default": "設為預設", + "agent_model_default_label": "預設", + "agent_model_delete_confirm": "確定要刪除此模型配置嗎?", + "agent_model_no_models": "暫無模型配置", + "agent_coming_soon": "開發中...", + "agent_chat_new": "新建會話", + "agent_chat_delete": "刪除會話", + "agent_chat_delete_confirm": "確定要刪除此會話嗎?", + "agent_chat_no_conversations": "暫無會話", + "agent_chat_input_placeholder": "輸入訊息...", + "agent_chat_send": "發送", + "agent_chat_stop": "停止", + "agent_chat_thinking": "思考過程", + "agent_chat_tool_call": "工具調用", + "agent_chat_error": "發生錯誤", + "agent_chat_no_model": "未配置模型,請先在模型服務中新增", + "agent_chat_model_select": "選擇模型", + "agent_chat_rename": "重新命名", + "agent_chat_copy": "複製", + "agent_chat_copy_success": "已複製", + "agent_chat_regenerate": "重新生成", + "agent_chat_streaming": "生成中..." +} diff --git a/src/pages/components/layout/Sider.tsx b/src/pages/components/layout/Sider.tsx index 37fd399ec..22f450929 100644 --- a/src/pages/components/layout/Sider.tsx +++ b/src/pages/components/layout/Sider.tsx @@ -1,3 +1,4 @@ +import Agent from "@App/pages/options/routes/Agent"; import Logger from "@App/pages/options/routes/Logger"; import ScriptEditor from "@App/pages/options/routes/script/ScriptEditor"; import ScriptList from "@App/pages/options/routes/ScriptList"; @@ -13,6 +14,7 @@ import { IconLink, IconQuestion, IconRight, + IconRobot, IconSettings, IconSubscribe, IconTool, @@ -37,6 +39,7 @@ if (!hash.length) { const Sider: React.FC = () => { const [menuSelect, setMenuSelect] = useState(hash); const [collapsed, setCollapsed] = useState(localStorage.collapsed === "true"); + const [openKeys, setOpenKeys] = useState(hash.startsWith("/agent") ? ["/agent"] : []); const { t } = useTranslation(); const guideRef = useRef<{ open: () => void }>(null); @@ -51,7 +54,14 @@ const Sider: React.FC = () => {
- + setOpenKeys(openKeys)} + selectable + onClickMenuItem={handleMenuClick} + > {t("installed_scripts")} @@ -62,6 +72,34 @@ const Sider: React.FC = () => { {t("subscribe")} + { + e.stopPropagation(); + setMenuSelect("/agent/chat"); + setOpenKeys((prev) => (prev.includes("/agent") ? prev : [...prev, "/agent"])); + window.location.hash = "/agent/chat"; + }} + > + {t("agent")} + + } + > + + {t("agent_chat")} + + + {t("agent_provider")} + + + {t("agent_mcp")} + + + {t("agent_skills")} + + {t("logs")} @@ -186,6 +224,7 @@ const Sider: React.FC = () => { } /> } /> } /> + } /> } />
diff --git a/src/pages/options/routes/Agent.tsx b/src/pages/options/routes/Agent.tsx new file mode 100644 index 000000000..61df8c093 --- /dev/null +++ b/src/pages/options/routes/Agent.tsx @@ -0,0 +1,30 @@ +import { Route, Routes } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Card, Space, Typography } from "@arco-design/web-react"; +import AgentProvider from "./AgentProvider"; +import AgentChat from "./AgentChat"; + +function ComingSoon() { + const { t } = useTranslation(); + return ( + + + {t("agent_coming_soon")} + + + ); +} + +function Agent() { + return ( + + } /> + } /> + } /> + } /> + } /> + + ); +} + +export default Agent; diff --git a/src/pages/options/routes/AgentChat/ChatArea.tsx b/src/pages/options/routes/AgentChat/ChatArea.tsx new file mode 100644 index 000000000..5c499297d --- /dev/null +++ b/src/pages/options/routes/AgentChat/ChatArea.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Empty } from "@arco-design/web-react"; +import type { AgentModelConfig } from "@App/pkg/config/config"; +import type { ChatMessage, ChatStreamEvent } from "@App/app/service/agent/types"; +import MessageItem from "./MessageItem"; +import ChatInput from "./ChatInput"; +import { useMessages, useStreamingChat, persistMessage, autoTitleConversation } from "./hooks"; + +function genId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} + +export default function ChatArea({ + conversationId, + models, + selectedModelId, + onModelChange, + onConversationTitleChange, +}: { + conversationId: string; + models: AgentModelConfig[]; + selectedModelId: string; + onModelChange: (id: string) => void; + onConversationTitleChange?: () => void; +}) { + const { t } = useTranslation(); + const { messages, setMessages, loadMessages } = useMessages(conversationId); + const { isStreaming, sendMessage, stopGeneration } = useStreamingChat(); + const messagesEndRef = useRef(null); + const streamingMsgRef = useRef(null); + + // 自动滚动到底部 + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + const handleSend = async (content: string) => { + if (!conversationId || !selectedModelId) return; + + // 添加用户消息 + const userMsg: ChatMessage = { + id: genId(), + conversationId, + role: "user", + content, + createdAt: Date.now(), + }; + await persistMessage(userMsg); + + // 创建助手消息占位 + const assistantMsg: ChatMessage = { + id: genId(), + conversationId, + role: "assistant", + content: "", + modelId: selectedModelId, + createdAt: Date.now(), + }; + streamingMsgRef.current = assistantMsg; + + setMessages((prev) => [...prev, userMsg, assistantMsg]); + + // 自动设置标题(仅首条消息时) + const isFirstMessage = messages.length === 0; + if (isFirstMessage) { + autoTitleConversation(conversationId, content).then(() => { + onConversationTitleChange?.(); + }); + } + + // 构造发送给 AI 的消息列表 + const allMsgs = [...messages, userMsg].map((m) => ({ + role: m.role, + content: m.content, + })); + + sendMessage( + conversationId, + selectedModelId, + allMsgs, + (event: ChatStreamEvent) => { + const msg = streamingMsgRef.current; + if (!msg) return; + + switch (event.type) { + case "content_delta": + msg.content += event.delta; + break; + case "thinking_delta": + if (!msg.thinking) msg.thinking = { content: "" }; + msg.thinking.content += event.delta; + break; + case "tool_call_start": + if (!msg.toolCalls) msg.toolCalls = []; + msg.toolCalls.push({ ...event.toolCall, status: "running" }); + break; + case "tool_call_delta": + if (msg.toolCalls?.length) { + const lastTc = msg.toolCalls[msg.toolCalls.length - 1]; + lastTc.arguments += event.delta; + } + break; + case "error": + msg.error = event.message; + break; + case "done": + // 完成 + break; + } + + // 更新 UI(创建新引用触发重渲染) + setMessages((prev) => { + const updated = [...prev]; + const idx = updated.findIndex((m) => m.id === msg.id); + if (idx >= 0) { + updated[idx] = { ...msg }; + } + return updated; + }); + }, + async () => { + // 流结束,持久化助手消息 + const msg = streamingMsgRef.current; + if (msg) { + await persistMessage(msg); + streamingMsgRef.current = null; + } + // 重新加载确保一致性 + loadMessages(); + } + ); + }; + + const noModel = models.length === 0; + + return ( +
+ {/* 消息列表 */} +
+
+ {!conversationId ? ( +
+ +
+ ) : messages.length === 0 && !isStreaming ? ( +
+ +
+ ) : ( + messages.map((msg) => ( + + )) + )} +
+
+
+ + {/* 输入区域 */} + + {noModel && ( +
+ {t("agent_chat_no_model")} +
+ )} +
+ ); +} diff --git a/src/pages/options/routes/AgentChat/ChatInput.tsx b/src/pages/options/routes/AgentChat/ChatInput.tsx new file mode 100644 index 000000000..d7f2cf60a --- /dev/null +++ b/src/pages/options/routes/AgentChat/ChatInput.tsx @@ -0,0 +1,99 @@ +import { useState, useRef, useEffect } from "react"; +import { Button, Select } from "@arco-design/web-react"; +import { IconSend, IconPause } from "@arco-design/web-react/icon"; +import { useTranslation } from "react-i18next"; +import type { AgentModelConfig } from "@App/pkg/config/config"; + +export default function ChatInput({ + models, + selectedModelId, + onModelChange, + onSend, + onStop, + isStreaming, + disabled, +}: { + models: AgentModelConfig[]; + selectedModelId: string; + onModelChange: (id: string) => void; + onSend: (content: string) => void; + onStop: () => void; + isStreaming: boolean; + disabled?: boolean; +}) { + const { t } = useTranslation(); + const [input, setInput] = useState(""); + const textareaRef = useRef(null); + + // 自动调整高度 + useEffect(() => { + const el = textareaRef.current; + if (el) { + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 200) + "px"; + } + }, [input]); + + const handleSend = () => { + const trimmed = input.trim(); + if (!trimmed || isStreaming || disabled) return; + onSend(trimmed); + setInput(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+
+
+ + +
+
+ `; + document.body.appendChild(overlay); + + const dialog = overlay.querySelector("#page-copilot-dialog"); + const body = dialog.querySelector(".pc-body"); + const input = dialog.querySelector(".pc-input"); + const sendBtn = dialog.querySelector(".pc-send"); + const closeBtn = dialog.querySelector(".pc-close"); + + closeBtn.addEventListener("click", close); + + // 自动调整输入框高度 + input.addEventListener("input", () => { + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 120) + "px"; + }); + + function close() { + overlay.remove(); + } + + function addMessage(text, type = "ai") { + const div = document.createElement("div"); + div.className = `pc-msg pc-msg-${type}`; + div.textContent = text; + body.appendChild(div); + body.scrollTop = body.scrollHeight; + return div; + } + + function setLoading(loading) { + sendBtn.disabled = loading; + input.disabled = loading; + sendBtn.textContent = loading ? "思考中..." : "发送"; + } + + return { overlay, body, input, sendBtn, close, addMessage, setLoading }; +} + +// ── 核心逻辑 ────────────────────────────────────── + +let conversation = null; + +async function ensureConversation() { + if (!conversation) { + conversation = await CAT.agent.conversation.create({ + system: SYSTEM_PROMPT, + skills: ["browser-automation"], + tools: [ + { + name: "get_selection", + description: "获取用户在页面上选中的文本", + parameters: { type: "object", properties: {} }, + handler: async () => { + const text = window.getSelection()?.toString()?.trim(); + return text || "当前没有选中任何文本"; + }, + }, + { + name: "copy_to_clipboard", + description: "将文本复制到用户的剪贴板", + parameters: { + type: "object", + properties: { text: { type: "string", description: "要复制的文本" } }, + required: ["text"], + }, + handler: async (a) => { + GM_setClipboard(a.text); + return "已复制到剪贴板"; + }, + }, + ], + }); + } + return conversation; +} + +async function handleSend(ui) { + const text = ui.input.value.trim(); + if (!text) return; + + ui.input.value = ""; + ui.input.style.height = "auto"; + ui.addMessage(text, "user"); + ui.setLoading(true); + + try { + const conv = await ensureConversation(); + // 获取选区作为上下文 + const selection = window.getSelection()?.toString()?.trim(); + let message = text; + if (selection) { + message = `[用户选中的文本]\n${selection}\n\n[用户需求]\n${message}`; + } + + const msgDiv = ui.addMessage("", "ai"); + let content = ""; + + // 流式输出 + const stream = await conv.chatStream(message); + for await (const chunk of stream) { + if (chunk.type === "content_delta") { + content += chunk.content; + msgDiv.textContent = content; + ui.body.scrollTop = ui.body.scrollHeight; + } else if (chunk.type === "tool_call") { + ui.addMessage(`🔧 ${chunk.toolCall.name}`, "tool"); + } else if (chunk.type === "error") { + msgDiv.textContent = content || `错误: ${chunk.error}`; + } + } + + if (!content) { + msgDiv.textContent = "(无回复)"; + } + } catch (e) { + ui.addMessage(`出错了: ${e.message || e}`, "ai"); + } finally { + ui.setLoading(false); + ui.input.focus(); + } +} + +// ── 入口 ────────────────────────────────────────── + +function openCopilot() { + // 防止重复打开 + if (document.getElementById("page-copilot-overlay")) return; + + const ui = createDialog(); + ui.input.focus(); + + // 如果有选中文本,显示上下文提示 + const selection = window.getSelection()?.toString()?.trim(); + if (selection) { + const ctx = document.createElement("div"); + ctx.className = "pc-context"; + ctx.textContent = `📋 已选中: ${selection.slice(0, 200)}${selection.length > 200 ? "..." : ""}`; + ui.body.appendChild(ctx); + } + + // 发送 + ui.sendBtn.addEventListener("click", () => handleSend(ui)); + ui.input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(ui); + } + }); +} + +// 注册右键菜单 +GM_registerMenuCommand("Page Copilot - AI 助手", openCopilot); diff --git a/packages/eslint/compat-grant.js b/packages/eslint/compat-grant.js index 210d35309..bf9039de7 100644 --- a/packages/eslint/compat-grant.js +++ b/packages/eslint/compat-grant.js @@ -10,6 +10,8 @@ const compatMap = { "CAT.agent.conversation": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }], "CAT.agent.tools": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }], "CAT.agent.skills": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }], + "CAT.agent.dom": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }], + "CAT.agent.task": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }], ...compat_grant.compatMap, }; diff --git a/src/app/service/service_worker/agent.test.ts b/src/app/service/service_worker/agent.test.ts index 761345f47..5cc4ff7da 100644 --- a/src/app/service/service_worker/agent.test.ts +++ b/src/app/service/service_worker/agent.test.ts @@ -2199,3 +2199,28 @@ describe("handleConversationChat 场景补充", () => { expect(toolNames).toContain("web-skill__web-tool"); }); }); + +describe.concurrent("AgentService.handleDomApi", () => { + it.concurrent("应将请求转发到 domService.handleDomApi", async () => { + const { service } = createTestService(); + const mockResult = [{ id: 1, title: "Test Tab", url: "https://example.com" }]; + const mockHandleDomApi = vi.fn().mockResolvedValue(mockResult); + (service as any).domService = { handleDomApi: mockHandleDomApi }; + + const request = { action: "listTabs" as const, scriptUuid: "test" }; + const result = await service.handleDomApi(request); + + expect(mockHandleDomApi).toHaveBeenCalledWith(request); + expect(result).toEqual(mockResult); + }); + + it.concurrent("应正确传递 domService 的错误", async () => { + const { service } = createTestService(); + const mockHandleDomApi = vi.fn().mockRejectedValue(new Error("DOM action failed")); + (service as any).domService = { handleDomApi: mockHandleDomApi }; + + await expect(service.handleDomApi({ action: "listTabs", scriptUuid: "test" })).rejects.toThrow( + "DOM action failed" + ); + }); +}); diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 5be6e38da..b4749041a 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest"; import { type IGetSender } from "@Packages/message/server"; import { type ExtMessageSender } from "@Packages/message/types"; import { ConnectMatch, getConnectMatched } from "./gm_api"; +import { PermissionVerifyApiGet } from "../permission_verify"; +// 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) +import "./gm_api"; // 小工具:建立假的 IGetSender const makeSender = (url?: string): IGetSender => ({ @@ -98,3 +101,25 @@ describe.concurrent("isConnectMatched", () => { expect(getConnectMatched(["Api.Example.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN); }); }); + +describe.concurrent("GM API 注册完整性", () => { + it.concurrent("CAT_agentDom 应已注册", () => { + const api = PermissionVerifyApiGet("CAT_agentDom"); + expect(api).toBeDefined(); + expect(api!.param.link).toContain("CAT.agent.dom"); + }); + + it.concurrent("Agent 相关 API 应全部注册", () => { + // 确保 Agent 相关的 GM API 不会因 import 遗漏而丢失 + const agentApis = [ + "CAT_agentConversation", + "CAT_agentConversationChat", + "CAT_agentTools", + "CAT_agentSkills", + "CAT_agentDom", + ]; + for (const name of agentApis) { + expect(PermissionVerifyApiGet(name), `${name} 应已注册`).toBeDefined(); + } + }); +}); From fb236d3d89f3cacb4d9bc5e5d5339e8c88632d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 14 Mar 2026 18:11:46 +0800 Subject: [PATCH 065/150] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=20Agent?= =?UTF-8?q?=20=E7=A4=BA=E4=BE=8B=E5=88=B0=E7=8B=AC=E7=AB=8B=20skills=20?= =?UTF-8?q?=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills、CATTools、示例脚本已迁移到 github.com/scriptscat/skills --- example/agent/README.md | 5 + example/agent/conversation/README.md | 90 ------ example/agent/conversation/basic_chat.js | 28 -- example/agent/conversation/multi_turn.js | 52 ---- example/agent/conversation/streaming.js | 53 ---- example/agent/conversation/tool_calling.js | 156 ---------- .../agent/conversation/webpage_summarizer.js | 79 ----- example/agent/dom/README.md | 108 ------- example/agent/dom/form_filler.js | 95 ------ example/agent/dom/page_reader.js | 88 ------ example/agent/dom/tab_manager.js | 101 ------- example/agent/dom/web_automation.js | 174 ----------- example/agent/page_copilot.user.js | 263 ----------------- .../agent/skills/browser_automation/SKILL.md | 125 -------- .../scripts/browser_action.js | 273 ------------------ .../scripts/click_and_wait.js | 175 ----------- .../browser_automation/scripts/list_tabs.js | 19 -- .../browser_automation/scripts/navigate.js | 36 --- .../browser_automation/scripts/screenshot.js | 39 --- .../browser_automation/scripts/scroll.js | 37 --- .../browser_automation/scripts/smart_fill.js | 56 ---- .../browser_automation/scripts/wait_for.js | 38 --- example/agent/tools/README.md | 66 ----- example/agent/tools/hello_world.js | 8 - example/agent/tools/json_formatter.js | 42 --- example/agent/tools/text_processor.js | 44 --- example/agent/tools/use_cattool.js | 111 ------- example/agent/tools/weather_query.js | 37 --- 28 files changed, 5 insertions(+), 2393 deletions(-) create mode 100644 example/agent/README.md delete mode 100644 example/agent/conversation/README.md delete mode 100644 example/agent/conversation/basic_chat.js delete mode 100644 example/agent/conversation/multi_turn.js delete mode 100644 example/agent/conversation/streaming.js delete mode 100644 example/agent/conversation/tool_calling.js delete mode 100644 example/agent/conversation/webpage_summarizer.js delete mode 100644 example/agent/dom/README.md delete mode 100644 example/agent/dom/form_filler.js delete mode 100644 example/agent/dom/page_reader.js delete mode 100644 example/agent/dom/tab_manager.js delete mode 100644 example/agent/dom/web_automation.js delete mode 100644 example/agent/page_copilot.user.js delete mode 100644 example/agent/skills/browser_automation/SKILL.md delete mode 100644 example/agent/skills/browser_automation/scripts/browser_action.js delete mode 100644 example/agent/skills/browser_automation/scripts/click_and_wait.js delete mode 100644 example/agent/skills/browser_automation/scripts/list_tabs.js delete mode 100644 example/agent/skills/browser_automation/scripts/navigate.js delete mode 100644 example/agent/skills/browser_automation/scripts/screenshot.js delete mode 100644 example/agent/skills/browser_automation/scripts/scroll.js delete mode 100644 example/agent/skills/browser_automation/scripts/smart_fill.js delete mode 100644 example/agent/skills/browser_automation/scripts/wait_for.js delete mode 100644 example/agent/tools/README.md delete mode 100644 example/agent/tools/hello_world.js delete mode 100644 example/agent/tools/json_formatter.js delete mode 100644 example/agent/tools/text_processor.js delete mode 100644 example/agent/tools/use_cattool.js delete mode 100644 example/agent/tools/weather_query.js diff --git a/example/agent/README.md b/example/agent/README.md new file mode 100644 index 000000000..f7437a428 --- /dev/null +++ b/example/agent/README.md @@ -0,0 +1,5 @@ +# Agent 示例 + +Agent 相关的 Skills、CATTools 和使用示例已迁移到独立仓库: + +- **GitHub**: https://github.com/scriptscat/skills diff --git a/example/agent/conversation/README.md b/example/agent/conversation/README.md deleted file mode 100644 index a23b5a2ad..000000000 --- a/example/agent/conversation/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# CAT.agent.conversation 示例 - -通过 `@grant CAT.agent.conversation` 在用户脚本中与 LLM 进行对话交互。 - -## 前置要求 - -1. 安装 ScriptCat 浏览器扩展 -2. 在 ScriptCat 设置中配置 AI 模型的 API Key(支持 OpenAI 兼容格式和 Anthropic) -3. 脚本中声明 `@grant CAT.agent.conversation` - -## 示例说明 - -| 文件 | 说明 | 关键 API | -| ----------------------- | ---------------------------------------- | ----------------------------------- | -| `basic_chat.js` | 创建对话、发送消息、查看 Token 用量 | `create()`, `chat()` | -| `multi_turn.js` | 多轮对话自动携带上下文;通过固定 ID 恢复 | `chat()`, `get()`, `getMessages()` | -| `streaming.js` | 流式接收生成内容,支持思考过程和工具调用 | `chatStream()`, `for await...of` | -| `tool_calling.js` | 注册自定义工具,LLM 自动调用并返回结果 | `tools` 参数, `handler` | -| `webpage_summarizer.js` | 实用案例:提取页面内容 + Agent 总结 | 页面脚本 + `GM_registerMenuCommand` | - -## 快速开始 - -```javascript -// ==UserScript== -// @grant CAT.agent.conversation -// ==/UserScript== - -const conv = await CAT.agent.conversation.create({ - system: "你是一个助手", -}); -const reply = await conv.chat("你好"); -console.log(reply.content); -``` - -## API 概览 - -### 创建和获取对话 - -- **`CAT.agent.conversation.create(options)`** — 创建新对话 - - `system`: 系统提示词 - - `model`: 模型 ID(可选) - - `tools`: 工具定义数组(可选) - - `maxIterations`: 工具调用最大循环次数(可选) -- **`CAT.agent.conversation.get(id)`** — 获取已有对话 - -### 对话实例方法 - -- **`conv.chat(message, options?)`** — 发送消息,等待完整回复 -- **`conv.chatStream(message, options?)`** — 流式发送消息,返回 AsyncIterable -- **`conv.getMessages()`** — 获取对话历史 - -### 流式事件类型 - -| 事件类型 | 说明 | -| ---------------- | ---------------------------- | -| `content_delta` | 文本片段,拼接为完整回复 | -| `thinking_delta` | 模型思考过程(部分模型支持) | -| `tool_call` | 工具调用通知 | -| `done` | 流式完成,包含 usage 信息 | -| `error` | 错误信息 | - -## Tool Calling - -支持在 `create()` 或 `chatStream()` 时传入工具定义,LLM 会自动决定何时调用工具: - -```javascript -const conv = await CAT.agent.conversation.create({ - tools: [ - { - name: "get_weather", - description: "获取城市天气", - parameters: { - type: "object", - properties: { - city: { type: "string", description: "城市名" }, - }, - required: ["city"], - }, - handler: async (args) => { - return { city: args.city, temperature: 22 }; - }, - }, - ], -}); - -const reply = await conv.chat("北京天气怎么样?"); -// LLM 自动调用 get_weather,再根据返回结果生成回复 -``` - -详细用法参见 `tool_calling.js`。 diff --git a/example/agent/conversation/basic_chat.js b/example/agent/conversation/basic_chat.js deleted file mode 100644 index 9450802ba..000000000 --- a/example/agent/conversation/basic_chat.js +++ /dev/null @@ -1,28 +0,0 @@ -// ==UserScript== -// @name Agent 基础对话 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description CAT.agent.conversation API 基础用法:创建对话、发送消息、获取回复 -// @author ScriptCat -// @background -// @grant CAT.agent.conversation -// @grant GM_log -// ==/UserScript== - -async function main() { - // 创建对话,可指定 system prompt 和模型 - const conv = await CAT.agent.conversation.create({ - system: "你是一个简洁的助手,回答尽量用一句话。", - // model: "your-model-id", // 不指定则使用默认模型 - }); - GM_log("对话创建成功, id: " + conv.id); - - // 发送消息并等待完整回复 - const reply = await conv.chat("1+1等于几?"); - GM_log("回复: " + reply.content); - if (reply.usage) { - GM_log("Token 用量: 输入 " + reply.usage.inputTokens + ", 输出 " + reply.usage.outputTokens); - } -} - -return main(); diff --git a/example/agent/conversation/multi_turn.js b/example/agent/conversation/multi_turn.js deleted file mode 100644 index a1fcce56f..000000000 --- a/example/agent/conversation/multi_turn.js +++ /dev/null @@ -1,52 +0,0 @@ -// ==UserScript== -// @name Agent 多轮对话 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 多轮对话示例:连续发送多条消息,自动携带上下文;查看对话历史;恢复已有对话 -// @author ScriptCat -// @background -// @grant CAT.agent.conversation -// @grant GM_log -// ==/UserScript== - -// 多轮对话:连续提问,LLM 会记住上下文 -async function multiTurn() { - const conv = await CAT.agent.conversation.create({ - system: "你是一个数学老师", - }); - - const reply1 = await conv.chat("什么是斐波那契数列?"); - GM_log("第一轮: " + reply1.content); - - // 第二轮自动携带上下文,LLM 知道在讨论斐波那契 - const reply2 = await conv.chat("它的前10个数是什么?"); - GM_log("第二轮: " + reply2.content); - - // 查看完整对话历史 - const messages = await conv.getMessages(); - GM_log("对话共 " + messages.length + " 条消息"); - for (const msg of messages) { - GM_log(` [${msg.role}] ${msg.content.slice(0, 50)}...`); - } -} - -// 恢复对话:通过固定 id 在多次脚本执行间保持同一个对话 -async function resumeConversation() { - // 第一次运行时创建对话 - let conv = await CAT.agent.conversation.get("my-persistent-chat"); - if (!conv) { - conv = await CAT.agent.conversation.create({ - id: "my-persistent-chat", - system: "你是助手,请记住用户告诉你的信息。", - }); - await conv.chat("记住:我喜欢蓝色"); - GM_log("首次对话完成,已告知偏好"); - } - - // 后续运行时恢复对话,LLM 能回忆之前的内容 - const reply = await conv.chat("我喜欢什么颜色?"); - GM_log("恢复对话回复: " + reply.content); -} - -return multiTurn(); -// return resumeConversation(); diff --git a/example/agent/conversation/streaming.js b/example/agent/conversation/streaming.js deleted file mode 100644 index 8210c5aab..000000000 --- a/example/agent/conversation/streaming.js +++ /dev/null @@ -1,53 +0,0 @@ -// ==UserScript== -// @name Agent 流式输出 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 使用 chatStream 实现流式输出,适合需要实时展示生成内容的场景 -// @author ScriptCat -// @background -// @grant CAT.agent.conversation -// @grant GM_log -// ==/UserScript== - -async function main() { - const conv = await CAT.agent.conversation.create({ - system: "你是一个诗人", - }); - - // chatStream 返回 AsyncIterable,可以用 for-await-of 逐块读取 - const stream = await conv.chatStream("写一首关于代码的五言绝句"); - - let fullContent = ""; - for await (const chunk of stream) { - switch (chunk.type) { - case "content_delta": - // 每个 delta 是一小段文本,拼接起来就是完整回复 - fullContent += chunk.content; - // 实际使用中可以在这里实时更新 UI - break; - - case "thinking_delta": - // 部分模型(如 Anthropic)会输出思考过程 - GM_log("[思考] " + chunk.content); - break; - - case "tool_call": - // 如果配置了 tools,LLM 调用工具时会产生此事件 - GM_log("[工具调用] " + chunk.toolCall.name); - break; - - case "done": - GM_log("流式完成: " + fullContent); - if (chunk.usage) { - GM_log("Token: 输入 " + chunk.usage.inputTokens + " / 输出 " + chunk.usage.outputTokens); - } - break; - - case "error": - GM_log("错误: " + chunk.error); - break; - } - } -} - -return main(); diff --git a/example/agent/conversation/tool_calling.js b/example/agent/conversation/tool_calling.js deleted file mode 100644 index 64f0160a2..000000000 --- a/example/agent/conversation/tool_calling.js +++ /dev/null @@ -1,156 +0,0 @@ -// ==UserScript== -// @name Agent Tool Calling -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description Tool Calling 示例:让 LLM 调用自定义工具获取外部数据,实现天气查询、网页搜索等功能 -// @author ScriptCat -// @background -// @grant CAT.agent.conversation -// @grant GM_log -// @grant GM_xmlhttpRequest -// ==/UserScript== - -// 示例 1: 单工具 - 天气查询(在 create 时注册工具) -async function weatherAssistant() { - const conv = await CAT.agent.conversation.create({ - system: "你是一个天气助手,当用户询问天气时使用 get_weather 工具获取信息。", - tools: [ - { - name: "get_weather", - description: "获取指定城市的当前天气信息", - parameters: { - type: "object", - properties: { - city: { - type: "string", - description: "城市名称,如'北京'、'上海'", - }, - }, - required: ["city"], - }, - // LLM 决定调用此工具时,handler 会被自动执行 - handler: async (args) => { - GM_log("get_weather 被调用, 参数: " + JSON.stringify(args)); - // 这里可以调用真实的天气 API(用 GM_xmlhttpRequest) - // 这里返回模拟数据 - return { - city: args.city, - temperature: 22, - condition: "多云", - humidity: 45, - }; - }, - }, - ], - }); - - // 工具已在 create 时注册,chat 时无需再传 - const reply = await conv.chat("北京今天天气怎么样?"); - - // LLM 会根据工具返回的结果生成自然语言回复 - GM_log("最终回复: " + reply.content); - - // 可以查看 tool 调用记录 - if (reply.toolCalls) { - for (const tc of reply.toolCalls) { - GM_log(`工具调用: ${tc.name}(${tc.arguments}) => ${tc.result}`); - } - } -} - -// 示例 2: 多工具协作 - 计算器助手(在 create 时注册工具) -async function calculatorAssistant() { - const conv = await CAT.agent.conversation.create({ - system: "你是一个计算助手。使用提供的工具来完成数学运算,不要自己心算。", - maxIterations: 10, // 最大工具调用循环次数 - tools: [ - { - name: "add", - description: "计算两个数的和", - parameters: { - type: "object", - properties: { - a: { type: "number", description: "第一个数" }, - b: { type: "number", description: "第二个数" }, - }, - required: ["a", "b"], - }, - handler: async (args) => { - const result = args.a + args.b; - GM_log(`add(${args.a}, ${args.b}) = ${result}`); - return result; - }, - }, - { - name: "multiply", - description: "计算两个数的乘积", - parameters: { - type: "object", - properties: { - a: { type: "number", description: "第一个数" }, - b: { type: "number", description: "第二个数" }, - }, - required: ["a", "b"], - }, - handler: async (args) => { - const result = args.a * args.b; - GM_log(`multiply(${args.a}, ${args.b}) = ${result}`); - return result; - }, - }, - ], - }); - - // 工具已在 create 时注册,多次 chat 都能使用 - const reply = await conv.chat("计算 (3 + 5) * 7 的结果"); - GM_log("计算结果回复: " + reply.content); -} - -// 示例 3: 流式 + Tool Calling -async function streamWithTools() { - const conv = await CAT.agent.conversation.create({ - system: "你是一个翻译助手,使用 translate 工具来翻译文本。", - }); - - const stream = await conv.chatStream("把'你好世界'翻译成英语、法语和日语", { - tools: [ - { - name: "translate", - description: "将文本翻译为目标语言", - parameters: { - type: "object", - properties: { - text: { type: "string", description: "要翻译的文本" }, - targetLang: { type: "string", description: "目标语言,如 'English', 'French'" }, - }, - required: ["text", "targetLang"], - }, - handler: async (args) => { - GM_log(`translate("${args.text}" -> ${args.targetLang})`); - // 模拟翻译结果 - const translations = { - English: "Hello World", - French: "Bonjour le monde", - Japanese: "こんにちは世界", - }; - return translations[args.targetLang] || `[翻译: ${args.text} -> ${args.targetLang}]`; - }, - }, - ], - }); - - let content = ""; - for await (const chunk of stream) { - if (chunk.type === "content_delta") { - content += chunk.content; - } else if (chunk.type === "tool_call") { - GM_log("[工具调用] " + chunk.toolCall.name); - } else if (chunk.type === "done") { - GM_log("翻译完成: " + content); - } - } -} - -return weatherAssistant(); -// return calculatorAssistant(); -// return streamWithTools(); diff --git a/example/agent/conversation/webpage_summarizer.js b/example/agent/conversation/webpage_summarizer.js deleted file mode 100644 index 39ad23dea..000000000 --- a/example/agent/conversation/webpage_summarizer.js +++ /dev/null @@ -1,79 +0,0 @@ -// ==UserScript== -// @name Agent 网页摘要助手 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 实用示例:在网页上使用 Agent 对话来总结页面内容、回答问题 -// @author ScriptCat -// @match *://*/* -// @grant CAT.agent.conversation -// @grant GM_log -// @grant GM_registerMenuCommand -// ==/UserScript== - -// 提取页面主要文本内容 -function getPageContent() { - // 优先取 article 或 main 区域 - const main = document.querySelector("article") || document.querySelector("main") || document.body; - // 简单提取纯文本,去除脚本和样式 - const clone = main.cloneNode(true); - clone.querySelectorAll("script, style, nav, footer, header").forEach((el) => el.remove()); - const text = clone.textContent.replace(/\s+/g, " ").trim(); - // 截断过长文本,避免超出 token 限制 - return text.slice(0, 8000); -} - -// 对页面内容进行总结 -async function summarizePage() { - const pageContent = getPageContent(); - if (!pageContent || pageContent.length < 50) { - GM_log("页面内容过少,无法总结"); - return; - } - - GM_log("正在总结页面内容..."); - - const conv = await CAT.agent.conversation.create({ - system: `你是一个网页内容分析助手。用户会给你一段网页文本,请用中文进行总结。 -要求: -1. 先用一句话概括主题 -2. 列出 3-5 个关键要点 -3. 如果有重要的数据或结论,单独列出`, - }); - - const reply = await conv.chat("请总结以下网页内容:\n\n" + pageContent); - GM_log("=== 页面摘要 ===\n" + reply.content); - - // 可以继续追问 - // const followUp = await conv.chat("这篇文章的主要论点有哪些?"); -} - -// 通过流式输出实时显示总结过程 -async function summarizePageStream() { - const pageContent = getPageContent(); - if (!pageContent || pageContent.length < 50) { - GM_log("页面内容过少"); - return; - } - - const conv = await CAT.agent.conversation.create({ - system: "你是网页内容分析助手,用中文总结网页内容。先概括主题,再列出关键要点。", - }); - - const stream = await conv.chatStream("总结这个网页:\n\n" + pageContent); - - let result = ""; - for await (const chunk of stream) { - if (chunk.type === "content_delta") { - result += chunk.content; - // 实时输出,实际使用中可以更新到页面上的浮窗 - } else if (chunk.type === "done") { - GM_log("=== 总结完成 ===\n" + result); - } else if (chunk.type === "error") { - GM_log("总结失败: " + chunk.error); - } - } -} - -// 注册右键菜单 -GM_registerMenuCommand("总结当前页面", summarizePage); -GM_registerMenuCommand("总结当前页面(流式)", summarizePageStream); diff --git a/example/agent/dom/README.md b/example/agent/dom/README.md deleted file mode 100644 index fd2b5ec73..000000000 --- a/example/agent/dom/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# CAT.agent.dom 示例 - -通过 `@grant CAT.agent.dom` 在用户脚本中操作浏览器标签页和页面 DOM。 - -## 前置要求 - -1. 安装 ScriptCat 浏览器扩展 -2. 脚本中声明 `@grant CAT.agent.dom` -3. 首次使用会弹出权限确认对话框 - -## 示例说明 - -| 文件 | 说明 | 关键 API | -| -------------------- | ---------------------------------------------- | --------------------------------------------------- | -| `page_reader.js` | 读取页面内容,支持 summary/detail 模式和选择器 | `readPage()` | -| `form_filler.js` | 自动填写表单,等待动态元素 | `fill()`, `click()`, `waitFor()` | -| `tab_manager.js` | 标签页管理、导航、截图 | `listTabs()`, `navigate()`, `screenshot()` | -| `web_automation.js` | 完整自动化流程:搜索、滚动采集、多页面操作 | 组合使用所有 API | - -## 快速开始 - -```javascript -// ==UserScript== -// @grant CAT.agent.dom -// ==/UserScript== - -// 读取当前页面结构 -const page = await CAT.agent.dom.readPage({ mode: "summary" }); -console.log(page.title, page.links.length + " 个链接"); - -// 点击某个按钮 -await CAT.agent.dom.click("#submit-btn"); -``` - -## API 概览 - -### 标签页管理 - -- **`CAT.agent.dom.listTabs()`** — 列出所有标签页,返回 `TabInfo[]` -- **`CAT.agent.dom.navigate(url, options?)`** — 打开/导航到 URL - - `tabId`: 指定标签页(不传则新建) - - `waitUntil`: 是否等待页面加载完成(默认 true) - - `timeout`: 超时毫秒数(默认 30000) - -### 页面内容 - -- **`CAT.agent.dom.readPage(options?)`** — 读取页面内容 - - `mode`: `"summary"`(骨架结构)或 `"detail"`(完整文本) - - `selector`: CSS 选择器,只读取匹配区域 - - `maxLength`: 最大内容长度(默认 4000) - - `viewportOnly`: 仅读取可视区域 - - `tabId`: 指定标签页 -- **`CAT.agent.dom.screenshot(options?)`** — 截图,返回 base64 data URL - - `quality`: 图片质量 0-100(默认 80) - - `tabId`: 指定标签页 - -### DOM 操作 - -- **`CAT.agent.dom.click(selector, options?)`** — 点击元素 - - `trusted`: 使用 CDP 模拟真实点击(需要 debugger 权限) - - 返回 `ActionResult`:包含导航/新标签页/弹窗信息 -- **`CAT.agent.dom.fill(selector, value, options?)`** — 填写表单字段 - - `trusted`: 使用 CDP 逐字符输入 -- **`CAT.agent.dom.scroll(direction, options?)`** — 滚动页面 - - `direction`: `"up"` / `"down"` / `"top"` / `"bottom"` - - `selector`: 滚动指定容器(不传则滚动整个页面) -- **`CAT.agent.dom.waitFor(selector, options?)`** — 等待元素出现 - - `timeout`: 超时毫秒数(默认 10000) - -### 返回值类型 - -#### PageContent(readPage 返回) - -```typescript -{ - title: string; // 页面标题 - url: string; // 页面 URL - content?: string; // 文本内容(detail 模式) - sections?: SectionInfo[];// 页面分区(summary 模式) - interactable: []; // 可交互元素(按钮、输入框等) - forms: []; // 表单及其字段 - links: []; // 链接列表 - truncated?: boolean; // 是否被截断 -} -``` - -#### ActionResult(click 返回) - -```typescript -{ - success: boolean; - navigated?: boolean; // 是否触发了页面跳转 - url: string; // 当前 URL - newTab?: { tabId, url }; // 是否打开了新标签页 - dialog?: { type, message }; // 是否触发了弹窗 -} -``` - -## Trusted 模式 - -传入 `{ trusted: true }` 可通过 Chrome DevTools Protocol(CDP)模拟真实的鼠标点击和键盘输入(`event.isTrusted === true`),用于绕过某些只响应真实用户事件的网页。首次使用需要授权 debugger 权限。 - -```javascript -// 真实点击 -await CAT.agent.dom.click("#btn", { trusted: true }); -// 真实键盘输入 -await CAT.agent.dom.fill("#input", "hello", { trusted: true }); -``` diff --git a/example/agent/dom/form_filler.js b/example/agent/dom/form_filler.js deleted file mode 100644 index af772bc38..000000000 --- a/example/agent/dom/form_filler.js +++ /dev/null @@ -1,95 +0,0 @@ -// ==UserScript== -// @name DOM API - 表单自动填写 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 示例:使用 CAT.agent.dom 自动填写表单,包括搜索框、登录框等场景 -// @author ScriptCat -// @match *://*/* -// @grant CAT.agent.dom -// @grant GM_log -// @grant GM_registerMenuCommand -// ==/UserScript== - -// 示例 1: 自动填写搜索框并提交 -async function autoSearch(keyword) { - GM_log(`准备搜索: ${keyword}`); - - // 先读取页面,找到搜索框 - const page = await CAT.agent.dom.readPage({ mode: "summary" }); - - // 查找搜索输入框 - const searchInput = page.interactable.find( - (el) => el.tag === "input" && (el.type === "search" || el.type === "text") - ); - if (!searchInput) { - GM_log("未找到搜索框"); - return; - } - - // 填写搜索关键词 - await CAT.agent.dom.fill(searchInput.selector, keyword); - GM_log("已填写搜索关键词"); - - // 查找搜索按钮 - const searchBtn = page.interactable.find( - (el) => (el.tag === "button" || el.role === "button") && /搜索|search|go/i.test(el.text) - ); - if (searchBtn) { - const result = await CAT.agent.dom.click(searchBtn.selector); - GM_log(`点击搜索按钮,页面跳转: ${result.navigated}, URL: ${result.url}`); - } -} - -// 示例 2: 自动填写表单的所有字段 -async function fillForm(formData) { - const page = await CAT.agent.dom.readPage({ mode: "summary" }); - - if (page.forms.length === 0) { - GM_log("页面上没有找到表单"); - return; - } - - const form = page.forms[0]; - GM_log(`找到表单: ${form.selector}, ${form.fields.length} 个字段`); - - // 逐一填写表单字段 - for (const field of form.fields) { - const value = formData[field.name]; - if (value) { - await CAT.agent.dom.fill(field.selector, value); - GM_log(`已填写 ${field.name} = ${value}`); - } - } - - GM_log("表单填写完毕"); -} - -// 示例 3: 等待元素出现后再操作 -async function waitAndFill() { - // 等待动态加载的表单出现 - const result = await CAT.agent.dom.waitFor("form.dynamic-form", { - timeout: 5000, - }); - - if (!result.found) { - GM_log("等待超时,表单未出现"); - return; - } - - GM_log(`表单已出现: ${result.element.selector}`); - - // 填写表单 - await CAT.agent.dom.fill("form.dynamic-form input[name='email']", "user@example.com"); - await CAT.agent.dom.fill("form.dynamic-form input[name='name']", "Test User"); - GM_log("动态表单填写完毕"); -} - -GM_registerMenuCommand("搜索示例", () => autoSearch("ScriptCat 油猴脚本")); -GM_registerMenuCommand("填写表单", () => - fillForm({ - username: "testuser", - email: "test@example.com", - password: "SecurePass123", - }) -); -GM_registerMenuCommand("等待并填写", waitAndFill); diff --git a/example/agent/dom/page_reader.js b/example/agent/dom/page_reader.js deleted file mode 100644 index ac1ab8335..000000000 --- a/example/agent/dom/page_reader.js +++ /dev/null @@ -1,88 +0,0 @@ -// ==UserScript== -// @name DOM API - 页面内容读取 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 示例:使用 CAT.agent.dom 读取页面内容,支持 summary/detail 两种模式 -// @author ScriptCat -// @match *://*/* -// @grant CAT.agent.dom -// @grant GM_log -// @grant GM_registerMenuCommand -// ==/UserScript== - -// 示例 1: 概要模式 — 获取页面骨架结构 -async function readPageSummary() { - GM_log("读取页面概要..."); - - const page = await CAT.agent.dom.readPage({ mode: "summary" }); - - GM_log(`标题: ${page.title}`); - GM_log(`URL: ${page.url}`); - GM_log(`可交互元素: ${page.interactable.length} 个`); - GM_log(`表单: ${page.forms.length} 个`); - GM_log(`链接: ${page.links.length} 个`); - - // 输出页面分区 - if (page.sections) { - GM_log("\n=== 页面分区 ==="); - for (const section of page.sections) { - GM_log(`[${section.selector}] ${section.summary.slice(0, 80)}... (${section.elementCount} 个子元素)`); - } - } - - // 输出可交互元素 - GM_log("\n=== 可交互元素 ==="); - for (const el of page.interactable.slice(0, 10)) { - GM_log(`<${el.tag}> ${el.text.slice(0, 50)} → ${el.selector}`); - } -} - -// 示例 2: 详细模式 — 获取页面完整文本内容 -async function readPageDetail() { - GM_log("读取页面详细内容..."); - - const page = await CAT.agent.dom.readPage({ - mode: "detail", - maxLength: 10000, - }); - - GM_log(`标题: ${page.title}`); - if (page.truncated) { - GM_log(`内容已截断,原始长度: ${page.totalLength}`); - } - GM_log("\n=== 页面内容 ==="); - GM_log(page.content); -} - -// 示例 3: 指定选择器 — 只读取页面某个区域 -async function readPageSelector() { - // 只读取 main 或 article 区域的内容 - const page = await CAT.agent.dom.readPage({ - mode: "detail", - selector: "main, article", - maxLength: 5000, - }); - - GM_log("=== 局部内容 ==="); - GM_log(page.content || "(未找到匹配元素)"); -} - -// 示例 4: 只读取可视区域 -async function readViewportOnly() { - const page = await CAT.agent.dom.readPage({ - mode: "summary", - viewportOnly: true, - }); - - GM_log("=== 当前可视区域 ==="); - GM_log(`可见交互元素: ${page.interactable.length} 个`); - GM_log(`可见链接: ${page.links.length} 个`); - for (const link of page.links.slice(0, 5)) { - GM_log(` ${link.text} → ${link.href}`); - } -} - -GM_registerMenuCommand("读取页面概要", readPageSummary); -GM_registerMenuCommand("读取页面详细内容", readPageDetail); -GM_registerMenuCommand("读取指定区域", readPageSelector); -GM_registerMenuCommand("读取可视区域", readViewportOnly); diff --git a/example/agent/dom/tab_manager.js b/example/agent/dom/tab_manager.js deleted file mode 100644 index 21171b3d6..000000000 --- a/example/agent/dom/tab_manager.js +++ /dev/null @@ -1,101 +0,0 @@ -// ==UserScript== -// @name DOM API - 标签页管理与导航 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 示例:使用 CAT.agent.dom 管理标签页、导航、截图 -// @author ScriptCat -// @background -// @grant CAT.agent.dom -// @grant GM_log -// ==/UserScript== - -// 示例 1: 列出所有标签页 -async function listAllTabs() { - const tabs = await CAT.agent.dom.listTabs(); - - GM_log(`共 ${tabs.length} 个标签页:`); - for (const tab of tabs) { - const status = tab.active ? "[活跃]" : tab.discarded ? "[已挂起]" : ""; - GM_log(` ${status} #${tab.tabId} ${tab.title} — ${tab.url}`); - } - - return tabs; -} - -// 示例 2: 打开新页面并等待加载 -async function navigateAndRead(url) { - GM_log(`导航到: ${url}`); - - // 打开新标签页并等待加载完成 - const nav = await CAT.agent.dom.navigate(url, { - waitUntil: true, - timeout: 15000, - }); - - GM_log(`页面加载完成: ${nav.title} (tabId: ${nav.tabId})`); - - // 读取新页面的内容 - const page = await CAT.agent.dom.readPage({ - tabId: nav.tabId, - mode: "summary", - }); - - GM_log(`页面有 ${page.interactable.length} 个可交互元素, ${page.links.length} 个链接`); - return nav; -} - -// 示例 3: 在指定标签页中导航(复用标签页) -async function navigateInTab(tabId, url) { - const nav = await CAT.agent.dom.navigate(url, { - tabId, - waitUntil: true, - }); - - GM_log(`标签页 #${tabId} 已导航到: ${nav.title}`); - return nav; -} - -// 示例 4: 截图 -async function takeScreenshot(tabId) { - GM_log("正在截图..."); - - const dataUrl = await CAT.agent.dom.screenshot({ - tabId, - quality: 90, - }); - - GM_log(`截图完成,数据长度: ${dataUrl.length} 字符`); - // dataUrl 是 base64 编码的 JPEG 图片 - // 可以通过 GM_xmlhttpRequest 上传或保存 - return dataUrl; -} - -// 示例 5: 多标签页批量操作 -async function batchReadTabs() { - const tabs = await CAT.agent.dom.listTabs(); - // 过滤出 http/https 页面 - const webTabs = tabs.filter((t) => t.url.startsWith("http")); - - GM_log(`准备读取 ${webTabs.length} 个网页标签页`); - - for (const tab of webTabs.slice(0, 5)) { - try { - const page = await CAT.agent.dom.readPage({ - tabId: tab.tabId, - mode: "summary", - maxLength: 2000, - }); - GM_log(`#${tab.tabId} [${page.title}] 链接: ${page.links.length}, 交互: ${page.interactable.length}`); - } catch (e) { - GM_log(`#${tab.tabId} 读取失败: ${e.message}`); - } - } -} - -// 运行示例 -(async () => { - await listAllTabs(); - // const nav = await navigateAndRead("https://example.com"); - // await takeScreenshot(nav.tabId); - // await batchReadTabs(); -})(); diff --git a/example/agent/dom/web_automation.js b/example/agent/dom/web_automation.js deleted file mode 100644 index 5d533d3f8..000000000 --- a/example/agent/dom/web_automation.js +++ /dev/null @@ -1,174 +0,0 @@ -// ==UserScript== -// @name DOM API - 网页自动化流程 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 示例:组合使用 CAT.agent.dom 的多个 API 实现完整的网页自动化操作流程 -// @author ScriptCat -// @background -// @grant CAT.agent.dom -// @grant GM_log -// ==/UserScript== - -// 示例 1: 搜索引擎自动搜索并采集结果 -async function searchAndCollect(keyword) { - GM_log(`开始搜索: ${keyword}`); - - // 1. 打开搜索引擎 - const nav = await CAT.agent.dom.navigate("https://www.bing.com", { - waitUntil: true, - }); - const tabId = nav.tabId; - - // 2. 等待搜索框出现 - const waitResult = await CAT.agent.dom.waitFor("#sb_form_q", { - tabId, - timeout: 5000, - }); - if (!waitResult.found) { - GM_log("搜索框未找到"); - return; - } - - // 3. 填写搜索关键词 - await CAT.agent.dom.fill("#sb_form_q", keyword, { tabId }); - GM_log("已输入关键词"); - - // 4. 点击搜索按钮(使用 trusted 模式模拟真实点击) - const clickResult = await CAT.agent.dom.click("#sb_form button[type='submit']", { - tabId, - trusted: true, - }); - GM_log(`搜索提交,页面跳转: ${clickResult.navigated}`); - - // 5. 等待搜索结果加载 - await CAT.agent.dom.waitFor("#b_results", { tabId, timeout: 10000 }); - - // 6. 读取搜索结果 - const page = await CAT.agent.dom.readPage({ - tabId, - mode: "detail", - selector: "#b_results", - maxLength: 8000, - }); - - GM_log("=== 搜索结果 ==="); - GM_log(page.content); - - return page; -} - -// 示例 2: 页面滚动加载 — 读取长页面内容 -async function scrollAndCollect(tabId) { - GM_log("开始滚动采集..."); - - const allContent = []; - let scrollCount = 0; - const maxScrolls = 5; - - while (scrollCount < maxScrolls) { - // 读取当前可视区域 - const page = await CAT.agent.dom.readPage({ - tabId, - mode: "detail", - viewportOnly: true, - maxLength: 3000, - }); - allContent.push(page.content); - - // 向下滚动一屏 - const scrollResult = await CAT.agent.dom.scroll("down", { tabId }); - scrollCount++; - - GM_log( - `第 ${scrollCount} 次滚动: scrollTop=${scrollResult.scrollTop}, ` + - `已到底: ${scrollResult.atBottom}` - ); - - // 如果已经到底部,停止滚动 - if (scrollResult.atBottom) { - GM_log("已到达页面底部"); - break; - } - - // 等待内容加载(用于懒加载页面) - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - const fullContent = allContent.join("\n---\n"); - GM_log(`采集完成,共 ${allContent.length} 段内容,总长度: ${fullContent.length}`); - return fullContent; -} - -// 示例 3: 多页面自动化 — 打开多个链接并采集 -async function multiPageCollect(urls) { - const results = []; - - for (const url of urls) { - try { - GM_log(`正在采集: ${url}`); - - const nav = await CAT.agent.dom.navigate(url, { waitUntil: true }); - - // 截图存档 - await CAT.agent.dom.screenshot({ tabId: nav.tabId, quality: 70 }); - - // 读取页面内容 - const page = await CAT.agent.dom.readPage({ - tabId: nav.tabId, - mode: "detail", - maxLength: 5000, - }); - - results.push({ - url: nav.url, - title: nav.title, - content: page.content, - links: page.links.length, - }); - - GM_log(`采集完成: ${nav.title}`); - } catch (e) { - GM_log(`采集失败 ${url}: ${e.message}`); - results.push({ url, title: "", content: "", error: e.message }); - } - } - - GM_log(`\n=== 采集汇总 ===`); - GM_log(`成功: ${results.filter((r) => !r.error).length} / ${results.length}`); - return results; -} - -// 示例 4: 点击 + 导航跟踪 -async function clickAndTrack() { - // 读取当前页面的链接 - const page = await CAT.agent.dom.readPage({ mode: "summary" }); - - if (page.links.length === 0) { - GM_log("页面上没有链接"); - return; - } - - // 点击第一个链接 - const firstLink = page.links[0]; - GM_log(`点击链接: ${firstLink.text} → ${firstLink.href}`); - - const result = await CAT.agent.dom.click(firstLink.selector); - - if (result.navigated) { - GM_log(`页面已跳转到: ${result.url}`); - } - if (result.newTab) { - GM_log(`新标签页打开: #${result.newTab.tabId} ${result.newTab.url}`); - } - if (result.dialog) { - GM_log(`弹窗: [${result.dialog.type}] ${result.dialog.message}`); - } -} - -// 运行示例 -(async () => { - // await searchAndCollect("ScriptCat 用户脚本管理器"); - // await scrollAndCollect(tabId); - // await multiPageCollect(["https://example.com", "https://example.org"]); - await clickAndTrack(); -})(); diff --git a/example/agent/page_copilot.user.js b/example/agent/page_copilot.user.js deleted file mode 100644 index 73d40f937..000000000 --- a/example/agent/page_copilot.user.js +++ /dev/null @@ -1,263 +0,0 @@ -// ==UserScript== -// @name Page Copilot -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description AI 网页助手 — 右键菜单唤起,帮你看/写/做任何事 -// @match *://*/* -// @grant GM_registerMenuCommand -// @grant GM_setClipboard -// @grant CAT.agent.conversation -// @grant CAT.agent.dom -// ==/UserScript== - -"use strict"; - -const SYSTEM_PROMPT = `你是一个网页智能助手(Page Copilot)。用户会在浏览网页时向你提出各种需求,你需要根据需求类型灵活应对: - -## 能力 -- **帮我看**:阅读、摘要、翻译、解释、对比页面内容 -- **帮我写**:根据页面上下文写回复、评论、邮件、文案 -- **帮我做**:自动化操作浏览器(填表、点击、搜索、批量操作等) - -## 上下文 -用户可能会提供选中的文本作为上下文,优先基于选中内容回答。 -如果需要了解页面全貌,使用 browser_action 工具分析页面。 -如果需要操作页面,遵循 analyze → act → analyze 循环。 - -## 原则 -- 先理解需求,再决定是否需要调用工具 -- 纯文本问答(摘要、翻译、解释)可以直接回复,不必调用工具 -- 需要操作页面时才使用浏览器工具 -- 回复使用中文,简洁明了`; - -// ── UI ────────────────────────────────────────── - -function createDialog() { - const overlay = document.createElement("div"); - overlay.id = "page-copilot-overlay"; - overlay.innerHTML = ` - -
-
- Page Copilot - -
-
-
- - -
-
- `; - document.body.appendChild(overlay); - - const dialog = overlay.querySelector("#page-copilot-dialog"); - const body = dialog.querySelector(".pc-body"); - const input = dialog.querySelector(".pc-input"); - const sendBtn = dialog.querySelector(".pc-send"); - const closeBtn = dialog.querySelector(".pc-close"); - - closeBtn.addEventListener("click", close); - - // 自动调整输入框高度 - input.addEventListener("input", () => { - input.style.height = "auto"; - input.style.height = Math.min(input.scrollHeight, 120) + "px"; - }); - - function close() { - overlay.remove(); - } - - function addMessage(text, type = "ai") { - const div = document.createElement("div"); - div.className = `pc-msg pc-msg-${type}`; - div.textContent = text; - body.appendChild(div); - body.scrollTop = body.scrollHeight; - return div; - } - - function setLoading(loading) { - sendBtn.disabled = loading; - input.disabled = loading; - sendBtn.textContent = loading ? "思考中..." : "发送"; - } - - return { overlay, body, input, sendBtn, close, addMessage, setLoading }; -} - -// ── 核心逻辑 ────────────────────────────────────── - -let conversation = null; - -async function ensureConversation() { - if (!conversation) { - conversation = await CAT.agent.conversation.create({ - system: SYSTEM_PROMPT, - skills: ["browser-automation"], - tools: [ - { - name: "get_selection", - description: "获取用户在页面上选中的文本", - parameters: { type: "object", properties: {} }, - handler: async () => { - const text = window.getSelection()?.toString()?.trim(); - return text || "当前没有选中任何文本"; - }, - }, - { - name: "copy_to_clipboard", - description: "将文本复制到用户的剪贴板", - parameters: { - type: "object", - properties: { text: { type: "string", description: "要复制的文本" } }, - required: ["text"], - }, - handler: async (a) => { - GM_setClipboard(a.text); - return "已复制到剪贴板"; - }, - }, - ], - }); - } - return conversation; -} - -async function handleSend(ui) { - const text = ui.input.value.trim(); - if (!text) return; - - ui.input.value = ""; - ui.input.style.height = "auto"; - ui.addMessage(text, "user"); - ui.setLoading(true); - - try { - const conv = await ensureConversation(); - // 获取选区作为上下文 - const selection = window.getSelection()?.toString()?.trim(); - let message = text; - if (selection) { - message = `[用户选中的文本]\n${selection}\n\n[用户需求]\n${message}`; - } - - const msgDiv = ui.addMessage("", "ai"); - let content = ""; - - // 流式输出 - const stream = await conv.chatStream(message); - for await (const chunk of stream) { - if (chunk.type === "content_delta") { - content += chunk.content; - msgDiv.textContent = content; - ui.body.scrollTop = ui.body.scrollHeight; - } else if (chunk.type === "tool_call") { - ui.addMessage(`🔧 ${chunk.toolCall.name}`, "tool"); - } else if (chunk.type === "error") { - msgDiv.textContent = content || `错误: ${chunk.error}`; - } - } - - if (!content) { - msgDiv.textContent = "(无回复)"; - } - } catch (e) { - ui.addMessage(`出错了: ${e.message || e}`, "ai"); - } finally { - ui.setLoading(false); - ui.input.focus(); - } -} - -// ── 入口 ────────────────────────────────────────── - -function openCopilot() { - // 防止重复打开 - if (document.getElementById("page-copilot-overlay")) return; - - const ui = createDialog(); - ui.input.focus(); - - // 如果有选中文本,显示上下文提示 - const selection = window.getSelection()?.toString()?.trim(); - if (selection) { - const ctx = document.createElement("div"); - ctx.className = "pc-context"; - ctx.textContent = `📋 已选中: ${selection.slice(0, 200)}${selection.length > 200 ? "..." : ""}`; - ui.body.appendChild(ctx); - } - - // 发送 - ui.sendBtn.addEventListener("click", () => handleSend(ui)); - ui.input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(ui); - } - }); -} - -// 注册右键菜单 -GM_registerMenuCommand("Page Copilot - AI 助手", openCopilot); diff --git a/example/agent/skills/browser_automation/SKILL.md b/example/agent/skills/browser_automation/SKILL.md deleted file mode 100644 index f77a07c26..000000000 --- a/example/agent/skills/browser_automation/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: browser-automation -description: Browser automation — analyze pages with a sub-agent, then perform DOM operations (click, fill, navigate, screenshot, scroll) ---- - -# Browser Automation Skill - -You now have tools to control the browser. They are split into **primitive tools** (direct single operations) and **compound tools** (multi-step operations with sub-agent analysis). - -## Tools - -### Primitive Tools - -| Tool | What it does | -|------|-------------| -| `list_tabs` | List all open tabs → find the `tabId` you need | -| `navigate` | Go to a URL | -| `screenshot` | Capture a screenshot (returned as image attachment) | -| `scroll` | Scroll up/down/top/bottom, returns position + atTop/atBottom flags | -| `wait_for` | Wait for a CSS selector to appear in the DOM | - -### Compound Tools - -| Tool | What it does | -|------|-------------| -| `browser_action` | Sub-agent reads the page and returns selectors / extracted data / suggestions — **read-only, no side effects** | -| `smart_fill` | Fill a form field with CDP trusted input + auto-verify the value | -| `click_and_wait` | CDP trusted click + wait for page changes (navigation, new tabs, DOM mutations) — sub-agent summarizes what changed | - -## Workflow - -Follow the **analyze → act → analyze → act** loop: - -1. `list_tabs` → pick the target `tabId` -2. `browser_action` → understand the page, get CSS selectors -3. Act on the selectors: - - **Form fields** → `smart_fill` (triggers proper framework events) - - **Clicks** → `click_and_wait` (captures navigation, popups, async UI) - - **Wait for loading** → `wait_for` - - **Load more content** → `scroll` -4. `browser_action` → verify the result or analyze the next step -5. Repeat until done - -## Examples - -**Search task**: -``` -→ list_tabs() -← tabId=123 [active] | Google | https://www.google.com - -→ browser_action("find the search input and search button selectors", tabId=123) -← "Search input: `textarea[name=q]`, Search button: `input[name=btnK]`" - -→ smart_fill("textarea[name=q]", "ScriptCat", tabId=123) -← { success: true, value: "ScriptCat" } - -→ click_and_wait("input[name=btnK]", tabId=123) -← { clicked: true, navigated: true, url: "https://www.google.com/search?q=ScriptCat" } - -→ browser_action("extract the top 5 search result titles and links", tabId=123) -← "1. ScriptCat - https://scriptcat.org/ ..." -``` - -**Navigate + screenshot**: -``` -→ navigate("https://example.com", tabId=123) -← { success: true, url: "https://example.com", tabId: 123 } - -→ screenshot(tabId=123) -← [image attachment] -``` - -**Scroll to load more**: -``` -→ scroll("down", tabId=123) -← { success: true, atBottom: false, scrollTop: 800 } - -→ wait_for(".lazy-loaded-item", tabId=123, timeout=5000) -← { found: true, tagName: "DIV", text: "..." } - -→ scroll("down", tabId=123) -← { success: true, atBottom: true } -``` - -**Click that triggers a popup (sub-agent analyzes DOM changes)**: -``` -→ click_and_wait(".delete-btn", tabId=123) -← { clicked: true, navigated: false, - pageChanges: "Confirmation dialog appeared: 'Are you sure?'. Click `.modal .btn-ok` to confirm." } -``` - -**Data extraction**: -``` -→ browser_action("extract the product list with names and prices", tabId=123) -← "1. Product A ¥99 2. Product B ¥199 ..." -``` - -**New tab opened by click**: -``` -→ click_and_wait("a.detail-link", tabId=123, timeout=5000) -← { clicked: true, navigated: false, newTabs: [{ tabId: 456, url: "https://..." }] } - -→ browser_action("read the page content", tabId=456) -``` - -## Tips for `browser_action` scenario - -The `scenario` parameter should be **specific and goal-oriented**: - -- Good: "find the login form's username input, password input, and submit button selectors" -- Good: "extract the first 5 search results with title, URL, and snippet" -- Good: "check if the user is logged in; if yes, find the search box selector" -- Bad: "analyze this page" — too vague, the sub-agent won't know what to look for - -After an action, describe what you expect: -- "verify the form was submitted successfully and identify the next page" -- "check if the item was added to cart — look for a success toast or badge update" - -## Important Notes - -- **Popup blocking**: Some clicks open new windows/tabs (`window.open`, `target="_blank"`). If the expected new tab doesn't appear, tell the user to go to the site's address bar → Site settings → allow "Pop-ups and redirects", then retry. -- `browser_action` is **read-only** — it never clicks, fills, or modifies the page. -- Each `browser_action` call is **stateless** — it does not remember previous analyses. -- `click_and_wait` auto-detects DOM changes and JS dialogs; its `pageChanges` summary often makes a follow-up `browser_action` unnecessary. -- If `browser_action` reports "element not found", check its suggestions — it may say you need to click something first to reveal the element. diff --git a/example/agent/skills/browser_automation/scripts/browser_action.js b/example/agent/skills/browser_automation/scripts/browser_action.js deleted file mode 100644 index e35d2cec3..000000000 --- a/example/agent/skills/browser_automation/scripts/browser_action.js +++ /dev/null @@ -1,273 +0,0 @@ -// ==CATTool== -// @name browser_action -// @description Analyze page content using a sub-agent — returns CSS selectors, extracted data, or action suggestions. Does NOT perform any clicks or form fills. Pass a specific scenario describing what to find or extract (e.g. "find the search input and submit button selectors", "extract the first 5 search results with titles and links"). -// @param scenario string [required] What to analyze or extract from the page — be specific -// @param tabId number Target tab ID (defaults to the active tab) -// @grant CAT.agent.conversation -// @grant CAT.agent.dom -// ==/CATTool== - -const SYSTEM_PROMPT = `你是一个页面分析专家。你的任务是读取和分析页面内容,返回精简的结果给调用者。 - -## 你的职责 - -你只负责**分析页面**,不执行点击、填写等操作。具体包括: -- 定位元素并返回可用的 CSS 选择器 -- 提取页面上的数据(文本、链接、列表等) -- 分析页面结构和表单字段 -- 检查页面状态(是否登录、是否有弹窗等) - -## 可用工具 - -- **read_page**: 读取页面骨架(精简 HTML:只保留定位属性和文本摘要,移除 src/href/style 等) - - selector: CSS 选择器缩小范围(推荐使用) - - maxLength: 最大返回长度 -- **execute_script**: 在页面中执行 JS 代码,适合精准提取信息 - - code: JavaScript 代码 - - **重要**: 代码通过 \`new Function(code)\` 执行,必须用 \`return\` 返回值。 - - 正确: \`return document.title\` - - 正确: \`const el = document.querySelector('#id'); return el ? el.textContent : null\` - - 错误: \`(() => { return document.title })()\` — IIFE 的 return 不是外层函数的 return,结果为 null - -## 分析流程 - -初始消息中已包含页面骨架 HTML,你**不需要调用 read_page** 就已经了解页面结构。 - -1. **直接分析骨架**:根据已有的骨架 HTML 判断页面结构、定位元素 -2. **如需精确数据**:用 **execute_script** 一次性提取所有需要的信息(合并到一个脚本) -3. **如果返回 null**:简化代码重试一次(大概率是语法错误) -4. **如需局部细节**:仅在骨架被截断或需要特定区域更多细节时,才用 **read_page** 传 selector 缩小范围读取 - -**最多调用 3 次工具,然后立即回复结果**。即使信息不完整,也要基于已有结果回复,说明哪些信息未能获取。如果骨架已经足够回答问题,**直接回复,不要调用任何工具**。 - -## execute_script 编写规范 - -**一次提取所有需要的信息**,返回一个结果对象: - -\`\`\`js -// 好:一次性提取商品页多个信息 -const title = document.querySelector('h1')?.textContent?.trim(); -const price = document.querySelector('.price')?.textContent?.trim(); -const btn = document.querySelector('.add-cart, [class*="cart"]'); -return { - title, - price, - cartBtn: btn ? { id: btn.id, class: btn.className, tag: btn.tagName } : null -}; -\`\`\` - -- 将所有需要查询的信息合并到一个脚本,返回一个对象 -- 不要写 cssPath 等辅助函数,直接返回元素的 id、className、文本等属性 -- 如果返回 null 且不符合预期,简化代码重试(大概率是语法错误) -- 使用 \`?.\` 可选链避免空指针,缺失的字段返回 null 即可 - -## 选择器构造 - -从 execute_script 返回的元素属性中,按以下优先级构造选择器: -1. \`#elementId\` — 有 id 直接用 -2. \`[name="fieldName"]\` — 表单元素优先用 name -3. \`[data-xxx="value"]\` — data 属性通常稳定 -4. \`[aria-label="text"]\` — 无障碍属性 -5. \`.className1.className2\` — 注意排除动态生成的 hash class(如 \`PurchasePanel--JEB_OhIE\`) -6. 文本匹配描述 — 如果以上都不可用,描述元素特征让调用者决定 - -**重要**:read_page 返回的是精简骨架,无属性的包装层(div/span 等)会被折叠。因此: -- 不要根据 read_page 的 HTML 层级关系构造 \`>\` 直接子选择器(如 \`.parent > button\`) -- 优先使用不依赖层级的选择器(id、name、class、属性) -- 如果必须用层级关系,用后代选择器 \`.parent button\` 而不是 \`.parent > button\` - -## 回复格式 - -针对不同任务返回不同格式: - -**定位元素时**:返回选择器 + 元素描述 -> 搜索框: \`#kw\`(input,placeholder="搜索") -> 搜索按钮: \`#su\`(button,文本"百度一下") - -**提取数据时**:返回结构化数据 -> 1. ScriptCat 官网 - https://scriptcat.org/ -> 2. GitHub - https://github.com/nicecai/scriptcat - -**分析状态时**:返回判断结论 + 依据 -> 当前已登录,用户昵称"幻想太美好8"(位于 \`.site-nav-login-info-nick\`) - -**找不到元素时**:说明原因 + 建议 -> 未找到"加入购物车"按钮,可能原因:该按钮在 SKU 选择面板中,需要先点击"购买规格"(\`.specTrigger\`)展开面板后才会出现。`; - -// 确定 tabId -let targetTabId = args.tabId; -if (!targetTabId) { - try { - const tabs = await CAT.agent.dom.listTabs(); - const activeTab = tabs.find((t) => t.active); - if (activeTab) { - targetTabId = activeTab.tabId; - } else if (tabs.length > 0) { - targetTabId = tabs[0].tabId; - } else { - return "错误:没有找到任何打开的标签页"; - } - } catch (e) { - return `错误:获取标签页列表失败: ${e.message || e}`; - } -} - -// 读取页面骨架的脚本(复用于预读取和 read_page 工具) -function buildSkeletonScript(selector, maxLength) { - return ` - var KEEP_ATTRS = ['id','class','name','type','placeholder','role','aria-label','value','for','action','method','data-testid']; - var SKIP_TAGS = ['SCRIPT','STYLE','NOSCRIPT','SVG','LINK','META','BR','HR','IMG','VIDEO','AUDIO','CANVAS','IFRAME']; - var WRAPPER_TAGS = ['DIV','SPAN','SECTION','ARTICLE','MAIN','HEADER','FOOTER','NAV','ASIDE','FIGURE','FIGCAPTION']; - var TEXT_LIMIT = 100; - var root = document.querySelector('${selector.replace(/'/g, "\\'")}'); - if (!root) return null; - function hasAttrs(el) { - for (var i = 0; i < KEEP_ATTRS.length; i++) { - if (el.getAttribute(KEEP_ATTRS[i])) return true; - } - return false; - } - function walk(node, depth) { - if (depth > 30) return ''; - if (node.nodeType === 3) { - var t = node.textContent.trim(); - if (!t) return ''; - return t.length > TEXT_LIMIT ? t.slice(0, TEXT_LIMIT) + '...' : t; - } - if (node.nodeType !== 1) return ''; - var el = node; - var tag = el.tagName; - if (SKIP_TAGS.indexOf(tag) >= 0) return ''; - var style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return ''; - var children = ''; - for (var j = 0; j < el.childNodes.length; j++) { - children += walk(el.childNodes[j], depth + 1); - } - if (!children.trim() && !hasAttrs(el)) return ''; - if (!hasAttrs(el) && WRAPPER_TAGS.indexOf(tag) >= 0) { - var elementChildren = []; - for (var k = 0; k < el.childNodes.length; k++) { - var c = el.childNodes[k]; - if (c.nodeType === 1) elementChildren.push(c); - else if (c.nodeType === 3 && c.textContent.trim()) elementChildren.push(c); - } - if (elementChildren.length <= 1) return children; - } - var attrs = ''; - for (var i = 0; i < KEEP_ATTRS.length; i++) { - var val = el.getAttribute(KEEP_ATTRS[i]); - if (val) attrs += ' ' + KEEP_ATTRS[i] + '="' + val.replace(/"/g, '"') + '"'; - } - var lower = tag.toLowerCase(); - return '<' + lower + attrs + '>' + children + ''; - } - var html = walk(root, 0); - var title = document.title; - var url = location.href; - return { title: title, url: url, html: html.slice(0, ${maxLength}), truncated: html.length > ${maxLength} }; - `; -} - -async function readPageSkeleton(selector, maxLength) { - try { - const result = await CAT.agent.dom.executeScript( - buildSkeletonScript(selector, maxLength), - { tabId: targetTabId } - ); - return result || { title: "", url: "", html: "元素未找到: " + selector }; - } catch (e) { - return { title: "", url: "", html: `页面读取失败: ${e.message || e}` }; - } -} - -// 预读取页面骨架,嵌入到初始消息中 -let initialSkeleton; -try { - initialSkeleton = await readPageSkeleton("body", 200000); -} catch (e) { - return `错误:读取页面骨架失败: ${e.message || e}`; -} - -// 创建无状态子 conversation,只提供分析类工具 -let conv; -try { - conv = await CAT.agent.conversation.create({ - ephemeral: true, - system: SYSTEM_PROMPT, - maxIterations: 5, - tools: [ - { - name: "read_page", - description: - "读取页面骨架 HTML(只保留元素定位属性和文本摘要)。用于缩小范围重新读取页面局部区域。初始消息中已包含 body 级别的完整骨架,通常不需要再次调用,除非需要读取特定区域的更多细节。", - parameters: { - type: "object", - properties: { - selector: { - type: "string", - description: "CSS 选择器,缩小读取范围", - }, - maxLength: { - type: "number", - description: "最大返回内容长度(默认 200000)", - }, - }, - required: ["selector"], - }, - handler: async (handlerArgs) => { - return await readPageSkeleton( - handlerArgs.selector, - handlerArgs.maxLength || 200000 - ); - }, - }, - { - name: "execute_script", - description: - "在页面中执行 JavaScript 代码。代码通过 new Function(code) 执行,必须用 return 语句返回值(不要用 IIFE)。例如:return document.querySelector('#id').textContent", - parameters: { - type: "object", - properties: { - code: { - type: "string", - description: "要执行的 JS 代码,必须用 return 返回值", - }, - }, - required: ["code"], - }, - handler: async (handlerArgs) => { - try { - return await CAT.agent.dom.executeScript(handlerArgs.code, { - tabId: targetTabId, - }); - } catch (e) { - return `脚本执行失败: ${e.message || e}`; - } - }, - }, - ], - }); -} catch (e) { - return `错误:创建分析会话失败: ${e.message || e}`; -} - -// 将页面骨架和任务描述一起发送,子 agent 无需再调用 read_page -const message = `## 任务 -${args.scenario} - -## 当前页面骨架 -- 标题: ${initialSkeleton.title} -- URL: ${initialSkeleton.url} -${initialSkeleton.truncated ? "- (骨架已截断,可用 read_page 读取局部区域)" : ""} - -\`\`\`html -${initialSkeleton.html} -\`\`\``; - -try { - const reply = await conv.chat(message); - return reply.content; -} catch (e) { - return `错误:页面分析失败: ${e.message || e}`; -} diff --git a/example/agent/skills/browser_automation/scripts/click_and_wait.js b/example/agent/skills/browser_automation/scripts/click_and_wait.js deleted file mode 100644 index d81f5cf8c..000000000 --- a/example/agent/skills/browser_automation/scripts/click_and_wait.js +++ /dev/null @@ -1,175 +0,0 @@ -// ==CATTool== -// @name click_and_wait -// @description Click an element via CDP trusted click, then wait for page changes (navigation, new tabs, DOM mutations, JS dialogs). A sub-agent analyzes any new DOM elements and returns a summary in `pageChanges`. Use this for any click that may trigger navigation, popups, or async UI updates. -// @param selector string [required] CSS selector of the element to click -// @param tabId number [required] Target tab ID -// @param timeout number Wait timeout in ms (default: 5000) -// @grant CAT.agent.dom -// @grant CAT.agent.conversation -// ==/CATTool== - -const timeout = args.timeout || 5000; -const interval = 500; - -// 快照当前标签页状态 -let initialTabs; -try { - initialTabs = await CAT.agent.dom.listTabs(); -} catch (e) { - return { clicked: false, error: `获取标签页列表失败: ${e.message || e}` }; -} - -const initialTabIds = new Set(initialTabs.map((t) => t.tabId)); -const targetTab = initialTabs.find((t) => t.tabId === args.tabId); -if (!targetTab) { - return { clicked: false, error: `标签页不存在: tabId=${args.tabId}` }; -} -const originalUrl = targetTab.url || ""; - -// 启动 CDP 监控(dialog 自动处理 + DOM 变化捕获) -try { - await CAT.agent.dom.startMonitor(args.tabId); -} catch (e) { - // monitor 启动失败不阻断点击操作,降级处理 -} - -// 检测导航或新标签页 -async function detectChanges() { - try { - const currentTabs = await CAT.agent.dom.listTabs(); - const tab = currentTabs.find((t) => t.tabId === args.tabId); - const currentUrl = tab ? tab.url : ""; - const navigated = currentUrl !== originalUrl; - const newTabs = currentTabs - .filter((t) => !initialTabIds.has(t.tabId)) - .map((t) => ({ tabId: t.tabId, url: t.url, title: t.title })); - return { navigated, currentUrl, newTabs }; - } catch (e) { - return { navigated: false, currentUrl: originalUrl, newTabs: [] }; - } -} - -// trusted 点击 -let clickResult; -try { - clickResult = await CAT.agent.dom.click(args.selector, { - tabId: args.tabId, - trusted: true, - }); -} catch (e) { - // 停止 monitor(忽略错误) - try { await CAT.agent.dom.stopMonitor(args.tabId); } catch (_) {} - const msg = e.message || String(e); - if (/not found|no such|no element/i.test(msg)) { - return { clicked: false, error: `元素未找到: ${args.selector}` }; - } - return { clicked: false, error: `点击失败: ${msg}` }; -} - -// click 本身已检测到导航或新标签 -if (clickResult.navigated || clickResult.newTab) { - let monitorResult = {}; - try { monitorResult = await CAT.agent.dom.stopMonitor(args.tabId); } catch (_) {} - const base = { - clicked: true, - navigated: clickResult.navigated || false, - url: clickResult.url || originalUrl, - newTabs: clickResult.newTab ? [clickResult.newTab] : [], - }; - if (monitorResult.dialogs && monitorResult.dialogs.length > 0) { - base.dialogs = monitorResult.dialogs; - } - return base; -} - -// 轮询等待变化 -const startTime = Date.now(); -let pollResult = null; -while (Date.now() - startTime < timeout) { - await new Promise((resolve) => setTimeout(resolve, interval)); - const { navigated, currentUrl, newTabs } = await detectChanges(); - if (navigated || newTabs.length > 0) { - pollResult = { clicked: true, navigated, url: currentUrl, newTabs }; - break; - } - // 检测 DOM 变化(弹框、新增元素等) - try { - const monitorStatus = await CAT.agent.dom.peekMonitor(args.tabId); - if (monitorStatus.hasChanges) { - pollResult = { clicked: true, navigated: false, url: currentUrl, newTabs: [], domChanged: true }; - break; - } - } catch (_) { - // peekMonitor 失败,继续轮询 - } -} - -// 停止监控,收集结果 -let monitorResult = {}; -try { monitorResult = await CAT.agent.dom.stopMonitor(args.tabId); } catch (_) {} - -if (!pollResult) { - // 超时 — 构造超时结果 - const { currentUrl, newTabs } = await detectChanges(); - pollResult = { clicked: true, navigated: false, url: currentUrl, newTabs, timedOut: true }; -} - -if (monitorResult.dialogs && monitorResult.dialogs.length > 0) { - pollResult.dialogs = monitorResult.dialogs; -} - -// 有 DOM 变化时用子 agent 分析 -const { dialogs, addedNodes } = monitorResult; -const parts = []; - -if (dialogs && dialogs.length > 0) { - parts.push( - "JS 弹框:\n" + dialogs.map((d) => `- ${d.type}: ${d.message}`).join("\n") - ); -} - -if (addedNodes && addedNodes.length > 0) { - const seen = new Set(); - const unique = addedNodes - .filter((n) => { - if (seen.has(n.text)) return false; - seen.add(n.text); - return true; - }) - .slice(0, 10); - parts.push( - "新增 DOM 元素:\n" + - unique - .map((n) => { - const attrs = [n.tag]; - if (n.id) attrs.push(`id="${n.id}"`); - if (n.class) attrs.push(`class="${n.class}"`); - if (n.role) attrs.push(`role="${n.role}"`); - return `- <${attrs.join(" ")}> ${n.text}`; - }) - .join("\n") - ); -} - -if (parts.length > 0) { - try { - const conv = await CAT.agent.conversation.create({ - ephemeral: true, - system: `你是一个页面变化分析专家。点击操作后页面出现了新的元素或弹框,请分析这些变化并用一句话总结: -- 这是什么类型的变化?(成功提示、错误提示、确认弹框、模态框、下拉菜单、加载状态等) -- 操作是否成功? -- 是否需要进一步操作?(如关闭弹框、确认、选择选项等)如果需要,给出元素的选择器。 -直接回复分析结果,不要调用任何工具。`, - maxIterations: 1, - tools: [], - }); - - const reply = await conv.chat(parts.join("\n\n")); - pollResult.pageChanges = reply.content; - } catch (e) { - // 子 agent 分析失败,回退到原始 DOM 变化数据 - pollResult.rawChanges = parts.join("\n\n"); - } -} - -return pollResult; diff --git a/example/agent/skills/browser_automation/scripts/list_tabs.js b/example/agent/skills/browser_automation/scripts/list_tabs.js deleted file mode 100644 index 5537d7244..000000000 --- a/example/agent/skills/browser_automation/scripts/list_tabs.js +++ /dev/null @@ -1,19 +0,0 @@ -// ==CATTool== -// @name list_tabs -// @description List all open browser tabs with tabId, URL, title, and active status. Use this first to find the target tabId for other tools. -// @grant CAT.agent.dom -// ==/CATTool== - -try { - const tabs = await CAT.agent.dom.listTabs(); - if (!tabs || tabs.length === 0) { - return "当前没有打开的标签页"; - } - const lines = tabs.map((t) => { - const active = t.active ? " [active]" : ""; - return `- tabId=${t.tabId}${active} | ${t.title || "(无标题)"} | ${t.url}`; - }); - return lines.join("\n"); -} catch (e) { - return `列出标签页失败: ${e.message || e}`; -} diff --git a/example/agent/skills/browser_automation/scripts/navigate.js b/example/agent/skills/browser_automation/scripts/navigate.js deleted file mode 100644 index 22d4ef588..000000000 --- a/example/agent/skills/browser_automation/scripts/navigate.js +++ /dev/null @@ -1,36 +0,0 @@ -// ==CATTool== -// @name navigate -// @description Navigate a tab to the specified URL. Returns the final URL and tabId after navigation. -// @param url string [required] Target URL (must start with http:// or https://) -// @param tabId number Target tab ID (defaults to the active tab) -// @param waitUntil boolean Whether to wait for page load to complete (default: true) -// @grant CAT.agent.dom -// ==/CATTool== - -try { - // URL 格式校验 - const url = args.url; - if (!url || typeof url !== "string") { - return { success: false, error: "缺少必要参数: url" }; - } - // 简单校验 URL 格式 - if (!/^https?:\/\//i.test(url) && !/^file:\/\//i.test(url)) { - return { success: false, error: `URL 格式不正确(需要以 http:// 或 https:// 开头): ${url}` }; - } - - const options = {}; - if (args.tabId != null) options.tabId = args.tabId; - if (args.waitUntil != null) options.waitUntil = args.waitUntil; - - const result = await CAT.agent.dom.navigate(url, options); - return { success: true, url: result.url || url, tabId: result.tabId }; -} catch (e) { - const msg = e.message || String(e); - if (/chrome:\/\/|edge:\/\/|about:/i.test(msg)) { - return { success: false, error: "无法导航到浏览器内部页面(chrome://、edge:// 等)" }; - } - if (/timeout/i.test(msg)) { - return { success: false, error: `导航超时: ${args.url}` }; - } - return { success: false, error: `导航失败: ${msg}` }; -} diff --git a/example/agent/skills/browser_automation/scripts/screenshot.js b/example/agent/skills/browser_automation/scripts/screenshot.js deleted file mode 100644 index 1df1141ba..000000000 --- a/example/agent/skills/browser_automation/scripts/screenshot.js +++ /dev/null @@ -1,39 +0,0 @@ -// ==CATTool== -// @name screenshot -// @description Capture a screenshot of the target tab. Returns an image attachment that vision models can view directly. -// @param tabId number Target tab ID (defaults to the active tab) -// @param quality number Image quality 1-100 (default: 80) -// @grant CAT.agent.dom -// ==/CATTool== - -try { - const options = {}; - if (args.tabId != null) options.tabId = args.tabId; - if (args.quality != null) options.quality = args.quality; - - const dataUrl = await CAT.agent.dom.screenshot(options); - - if (!dataUrl) { - return { content: "截图失败:未获取到图片数据", attachments: [] }; - } - - return { - content: "截图已拍摄", - attachments: [ - { - type: "image", - mediaType: "image/jpeg", - data: dataUrl, - }, - ], - }; -} catch (e) { - const msg = e.message || String(e); - if (/tab/i.test(msg) && /not found|no such/i.test(msg)) { - return { content: `截图失败:标签页不存在(tabId=${args.tabId})`, attachments: [] }; - } - if (/chrome:\/\/|edge:\/\/|about:/i.test(msg)) { - return { content: "截图失败:无法对浏览器内部页面截图", attachments: [] }; - } - return { content: `截图失败: ${msg}`, attachments: [] }; -} diff --git a/example/agent/skills/browser_automation/scripts/scroll.js b/example/agent/skills/browser_automation/scripts/scroll.js deleted file mode 100644 index 1f68ac4d3..000000000 --- a/example/agent/skills/browser_automation/scripts/scroll.js +++ /dev/null @@ -1,37 +0,0 @@ -// ==CATTool== -// @name scroll -// @description Scroll the page or a specific container. Returns scroll position and whether top/bottom has been reached (atTop, atBottom). -// @param direction string [required] Scroll direction: up, down, top, bottom -// @param tabId number Target tab ID (defaults to the active tab) -// @param selector string CSS selector of a scrollable container (defaults to the whole page) -// @grant CAT.agent.dom -// ==/CATTool== - -try { - const direction = args.direction; - if (!direction || !["up", "down", "top", "bottom"].includes(direction)) { - return { success: false, error: `无效的滚动方向: ${direction},可选值: up, down, top, bottom` }; - } - - const options = {}; - if (args.tabId != null) options.tabId = args.tabId; - if (args.selector) options.selector = args.selector; - - const result = await CAT.agent.dom.scroll(direction, options); - - return { - success: true, - direction, - scrollTop: result.scrollTop, - scrollHeight: result.scrollHeight, - clientHeight: result.clientHeight, - atTop: result.scrollTop <= 0, - atBottom: result.scrollTop + result.clientHeight >= result.scrollHeight - 5, - }; -} catch (e) { - const msg = e.message || String(e); - if (args.selector && /not found|no such/i.test(msg)) { - return { success: false, error: `滚动失败:元素未找到 (${args.selector})` }; - } - return { success: false, error: `滚动失败: ${msg}` }; -} diff --git a/example/agent/skills/browser_automation/scripts/smart_fill.js b/example/agent/skills/browser_automation/scripts/smart_fill.js deleted file mode 100644 index 3dc4de968..000000000 --- a/example/agent/skills/browser_automation/scripts/smart_fill.js +++ /dev/null @@ -1,56 +0,0 @@ -// ==CATTool== -// @name smart_fill -// @description Fill a form field using CDP trusted input and verify the value afterwards. Use this instead of execute_script for form filling — it triggers proper input/change events that frameworks (React, Vue) can detect. -// @param selector string [required] CSS selector of the form element -// @param value string [required] The value to fill in -// @param tabId number [required] Target tab ID -// @param checkDelay number Delay before verification in ms (default: 500) -// @grant CAT.agent.dom -// ==/CATTool== - -const checkDelay = args.checkDelay || 500; -const escapedSelector = args.selector.replace(/'/g, "\\'"); - -try { - // 检查元素是否存在 - const beforeValue = await CAT.agent.dom.executeScript( - `const el = document.querySelector('${escapedSelector}'); - if (!el) return { exists: false }; - return { exists: true, value: el.value ?? el.textContent ?? '', tagName: el.tagName, type: el.type || '' };`, - { tabId: args.tabId } - ); - - if (!beforeValue || !beforeValue.exists) { - return { success: false, error: `元素未找到: ${args.selector}` }; - } - - // 直接使用 trusted 模式填充 - await CAT.agent.dom.fill(args.selector, args.value, { - tabId: args.tabId, - trusted: true, - }); - - // 等待后验证 - await new Promise((resolve) => setTimeout(resolve, checkDelay)); - - const currentValue = await CAT.agent.dom.executeScript( - `const el = document.querySelector('${escapedSelector}'); - return el ? (el.value ?? el.textContent ?? '') : null;`, - { tabId: args.tabId } - ); - - if (currentValue === args.value) { - return { success: true, value: args.value }; - } - - // 验证失败,返回详细信息 - return { - success: false, - error: "填充后验证失败:值不匹配", - expectedValue: args.value, - actualValue: currentValue, - element: { tagName: beforeValue.tagName, type: beforeValue.type }, - }; -} catch (e) { - return { success: false, error: `填充失败: ${e.message || e}` }; -} diff --git a/example/agent/skills/browser_automation/scripts/wait_for.js b/example/agent/skills/browser_automation/scripts/wait_for.js deleted file mode 100644 index 604d78608..000000000 --- a/example/agent/skills/browser_automation/scripts/wait_for.js +++ /dev/null @@ -1,38 +0,0 @@ -// ==CATTool== -// @name wait_for -// @description Wait for an element matching the CSS selector to appear in the DOM. Returns element info (tagName, text, id, className) on success, or an error on timeout. -// @param selector string [required] CSS selector of the element to wait for -// @param tabId number Target tab ID (defaults to the active tab) -// @param timeout number Timeout in milliseconds (default: 10000) -// @grant CAT.agent.dom -// ==/CATTool== - -try { - const selector = args.selector; - if (!selector || typeof selector !== "string") { - return { found: false, error: "缺少必要参数: selector" }; - } - - const options = {}; - if (args.tabId != null) options.tabId = args.tabId; - if (args.timeout != null) options.timeout = args.timeout; - - const result = await CAT.agent.dom.waitFor(selector, options); - - if (result && result.found) { - return { - found: true, - tagName: result.tagName, - text: result.text, - id: result.id, - className: result.className, - }; - } - return { found: false, error: `元素未在超时时间内出现: ${selector}` }; -} catch (e) { - const msg = e.message || String(e); - if (/timeout/i.test(msg)) { - return { found: false, error: `等待超时(${args.timeout || 10000}ms): ${selector}` }; - } - return { found: false, error: `等待失败: ${msg}` }; -} diff --git a/example/agent/tools/README.md b/example/agent/tools/README.md deleted file mode 100644 index 7ee797b78..000000000 --- a/example/agent/tools/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# CATTool 示例 - -CATTool 是 ScriptCat Agent 的工具扩展机制。安装后,CATTool 会自动注册为 Agent 内置工具,LLM 在对话中可以自动调用。 - -## 文件说明 - -| 文件 | 说明 | -| ------------------- | ------------------------------------------------------------------ | -| `hello_world.js` | 最简示例 — 无 grant,单参数 | -| `text_processor.js` | 纯计算工具 — 多参数、enum 类型、switch 分支 | -| `json_formatter.js` | JSON 处理 — 路径查询、错误处理 | -| `weather_query.js` | 网络请求 — 使用 `GM_xmlhttpRequest` 调用外部 API | -| `use_cattool.js` | 调用方脚本 — 演示如何通过 `CAT.agent.tools` API 安装和使用 CATTool | - -## CATTool 元数据格式 - -```javascript -// ==CATTool== -// @name tool_name (必填)工具名称,LLM 通过此名称调用 -// @description 工具描述 (推荐)告诉 LLM 这个工具做什么 -// @param paramName type [required] 参数描述 -// @grant GM_xmlhttpRequest 需要的 GM API 权限 -// ==/CATTool== -``` - -### @param 语法 - -``` -@param 参数名 类型 [required] 描述 -``` - -- **类型**: `string` / `number` / `boolean` -- **enum**: `string[val1,val2,val3]` — 限定可选值 -- **[required]**: 标记为必填参数(可选) - -### 运行时变量 - -- `args` — 包含 LLM 传入的所有参数,按 `@param` 定义的类型自动转换 -- 通过 `return` 返回结果(对象会被 JSON 序列化后返回给 LLM) - -## 安装方式 - -### 方式 1: 通过安装页面 - -直接在浏览器中打开 `.js` 文件的 URL(本地或远程),ScriptCat 会识别 `==CATTool==` 头并显示安装界面。 - -### 方式 2: 通过脚本 API - -在 UserScript 中使用 `CAT.agent.tools` API: - -```javascript -// @grant CAT.agent.tools -const code = `...CATTool 代码...`; -await CAT.agent.tools.install(code); -``` - -参考 `use_cattool.js` 获取完整示例。 - -## 测试方法 - -1. **安装 CATTool**: 通过安装页面或 `CAT.agent.tools.install()` 安装 -2. **在 Agent 聊天中测试**: 打开 ScriptCat 设置页的 Agent Chat,直接对话即可触发工具调用 - - 例如安装 `hello_world` 后,对 Agent 说"向张三打招呼" - - 例如安装 `weather_query` 后,对 Agent 说"北京今天天气怎么样" -3. **通过脚本调用**: 使用 `CAT.agent.tools.call(name, params)` 直接调用,参考 `use_cattool.js` -4. **查看已安装工具**: `CAT.agent.tools.list()` 返回所有已安装的 CATTool diff --git a/example/agent/tools/hello_world.js b/example/agent/tools/hello_world.js deleted file mode 100644 index 8e312a989..000000000 --- a/example/agent/tools/hello_world.js +++ /dev/null @@ -1,8 +0,0 @@ -// ==CATTool== -// @name hello_world -// @description 一个最简单的 CATTool 示例,向指定的人打招呼 -// @param name string [required] 要打招呼的人名 -// ==/CATTool== - -// args 由运行时自动注入,包含 LLM 传入的参数 -return `你好,${args.name}!欢迎使用 ScriptCat CATTool。`; diff --git a/example/agent/tools/json_formatter.js b/example/agent/tools/json_formatter.js deleted file mode 100644 index e881a0a61..000000000 --- a/example/agent/tools/json_formatter.js +++ /dev/null @@ -1,42 +0,0 @@ -// ==CATTool== -// @name json_formatter -// @description 格式化、验证和查询 JSON 数据,支持 JSONPath 风格的简单路径查询 -// @param json string [required] JSON 字符串 -// @param path string 可选的查询路径,如 data.items.0.name -// @param indent number 缩进空格数,默认 2 -// ==/CATTool== - -const indent = args.indent || 2; - -let parsed; -try { - parsed = JSON.parse(args.json); -} catch (e) { - return { error: "JSON 解析失败: " + e.message, valid: false }; -} - -// 如果指定了路径,按路径查询 -if (args.path) { - const keys = args.path.split("."); - let current = parsed; - for (const key of keys) { - if (current === undefined || current === null) { - return { error: `路径 "${args.path}" 无效:在 "${key}" 处值为 ${current}` }; - } - current = current[key]; - } - return { - valid: true, - path: args.path, - value: current, - type: Array.isArray(current) ? "array" : typeof current, - }; -} - -// 否则返回格式化结果 -return { - valid: true, - formatted: JSON.stringify(parsed, null, indent), - type: Array.isArray(parsed) ? "array" : typeof parsed, - keys: typeof parsed === "object" && parsed !== null ? Object.keys(parsed) : [], -}; diff --git a/example/agent/tools/text_processor.js b/example/agent/tools/text_processor.js deleted file mode 100644 index dcf4dd913..000000000 --- a/example/agent/tools/text_processor.js +++ /dev/null @@ -1,44 +0,0 @@ -// ==CATTool== -// @name text_processor -// @description 文本处理工具,支持字数统计、提取关键词、文本摘要等操作 -// @param text string [required] 要处理的文本内容 -// @param action string[count,keywords,reverse] 处理动作 -// @param maxLength number 最大输出长度限制 -// ==/CATTool== - -const text = args.text; -const action = args.action || "count"; -const maxLength = args.maxLength || 500; - -switch (action) { - case "count": { - // 字数统计 - const chars = text.length; - const words = text.split(/\s+/).filter(Boolean).length; - const lines = text.split("\n").length; - return { chars, words, lines }; - } - case "keywords": { - // 简单关键词提取:按词频排序 - const words = text - .toLowerCase() - .replace(/[^\w\u4e00-\u9fff\s]/g, "") - .split(/\s+/) - .filter((w) => w.length > 1); - const freq = {}; - for (const w of words) { - freq[w] = (freq[w] || 0) + 1; - } - const sorted = Object.entries(freq) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10); - return { keywords: sorted.map(([word, count]) => ({ word, count })) }; - } - case "reverse": { - // 文本反转 - const result = text.split("").reverse().join(""); - return { result: result.slice(0, maxLength) }; - } - default: - return { error: `未知的操作: ${action},支持: count, keywords, reverse` }; -} diff --git a/example/agent/tools/use_cattool.js b/example/agent/tools/use_cattool.js deleted file mode 100644 index c52eacc9d..000000000 --- a/example/agent/tools/use_cattool.js +++ /dev/null @@ -1,111 +0,0 @@ -// ==UserScript== -// @name CATTool 安装与使用示例 -// @namespace https://scriptcat.org/ -// @version 0.1.0 -// @description 演示如何通过 CAT.agent.tools API 安装、管理和调用 CATTool -// @author ScriptCat -// @background -// @grant CAT.agent.tools -// @grant CAT.agent.conversation -// @grant GM_log -// ==/UserScript== - -// CATTool 代码可以内联定义,也可以从远程 URL 获取 -const helloToolCode = ` -// ==CATTool== -// @name hello_world -// @description 向指定的人打招呼 -// @param name string [required] 要打招呼的人名 -// ==/CATTool== - -return "你好," + args.name + "!欢迎使用 ScriptCat CATTool。"; -`; - -const textProcessorCode = ` -// ==CATTool== -// @name text_processor -// @description 文本处理工具,支持字数统计、关键词提取、文本反转 -// @param text string [required] 要处理的文本内容 -// @param action string[count,keywords,reverse] 处理动作 -// ==/CATTool== - -const text = args.text; -const action = args.action || "count"; - -if (action === "count") { - return { chars: text.length, words: text.split(/\\s+/).filter(Boolean).length }; -} -if (action === "reverse") { - return { result: text.split("").reverse().join("") }; -} -return { error: "未知操作: " + action }; -`; - -// 示例 1: 安装与管理 CATTool -async function manageCATTools() { - GM_log("=== CATTool 管理示例 ==="); - - // 安装 CATTool - await CAT.agent.tools.install(helloToolCode); - GM_log("hello_world 工具已安装"); - - await CAT.agent.tools.install(textProcessorCode); - GM_log("text_processor 工具已安装"); - - // 列出所有已安装的工具 - const tools = await CAT.agent.tools.list(); - GM_log("已安装工具数量: " + tools.length); - for (const tool of tools) { - GM_log(` - ${tool.name}: ${tool.description}`); - } - - // 直接调用工具(不通过 LLM) - const greeting = await CAT.agent.tools.call("hello_world", { name: "ScriptCat" }); - GM_log("直接调用结果: " + JSON.stringify(greeting)); - - const stats = await CAT.agent.tools.call("text_processor", { - text: "Hello World 你好世界", - action: "count", - }); - GM_log("文本统计: " + JSON.stringify(stats)); - - // 删除工具 - // await CAT.agent.tools.remove("hello_world"); - // GM_log("hello_world 工具已删除"); -} - -// 示例 2: CATTool + Agent 对话联动 -// 安装 CATTool 后,Agent 在对话中会自动使用这些工具 -async function chatWithCATTools() { - GM_log("=== CATTool + Agent 对话示例 ==="); - - // 确保工具已安装 - await CAT.agent.tools.install(helloToolCode); - await CAT.agent.tools.install(textProcessorCode); - - // 创建对话 — 已安装的 CATTool 会自动注册为可用工具 - const conv = await CAT.agent.conversation.create({ - system: "你是一个助手,可以使用工具来完成任务。", - }); - - // LLM 会自动识别并调用 hello_world 工具 - const reply1 = await conv.chat("请向小明打个招呼"); - GM_log("回复1: " + reply1.content); - if (reply1.toolCalls) { - for (const tc of reply1.toolCalls) { - GM_log(` 工具调用: ${tc.name}(${tc.arguments}) => ${tc.result}`); - } - } - - // LLM 会自动调用 text_processor 工具 - const reply2 = await conv.chat("帮我统计一下这段文字的字数:今天天气真好,适合出去走走。"); - GM_log("回复2: " + reply2.content); -} - -// 运行示例 -async function main() { - await manageCATTools(); - // await chatWithCATTools(); -} - -return main(); diff --git a/example/agent/tools/weather_query.js b/example/agent/tools/weather_query.js deleted file mode 100644 index 58583be43..000000000 --- a/example/agent/tools/weather_query.js +++ /dev/null @@ -1,37 +0,0 @@ -// ==CATTool== -// @name weather_query -// @description 查询指定城市的实时天气信息,包括温度、天气状况和湿度 -// @param city string [required] 城市名称,如 北京、上海、Tokyo -// @param unit string[celsius,fahrenheit] 温度单位,默认 celsius -// @grant GM_xmlhttpRequest -// ==/CATTool== - -// 演示:使用 GM_xmlhttpRequest 调用外部 API -// 这里使用 wttr.in 的免费天气 API 作为示例 -const city = encodeURIComponent(args.city); -const unit = args.unit === "fahrenheit" ? "u" : "m"; - -const response = await new Promise((resolve, reject) => { - GM_xmlhttpRequest({ - method: "GET", - url: `https://wttr.in/${city}?format=j1&${unit}`, - onload: (res) => { - if (res.status === 200) { - resolve(JSON.parse(res.responseText)); - } else { - reject(new Error(`HTTP ${res.status}: ${res.statusText}`)); - } - }, - onerror: (err) => reject(new Error("请求失败: " + err.error)), - }); -}); - -const current = response.current_condition[0]; -return { - city: args.city, - temperature: args.unit === "fahrenheit" ? current.temp_F + "°F" : current.temp_C + "°C", - feelsLike: args.unit === "fahrenheit" ? current.FeelsLikeF + "°F" : current.FeelsLikeC + "°C", - condition: current.weatherDesc[0].value, - humidity: current.humidity + "%", - windSpeed: current.windspeedKmph + " km/h", -}; From 5cb0e6a74c0fb952ce7782b0679f35a9db044d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 14 Mar 2026 20:04:04 +0800 Subject: [PATCH 066/150] =?UTF-8?q?feat:=20Skill=20Config=20=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20=E2=80=94=20=E6=94=AF=E6=8C=81=20SKILL.md=20?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E9=85=8D=E7=BD=AE=E5=AD=97=E6=AE=B5=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E6=B2=99=E7=AE=B1=E4=B8=AD=E6=B3=A8=E5=85=A5=20CAT=5F?= =?UTF-8?q?CONFIG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 SKILL.md frontmatter 中通过 config 块声明配置字段(text/number/select/switch), 用户在 Skills 管理 UI 填写值(支持 secret 遮蔽),CATTool 执行时以 CAT_CONFIG 全局 对象注入沙箱,使脚本可安全访问 API Key 等敏感配置。 主要改动: - 用 yaml 包重写 frontmatter 解析器,支持嵌套 config 块 - 新增 SkillConfigField 类型,扩展 SkillMetadata/Record/Summary - CATToolExecutor → offscreen → sandbox 全链路透传 configValues - SkillRepo 新增 config_values.json 读写 - Agent Service 注册 getSkillConfigValues/saveSkillConfig handler - UI: SkillConfigModal 配置表单 + SkillInstallView 配置预览 - scriptcat.d.ts 声明 CAT_CONFIG 全局变量 --- src/app/repo/skill_repo.ts | 16 ++ src/app/service/agent/cattool_executor.ts | 4 +- src/app/service/agent/types.ts | 13 ++ src/app/service/offscreen/client.ts | 1 + src/app/service/sandbox/runtime.ts | 2 + src/app/service/service_worker/agent.ts | 17 +- src/app/service/service_worker/client.ts | 10 +- src/locales/en-US/translation.json | 2 + src/locales/zh-CN/translation.json | 2 + .../install/components/SkillInstallView.tsx | 39 +++- src/pages/options/routes/AgentSkills.tsx | 192 +++++++++++++++++- src/pkg/utils/skill.test.ts | 121 +++++++++++ src/pkg/utils/skill.ts | 63 ++++-- src/types/scriptcat.d.ts | 3 + 14 files changed, 456 insertions(+), 29 deletions(-) diff --git a/src/app/repo/skill_repo.ts b/src/app/repo/skill_repo.ts index 98d040157..0b563740e 100644 --- a/src/app/repo/skill_repo.ts +++ b/src/app/repo/skill_repo.ts @@ -5,6 +5,7 @@ const REGISTRY_FILE = "registry.json"; const DATA_DIR = "data"; const SCRIPTS_DIR = "scripts"; const REFERENCES_DIR = "references"; +const CONFIG_VALUES_FILE = "config_values.json"; // 目录结构: // agents/skills/registry.json — SkillSummary[] @@ -86,6 +87,7 @@ export class SkillRepo extends OPFSRepo { description: record.description, toolNames: record.toolNames, referenceNames: record.referenceNames, + ...(record.config && Object.keys(record.config).length > 0 ? { hasConfig: true } : {}), installtime: record.installtime, updatetime: record.updatetime, }; @@ -153,4 +155,18 @@ export class SkillRepo extends OPFSRepo { return null; } } + + async getConfigValues(name: string): Promise> { + try { + const skillDir = await this.getSkillDir(name); + return this.readJsonFile>(CONFIG_VALUES_FILE, {}, skillDir); + } catch { + return {}; + } + } + + async saveConfigValues(name: string, values: Record): Promise { + const skillDir = await this.getSkillDir(name); + await this.writeJsonFile(CONFIG_VALUES_FILE, values, skillDir); + } } diff --git a/src/app/service/agent/cattool_executor.ts b/src/app/service/agent/cattool_executor.ts index f56510da0..33c278556 100644 --- a/src/app/service/agent/cattool_executor.ts +++ b/src/app/service/agent/cattool_executor.ts @@ -35,7 +35,8 @@ export class CATToolExecutor implements ToolExecutor { constructor( private record: CATToolRecord, private sender: MessageSend, - private requireLoader?: RequireLoader + private requireLoader?: RequireLoader, + private configValues?: Record ) {} async execute(args: Record): Promise { @@ -84,6 +85,7 @@ export class CATToolExecutor implements ToolExecutor { grants: this.record.grants, name: this.record.name, requires, + configValues: this.configValues, }); const timeoutPromise = new Promise((_, reject) => setTimeout( diff --git a/src/app/service/agent/types.ts b/src/app/service/agent/types.ts index 831b20dd4..10d27ef54 100644 --- a/src/app/service/agent/types.ts +++ b/src/app/service/agent/types.ts @@ -159,12 +159,23 @@ export type StreamChunk = { // ---- Skill 类型 ---- +// Skill config 字段定义(SKILL.md frontmatter 中声明) +export type SkillConfigField = { + title: string; + type: "text" | "number" | "select" | "switch"; + secret?: boolean; + required?: boolean; + default?: unknown; + values?: string[]; // select 类型的选项列表 +}; + // Skill 摘要(registry.json 中) export type SkillSummary = { name: string; description: string; toolNames: string[]; // 随 Skill 打包的 CATTool 名称(scripts/ 目录下) referenceNames: string[]; // 参考资料名称(references/ 目录下) + hasConfig?: boolean; // 是否有 config 字段声明 installtime: number; updatetime: number; }; @@ -173,11 +184,13 @@ export type SkillSummary = { export type SkillMetadata = { name: string; description: string; + config?: Record; }; // 完整 Skill 记录 export type SkillRecord = SkillSummary & { prompt: string; // SKILL.md body(去 frontmatter 后的 markdown) + config?: Record; // config schema(来自 SKILL.md frontmatter) }; // Skill 参考资料 diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index 14250aeb1..2fe9ceb1f 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -47,6 +47,7 @@ export function executeCATTool( grants: string[]; name: string; requires?: Array<{ url: string; content: string }>; + configValues?: Record; } ) { return sendMessage(msgSender, "offscreen/executeCATTool", params); diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 62593c6c7..91ddf4671 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -359,6 +359,7 @@ export class Runtime { grants: string[]; name: string; requires?: Array<{ url: string; content: string }>; + configValues?: Record; }): Promise { const uuid = params.uuid; const metadata: any = { @@ -399,6 +400,7 @@ export class Runtime { // sandboxContext 已经包含了这些,再追加 args 即可) if ((exec as any).sandboxContext) { (exec as any).sandboxContext.args = params.args; + (exec as any).sandboxContext.CAT_CONFIG = Object.freeze(params.configValues || {}); } try { diff --git a/src/app/service/service_worker/agent.ts b/src/app/service/service_worker/agent.ts index 52d11cb4d..630797f05 100644 --- a/src/app/service/service_worker/agent.ts +++ b/src/app/service/service_worker/agent.ts @@ -16,6 +16,7 @@ import type { DomApiRequest, MCPApiRequest, SkillApiRequest, + SkillMetadata, SkillRecord, MessageContent, ContentBlock, @@ -162,6 +163,12 @@ export class AgentService { ); this.group.on("removeSkill", (name: string) => this.removeSkill(name)); this.group.on("refreshSkill", (name: string) => this.refreshSkill(name)); + this.group.on("getSkillConfigValues", (name: string) => this.skillRepo.getConfigValues(name)); + this.group.on( + "saveSkillConfig", + (params: { name: string; values: Record }) => + this.skillRepo.saveConfigValues(params.name, params.values) + ); // Skill ZIP 安装页面相关消息 this.group.on("prepareSkillInstall", (zipBase64: string) => this.prepareSkillInstall(zipBase64)); this.group.on("getSkillInstallData", (uuid: string) => this.getSkillInstallData(uuid)); @@ -481,6 +488,7 @@ export class AgentService { toolNames, referenceNames, prompt: parsed.prompt, + ...(parsed.metadata.config ? { config: parsed.metadata.config } : {}), installtime: existing?.installtime || now, updatetime: now, }; @@ -522,7 +530,7 @@ export class AgentService { // 获取缓存的 Skill ZIP 数据并解析 async getSkillInstallData(uuid: string): Promise<{ skillMd: string; - metadata: { name: string; description: string }; + metadata: SkillMetadata; prompt: string; scripts: Array<{ name: string; code: string }>; references: Array<{ name: string; content: string }>; @@ -660,6 +668,8 @@ export class AgentService { // 动态注册该 skill 的 CATTool 为独立 LLM tool if (record.toolNames.length > 0) { const toolRecords = await this.skillRepo.getSkillScripts(skillName); + // 读取 skill 的用户配置值(如 API Key 等),注入到每个 CATTool 执行器 + const configValues = record.config ? await this.skillRepo.getConfigValues(skillName) : undefined; for (const tool of toolRecords) { const def = catToolToToolDefinition({ name: tool.name, @@ -669,7 +679,10 @@ export class AgentService { requires: tool.requires || [], }); const prefixed = prefixToolDefinition(skillName, def); - this.toolRegistry.registerBuiltin(prefixed, new CATToolExecutor(tool, this.sender, this.createRequireLoader())); + this.toolRegistry.registerBuiltin( + prefixed, + new CATToolExecutor(tool, this.sender, this.createRequireLoader(), configValues) + ); dynamicToolNames.push(prefixed.name); } } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 26d32e13c..00db27928 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -376,7 +376,7 @@ export class AgentClient extends Client { getSkillInstallData(uuid: string): Promise<{ skillMd: string; - metadata: { name: string; description: string }; + metadata: { name: string; description: string; config?: Record }; prompt: string; scripts: Array<{ name: string; code: string }>; references: Array<{ name: string; content: string }>; @@ -392,4 +392,12 @@ export class AgentClient extends Client { cancelSkillInstall(uuid: string): Promise { return this.do("cancelSkillInstall", uuid); } + + getSkillConfigValues(name: string): Promise> { + return this.doThrow("getSkillConfigValues", name); + } + + saveSkillConfig(params: { name: string; values: Record }): Promise { + return this.doThrow("saveSkillConfig", params); + } } diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 43c024192..0049e21e4 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -633,6 +633,8 @@ "agent_skills_refresh_success": "Refreshed successfully", "agent_skills_tool_code": "Tool Code", "agent_skills_click_to_view_code": "Click tool name to view code", + "agent_skills_config": "Config", + "agent_skills_config_saved": "Config saved", "agent_chat_new": "New Chat", "agent_chat_delete": "Delete Chat", "agent_chat_delete_confirm": "Delete this conversation?", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 7f8c9088e..c8b692aba 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -633,6 +633,8 @@ "agent_skills_refresh_success": "刷新成功", "agent_skills_tool_code": "工具代码", "agent_skills_click_to_view_code": "点击工具名称查看代码", + "agent_skills_config": "配置", + "agent_skills_config_saved": "配置已保存", "agent_chat_new": "新建会话", "agent_chat_delete": "删除会话", "agent_chat_delete_confirm": "确定要删除此会话吗?", diff --git a/src/pages/install/components/SkillInstallView.tsx b/src/pages/install/components/SkillInstallView.tsx index 581bc4a2b..bc5909f7f 100644 --- a/src/pages/install/components/SkillInstallView.tsx +++ b/src/pages/install/components/SkillInstallView.tsx @@ -3,9 +3,10 @@ import { Button, Space, Tag, Typography } from "@arco-design/web-react"; import { IconDown, IconUp } from "@arco-design/web-react/icon"; import { useTranslation } from "react-i18next"; import { parseCATToolMetadata } from "@App/pkg/utils/cattool"; +import type { SkillConfigField } from "@App/app/service/agent/types"; interface SkillInstallViewProps { - metadata: { name: string; description: string }; + metadata: { name: string; description: string; config?: Record }; prompt: string; scripts: Array<{ name: string; code: string }>; references: Array<{ name: string; content: string }>; @@ -141,6 +142,42 @@ function SkillInstallView({
)} + {/* Config Fields */} + {metadata.config && Object.keys(metadata.config).length > 0 && ( +
+ {`${t("agent_skills_config")} (${Object.keys(metadata.config).length}):`} +
+ {Object.entries(metadata.config).map(([key, field]) => ( +
+
+ + {key} + + + {field.type} + + {field.required && ( + + {t("cattool_required")} + + )} + {field.secret && ( + + secret + + )} +
+ {field.title && ( + + {field.title} + + )} +
+ ))} +
+
+ )} + {/* References */} {references.length > 0 && (
diff --git a/src/pages/options/routes/AgentSkills.tsx b/src/pages/options/routes/AgentSkills.tsx index 268a63052..d42efba45 100644 --- a/src/pages/options/routes/AgentSkills.tsx +++ b/src/pages/options/routes/AgentSkills.tsx @@ -1,8 +1,28 @@ -import { Button, Card, Empty, Input, Message, Modal, Popconfirm, Space, Tag, Typography } from "@arco-design/web-react"; -import { IconDelete, IconEye, IconPlus, IconRefresh } from "@arco-design/web-react/icon"; +import { + Button, + Card, + Empty, + Input, + InputNumber, + Message, + Modal, + Popconfirm, + Select, + Space, + Switch, + Tag, + Typography, +} from "@arco-design/web-react"; +import { IconDelete, IconEye, IconPlus, IconRefresh, IconSettings } from "@arco-design/web-react/icon"; import { useTranslation } from "react-i18next"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { SkillSummary, SkillRecord, SkillReference, CATToolRecord } from "@App/app/service/agent/types"; +import type { + SkillSummary, + SkillRecord, + SkillReference, + CATToolRecord, + SkillConfigField, +} from "@App/app/service/agent/types"; import { SkillRepo } from "@App/app/repo/skill_repo"; import { agentClient } from "@App/pages/store/features/script"; @@ -15,12 +35,14 @@ function SkillCard({ onDetail, onUninstall, onRefresh, + onConfig, t, }: { skill: SkillSummary; onDetail: () => void; onUninstall: () => void; onRefresh: () => void; + onConfig?: () => void; t: (key: string, opts?: Record) => string; }) { return ( @@ -54,6 +76,11 @@ function SkillCard({ {t("agent_skills_references")}: {skill.referenceNames.length} )} + {skill.hasConfig && ( + + {t("agent_skills_config")} + + )}
{/* Install time */} @@ -66,6 +93,11 @@ function SkillCard({ + {skill.hasConfig && onConfig && ( + + )} @@ -129,6 +161,142 @@ function ToolCodeModal({ ); } +// ---- Config Modal ---- + +function SkillConfigModal({ + visible, + skill, + onClose, + t, +}: { + visible: boolean; + skill: SkillRecord | null; + onClose: () => void; + t: (key: string) => string; +}) { + const [values, setValues] = useState>({}); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (visible && skill?.config) { + setLoading(true); + agentClient + .getSkillConfigValues(skill.name) + .then((saved) => { + // 用 default 值填充未保存的字段 + const merged: Record = {}; + for (const [key, field] of Object.entries(skill.config!)) { + merged[key] = saved[key] !== undefined ? saved[key] : (field.default ?? ""); + } + setValues(merged); + }) + .catch(() => { + // 初始化为默认值 + const defaults: Record = {}; + for (const [key, field] of Object.entries(skill.config!)) { + defaults[key] = field.default ?? ""; + } + setValues(defaults); + }) + .finally(() => setLoading(false)); + } + }, [visible, skill]); + + const handleSave = async () => { + if (!skill) return; + setSaving(true); + try { + await agentClient.saveSkillConfig({ name: skill.name, values }); + Message.success(t("agent_skills_config_saved")); + onClose(); + } catch (e: any) { + Message.error(e.message || String(e)); + } finally { + setSaving(false); + } + }; + + if (!skill?.config) return null; + + const renderField = (key: string, field: SkillConfigField) => { + const value = values[key]; + const onChange = (v: unknown) => setValues((prev) => ({ ...prev, [key]: v })); + const label = ( +
+ {field.title || key} + {field.required && *} +
+ ); + + switch (field.type) { + case "number": + return ( +
+ {label} + onChange(v)} className="tw-w-full" /> +
+ ); + case "select": + return ( +
+ {label} + +
+ ); + case "switch": + return ( +
+ + {field.title || key} + {field.required && *} + + onChange(v)} /> +
+ ); + default: // text + return ( +
+ {label} + {field.secret ? ( + onChange(v)} /> + ) : ( + onChange(v)} /> + )} +
+ ); + } + }; + + return ( + + {loading ? ( +
Loading...
+ ) : ( + + {Object.entries(skill.config).map(([key, field]) => renderField(key, field))} + + )} +
+ ); +} + // ---- Detail/Edit Modal ---- function SkillDetailModal({ @@ -274,6 +442,8 @@ function AgentSkills() { const [skills, setSkills] = useState([]); const [detailVisible, setDetailVisible] = useState(false); const [detailSkill, setDetailSkill] = useState(null); + const [configVisible, setConfigVisible] = useState(false); + const [configSkill, setConfigSkill] = useState(null); const fileInputRef = useRef(null); const loadSkills = useCallback(async () => { @@ -298,6 +468,14 @@ function AgentSkills() { loadSkills(); }; + const handleConfig = async (name: string) => { + const record = await skillRepo.getSkill(name); + if (record?.config) { + setConfigSkill(record); + setConfigVisible(true); + } + }; + const handleRefresh = async (name: string) => { try { await agentClient.refreshSkill(name); @@ -365,6 +543,7 @@ function AgentSkills() { onDetail={() => handleDetail(skill.name)} onUninstall={() => handleUninstall(skill.name)} onRefresh={() => handleRefresh(skill.name)} + onConfig={skill.hasConfig ? () => handleConfig(skill.name) : undefined} t={t} /> ))} @@ -379,6 +558,13 @@ function AgentSkills() { onSaved={loadSkills} t={t} /> + + setConfigVisible(false)} + t={t} + /> ); } diff --git a/src/pkg/utils/skill.test.ts b/src/pkg/utils/skill.test.ts index 6f27cf6ce..9c20cd875 100644 --- a/src/pkg/utils/skill.test.ts +++ b/src/pkg/utils/skill.test.ts @@ -96,6 +96,127 @@ Minimal prompt.`; expect(result.prompt).toBe("Minimal prompt."); }); + // ---- config 字段解析测试 ---- + + it("应正确解析含 config 的 SKILL.md", () => { + const content = `--- +name: weather-query +description: 查询天气信息 +config: + WEATHER_API_KEY: + title: "OpenWeatherMap API Key" + type: text + secret: true + required: true + DEFAULT_CITY: + title: "默认城市" + type: text + default: "Beijing" +--- + +Use weather API to query.`; + + const result = parseSkillMd(content)!; + expect(result.metadata.name).toBe("weather-query"); + expect(result.metadata.config).toBeDefined(); + const config = result.metadata.config!; + expect(Object.keys(config)).toHaveLength(2); + + expect(config.WEATHER_API_KEY.title).toBe("OpenWeatherMap API Key"); + expect(config.WEATHER_API_KEY.type).toBe("text"); + expect(config.WEATHER_API_KEY.secret).toBe(true); + expect(config.WEATHER_API_KEY.required).toBe(true); + + expect(config.DEFAULT_CITY.title).toBe("默认城市"); + expect(config.DEFAULT_CITY.type).toBe("text"); + expect(config.DEFAULT_CITY.default).toBe("Beijing"); + }); + + it("config 中 type 缺失时默认为 text", () => { + const content = `--- +name: test +config: + API_KEY: + title: "API Key" +--- + +Prompt.`; + + const result = parseSkillMd(content)!; + expect(result.metadata.config!.API_KEY.type).toBe("text"); + }); + + it("应解析 select 类型的 values 字段", () => { + const content = `--- +name: test +config: + REGION: + title: "Region" + type: select + values: + - us-east-1 + - eu-west-1 + - ap-northeast-1 + default: us-east-1 +--- + +Prompt.`; + + const result = parseSkillMd(content)!; + const field = result.metadata.config!.REGION; + expect(field.type).toBe("select"); + expect(field.values).toEqual(["us-east-1", "eu-west-1", "ap-northeast-1"]); + expect(field.default).toBe("us-east-1"); + }); + + it("应解析 switch 和 number 类型", () => { + const content = `--- +name: test +config: + ENABLED: + title: "Enable feature" + type: switch + default: true + MAX_RESULTS: + title: "Max results" + type: number + default: 10 +--- + +Prompt.`; + + const result = parseSkillMd(content)!; + const config = result.metadata.config!; + expect(config.ENABLED.type).toBe("switch"); + expect(config.ENABLED.default).toBe(true); + expect(config.MAX_RESULTS.type).toBe("number"); + expect(config.MAX_RESULTS.default).toBe(10); + }); + + it("无 config 时 metadata.config 应为 undefined", () => { + const content = `--- +name: no-config +description: test +--- + +Prompt.`; + + const result = parseSkillMd(content)!; + expect(result.metadata.config).toBeUndefined(); + }); + + it("空 config 对象时为 undefined", () => { + const content = `--- +name: empty-config +config: {} +--- + +Prompt.`; + + const result = parseSkillMd(content)!; + expect(result.metadata.config).toBeUndefined(); + }); + it("应正确处理多行 prompt 内容", () => { const content = `--- name: multi-line diff --git a/src/pkg/utils/skill.ts b/src/pkg/utils/skill.ts index 61cf49073..58fb3237d 100644 --- a/src/pkg/utils/skill.ts +++ b/src/pkg/utils/skill.ts @@ -1,6 +1,21 @@ -import type { SkillMetadata } from "@App/app/service/agent/types"; +import type { SkillConfigField, SkillMetadata } from "@App/app/service/agent/types"; +import { parse as parseYaml } from "yaml"; import { loadAsyncJSZip } from "./jszip-x"; +// 校验并规范化单个 config 字段 +function normalizeConfigField(raw: Record): SkillConfigField { + const type = (raw.type as string) || "text"; + const field: SkillConfigField = { + title: String(raw.title || ""), + type: type as SkillConfigField["type"], + }; + if (raw.secret === true) field.secret = true; + if (raw.required === true) field.required = true; + if (raw.default !== undefined) field.default = raw.default; + if (Array.isArray(raw.values)) field.values = raw.values.map(String); + return field; +} + // 解析 SKILL.md 内容:YAML frontmatter + markdown body export function parseSkillMd(content: string): { metadata: SkillMetadata; prompt: string } | null { const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/); @@ -8,33 +23,39 @@ export function parseSkillMd(content: string): { metadata: SkillMetadata; prompt const [, frontmatter, body] = match; - let name = ""; - let description = ""; + let parsed: Record; + try { + parsed = parseYaml(frontmatter); + } catch { + return null; + } - for (const line of frontmatter.split("\n")) { - const trimmed = line.trim(); - // 解析 key: value 格式 - const kvMatch = trimmed.match(/^(\w+)\s*:\s*(.*)$/); - if (!kvMatch) continue; + if (!parsed || typeof parsed !== "object") return null; - const [, key, rawValue] = kvMatch; - // 去除引号 - const value = rawValue.replace(/^["']|["']$/g, "").trim(); + const name = typeof parsed.name === "string" ? parsed.name : ""; + if (!name) return null; - switch (key) { - case "name": - name = value; - break; - case "description": - description = value; - break; + const description = typeof parsed.description === "string" ? parsed.description : ""; + + // 解析 config 块 + let config: Record | undefined; + if (parsed.config && typeof parsed.config === "object" && !Array.isArray(parsed.config)) { + const rawConfig = parsed.config as Record; + const entries = Object.entries(rawConfig); + if (entries.length > 0) { + config = {}; + for (const [key, value] of entries) { + if (value && typeof value === "object" && !Array.isArray(value)) { + config[key] = normalizeConfigField(value as Record); + } + } + // 如果解析后没有有效字段,置为 undefined + if (Object.keys(config).length === 0) config = undefined; } } - if (!name) return null; - return { - metadata: { name, description }, + metadata: { name, description, ...(config ? { config } : {}) }, prompt: body.trim(), }; } diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index f0ff97b56..7e89b8902 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -1021,3 +1021,6 @@ declare const CAT: { task: CATAgentTask.TaskAPI; }; }; + +/** Skill 配置值,通过 SKILL.md frontmatter 的 config 块声明,用户在 UI 中填写,执行时注入沙箱 */ +declare const CAT_CONFIG: Record; From e98bdc9ab5a295479d638702f44c9245b4ecec93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 14 Mar 2026 20:21:59 +0800 Subject: [PATCH 067/150] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20scriptcat.?= =?UTF-8?q?d.ts=20=E7=B1=BB=E5=9E=8B=E5=A3=B0=E6=98=8E=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=B8=AD=E6=96=87=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scriptcat.d.ts: 全量同步实际实现,所有 GM_*/CAT_*/CAT.agent API 添加英文 JSDoc - scriptcat.zh-CN.d.ts: 新增全量中文版类型声明(含 GM_*/CAT_*/CAT.agent 所有 API) - CAT_CONFIG 类型改为 Readonly> 与 Object.freeze 实现一致 --- src/types/scriptcat.d.ts | 935 +++++++++++++++----- src/types/scriptcat.zh-CN.d.ts | 1491 ++++++++++++++++++++++++++++++++ 2 files changed, 2188 insertions(+), 238 deletions(-) create mode 100644 src/types/scriptcat.zh-CN.d.ts diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 7e89b8902..30cbefa7f 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -6,134 +6,147 @@ declare type ConfigType = "text" | "checkbox" | "select" | "mult-select" | "numb declare interface Config { [key: string]: unknown; + /** Config item title. */ title: string; + /** Config item description. */ description: string; + /** Default value. */ default?: unknown; + /** UI widget type. */ type?: ConfigType; + /** Binding key for two-way data flow. */ bind?: string; + /** Allowed values (for select/multi-select). */ values?: unknown[]; + /** Whether to mask input (password field). */ password?: boolean; - // 文本类型时是字符串长度,数字类型时是最大值 + /** Max string length (for text) or max numeric value (for number). */ max?: number; + /** Min numeric value. */ min?: number; - rows?: number; // textarea行数 - index: number; // 配置项排序位置 + /** Number of rows (for textarea). */ + rows?: number; + /** Sort index among config items. */ + index: number; } declare type UserConfig = { [key: string]: { [key: string]: Config } }; +/** Script and environment metadata, compatible with Tampermonkey's `GM_info`. */ declare const GM_info: { + /** ScriptCat version string. */ version: string; + /** Whether auto-update is enabled for this script. */ scriptWillUpdate: boolean; + /** Always `"ScriptCat"`. */ scriptHandler: "ScriptCat"; scriptUpdateURL?: string; - // scriptSource: string; scriptMetaStr?: string; userConfig?: UserConfig; userConfigStr?: string; + /** Whether running in an incognito/private window. */ isIncognito: boolean; - sandboxMode: "raw"; // "js" | "raw" | "none"; + /** Sandbox mode (ScriptCat always uses `"raw"`). */ + sandboxMode: "raw"; userAgentData: { - brands?: { - brand: string; - version: string; - }[]; + brands?: { brand: string; version: string }[]; mobile?: boolean; platform?: string; architecture?: string; bitness?: string; }; - downloadMode: "native"; // "native" | "disabled" | "browser"; + /** Download mode (ScriptCat uses `"native"`). */ + downloadMode: "native"; + /** Metadata parsed from the script header. */ script: { author?: string; description?: string; - // excludes: string[]; grant: string[]; header: string; - // homepage?: string; icon?: string; icon64?: string; includes?: string[]; - // lastModified: number; matches: string[]; name: string; namespace?: string; - // position: number; "run-at": string; "run-in": string[]; - // resources: string[]; - // unwrap: boolean; version: string; - /* options: { - awareOfChrome: boolean; - run_at: string; - noframes?: boolean; - compat_arrayLeft: boolean; - compat_foreach: boolean; - compat_forvarin: boolean; - compat_metadata: boolean; - compat_uW_gmonkey: boolean; - override: { - orig_excludes: string[]; - orig_includes: string[]; - use_includes: string[]; - use_excludes: string[]; - [key: string]: any; - }; - [key: string]: any; - }; */ [key: string]: unknown; }; [key: string]: unknown; }; +// =========================================================================== +// GM_* functions (Greasemonkey/Tampermonkey compatible, synchronous style) +// =========================================================================== + +/** List all stored value keys. */ declare function GM_listValues(): string[]; +/** Listen for changes to a stored value. Returns a listener ID. */ declare function GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number; +/** Remove a value change listener by ID. */ declare function GM_removeValueChangeListener(listenerId: number): void; +/** Store a value. */ declare function GM_setValue(name: string, value: any): void; -// 设置多个值, values是一个对象, 键为值的名称, 值为值的内容 + +/** Store multiple values at once. Keys are value names. */ declare function GM_setValues(values: { [key: string]: any }): void; +/** Retrieve a stored value (returns `defaultValue` if not found). */ declare function GM_getValue(name: string, defaultValue?: any): any; -// 获取多个值, 如果keysOrDefaults是一个对象, 则使用对象的值作为默认值 +/** + * Retrieve multiple values. If `keysOrDefaults` is an object, its values are used as defaults. + * If it is an array, each element is a key name (no defaults). + */ declare function GM_getValues(keysOrDefaults: { [key: string]: any } | string[] | null | undefined): { [key: string]: any; }; +/** Delete a stored value. */ declare function GM_deleteValue(name: string): void; -// 删除多个值, names是一个字符串数组 +/** Delete multiple stored values. */ declare function GM_deleteValues(names: string[]): void; -// 支持level和label -declare function GM_log(message: string, level?: GMTypes.LoggerLevel, labels?: GMTypes.LoggerLabel): void; +/** Log a message with optional level and structured labels. */ +declare function GM_log(message: string, level?: GMTypes.LoggerLevel, ...labels: GMTypes.LoggerLabel[]): void; +/** Get the text content of a `@resource` by name. */ declare function GM_getResourceText(name: string): string | undefined; +/** Get a URL (data: or blob:) for a `@resource` by name. */ declare function GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined; -function GM_registerMenuCommand( +/** Register a menu command in the ScriptCat popup. */ +declare function GM_registerMenuCommand( name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: | { id?: number | string; - accessKey?: string; // 菜单快捷键 - autoClose?: boolean; // 默认为 true,false 时点击后不关闭弹出菜单页面 - nested?: boolean; // SC特有配置,默认为 true,false 的话浏览器右键菜单项目由三级菜单升至二级菜单 - individual?: boolean; // SC特有配置,默认为 false,true 表示相同的菜单项不合并显示 + /** Keyboard shortcut key. */ + accessKey?: string; + /** Whether clicking the menu closes the popup (default: true). */ + autoClose?: boolean; + /** SC extension: nest under a parent menu (default: true). `false` promotes to browser context menu. */ + nested?: boolean; + /** SC extension: do not merge identical menu items (default: false). */ + individual?: boolean; } | string ): number; +/** Unregister a menu command by ID. */ declare function GM_unregisterMenuCommand(id: number): void; /** - * 注册一个菜单输入框, 允许用户输入值, 并在输入完成后用回调函数 + * Register a menu item with an input field, allowing the user to enter a value. + * The callback receives the user's input. */ declare function CAT_registerMenuInput( name: string, @@ -141,42 +154,64 @@ declare function CAT_registerMenuInput( options_or_accessKey?: | { id?: number | string; - accessKey?: string; // 菜单快捷键 - autoClose?: boolean; // 默认为 true,false 时点击后不关闭弹出菜单页面 - nested?: boolean; // SC特有配置,默认为 true,false 的话浏览器右键菜单项目由三级菜单升至二级菜单 - individual?: boolean; // SC特有配置,默认为 false,true 表示相同的菜单项不合并显示 - // 可选输入框 + accessKey?: string; + autoClose?: boolean; + nested?: boolean; + individual?: boolean; + /** Input widget type. */ inputType?: "text" | "number" | "boolean"; - title?: string; // title 只适用于输入框类型 + /** Dialog title (for the input popup). */ + title?: string; + /** Label shown next to the input. */ inputLabel?: string; + /** Default value for the input. */ inputDefaultValue?: string | number | boolean; + /** Placeholder text. */ inputPlaceholder?: string; } | string ): number; +/** Unregister a menu input (alias of `GM_unregisterMenuCommand`). */ declare const CAT_unregisterMenuInput: typeof GM_unregisterMenuCommand; -/** - * 当使用 @early-start 时,可以使用此函数来等待脚本完全加载完成 - */ +/** Wait for the script to be fully loaded. Used with `@early-start`. */ declare function CAT_scriptLoaded(): Promise; +/** Create a blob URL from a Blob object. ScriptCat manages the URL lifecycle. */ +declare function CAT_createBlobUrl(blob: Blob): Promise; + +/** Fetch a blob URL and return the Blob data. Helper for `GM_xmlhttpRequest` stream responses. */ +declare function CAT_fetchBlob(url: string): Promise; + +/** Fetch a URL and parse it as a Document (in the content page context if available). */ +declare function CAT_fetchDocument(url: string): Promise; + +/** Open a URL in a new tab. Returns a Tab handle (or `undefined` if context is invalid). */ declare function GM_openInTab(url: string, options: GMTypes.OpenTabOptions): GMTypes.Tab | undefined; declare function GM_openInTab(url: string, loadInBackground: boolean): GMTypes.Tab | undefined; declare function GM_openInTab(url: string): GMTypes.Tab | undefined; +/** Close a tab opened by `GM_openInTab`. */ +declare function GM_closeInTab(tabId: string): void; + +/** Perform a cross-origin XMLHttpRequest. Requires `@connect` for the target domain. */ declare function GM_xmlhttpRequest(details: GMTypes.XHRDetails): GMTypes.AbortHandle; +/** Download a file. */ declare function GM_download(details: GMTypes.DownloadDetails): GMTypes.AbortHandle; declare function GM_download(url: string, filename: string): GMTypes.AbortHandle; +/** Get the tab's persistent storage object. */ declare function GM_getTab(callback: (tab: object) => void): void; -declare function GM_saveTab(tab: object): Promise; +/** Save the tab's persistent storage object. */ +declare function GM_saveTab(tab: object): void; +/** Get all tabs' persistent storage objects. */ declare function GM_getTabs(callback: (tabs: { [key: number]: object }) => void): void; +/** Show a desktop notification. */ declare function GM_notification(details: GMTypes.NotificationDetails, ondone?: GMTypes.NotificationOnDone): void; declare function GM_notification( text: string, @@ -185,12 +220,16 @@ declare function GM_notification( onclick?: GMTypes.NotificationOnClick ): void; +/** Close a notification by ID. */ declare function GM_closeNotification(id: string): void; +/** Update a notification by ID. */ declare function GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void; +/** Copy text to the clipboard. */ declare function GM_setClipboard(data: string, info?: string | { type?: string; mimetype?: string }): void; +/** Add a DOM element to the page. */ declare function GM_addElement(tag: string, attributes: Record): HTMLElement; declare function GM_addElement( parentNode: Node, @@ -198,149 +237,173 @@ declare function GM_addElement( attrs: Record ): HTMLElement; -declare function GM_addStyle(css: string): HTMLStyleElement; +/** Inject a CSS stylesheet into the page. */ +declare function GM_addStyle(css: string): Element | undefined; -// name和domain不能都为空 +/** + * Perform cookie operations. Both `name` and `domain` cannot be empty simultaneously. + * @param action - `"list"` | `"set"` | `"delete"` + */ declare function GM_cookie( action: GMTypes.CookieAction, details: GMTypes.CookieDetails, ondone: (cookie: GMTypes.Cookie[], error: unknown | undefined) => void ): void; -/** - * GM.* API (兼容 Greasemonkey4/Tampermonkey 4+ 的 Promise 风格) - */ +// =========================================================================== +// GM.* object (Greasemonkey 4 / Tampermonkey 4+ Promise-style API) +// =========================================================================== + +/** Promise-based API object. Each method corresponds to a `GM_*` function. */ declare const GM: { - /** 脚本信息 */ + /** Script and environment metadata (same as `GM_info`). */ readonly info: typeof GM_info; - /** 获取一个值 */ + /** Retrieve a stored value. */ getValue(name: string, defaultValue?: T): Promise; - /** 获取多个值, 如果keysOrDefaults是一个对象, 则使用对象的值作为默认值 */ + /** Retrieve multiple stored values. If `keysOrDefaults` is an object, values are used as defaults. */ getValues(keysOrDefaults: { [key: string]: any } | string[] | null | undefined): Promise<{ [key: string]: any }>; - /** 设置一个值 */ + /** Store a value. */ setValue(name: string, value: any): Promise; - /** 设置多个值, values是一个对象, 键为值的名称, 值为值的内容 */ + /** Store multiple values at once. */ setValues(values: { [key: string]: any }): Promise; - /** 删除一个值 */ + /** Delete a stored value. */ deleteValue(name: string): Promise; - /** 删除多个值, names是一个字符串数组 */ + /** Delete multiple stored values. */ deleteValues(names: string[]): Promise; - /** 获取所有已保存值的 key 列表 */ + /** List all stored value keys. */ listValues(): Promise; - /** 值变更监听 */ + /** Listen for changes to a stored value. */ addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): Promise; + /** Remove a value change listener. */ removeValueChangeListener(listenerId: number): Promise; - /** 支持level和label */ - log(message: string, level?: GMTypes.LoggerLevel, labels?: GMTypes.LoggerLabel): Promise; + /** Log a message with optional level and structured labels. */ + log(message: string, level?: GMTypes.LoggerLevel, ...labels: GMTypes.LoggerLabel[]): Promise; - /** 获取资源文本 */ + /** Get the text content of a `@resource`. */ getResourceText(name: string): Promise; - /** 获取资源URL */ + /** Get a URL for a `@resource`. */ getResourceURL(name: string, isBlobUrl?: boolean): Promise; - /** 注册菜单 */ + /** Register a menu command. */ registerMenuCommand( name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: | { id?: number | string; - accessKey?: string; // 菜单快捷键 - autoClose?: boolean; // 默认为 true - title?: string; // 菜单提示 - // ScriptCat 扩展 - icon?: string; // 菜单图标 - // ScriptCat 扩展 - closeOnClick?: boolean; // 点击菜单后是否关闭, 与autoClose含义相同 + accessKey?: string; + autoClose?: boolean; + title?: string; + /** SC extension: menu icon URL. */ + icon?: string; + /** SC extension: alias for `autoClose`. */ + closeOnClick?: boolean; } | string ): Promise; - /** 注销菜单 */ + /** Unregister a menu command. */ unregisterMenuCommand(id: number | string): Promise; - /** 样式注入 */ - addStyle(css: string): Promise; + /** Inject a CSS stylesheet. */ + addStyle(css: string): Promise; - /** 通知 */ + /** Show a desktop notification. */ notification(details: GMTypes.NotificationDetails, ondone?: GMTypes.NotificationOnDone): Promise; notification(text: string, title: string, image: string, onclick?: GMTypes.NotificationOnClick): Promise; + /** Close a notification. */ closeNotification(id: string): Promise; + /** Update a notification. */ updateNotification(id: string, details: GMTypes.NotificationDetails): Promise; - /** 设置剪贴板 */ + /** Copy text to the clipboard. */ setClipboard(data: string, info?: string | { type?: string; mimetype?: string }): Promise; - /** 添加元素 */ + /** Add a DOM element. */ addElement(tag: string, attributes: Record): Promise; addElement(parentNode: Node, tag: string, attrs: Record): Promise; - /** XMLHttpRequest */ - xmlHttpRequest(details: GMTypes.XHRDetails): Promise; + /** Perform a cross-origin XMLHttpRequest. The returned Promise also has an `.abort()` method. */ + xmlHttpRequest(details: GMTypes.XHRDetails): Promise & GMTypes.AbortHandle; - /** 下载 */ + /** Download a file. */ download(details: GMTypes.DownloadDetails): Promise; download(url: string, filename: string): Promise; - /** Tab 存储 */ + /** Get the tab's persistent storage object. */ getTab(): Promise; + /** Save the tab's persistent storage object. */ saveTab(tab: object): Promise; + /** Get all tabs' persistent storage objects. */ getTabs(): Promise<{ [key: number]: object }>; - /** 打开新标签页 */ + /** Open a URL in a new tab. */ openInTab(url: string, options: GMTypes.OpenTabOptions): Promise; openInTab(url: string, loadInBackground: boolean): Promise; openInTab(url: string): Promise; - /** Cookie 操作 */ - cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise; + /** Close a tab opened by `openInTab`. */ + closeInTab(tabId: string): Promise; + + /** Cookie operations with sub-methods. */ + cookie: { + (action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise; + /** Set a cookie. */ + set(details: GMTypes.CookieDetails): Promise; + /** List cookies matching the filter. */ + list(details: GMTypes.CookieDetails): Promise; + /** Delete a cookie. */ + delete(details: GMTypes.CookieDetails): Promise; + }; }; +// =========================================================================== +// CAT_* functions (ScriptCat-specific extensions) +// =========================================================================== + /** - * 设置浏览器代理 - * @deprecated 正式版中已废弃,后续可能会在beta版本中添加 + * Set browser proxy rules. + * @deprecated Removed in stable release; may return in beta. */ declare function CAT_setProxy(rule: CATType.ProxyRule[] | string): void; /** - * 清理所有代理规则 - * @deprecated 正式版中已废弃,后续可能会在beta版本中添加 + * Clear all proxy rules. + * @deprecated Removed in stable release; may return in beta. */ declare function CAT_clearProxy(): void; /** - * 输入x、y,模拟真实点击 - * @deprecated 正式版中已废弃,后续可能会在beta版本中添加 + * Simulate a real click at coordinates (x, y). + * @deprecated Removed in stable release; may return in beta. */ declare function CAT_click(x: number, y: number): void; -/** - * 打开脚本的用户配置页面 - */ +/** Open the script's user configuration page. */ declare function CAT_userConfig(): void; /** - * 操控管理器设置的储存系统,将会在目录下创建一个app/uuid目录供此 API 使用,如果指定了baseDir参数,则会使用baseDir作为基础目录 - * 上传时默认覆盖同名文件 - * @param action 操作类型 list 列出指定目录所有文件, upload 上传文件, download 下载文件, delete 删除文件, config 打开配置页, 暂时不提供move/mkdir等操作 - * @param details + * Interact with the managed file storage system. + * Creates an `app/` directory for this script (or uses `baseDir`). + * Upload overwrites files with the same name. + * @param action - `"list"` | `"upload"` | `"download"` | `"delete"` | `"config"` */ declare function CAT_fileStorage( action: "list", details: { - // 文件路径 + /** Directory path to list. */ path?: string; - // 基础目录,如果未设置,则将脚本uuid作为目录 + /** Base directory; defaults to the script's UUID. */ baseDir?: string; onload?: (files: CATType.FileStorageFileInfo[]) => void; onerror?: (error: CATType.FileStorageError) => void; @@ -349,58 +412,52 @@ declare function CAT_fileStorage( declare function CAT_fileStorage( action: "download", details: { - file: CATType.FileStorageFileInfo; // 某些平台需要提供文件的hash值,所以需要传入文件信息 + /** File info object (some platforms need the file hash). */ + file: CATType.FileStorageFileInfo; onload: (data: Blob) => void; - // onprogress?: (progress: number) => void; onerror?: (error: CATType.FileStorageError) => void; - // public?: boolean; } ): void; declare function CAT_fileStorage( action: "delete", details: { + /** File path to delete. */ path: string; onload?: () => void; onerror?: (error: CATType.FileStorageError) => void; - // public?: boolean; } ): void; declare function CAT_fileStorage( action: "upload", details: { + /** Destination file path. */ path: string; - // 基础目录,如果未设置,则将脚本uuid作为目录 + /** Base directory; defaults to the script's UUID. */ baseDir?: string; + /** File data to upload. */ data: Blob; onload?: () => void; - // onprogress?: (progress: number) => void; onerror?: (error: CATType.FileStorageError) => void; - // public?: boolean; } ): void; +/** Open the file storage configuration page. */ declare function CAT_fileStorage(action: "config"): void; /** - * 脚本猫后台脚本重试, 当你的脚本出现错误时, 可以reject返回此错误, 以便脚本猫重试 - * 重试时间请注意不要与脚本执行时间冲突, 否则可能会导致重复执行, 最小重试时间为5s - * @class CATRetryError + * Retry error for background scripts. Throw this to make ScriptCat retry later. + * Minimum retry interval is 5 seconds. Avoid overlapping with the script's own schedule. */ declare class CATRetryError { - /** - * constructor 构造函数 - * @param {string} message 错误信息 - * @param {number} seconds x秒后重试, 单位秒 - */ + /** @param message - Error message. @param seconds - Retry after N seconds. */ constructor(message: string, seconds: number); - - /** - * constructor 构造函数 - * @param {string} message 错误信息 - * @param {Date} date 重试时间, 指定时间后重试 - */ + /** @param message - Error message. @param date - Retry at a specific time. */ constructor(message: string, date: Date); } +// =========================================================================== +// CATType namespace (ScriptCat-specific types) +// =========================================================================== + declare namespace CATType { interface ProxyRule { proxyServer: ProxyServer; @@ -416,26 +473,30 @@ declare namespace CATType { } interface FileStorageError { - // 错误码 -1 未知错误 1 用户未配置文件储存源 2 文件储存源配置错误 3 路径不存在 - // 4 上传失败 5 下载失败 6 删除失败 7 不允许的文件路径 8 网络类型的错误 + /** + * Error code: + * -1 = unknown, 1 = storage not configured, 2 = config error, 3 = path not found, + * 4 = upload failed, 5 = download failed, 6 = delete failed, + * 7 = disallowed file path, 8 = network error + */ code: -1 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; error: string; } interface FileStorageFileInfo { - // 文件名 + /** File name. */ name: string; - // 文件路径 + /** Relative file path. */ path: string; - // 储存空间绝对路径 + /** Absolute path in the storage space. */ absPath: string; - // 文件大小 + /** File size in bytes. */ size: number; - // 文件摘要 + /** File content digest/hash. */ digest: string; - // 文件创建时间 + /** Creation timestamp. */ createtime: number; - // 文件修改时间 + /** Last modification timestamp. */ updatetime: number; } @@ -448,6 +509,10 @@ declare namespace CATType { }; } +// =========================================================================== +// GMTypes namespace (Greasemonkey/Tampermonkey compatible types) +// =========================================================================== + declare namespace GMTypes { type CookieAction = "list" | "delete" | "set"; @@ -487,7 +552,7 @@ declare namespace GMTypes { sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; } - // tabid是只有后台脚本监听才有的参数 + /** Value change listener. `tabid` is only available for background script listeners. */ type ValueChangeListener = ( name: string, oldValue: unknown, @@ -498,81 +563,55 @@ declare namespace GMTypes { interface OpenTabOptions { /** - * 决定新标签页是否在打开时获得焦点。 - * - * - `true` → 新标签页会立即切换到前台。 - * - `false` → 新标签页在后台打开,不会打断当前页面的焦点。 - * - * 默认值:true + * Whether the new tab gains focus immediately. + * - `true` — tab opens in foreground. + * - `false` — tab opens in background. + * @default true */ active?: boolean; /** - * 决定新标签页插入位置。 - * - * - 如果是 `boolean`: - * - `true` → 插入在当前标签页之后。 - * - `false` → 插入到窗口末尾。 - * - 如果是 `number`: - * - `0` → 插入到当前标签前一格。 - * - `1` → 插入到当前标签后一格。 - * - * 默认值:true + * Tab insertion position. + * - `true` / `1` — insert after the current tab. + * - `false` — append to the end of the window. + * - `0` — insert before the current tab. + * @default true */ insert?: boolean | number; /** - * 决定是否设置父标签页(即 `openerTabId`)。 - * - * - `true` → 浏览器能追踪由哪个标签打开的子标签, - * 有助于某些扩展(如标签树管理器)识别父子关系。 - * - * 默认值:true + * Set the opener tab ID so browsers can track parent-child relationships. + * @default true */ setParent?: boolean; /** - * 是否在隐私窗口(无痕模式)中打开标签页。 - * - * 注意:ScriptCat 的 manifest.json 配置了 `"incognito": "split"`, - * 在 normal window 中执行时,tabId/windowId 将不可用, - * 只能执行「打开新标签页」动作。 - * - * 默认值:false + * Open in an incognito/private window. + * Note: ScriptCat uses `"incognito": "split"` — in a normal window, + * tabId/windowId will not be available. + * @default false */ incognito?: boolean; /** - * 历史兼容字段,仅 TM 支持。 - * 语义与 `active` **相反**: - * - * - `true` → 等价于 `active = false`(后台加载)。 - * - `false` → 等价于 `active = true`(前台加载)。 - * - * ⚠️ 不推荐使用:与 `active` 功能重复且容易混淆。 - * - * 默认值:false - * @deprecated 请使用 `active` 替代 + * Legacy field (TM only). Semantics are the **opposite** of `active`: + * `true` = background, `false` = foreground. + * @default false + * @deprecated Use `active` instead. */ loadInBackground?: boolean; /** - * 是否将新标签页固定(pin)在浏览器标签栏左侧。 - * - * - `true` → 新标签页为固定状态。 - * - `false` → 普通标签页。 - * - * 默认值:false + * Pin the new tab in the browser tab bar. + * @default false */ pinned?: boolean; /** - * 使用 `window.open` 打开新标签,而不是 `chrome.tabs.create` - * 在打开一些特殊协议的链接时很有用,例如 `vscode://`, `m3u8dl://` - * 其他参数在这个打开方式下无效 - * - * 相关:Issue #178 #1043 - * 默认值:false + * Use `window.open` instead of `chrome.tabs.create`. + * Useful for special protocols like `vscode://`, `m3u8dl://`. + * Other options are ignored in this mode. + * @default false */ useOpen?: boolean; } @@ -580,7 +619,7 @@ declare namespace GMTypes { type SWOpenTabOptions = OpenTabOptions & Required>; /** - * XMLHttpRequest readyState 状态值 + * XMLHttpRequest readyState values. * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/readyState */ type ReadyState = @@ -625,20 +664,25 @@ declare namespace GMTypes { binary?: boolean; timeout?: number; context?: ContextType; - responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; // stream 在当前版本是一个较为简陋的实现 + /** Response type. `"stream"` support is rudimentary in the current version. */ + responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; overrideMimeType?: string; + /** Send request without cookies (Tampermonkey compatible). */ anonymous?: boolean; - mozAnon?: boolean; // 发送请求时不携带cookie (兼容Greasemonkey) + /** Send request without cookies (Greasemonkey compatible). */ + mozAnon?: boolean; + /** Force using the Fetch API internally. */ fetch?: boolean; user?: string; password?: string; + /** Disable caching. */ nocache?: boolean; - revalidate?: boolean; // 强制重新验证缓存内容:允许缓存,但必须在使用缓存内容之前重新验证 - redirect?: "follow" | "error" | "manual"; // 为了与tm保持一致, 在v0.17.0后废弃maxRedirects, 使用redirect替代, 会强制使用fetch模式 - cookiePartition?: Record & { - topLevelSite?: string; // 表示分区 cookie 的顶部帧站点 - }; // 包含用于发送和接收的分区 cookie 的分区键 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning - context?: any; // 自定义值,传递给响应的 response.context 属性 + /** Force revalidation: allow cache but revalidate before using. */ + revalidate?: boolean; + /** Redirect handling. Forces fetch mode internally. */ + redirect?: "follow" | "error" | "manual"; + /** Partitioned cookie key for storage partitioning. */ + cookiePartition?: Record & { topLevelSite?: string }; onload?: Listener; onloadstart?: Listener; @@ -660,25 +704,26 @@ declare namespace GMTypes { } interface DownloadDetails { - // TM/SC 标准参数 + // Standard parameters (TM/SC) url: URL; name: string; headers?: { [key: string]: string }; saveAs?: boolean; conflictAction?: "uniquify" | "overwrite" | "prompt"; - // 其他参数 - timeout?: number; // SC/VM - anonymous?: boolean; // SC/VM - context?: ContextType; // SC/VM - user?: string; // SC/VM - password?: string; // SC/VM + // Extended parameters (SC/VM) + timeout?: number; + anonymous?: boolean; + context?: ContextType; + user?: string; + password?: string; - method?: "GET" | "POST"; // SC - downloadMode?: "native" | "browser"; // SC - cookie?: string; // SC + // SC-only parameters + method?: "GET" | "POST"; + downloadMode?: "native" | "browser"; + cookie?: string; - // TM/SC 标准回调 + // Callbacks onload?: Listener; onerror?: Listener; onprogress?: Listener<{ @@ -733,7 +778,7 @@ declare namespace GMTypes { ondone?: NotificationOnDone; progress?: number; oncreate?: NotificationOnClick; - // 只能存在2个 + /** Max 2 buttons. */ buttons?: NotificationButton[]; } @@ -747,189 +792,442 @@ declare namespace GMTypes { type GMClipboardInfo = string | { type?: string; mimetype?: string }; } -// ---- CAT.agent.conversation API ---- +// =========================================================================== +// CAT.agent — ScriptCat Agent API +// @grant CAT.agent.conversation / CAT.agent.tools / CAT.agent.dom / +// CAT.agent.task / CAT.agent.skills +// =========================================================================== +/** CAT Agent conversation, content blocks, and streaming types. */ declare namespace CATAgent { + // ---- Content Block types ---- + + /** Plain text content block. */ type TextBlock = { type: "text"; text: string }; + + /** Image content block. Data is stored in OPFS and referenced by `attachmentId`. */ type ImageBlock = { type: "image"; attachmentId: string; mimeType: string; name?: string }; + + /** File content block. */ type FileBlock = { type: "file"; attachmentId: string; mimeType: string; name: string; size?: number }; - type AudioBlock = { type: "audio"; attachmentId: string; mimeType: string; name?: string; durationMs?: number }; + + /** Audio content block. */ + type AudioBlock = { + type: "audio"; + attachmentId: string; + mimeType: string; + name?: string; + /** Duration in milliseconds. */ + durationMs?: number; + }; + + /** Union of all content block types. */ type ContentBlock = TextBlock | ImageBlock | FileBlock | AudioBlock; + /** Message content: plain string or an array of content blocks (multimodal). */ + type MessageContent = string | ContentBlock[]; + + // ---- Tool types ---- + + /** + * Tool definition with an inline handler function. + * Use in `ConversationCreateOptions.tools` or `ChatOptions.tools` + * to register tools that the LLM can call. + */ interface ToolDefinition { + /** Unique tool name. */ name: string; + /** Human-readable description. */ description: string; + /** JSON Schema describing the tool parameters. */ parameters: Record; + /** Handler invoked when the LLM calls this tool. */ handler: (args: Record) => Promise; } + /** + * Custom command handler. Commands are prefixed with `/` (e.g. `/new`). + * Return a string to display as the reply, or void. + */ + type CommandHandler = (args: string, conv: ConversationInstance) => Promise; + + // ---- Conversation options ---- + + /** Options for creating a new conversation via `CAT.agent.conversation.create()`. */ interface ConversationCreateOptions { + /** Custom conversation ID; auto-generated if omitted. */ id?: string; + /** System prompt. */ system?: string; + /** Model ID; uses the default model if omitted. */ model?: string; + /** Max tool-calling loop iterations (default: 20). */ maxIterations?: number; + /** Skills to load: `"auto"` loads all installed skills, or specify names. */ + skills?: "auto" | string[]; + /** Tools with inline handlers, available for the lifetime of this conversation. */ + tools?: ToolDefinition[]; + /** + * Custom slash-command handlers (e.g. `{ "/reset": handler }`). + * The built-in `/new` command (clear conversation) can be overridden. + */ + commands?: Record; + /** + * Ephemeral mode: messages are kept in memory only, not persisted. + * Built-in tools/skills are NOT loaded; the script must supply all tools. + */ + ephemeral?: boolean; } + /** Options for a single `chat()` / `chatStream()` call. */ interface ChatOptions { + /** Additional tools for this call only (merged with conversation-level tools). */ tools?: ToolDefinition[]; } + // ---- Tool call ---- + + /** Record of a tool call made by the LLM. */ interface ToolCallInfo { + /** Unique call ID. */ id: string; + /** Tool name. */ name: string; + /** JSON-serialized arguments. */ arguments: string; + /** Tool execution result (populated after execution). */ result?: string; + /** Call status. */ + status?: "pending" | "running" | "completed" | "error"; } + // ---- Chat reply ---- + + /** Result of a non-streaming `chat()` call. */ interface ChatReply { - content: string | ContentBlock[]; + /** Response content. */ + content: MessageContent; + /** Model thinking/reasoning text (if available). */ thinking?: string; + /** Tool calls made during this turn. */ toolCalls?: ToolCallInfo[]; + /** Token usage. */ usage?: { inputTokens: number; outputTokens: number }; + /** `true` when the reply was produced by a command handler, not the LLM. */ + command?: boolean; } + /** A single chunk emitted during streaming via `chatStream()`. */ interface StreamChunk { + /** + * Chunk type: + * - `"content_delta"` — incremental text + * - `"thinking_delta"` — incremental thinking/reasoning + * - `"tool_call"` — a tool call event + * - `"content_block"` — a complete non-text content block + * - `"done"` — stream finished + * - `"error"` — an error occurred + */ type: "content_delta" | "thinking_delta" | "tool_call" | "content_block" | "done" | "error"; + /** Text delta (for content_delta / thinking_delta). */ content?: string; + /** Complete content block (for content_block). */ block?: ContentBlock; + /** Tool call info (for tool_call). */ toolCall?: ToolCallInfo; + /** Token usage (for done). */ usage?: { inputTokens: number; outputTokens: number }; + /** Error message (for error). */ error?: string; - /** 错误分类码:"rate_limit" | "auth" | "tool_timeout" | "max_iterations" | "api_error" */ + /** Error classification: `"rate_limit"` | `"auth"` | `"tool_timeout"` | `"max_iterations"` | `"api_error"` */ errorCode?: string; + /** `true` when the chunk was produced by a command handler. */ + command?: boolean; } + // ---- Chat message ---- + + /** A persisted chat message in a conversation. */ interface ChatMessage { + /** Message ID. */ id: string; + /** Parent conversation ID. */ conversationId: string; + /** Message role. */ role: "user" | "assistant" | "system" | "tool"; - content: string | ContentBlock[]; + /** Message content (text or multimodal). */ + content: MessageContent; + /** Model thinking/reasoning block. */ + thinking?: { content: string }; + /** Tool calls in this message. */ toolCalls?: ToolCallInfo[]; + /** Associated tool_call ID (for role="tool" messages). */ toolCallId?: string; - createdAt: number; + /** Error message (if the turn errored). */ + error?: string; + /** Model ID used for this message. */ + modelId?: string; + /** Token usage for this message. */ + usage?: { + inputTokens: number; + outputTokens: number; + /** Anthropic cache creation input tokens. */ + cacheCreationInputTokens?: number; + /** Anthropic cache read input tokens. */ + cacheReadInputTokens?: number; + }; + /** Total response duration in ms. */ + durationMs?: number; + /** Time-to-first-token in ms. */ + firstTokenMs?: number; + /** Parent message ID (for branching). */ + parentId?: string; + /** Creation timestamp. */ + createtime: number; } + // ---- Conversation instance ---- + + /** + * A conversation instance returned by `CAT.agent.conversation.create()` or `.get()`. + * Provides methods for chatting, streaming, and managing message history. + */ interface ConversationInstance { + /** Conversation ID. */ readonly id: string; + /** Conversation title. */ readonly title: string; + /** Model ID used. */ readonly modelId: string; - chat(content: string | ContentBlock[], options?: ChatOptions): Promise; - chatStream(content: string | ContentBlock[], options?: ChatOptions): Promise>; + + /** Send a message and wait for the full reply (with automatic tool-calling loop). */ + chat(content: MessageContent, options?: ChatOptions): Promise; + + /** Send a message and receive a streaming response. */ + chatStream(content: MessageContent, options?: ChatOptions): Promise>; + + /** Get all messages in this conversation. */ getMessages(): Promise; + + /** Clear all messages in this conversation. */ + clear(): Promise; + + /** Persist the conversation to storage. */ save(): Promise; } + // ---- Conversation API ---- + + /** + * `CAT.agent.conversation` — create and retrieve conversation instances. + * @grant CAT.agent.conversation + */ interface ConversationAPI { + /** Create a new conversation. */ create(options?: ConversationCreateOptions): Promise; + + /** Get an existing conversation by ID. Returns `null` if not found. */ get(id: string): Promise; } } +// ---- CAT.agent.tools — CATTool management API ---- + +/** CATTool management types — install, remove, list, and invoke CATTools. */ declare namespace CATAgentTools { + /** A parameter definition for a CATTool. */ interface CATToolParam { + /** Parameter name. */ name: string; + /** Parameter type. */ type: "string" | "number" | "boolean"; + /** Whether required. */ required: boolean; + /** Parameter description. */ description: string; + /** Allowed values (for enum parameters). */ enum?: string[]; } + /** A persisted CATTool record. */ interface CATToolRecord { + /** Internal UUID. */ + id: string; + /** Tool name (from `@name` metadata). */ name: string; + /** Tool description. */ description: string; + /** Parameter definitions. */ params: CATToolParam[]; + /** Required GM grants. */ grants: string[]; + /** `@require` URL list. */ + requires?: string[]; + /** Full source code (including metadata header). */ code: string; - installedAt: number; - updatedAt: number; + /** UUID of the script that installed this tool. */ + sourceScriptUuid?: string; + /** Name of the script that installed this tool. */ + sourceScriptName?: string; + /** Installation timestamp. */ + installtime: number; + /** Last update timestamp. */ + updatetime: number; } + /** + * `CAT.agent.tools` — manage and invoke CATTools. + * @grant CAT.agent.tools + */ interface ToolsAPI { + /** Install a CATTool from source code. */ install(code: string): Promise; + + /** Remove a CATTool by name. */ remove(name: string): Promise; + + /** List all installed CATTools. */ list(): Promise; + + /** Invoke a CATTool by name with optional parameters. */ call(name: string, params?: Record): Promise; } } -// ---- CAT.agent.dom API ---- +// ---- CAT.agent.dom — Browser DOM automation API ---- +/** DOM automation types — interact with browser tabs, pages, and elements. */ declare namespace CATAgentDom { + /** Information about a browser tab. */ interface TabInfo { + /** Tab ID. */ tabId: number; + /** Current URL. */ url: string; + /** Page title. */ title: string; + /** Whether the tab is active. */ active: boolean; + /** Window ID. */ windowId: number; + /** Whether the tab is discarded (unloaded from memory). */ discarded: boolean; } + /** Result of a DOM action (click, fill, etc.). */ interface ActionResult { + /** Whether the action succeeded. */ success: boolean; + /** Whether a navigation occurred as a result. */ navigated?: boolean; + /** Current URL after the action. */ url?: string; + /** New tab opened as a result. */ newTab?: { tabId: number; url: string }; + /** Dialog that appeared (alert/confirm/prompt). */ dialog?: { type: "alert" | "confirm" | "prompt"; message: string }; } + /** Page content returned by `readPage()`. */ interface PageContent { + /** Page title. */ title: string; + /** Page URL. */ url: string; + /** HTML content (or selected fragment). */ html: string; + /** Whether the content was truncated due to `maxLength`. */ truncated?: boolean; + /** Original total length before truncation. */ totalLength?: number; } + /** Options for `readPage()`. */ interface ReadPageOptions { + /** Target tab ID; defaults to the active tab. */ tabId?: number; + /** CSS selector to read a specific element. */ selector?: string; + /** Maximum content length in characters. */ maxLength?: number; + /** Tags/selectors to remove before reading (e.g. `["script", "style", "svg"]`). */ + removeTags?: string[]; } + /** Options for DOM actions (click, fill). */ interface DomActionOptions { + /** Target tab ID. */ tabId?: number; + /** Use trusted (CDP-dispatched) events instead of synthetic JS events. */ trusted?: boolean; } + /** Options for `screenshot()`. */ interface ScreenshotOptions { + /** Target tab ID. */ tabId?: number; + /** JPEG quality (0–100). */ quality?: number; + /** Capture the full scrollable page. */ fullPage?: boolean; } + /** Options for `navigate()`. */ interface NavigateOptions { + /** Target tab ID. */ tabId?: number; + /** Wait until the page is fully loaded. */ waitUntil?: boolean; + /** Navigation timeout in ms. */ timeout?: number; } + /** Scroll direction. */ type ScrollDirection = "up" | "down" | "top" | "bottom"; + /** Options for `scroll()`. */ interface ScrollOptions { + /** Target tab ID. */ tabId?: number; + /** Scroll within a specific element. */ selector?: string; } + /** Result of a scroll operation. */ interface ScrollResult { + /** Current scroll position. */ scrollTop: number; + /** Total scrollable height. */ scrollHeight: number; + /** Visible viewport height. */ clientHeight: number; + /** Whether scrolled to the bottom. */ atBottom: boolean; } + /** Result of a `navigate()` call. */ interface NavigateResult { + /** Tab ID. */ tabId: number; + /** Final URL after navigation. */ url: string; + /** Page title. */ title: string; } + /** Options for `waitFor()`. */ interface WaitForOptions { + /** Target tab ID. */ tabId?: number; + /** Timeout in ms. */ timeout?: number; } + /** Result of `waitFor()`. */ interface WaitForResult { + /** Whether the element was found. */ found: boolean; + /** Element details (when found). */ element?: { selector: string; tag: string; @@ -940,87 +1238,248 @@ declare namespace CATAgentDom { }; } + /** Options for `executeScript()`. */ interface ExecuteScriptOptions { + /** Target tab ID. */ tabId?: number; } + /** Result of `peekMonitor()` — summary of DOM changes being monitored. */ + interface MonitorStatus { + /** Whether any changes were detected. */ + hasChanges: boolean; + /** Number of dialogs captured. */ + dialogCount: number; + /** Number of added DOM nodes captured. */ + nodeCount: number; + } + + /** + * `CAT.agent.dom` — browser tab and DOM automation. + * @grant CAT.agent.dom + */ interface DomAPI { + /** List all open browser tabs. */ listTabs(): Promise; + + /** Navigate a tab to a URL. */ navigate(url: string, options?: NavigateOptions): Promise; + + /** Read the HTML content of a page (or a selected element). */ readPage(options?: ReadPageOptions): Promise; + + /** Capture a screenshot of a tab. Returns a base64-encoded data URL. */ screenshot(options?: ScreenshotOptions): Promise; + + /** Click an element matching the CSS selector. */ click(selector: string, options?: DomActionOptions): Promise; + + /** Fill an input/textarea matching the CSS selector with the given value. */ fill(selector: string, value: string, options?: DomActionOptions): Promise; + + /** Scroll a page or element. */ scroll(direction: ScrollDirection, options?: ScrollOptions): Promise; + + /** Wait for an element matching the CSS selector to appear. */ waitFor(selector: string, options?: WaitForOptions): Promise; + + /** Execute JavaScript code in the page context. */ executeScript(code: string, options?: ExecuteScriptOptions): Promise; + + /** Start monitoring DOM changes on a tab (dialogs, added nodes). */ + startMonitor(tabId: number): Promise; + + /** Stop monitoring DOM changes on a tab. */ + stopMonitor(tabId: number): Promise; + + /** Peek at the current monitor status for a tab. */ + peekMonitor(tabId: number): Promise; } } -// ---- CAT.agent.task API ---- +// ---- CAT.agent.task — Scheduled task API ---- +/** Scheduled task types — create cron-based tasks that run agent conversations or emit events. */ declare namespace CATAgentTask { + /** A scheduled agent task record. */ interface AgentTask { + /** Task ID. */ id: string; + /** Task name. */ name: string; + /** Cron expression. */ crontab: string; + /** + * Execution mode: + * - `"internal"` — Service Worker runs an LLM conversation automatically. + * - `"event"` — Notifies the script via `addListener`. + */ mode: "internal" | "event"; + /** Whether the task is enabled. */ enabled: boolean; + /** Whether to show a browser notification on trigger. */ notify: boolean; + + // --- internal mode fields --- + /** Prompt to send on each trigger. */ prompt?: string; + /** Model ID to use. */ modelId?: string; + /** Existing conversation ID to continue. */ conversationId?: string; + /** Skills to load. */ skills?: "auto" | string[]; + /** Max tool-calling iterations (default: 10). */ maxIterations?: number; + + // --- event mode fields --- + /** UUID of the script that created this task. */ sourceScriptUuid?: string; + + // --- runtime status --- + /** Last run timestamp. */ lastruntime?: number; + /** Next scheduled run timestamp. */ nextruntime?: number; + /** Last run result status. */ lastRunStatus?: "success" | "error"; + /** Last run error message. */ lastRunError?: string; + /** Creation timestamp. */ createtime: number; + /** Last update timestamp. */ updatetime: number; } + /** Event payload delivered to `addListener` callbacks when a task triggers. */ interface AgentTaskTrigger { + /** Task ID. */ taskId: string; + /** Task name. */ name: string; + /** Cron expression. */ crontab: string; + /** Trigger timestamp. */ triggeredAt: number; } - interface AgentTaskCreateOptions { - name: string; - crontab: string; - mode: "internal" | "event"; - enabled: boolean; - notify?: boolean; - prompt?: string; - modelId?: string; - conversationId?: string; - skills?: "auto" | string[]; - maxIterations?: number; - } + /** Options for creating a new task (fields auto-populated by the system are omitted). */ + type AgentTaskCreateOptions = Omit; + /** + * `CAT.agent.task` — create and manage scheduled agent tasks. + * @grant CAT.agent.task + */ interface TaskAPI { + /** Create a new scheduled task. */ create(options: AgentTaskCreateOptions): Promise; + + /** List all tasks. */ list(): Promise; + + /** Get a task by ID. */ get(id: string): Promise; + + /** Update a task. */ update(id: string, task: Partial): Promise; + + /** Remove a task by ID. */ remove(id: string): Promise; + + /** Immediately trigger a task (regardless of cron schedule). */ runNow(id: string): Promise; + + /** + * Listen for task trigger events (for `mode: "event"` tasks). + * Returns a listener ID for later removal. + */ addListener(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number; + + /** Remove a previously registered listener. */ removeListener(listenerId: number): void; } } +// ---- CAT.agent.skills — Skill management API ---- + +/** Skill management types — install, remove, and query Agent Skills. */ +declare namespace CATAgentSkills { + /** Summary info for an installed Skill. */ + interface SkillSummary { + /** Skill name. */ + name: string; + /** Skill description. */ + description: string; + /** CATTool names bundled in this Skill (from `scripts/` directory). */ + toolNames: string[]; + /** Reference document names (from `references/` directory). */ + referenceNames: string[]; + /** Installation timestamp. */ + installtime: number; + /** Last update timestamp. */ + updatetime: number; + } + + /** Full Skill record including the prompt. */ + interface SkillRecord extends SkillSummary { + /** SKILL.md body (markdown after frontmatter removal). */ + prompt: string; + } + + /** + * `CAT.agent.skills` — manage Agent Skills (packaged prompts + tools + references). + * @grant CAT.agent.skills + */ + interface SkillsAPI { + /** List all installed Skills. */ + list(): Promise; + + /** Get full details of a Skill by name. Returns `null` if not found. */ + get(name: string): Promise; + + /** + * Install a Skill from a SKILL.md string, with optional bundled scripts and references. + * @param skillMd - The SKILL.md content (with YAML frontmatter). + * @param scripts - CATTool scripts to bundle. + * @param references - Reference documents to bundle. + */ + install( + skillMd: string, + scripts?: Array<{ name: string; code: string }>, + references?: Array<{ name: string; content: string }> + ): Promise; + + /** Remove a Skill by name. */ + remove(name: string): Promise; + } +} + +// ---- CAT global object ---- + +/** + * ScriptCat Agent global object — provides access to conversation, tools, DOM, task, and skills APIs. + * Each sub-API requires its own `@grant` declaration. + */ declare const CAT: { agent: { + /** @grant CAT.agent.conversation */ conversation: CATAgent.ConversationAPI; + /** @grant CAT.agent.tools */ tools: CATAgentTools.ToolsAPI; + /** @grant CAT.agent.dom */ dom: CATAgentDom.DomAPI; + /** @grant CAT.agent.task */ task: CATAgentTask.TaskAPI; + /** @grant CAT.agent.skills */ + skills: CATAgentSkills.SkillsAPI; }; }; -/** Skill 配置值,通过 SKILL.md frontmatter 的 config 块声明,用户在 UI 中填写,执行时注入沙箱 */ -declare const CAT_CONFIG: Record; +/** + * Skill configuration values injected into the CATTool sandbox at runtime. + * + * Declared in the `config` block of a SKILL.md frontmatter and filled in by + * the user through the Skill settings UI. The object is frozen at injection + * time, so properties are read-only. + */ +declare const CAT_CONFIG: Readonly>; diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts new file mode 100644 index 000000000..8c98f93f2 --- /dev/null +++ b/src/types/scriptcat.zh-CN.d.ts @@ -0,0 +1,1491 @@ +// ============================================================================ +// scriptcat.zh-CN.d.ts — ScriptCat 全量中文类型声明 +// 此文件为 scriptcat.d.ts 的中文翻译版本,包含所有 GM_*/CAT_*/CAT.agent API。 +// 如需接入,请在 tsconfig.json 中替换或追加此文件。 +// ============================================================================ + +// @copyright https://github.com/silverwzw/Tampermonkey-Typescript-Declaration + +declare const unsafeWindow: Window; + +declare type ConfigType = "text" | "checkbox" | "select" | "mult-select" | "number" | "textarea" | "time"; + +declare interface Config { + [key: string]: unknown; + /** 配置项标题。 */ + title: string; + /** 配置项描述。 */ + description: string; + /** 默认值。 */ + default?: unknown; + /** UI 控件类型。 */ + type?: ConfigType; + /** 双向绑定的键名。 */ + bind?: string; + /** 允许的值(用于 select / multi-select)。 */ + values?: unknown[]; + /** 是否隐藏输入内容(密码字段)。 */ + password?: boolean; + /** 文本最大长度 / 数值最大值。 */ + max?: number; + /** 数值最小值。 */ + min?: number; + /** 行数(用于 textarea)。 */ + rows?: number; + /** 配置项排序索引。 */ + index: number; +} + +declare type UserConfig = { [key: string]: { [key: string]: Config } }; + +/** 脚本及环境元数据,兼容 Tampermonkey 的 `GM_info`。 */ +declare const GM_info: { + /** ScriptCat 版本号。 */ + version: string; + /** 脚本是否已启用自动更新。 */ + scriptWillUpdate: boolean; + /** 始终为 `"ScriptCat"`。 */ + scriptHandler: "ScriptCat"; + scriptUpdateURL?: string; + scriptMetaStr?: string; + userConfig?: UserConfig; + userConfigStr?: string; + /** 是否在隐私/无痕窗口中运行。 */ + isIncognito: boolean; + /** 沙箱模式(ScriptCat 始终使用 `"raw"`)。 */ + sandboxMode: "raw"; + userAgentData: { + brands?: { brand: string; version: string }[]; + mobile?: boolean; + platform?: string; + architecture?: string; + bitness?: string; + }; + /** 下载模式(ScriptCat 使用 `"native"`)。 */ + downloadMode: "native"; + /** 从脚本头部解析的元数据。 */ + script: { + author?: string; + description?: string; + grant: string[]; + header: string; + icon?: string; + icon64?: string; + includes?: string[]; + matches: string[]; + name: string; + namespace?: string; + "run-at": string; + "run-in": string[]; + version: string; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +// =========================================================================== +// GM_* 函数(Greasemonkey/Tampermonkey 兼容,同步风格) +// =========================================================================== + +/** 列出所有已存储的值的键名。 */ +declare function GM_listValues(): string[]; + +/** 监听某个存储值的变化。返回监听器 ID。 */ +declare function GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number; + +/** 根据 ID 移除值变化监听器。 */ +declare function GM_removeValueChangeListener(listenerId: number): void; + +/** 存储一个值。 */ +declare function GM_setValue(name: string, value: any): void; + +/** 批量存储多个值。键为值的名称。 */ +declare function GM_setValues(values: { [key: string]: any }): void; + +/** 获取存储的值(未找到时返回 `defaultValue`)。 */ +declare function GM_getValue(name: string, defaultValue?: any): any; + +/** + * 批量获取多个值。 + * 若 `keysOrDefaults` 为对象,其值作为默认值;若为数组,每个元素为键名(无默认值)。 + */ +declare function GM_getValues(keysOrDefaults: { [key: string]: any } | string[] | null | undefined): { + [key: string]: any; +}; + +/** 删除一个存储的值。 */ +declare function GM_deleteValue(name: string): void; + +/** 批量删除多个存储的值。 */ +declare function GM_deleteValues(names: string[]): void; + +/** 记录日志,可选等级和结构化标签。 */ +declare function GM_log(message: string, level?: GMTypes.LoggerLevel, ...labels: GMTypes.LoggerLabel[]): void; + +/** 根据名称获取 `@resource` 的文本内容。 */ +declare function GM_getResourceText(name: string): string | undefined; + +/** 根据名称获取 `@resource` 的 URL(data: 或 blob:)。 */ +declare function GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined; + +/** 在 ScriptCat 弹出面板中注册菜单命令。 */ +declare function GM_registerMenuCommand( + name: string, + listener?: (inputValue?: any) => void, + options_or_accessKey?: + | { + id?: number | string; + /** 键盘快捷键。 */ + accessKey?: string; + /** 点击菜单后是否关闭弹出面板(默认 true)。 */ + autoClose?: boolean; + /** SC 扩展:嵌套在父菜单下(默认 true)。`false` 提升到浏览器右键菜单。 */ + nested?: boolean; + /** SC 扩展:不合并相同菜单项(默认 false)。 */ + individual?: boolean; + } + | string +): number; + +/** 根据 ID 注销菜单命令。 */ +declare function GM_unregisterMenuCommand(id: number): void; + +/** + * 注册带输入框的菜单项,允许用户输入值。 + * 回调接收用户的输入。 + */ +declare function CAT_registerMenuInput( + name: string, + listener?: (inputValue?: any) => void, + options_or_accessKey?: + | { + id?: number | string; + accessKey?: string; + autoClose?: boolean; + nested?: boolean; + individual?: boolean; + /** 输入控件类型。 */ + inputType?: "text" | "number" | "boolean"; + /** 对话框标题。 */ + title?: string; + /** 输入框旁显示的标签。 */ + inputLabel?: string; + /** 输入框默认值。 */ + inputDefaultValue?: string | number | boolean; + /** 占位文本。 */ + inputPlaceholder?: string; + } + | string +): number; + +/** 注销菜单输入(`GM_unregisterMenuCommand` 的别名)。 */ +declare const CAT_unregisterMenuInput: typeof GM_unregisterMenuCommand; + +/** 等待脚本完全加载。配合 `@early-start` 使用。 */ +declare function CAT_scriptLoaded(): Promise; + +/** 从 Blob 对象创建 blob URL。ScriptCat 管理 URL 生命周期。 */ +declare function CAT_createBlobUrl(blob: Blob): Promise; + +/** 获取 blob URL 并返回 Blob 数据。用于 `GM_xmlhttpRequest` stream 响应的辅助函数。 */ +declare function CAT_fetchBlob(url: string): Promise; + +/** 获取 URL 并解析为 Document(优先在内容页上下文中执行)。 */ +declare function CAT_fetchDocument(url: string): Promise; + +/** 在新标签页中打开 URL。返回 Tab 句柄(上下文无效时返回 `undefined`)。 */ +declare function GM_openInTab(url: string, options: GMTypes.OpenTabOptions): GMTypes.Tab | undefined; +declare function GM_openInTab(url: string, loadInBackground: boolean): GMTypes.Tab | undefined; +declare function GM_openInTab(url: string): GMTypes.Tab | undefined; + +/** 关闭由 `GM_openInTab` 打开的标签页。 */ +declare function GM_closeInTab(tabId: string): void; + +/** 执行跨域 XMLHttpRequest。目标域名需在 `@connect` 中声明。 */ +declare function GM_xmlhttpRequest(details: GMTypes.XHRDetails): GMTypes.AbortHandle; + +/** 下载文件。 */ +declare function GM_download(details: GMTypes.DownloadDetails): GMTypes.AbortHandle; +declare function GM_download(url: string, filename: string): GMTypes.AbortHandle; + +/** 获取标签页的持久化存储对象。 */ +declare function GM_getTab(callback: (tab: object) => void): void; + +/** 保存标签页的持久化存储对象。 */ +declare function GM_saveTab(tab: object): void; + +/** 获取所有标签页的持久化存储对象。 */ +declare function GM_getTabs(callback: (tabs: { [key: number]: object }) => void): void; + +/** 显示桌面通知。 */ +declare function GM_notification(details: GMTypes.NotificationDetails, ondone?: GMTypes.NotificationOnDone): void; +declare function GM_notification( + text: string, + title: string, + image: string, + onclick?: GMTypes.NotificationOnClick +): void; + +/** 根据 ID 关闭通知。 */ +declare function GM_closeNotification(id: string): void; + +/** 根据 ID 更新通知。 */ +declare function GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void; + +/** 复制文本到剪贴板。 */ +declare function GM_setClipboard(data: string, info?: string | { type?: string; mimetype?: string }): void; + +/** 向页面添加 DOM 元素。 */ +declare function GM_addElement(tag: string, attributes: Record): HTMLElement; +declare function GM_addElement( + parentNode: Node, + tag: string, + attrs: Record +): HTMLElement; + +/** 向页面注入 CSS 样式表。 */ +declare function GM_addStyle(css: string): Element | undefined; + +/** + * 执行 Cookie 操作。`name` 和 `domain` 不能同时为空。 + * @param action - `"list"` | `"set"` | `"delete"` + */ +declare function GM_cookie( + action: GMTypes.CookieAction, + details: GMTypes.CookieDetails, + ondone: (cookie: GMTypes.Cookie[], error: unknown | undefined) => void +): void; + +// =========================================================================== +// GM.* 对象(Greasemonkey 4 / Tampermonkey 4+ Promise 风格 API) +// =========================================================================== + +/** Promise 风格的 API 对象。每个方法对应一个 `GM_*` 函数。 */ +declare const GM: { + /** 脚本及环境元数据(同 `GM_info`)。 */ + readonly info: typeof GM_info; + + /** 获取存储的值。 */ + getValue(name: string, defaultValue?: T): Promise; + + /** 批量获取多个存储的值。若 `keysOrDefaults` 为对象,其值作为默认值。 */ + getValues(keysOrDefaults: { [key: string]: any } | string[] | null | undefined): Promise<{ [key: string]: any }>; + + /** 存储一个值。 */ + setValue(name: string, value: any): Promise; + + /** 批量存储多个值。 */ + setValues(values: { [key: string]: any }): Promise; + + /** 删除一个存储的值。 */ + deleteValue(name: string): Promise; + + /** 批量删除多个存储的值。 */ + deleteValues(names: string[]): Promise; + + /** 列出所有已存储的值的键名。 */ + listValues(): Promise; + + /** 监听存储值的变化。 */ + addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): Promise; + /** 移除值变化监听器。 */ + removeValueChangeListener(listenerId: number): Promise; + + /** 记录日志,可选等级和结构化标签。 */ + log(message: string, level?: GMTypes.LoggerLevel, ...labels: GMTypes.LoggerLabel[]): Promise; + + /** 获取 `@resource` 的文本内容。 */ + getResourceText(name: string): Promise; + + /** 获取 `@resource` 的 URL。 */ + getResourceURL(name: string, isBlobUrl?: boolean): Promise; + + /** 注册菜单命令。 */ + registerMenuCommand( + name: string, + listener?: (inputValue?: any) => void, + options_or_accessKey?: + | { + id?: number | string; + accessKey?: string; + autoClose?: boolean; + title?: string; + /** SC 扩展:菜单图标 URL。 */ + icon?: string; + /** SC 扩展:`autoClose` 的别名。 */ + closeOnClick?: boolean; + } + | string + ): Promise; + + /** 注销菜单命令。 */ + unregisterMenuCommand(id: number | string): Promise; + + /** 注入 CSS 样式表。 */ + addStyle(css: string): Promise; + + /** 显示桌面通知。 */ + notification(details: GMTypes.NotificationDetails, ondone?: GMTypes.NotificationOnDone): Promise; + notification(text: string, title: string, image: string, onclick?: GMTypes.NotificationOnClick): Promise; + /** 关闭通知。 */ + closeNotification(id: string): Promise; + /** 更新通知。 */ + updateNotification(id: string, details: GMTypes.NotificationDetails): Promise; + + /** 复制文本到剪贴板。 */ + setClipboard(data: string, info?: string | { type?: string; mimetype?: string }): Promise; + + /** 添加 DOM 元素。 */ + addElement(tag: string, attributes: Record): Promise; + addElement(parentNode: Node, tag: string, attrs: Record): Promise; + + /** 执行跨域 XMLHttpRequest。返回的 Promise 同时具有 `.abort()` 方法。 */ + xmlHttpRequest(details: GMTypes.XHRDetails): Promise & GMTypes.AbortHandle; + + /** 下载文件。 */ + download(details: GMTypes.DownloadDetails): Promise; + download(url: string, filename: string): Promise; + + /** 获取标签页的持久化存储对象。 */ + getTab(): Promise; + /** 保存标签页的持久化存储对象。 */ + saveTab(tab: object): Promise; + /** 获取所有标签页的持久化存储对象。 */ + getTabs(): Promise<{ [key: number]: object }>; + + /** 在新标签页中打开 URL。 */ + openInTab(url: string, options: GMTypes.OpenTabOptions): Promise; + openInTab(url: string, loadInBackground: boolean): Promise; + openInTab(url: string): Promise; + + /** 关闭由 `openInTab` 打开的标签页。 */ + closeInTab(tabId: string): Promise; + + /** Cookie 操作(含子方法)。 */ + cookie: { + (action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise; + /** 设置 Cookie。 */ + set(details: GMTypes.CookieDetails): Promise; + /** 列出匹配的 Cookie。 */ + list(details: GMTypes.CookieDetails): Promise; + /** 删除 Cookie。 */ + delete(details: GMTypes.CookieDetails): Promise; + }; +}; + +// =========================================================================== +// CAT_* 函数(ScriptCat 专有扩展) +// =========================================================================== + +/** + * 设置浏览器代理规则。 + * @deprecated 已从稳定版移除;可能在 beta 版中恢复。 + */ +declare function CAT_setProxy(rule: CATType.ProxyRule[] | string): void; + +/** + * 清除所有代理规则。 + * @deprecated 已从稳定版移除;可能在 beta 版中恢复。 + */ +declare function CAT_clearProxy(): void; + +/** + * 在坐标 (x, y) 模拟真实点击。 + * @deprecated 已从稳定版移除;可能在 beta 版中恢复。 + */ +declare function CAT_click(x: number, y: number): void; + +/** 打开脚本的用户配置页面。 */ +declare function CAT_userConfig(): void; + +/** + * 与托管文件存储系统交互。 + * 为当前脚本创建 `app/` 目录(或使用 `baseDir`)。 + * 上传会覆盖同名文件。 + * @param action - `"list"` | `"upload"` | `"download"` | `"delete"` | `"config"` + */ +declare function CAT_fileStorage( + action: "list", + details: { + /** 要列出的目录路径。 */ + path?: string; + /** 基础目录;默认为脚本的 UUID。 */ + baseDir?: string; + onload?: (files: CATType.FileStorageFileInfo[]) => void; + onerror?: (error: CATType.FileStorageError) => void; + } +): void; +declare function CAT_fileStorage( + action: "download", + details: { + /** 文件信息对象(某些平台需要文件哈希)。 */ + file: CATType.FileStorageFileInfo; + onload: (data: Blob) => void; + onerror?: (error: CATType.FileStorageError) => void; + } +): void; +declare function CAT_fileStorage( + action: "delete", + details: { + /** 要删除的文件路径。 */ + path: string; + onload?: () => void; + onerror?: (error: CATType.FileStorageError) => void; + } +): void; +declare function CAT_fileStorage( + action: "upload", + details: { + /** 目标文件路径。 */ + path: string; + /** 基础目录;默认为脚本的 UUID。 */ + baseDir?: string; + /** 要上传的文件数据。 */ + data: Blob; + onload?: () => void; + onerror?: (error: CATType.FileStorageError) => void; + } +): void; +/** 打开文件存储配置页面。 */ +declare function CAT_fileStorage(action: "config"): void; + +/** + * 后台脚本重试错误。抛出此错误可让 ScriptCat 稍后重试。 + * 最小重试间隔为 5 秒。避免与脚本自身的调度重叠。 + */ +declare class CATRetryError { + /** @param message - 错误信息。 @param seconds - N 秒后重试。 */ + constructor(message: string, seconds: number); + /** @param message - 错误信息。 @param date - 在指定时间重试。 */ + constructor(message: string, date: Date); +} + +// =========================================================================== +// CATType 命名空间(ScriptCat 专有类型) +// =========================================================================== + +declare namespace CATType { + interface ProxyRule { + proxyServer: ProxyServer; + matchUrl: string[]; + } + + type ProxyScheme = "http" | "https" | "quic" | "socks4" | "socks5"; + + interface ProxyServer { + scheme?: ProxyScheme; + host: string; + port?: number; + } + + interface FileStorageError { + /** + * 错误码: + * -1 = 未知,1 = 存储未配置,2 = 配置错误,3 = 路径不存在, + * 4 = 上传失败,5 = 下载失败,6 = 删除失败, + * 7 = 不允许的文件路径,8 = 网络错误 + */ + code: -1 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; + error: string; + } + + interface FileStorageFileInfo { + /** 文件名。 */ + name: string; + /** 相对文件路径。 */ + path: string; + /** 存储空间中的绝对路径。 */ + absPath: string; + /** 文件大小(字节)。 */ + size: number; + /** 文件内容摘要/哈希。 */ + digest: string; + /** 创建时间戳。 */ + createtime: number; + /** 最后修改时间戳。 */ + updatetime: number; + } + + type CATFileStorageDetails = { + baseDir: string; + path: string; + filename: any; + file: FileStorageFileInfo; + data?: string; + }; +} + +// =========================================================================== +// GMTypes 命名空间(Greasemonkey/Tampermonkey 兼容类型) +// =========================================================================== + +declare namespace GMTypes { + type CookieAction = "list" | "delete" | "set"; + + type LoggerLevel = "debug" | "info" | "warn" | "error"; + + type LoggerLabel = { + [key: string]: string | boolean | number | undefined; + }; + + interface CookieDetailsPartitionKeyType { + topLevelSite?: string; + } + + interface CookieDetails { + url?: string; + name?: string; + value?: string; + domain?: string; + path?: string; + secure?: boolean; + session?: boolean; + httpOnly?: boolean; + expirationDate?: number; + partitionKey?: CookieDetailsPartitionKeyType; + } + + interface Cookie { + domain: string; + name: string; + value: string; + session: boolean; + hostOnly: boolean; + expirationDate?: number; + path: string; + httpOnly: boolean; + secure: boolean; + sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; + } + + /** 值变化监听器。`tabid` 仅在后台脚本监听器中可用。 */ + type ValueChangeListener = ( + name: string, + oldValue: unknown, + newValue: unknown, + remote: boolean, + tabid?: number + ) => unknown; + + interface OpenTabOptions { + /** + * 新标签页是否立即获得焦点。 + * - `true` — 前台打开。 + * - `false` — 后台打开。 + * @default true + */ + active?: boolean; + + /** + * 标签页插入位置。 + * - `true` / `1` — 插入到当前标签页之后。 + * - `false` — 追加到窗口末尾。 + * - `0` — 插入到当前标签页之前。 + * @default true + */ + insert?: boolean | number; + + /** + * 设置 opener 标签页 ID,以便浏览器追踪父子关系。 + * @default true + */ + setParent?: boolean; + + /** + * 在隐私/无痕窗口中打开。 + * 注意:ScriptCat 使用 `"incognito": "split"` — 在普通窗口中, + * tabId/windowId 将不可用。 + * @default false + */ + incognito?: boolean; + + /** + * 旧版字段(仅 TM)。语义与 `active` **相反**: + * `true` = 后台,`false` = 前台。 + * @default false + * @deprecated 请使用 `active` 代替。 + */ + loadInBackground?: boolean; + + /** + * 将新标签页固定在浏览器标签栏。 + * @default false + */ + pinned?: boolean; + + /** + * 使用 `window.open` 代替 `chrome.tabs.create`。 + * 适用于特殊协议如 `vscode://`、`m3u8dl://`。 + * 此模式下其他选项会被忽略。 + * @default false + */ + useOpen?: boolean; + } + + type SWOpenTabOptions = OpenTabOptions & Required>; + + /** + * XMLHttpRequest readyState 值。 + * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/readyState + */ + type ReadyState = + | 0 // UNSENT + | 1 // OPENED + | 2 // HEADERS_RECEIVED + | 3 // LOADING + | 4; // DONE + + interface XHRResponse { + finalUrl?: string; + readyState?: ReadyState; + responseHeaders?: string; + status?: number; + statusText?: string; + response?: string | Blob | ArrayBuffer | Document | ReadableStream> | null | undefined; + responseText?: string | undefined; + responseXML?: Document | null | undefined; + responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | ""; + } + + interface XHRProgress extends XHRResponse { + done: number; + lengthComputable: boolean; + loaded: number; + position?: number; + total: number; + totalSize: number; + } + + type Listener = (event: OBJ) => unknown; + type ContextType = unknown; + + type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + + interface XHRDetails { + method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; + url: string | URL | File | Blob; + headers?: { [key: string]: string }; + data?: GMXHRDataType; + cookie?: string; + binary?: boolean; + timeout?: number; + context?: ContextType; + /** 响应类型。当前版本中 `"stream"` 支持有限。 */ + responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; + overrideMimeType?: string; + /** 发送不带 Cookie 的请求(Tampermonkey 兼容)。 */ + anonymous?: boolean; + /** 发送不带 Cookie 的请求(Greasemonkey 兼容)。 */ + mozAnon?: boolean; + /** 强制内部使用 Fetch API。 */ + fetch?: boolean; + user?: string; + password?: string; + /** 禁用缓存。 */ + nocache?: boolean; + /** 强制重新验证:允许缓存但使用前重新验证。 */ + revalidate?: boolean; + /** 重定向处理。内部强制使用 fetch 模式。 */ + redirect?: "follow" | "error" | "manual"; + /** 分区 Cookie 键,用于存储分区。 */ + cookiePartition?: Record & { topLevelSite?: string }; + + onload?: Listener; + onloadstart?: Listener; + onloadend?: Listener; + onprogress?: Listener; + onreadystatechange?: Listener; + ontimeout?: Listener; + onabort?: Listener; + onerror?: (err: string | (XHRResponse & { error: string })) => void; + } + + interface AbortHandle { + abort(): RETURN_TYPE; + } + + interface DownloadError { + error: "not_enabled" | "not_whitelisted" | "not_permitted" | "not_supported" | "not_succeeded" | "unknown"; + details?: string; + } + + interface DownloadDetails { + // 标准参数(TM/SC) + url: URL; + name: string; + headers?: { [key: string]: string }; + saveAs?: boolean; + conflictAction?: "uniquify" | "overwrite" | "prompt"; + + // 扩展参数(SC/VM) + timeout?: number; + anonymous?: boolean; + context?: ContextType; + user?: string; + password?: string; + + // SC 专有参数 + method?: "GET" | "POST"; + downloadMode?: "native" | "browser"; + cookie?: string; + + // 回调 + onload?: Listener; + onerror?: Listener; + onprogress?: Listener<{ + done: number; + lengthComputable: boolean; + loaded: number; + position?: number; + total: number; + totalSize: number; + }>; + ontimeout?: (arg1?: any) => void; + } + + interface NotificationThis extends NotificationDetails { + id: string; + } + + type NotificationOnClickEvent = { + event: "click" | "buttonClick"; + id: string; + isButtonClick: boolean; + buttonClickIndex: number | undefined; + byUser: boolean | undefined; + preventDefault: () => void; + highlight: NotificationDetails["highlight"]; + image: NotificationDetails["image"]; + silent: NotificationDetails["silent"]; + tag: NotificationDetails["tag"]; + text: NotificationDetails["tag"]; + timeout: NotificationDetails["timeout"]; + title: NotificationDetails["title"]; + url: NotificationDetails["url"]; + }; + type NotificationOnClick = (this: NotificationThis, event: NotificationOnClickEvent) => unknown; + type NotificationOnDone = (this: NotificationThis, user?: boolean) => unknown; + + interface NotificationButton { + title: string; + iconUrl?: string; + } + + interface NotificationDetails { + text?: string; + title?: string; + tag?: string; + image?: string; + highlight?: boolean; + silent?: boolean; + timeout?: number; + url?: string; + onclick?: NotificationOnClick; + ondone?: NotificationOnDone; + progress?: number; + oncreate?: NotificationOnClick; + /** 最多 2 个按钮。 */ + buttons?: NotificationButton[]; + } + + interface Tab { + close(): void; + onclose?: () => void; + closed?: boolean; + name?: string; + } + + type GMClipboardInfo = string | { type?: string; mimetype?: string }; +} + +// =========================================================================== +// CAT.agent — ScriptCat Agent API +// @grant CAT.agent.conversation / CAT.agent.tools / CAT.agent.dom / +// CAT.agent.task / CAT.agent.skills +// =========================================================================== + +// ---- CAT.agent.conversation API ---- + +/** CAT Agent 对话、内容块和流式类型。 */ +declare namespace CATAgent { + // ---- 内容块类型 ---- + + /** 纯文本内容块。 */ + type TextBlock = { type: "text"; text: string }; + + /** 图片内容块。数据存储在 OPFS 中,通过 `attachmentId` 引用。 */ + type ImageBlock = { type: "image"; attachmentId: string; mimeType: string; name?: string }; + + /** 文件内容块。 */ + type FileBlock = { type: "file"; attachmentId: string; mimeType: string; name: string; size?: number }; + + /** 音频内容块。 */ + type AudioBlock = { + type: "audio"; + attachmentId: string; + mimeType: string; + name?: string; + /** 时长(毫秒)。 */ + durationMs?: number; + }; + + /** 所有内容块类型的联合类型。 */ + type ContentBlock = TextBlock | ImageBlock | FileBlock | AudioBlock; + + /** 消息内容:纯字符串或内容块数组(多模态)。 */ + type MessageContent = string | ContentBlock[]; + + // ---- 工具类型 ---- + + /** + * 工具定义(含内联处理函数)。 + * 用于 `ConversationCreateOptions.tools` 或 `ChatOptions.tools`,注册可供 LLM 调用的工具。 + */ + interface ToolDefinition { + /** 工具唯一名称。 */ + name: string; + /** 工具描述。 */ + description: string; + /** 描述工具参数的 JSON Schema。 */ + parameters: Record; + /** LLM 调用此工具时执行的处理函数。 */ + handler: (args: Record) => Promise; + } + + /** + * 自定义命令处理器。命令以 `/` 开头(如 `/new`)。 + * 返回字符串作为回复内容,或返回 void。 + */ + type CommandHandler = (args: string, conv: ConversationInstance) => Promise; + + // ---- 对话选项 ---- + + /** 通过 `CAT.agent.conversation.create()` 创建对话时的选项。 */ + interface ConversationCreateOptions { + /** 自定义对话 ID,省略则自动生成。 */ + id?: string; + /** 系统提示词。 */ + system?: string; + /** 模型 ID,省略则使用默认模型。 */ + model?: string; + /** 工具调用循环最大迭代次数(默认 20)。 */ + maxIterations?: number; + /** 加载的 Skill:`"auto"` 加载全部已安装 Skill,或指定名称数组。 */ + skills?: "auto" | string[]; + /** 带内联处理函数的工具,在此对话生命周期内可用。 */ + tools?: ToolDefinition[]; + /** + * 自定义斜杠命令处理器(如 `{ "/reset": handler }`)。 + * 内置的 `/new` 命令(清空对话)可被覆盖。 + */ + commands?: Record; + /** + * 临时模式:消息仅保留在内存中,不持久化。 + * 不加载内置工具/Skill,脚本需自行提供所有工具。 + */ + ephemeral?: boolean; + } + + /** 单次 `chat()` / `chatStream()` 调用的选项。 */ + interface ChatOptions { + /** 仅用于此次调用的附加工具(与对话级工具合并)。 */ + tools?: ToolDefinition[]; + } + + // ---- 工具调用 ---- + + /** LLM 发起的工具调用记录。 */ + interface ToolCallInfo { + /** 唯一调用 ID。 */ + id: string; + /** 工具名称。 */ + name: string; + /** JSON 序列化的参数。 */ + arguments: string; + /** 工具执行结果(执行后填充)。 */ + result?: string; + /** 调用状态。 */ + status?: "pending" | "running" | "completed" | "error"; + } + + // ---- 聊天回复 ---- + + /** 非流式 `chat()` 调用的返回结果。 */ + interface ChatReply { + /** 回复内容。 */ + content: MessageContent; + /** 模型思考/推理文本(如有)。 */ + thinking?: string; + /** 本轮中的工具调用。 */ + toolCalls?: ToolCallInfo[]; + /** Token 用量。 */ + usage?: { inputTokens: number; outputTokens: number }; + /** 当回复由命令处理器产生(而非 LLM)时为 `true`。 */ + command?: boolean; + } + + /** 通过 `chatStream()` 流式返回的单个数据块。 */ + interface StreamChunk { + /** + * 数据块类型: + * - `"content_delta"` — 增量文本 + * - `"thinking_delta"` — 增量思考/推理 + * - `"tool_call"` — 工具调用事件 + * - `"content_block"` — 完整的非文本内容块 + * - `"done"` — 流结束 + * - `"error"` — 发生错误 + */ + type: "content_delta" | "thinking_delta" | "tool_call" | "content_block" | "done" | "error"; + /** 文本增量(用于 content_delta / thinking_delta)。 */ + content?: string; + /** 完整内容块(用于 content_block)。 */ + block?: ContentBlock; + /** 工具调用信息(用于 tool_call)。 */ + toolCall?: ToolCallInfo; + /** Token 用量(用于 done)。 */ + usage?: { inputTokens: number; outputTokens: number }; + /** 错误信息(用于 error)。 */ + error?: string; + /** 错误分类码:`"rate_limit"` | `"auth"` | `"tool_timeout"` | `"max_iterations"` | `"api_error"` */ + errorCode?: string; + /** 当数据块由命令处理器产生时为 `true`。 */ + command?: boolean; + } + + // ---- 聊天消息 ---- + + /** 对话中持久化的聊天消息。 */ + interface ChatMessage { + /** 消息 ID。 */ + id: string; + /** 所属对话 ID。 */ + conversationId: string; + /** 消息角色。 */ + role: "user" | "assistant" | "system" | "tool"; + /** 消息内容(文本或多模态)。 */ + content: MessageContent; + /** 模型思考/推理块。 */ + thinking?: { content: string }; + /** 此消息中的工具调用。 */ + toolCalls?: ToolCallInfo[]; + /** 关联的 tool_call ID(用于 role="tool" 的消息)。 */ + toolCallId?: string; + /** 错误信息(当轮次出错时)。 */ + error?: string; + /** 生成此消息使用的模型 ID。 */ + modelId?: string; + /** 此消息的 Token 用量。 */ + usage?: { + inputTokens: number; + outputTokens: number; + /** Anthropic 缓存创建输入 tokens。 */ + cacheCreationInputTokens?: number; + /** Anthropic 缓存读取输入 tokens。 */ + cacheReadInputTokens?: number; + }; + /** 总响应时长(毫秒)。 */ + durationMs?: number; + /** 首 token 时间(毫秒)。 */ + firstTokenMs?: number; + /** 父消息 ID(用于分支)。 */ + parentId?: string; + /** 创建时间戳。 */ + createtime: number; + } + + // ---- 对话实例 ---- + + /** + * 由 `CAT.agent.conversation.create()` 或 `.get()` 返回的对话实例。 + * 提供聊天、流式传输和管理消息历史的方法。 + */ + interface ConversationInstance { + /** 对话 ID。 */ + readonly id: string; + /** 对话标题。 */ + readonly title: string; + /** 使用的模型 ID。 */ + readonly modelId: string; + + /** 发送消息并等待完整回复(自动执行工具调用循环)。 */ + chat(content: MessageContent, options?: ChatOptions): Promise; + + /** 发送消息并接收流式响应。 */ + chatStream(content: MessageContent, options?: ChatOptions): Promise>; + + /** 获取此对话中的所有消息。 */ + getMessages(): Promise; + + /** 清空此对话中的所有消息。 */ + clear(): Promise; + + /** 将对话持久化到存储。 */ + save(): Promise; + } + + // ---- 对话 API ---- + + /** + * `CAT.agent.conversation` — 创建和获取对话实例。 + * @grant CAT.agent.conversation + */ + interface ConversationAPI { + /** 创建新对话。 */ + create(options?: ConversationCreateOptions): Promise; + + /** 根据 ID 获取已有对话。未找到时返回 `null`。 */ + get(id: string): Promise; + } +} + +// ---- CAT.agent.tools — CATTool 管理 API ---- + +/** CATTool 管理类型 — 安装、卸载、列出和调用 CATTool。 */ +declare namespace CATAgentTools { + /** CATTool 的参数定义。 */ + interface CATToolParam { + /** 参数名称。 */ + name: string; + /** 参数类型。 */ + type: "string" | "number" | "boolean"; + /** 是否必填。 */ + required: boolean; + /** 参数描述。 */ + description: string; + /** 允许的值(用于枚举参数)。 */ + enum?: string[]; + } + + /** 持久化的 CATTool 记录。 */ + interface CATToolRecord { + /** 内部 UUID。 */ + id: string; + /** 工具名称(来自 `@name` 元数据)。 */ + name: string; + /** 工具描述。 */ + description: string; + /** 参数定义列表。 */ + params: CATToolParam[]; + /** 所需的 GM 权限。 */ + grants: string[]; + /** `@require` URL 列表。 */ + requires?: string[]; + /** 完整源代码(含元数据头)。 */ + code: string; + /** 安装此工具的脚本 UUID。 */ + sourceScriptUuid?: string; + /** 安装此工具的脚本名称。 */ + sourceScriptName?: string; + /** 安装时间戳。 */ + installtime: number; + /** 最后更新时间戳。 */ + updatetime: number; + } + + /** + * `CAT.agent.tools` — 管理和调用 CATTool。 + * @grant CAT.agent.tools + */ + interface ToolsAPI { + /** 从源代码安装 CATTool。 */ + install(code: string): Promise; + + /** 按名称卸载 CATTool。 */ + remove(name: string): Promise; + + /** 列出所有已安装的 CATTool。 */ + list(): Promise; + + /** 按名称调用 CATTool,可传入参数。 */ + call(name: string, params?: Record): Promise; + } +} + +// ---- CAT.agent.dom — 浏览器 DOM 自动化 API ---- + +/** DOM 自动化类型 — 与浏览器标签页、页面和元素交互。 */ +declare namespace CATAgentDom { + /** 浏览器标签页信息。 */ + interface TabInfo { + /** 标签页 ID。 */ + tabId: number; + /** 当前 URL。 */ + url: string; + /** 页面标题。 */ + title: string; + /** 标签页是否处于激活状态。 */ + active: boolean; + /** 窗口 ID。 */ + windowId: number; + /** 标签页是否已被丢弃(从内存中卸载)。 */ + discarded: boolean; + } + + /** DOM 操作(点击、填充等)的结果。 */ + interface ActionResult { + /** 操作是否成功。 */ + success: boolean; + /** 操作是否导致了导航。 */ + navigated?: boolean; + /** 操作后的当前 URL。 */ + url?: string; + /** 操作导致打开的新标签页。 */ + newTab?: { tabId: number; url: string }; + /** 出现的对话框。 */ + dialog?: { type: "alert" | "confirm" | "prompt"; message: string }; + } + + /** `readPage()` 返回的页面内容。 */ + interface PageContent { + /** 页面标题。 */ + title: string; + /** 页面 URL。 */ + url: string; + /** HTML 内容(或选定的片段)。 */ + html: string; + /** 内容是否因 `maxLength` 而被截断。 */ + truncated?: boolean; + /** 截断前的原始总长度。 */ + totalLength?: number; + } + + /** `readPage()` 的选项。 */ + interface ReadPageOptions { + /** 目标标签页 ID,默认为活动标签页。 */ + tabId?: number; + /** 读取特定元素的 CSS 选择器。 */ + selector?: string; + /** 最大内容长度(字符数)。 */ + maxLength?: number; + /** 读取前要移除的标签/选择器(如 `["script", "style", "svg"]`)。 */ + removeTags?: string[]; + } + + /** DOM 操作(点击、填充)的选项。 */ + interface DomActionOptions { + /** 目标标签页 ID。 */ + tabId?: number; + /** 使用可信的(CDP 派发的)事件,而非合成 JS 事件。 */ + trusted?: boolean; + } + + /** `screenshot()` 的选项。 */ + interface ScreenshotOptions { + /** 目标标签页 ID。 */ + tabId?: number; + /** JPEG 质量(0–100)。 */ + quality?: number; + /** 捕获完整可滚动页面。 */ + fullPage?: boolean; + } + + /** `navigate()` 的选项。 */ + interface NavigateOptions { + /** 目标标签页 ID。 */ + tabId?: number; + /** 等待页面完全加载。 */ + waitUntil?: boolean; + /** 导航超时时间(毫秒)。 */ + timeout?: number; + } + + /** 滚动方向。 */ + type ScrollDirection = "up" | "down" | "top" | "bottom"; + + /** `scroll()` 的选项。 */ + interface ScrollOptions { + /** 目标标签页 ID。 */ + tabId?: number; + /** 在特定元素内滚动。 */ + selector?: string; + } + + /** 滚动操作的结果。 */ + interface ScrollResult { + /** 当前滚动位置。 */ + scrollTop: number; + /** 总可滚动高度。 */ + scrollHeight: number; + /** 可见视口高度。 */ + clientHeight: number; + /** 是否已滚动到底部。 */ + atBottom: boolean; + } + + /** `navigate()` 调用的结果。 */ + interface NavigateResult { + /** 标签页 ID。 */ + tabId: number; + /** 导航后的最终 URL。 */ + url: string; + /** 页面标题。 */ + title: string; + } + + /** `waitFor()` 的选项。 */ + interface WaitForOptions { + /** 目标标签页 ID。 */ + tabId?: number; + /** 超时时间(毫秒)。 */ + timeout?: number; + } + + /** `waitFor()` 的结果。 */ + interface WaitForResult { + /** 是否找到了元素。 */ + found: boolean; + /** 元素详情(找到时)。 */ + element?: { + selector: string; + tag: string; + text: string; + role?: string; + type?: string; + visible: boolean; + }; + } + + /** `executeScript()` 的选项。 */ + interface ExecuteScriptOptions { + /** 目标标签页 ID。 */ + tabId?: number; + } + + /** `peekMonitor()` 的结果 — 正在监控的 DOM 变更摘要。 */ + interface MonitorStatus { + /** 是否检测到变更。 */ + hasChanges: boolean; + /** 捕获的对话框数量。 */ + dialogCount: number; + /** 捕获的新增 DOM 节点数量。 */ + nodeCount: number; + } + + /** + * `CAT.agent.dom` — 浏览器标签页和 DOM 自动化。 + * @grant CAT.agent.dom + */ + interface DomAPI { + /** 列出所有打开的浏览器标签页。 */ + listTabs(): Promise; + + /** 导航标签页到指定 URL。 */ + navigate(url: string, options?: NavigateOptions): Promise; + + /** 读取页面的 HTML 内容(或选定元素)。 */ + readPage(options?: ReadPageOptions): Promise; + + /** 截取标签页的屏幕截图。返回 base64 编码的 data URL。 */ + screenshot(options?: ScreenshotOptions): Promise; + + /** 点击匹配 CSS 选择器的元素。 */ + click(selector: string, options?: DomActionOptions): Promise; + + /** 向匹配 CSS 选择器的输入框/文本域填入指定值。 */ + fill(selector: string, value: string, options?: DomActionOptions): Promise; + + /** 滚动页面或元素。 */ + scroll(direction: ScrollDirection, options?: ScrollOptions): Promise; + + /** 等待匹配 CSS 选择器的元素出现。 */ + waitFor(selector: string, options?: WaitForOptions): Promise; + + /** 在页面上下文中执行 JavaScript 代码。 */ + executeScript(code: string, options?: ExecuteScriptOptions): Promise; + + /** 开始监控标签页上的 DOM 变更(对话框、新增节点)。 */ + startMonitor(tabId: number): Promise; + + /** 停止监控标签页上的 DOM 变更。 */ + stopMonitor(tabId: number): Promise; + + /** 查看标签页的当前监控状态。 */ + peekMonitor(tabId: number): Promise; + } +} + +// ---- CAT.agent.task — 定时任务 API ---- + +/** 定时任务类型 — 创建基于 cron 的任务,运行 Agent 对话或发出事件。 */ +declare namespace CATAgentTask { + /** 定时 Agent 任务记录。 */ + interface AgentTask { + /** 任务 ID。 */ + id: string; + /** 任务名称。 */ + name: string; + /** Cron 表达式。 */ + crontab: string; + /** + * 执行模式: + * - `"internal"` — Service Worker 自动运行 LLM 对话。 + * - `"event"` — 通过 `addListener` 通知脚本。 + */ + mode: "internal" | "event"; + /** 任务是否启用。 */ + enabled: boolean; + /** 触发时是否显示浏览器通知。 */ + notify: boolean; + + // --- internal 模式字段 --- + /** 每次触发时发送的提示词。 */ + prompt?: string; + /** 使用的模型 ID。 */ + modelId?: string; + /** 要续接的已有对话 ID。 */ + conversationId?: string; + /** 加载的 Skill。 */ + skills?: "auto" | string[]; + /** 工具调用最大迭代次数(默认 10)。 */ + maxIterations?: number; + + // --- event 模式字段 --- + /** 创建此任务的脚本 UUID。 */ + sourceScriptUuid?: string; + + // --- 运行状态 --- + /** 上次运行时间戳。 */ + lastruntime?: number; + /** 下次计划运行时间戳。 */ + nextruntime?: number; + /** 上次运行结果状态。 */ + lastRunStatus?: "success" | "error"; + /** 上次运行错误信息。 */ + lastRunError?: string; + /** 创建时间戳。 */ + createtime: number; + /** 最后更新时间戳。 */ + updatetime: number; + } + + /** 任务触发时通过 `addListener` 回调传递的事件载荷。 */ + interface AgentTaskTrigger { + /** 任务 ID。 */ + taskId: string; + /** 任务名称。 */ + name: string; + /** Cron 表达式。 */ + crontab: string; + /** 触发时间戳。 */ + triggeredAt: number; + } + + /** 创建新任务的选项(系统自动填充的字段已省略)。 */ + type AgentTaskCreateOptions = Omit; + + /** + * `CAT.agent.task` — 创建和管理定时 Agent 任务。 + * @grant CAT.agent.task + */ + interface TaskAPI { + /** 创建新的定时任务。 */ + create(options: AgentTaskCreateOptions): Promise; + + /** 列出所有任务。 */ + list(): Promise; + + /** 根据 ID 获取任务。 */ + get(id: string): Promise; + + /** 更新任务。 */ + update(id: string, task: Partial): Promise; + + /** 根据 ID 删除任务。 */ + remove(id: string): Promise; + + /** 立即触发任务(不受 cron 计划限制)。 */ + runNow(id: string): Promise; + + /** + * 监听任务触发事件(用于 `mode: "event"` 的任务)。 + * 返回监听器 ID,可用于后续移除。 + */ + addListener(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number; + + /** 移除之前注册的监听器。 */ + removeListener(listenerId: number): void; + } +} + +// ---- CAT.agent.skills — Skill 管理 API ---- + +/** Skill 管理类型 — 安装、卸载和查询 Agent Skill。 */ +declare namespace CATAgentSkills { + /** 已安装 Skill 的摘要信息。 */ + interface SkillSummary { + /** Skill 名称。 */ + name: string; + /** Skill 描述。 */ + description: string; + /** 此 Skill 中打包的 CATTool 名称(来自 `scripts/` 目录)。 */ + toolNames: string[]; + /** 参考资料名称(来自 `references/` 目录)。 */ + referenceNames: string[]; + /** 安装时间戳。 */ + installtime: number; + /** 最后更新时间戳。 */ + updatetime: number; + } + + /** 包含 prompt 的完整 Skill 记录。 */ + interface SkillRecord extends SkillSummary { + /** SKILL.md 正文(去除 frontmatter 后的 markdown)。 */ + prompt: string; + } + + /** + * `CAT.agent.skills` — 管理 Agent Skill(打包的提示词 + 工具 + 参考资料)。 + * @grant CAT.agent.skills + */ + interface SkillsAPI { + /** 列出所有已安装的 Skill。 */ + list(): Promise; + + /** 根据名称获取 Skill 的完整详情。未找到时返回 `null`。 */ + get(name: string): Promise; + + /** + * 从 SKILL.md 字符串安装 Skill,可附带打包的脚本和参考资料。 + * @param skillMd - SKILL.md 内容(含 YAML frontmatter)。 + * @param scripts - 要打包的 CATTool 脚本。 + * @param references - 要打包的参考资料。 + */ + install( + skillMd: string, + scripts?: Array<{ name: string; code: string }>, + references?: Array<{ name: string; content: string }> + ): Promise; + + /** 根据名称卸载 Skill。 */ + remove(name: string): Promise; + } +} + +// ---- CAT 全局对象 ---- + +/** + * ScriptCat Agent 全局对象 — 提供对话、工具、DOM、任务和 Skill API 的访问。 + * 每个子 API 需要各自的 `@grant` 声明。 + */ +declare const CAT: { + agent: { + /** @grant CAT.agent.conversation */ + conversation: CATAgent.ConversationAPI; + /** @grant CAT.agent.tools */ + tools: CATAgentTools.ToolsAPI; + /** @grant CAT.agent.dom */ + dom: CATAgentDom.DomAPI; + /** @grant CAT.agent.task */ + task: CATAgentTask.TaskAPI; + /** @grant CAT.agent.skills */ + skills: CATAgentSkills.SkillsAPI; + }; +}; + +/** + * Skill 配置值,运行时注入到 CATTool 沙箱中。 + * + * 在 SKILL.md frontmatter 的 `config` 块中声明,由用户在 Skill 设置 UI 中填写。 + * 注入时对象已被冻结,属性为只读。 + */ +declare const CAT_CONFIG: Readonly>; From aa8c203558d8c430c07354816301c98d9dcc88ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 14 Mar 2026 20:50:44 +0800 Subject: [PATCH 068/150] =?UTF-8?q?refactor:=20=E6=98=8E=E7=A1=AE=20Agent?= =?UTF-8?q?=20GM=20API=20=E8=BF=94=E5=9B=9E=E5=80=BC=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=20unknown/any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 JsonValue 通用类型用于 CATTool 动态返回值 - cat_agent_tools.ts: install→CATToolRecord, remove→boolean, list→CATToolSummary[], call→JsonValue - cat_agent_dom.ts: 所有 DOM 操作方法使用具体返回类型(TabInfo[], ActionResult 等) - cat_agent_skills.ts: list→SkillSummary[], get→SkillRecord|null, install→SkillRecord, remove→boolean - agent.ts: handleToolsApi/handleSkillsApi/callCATTool 返回类型同步更新 - cattool_executor.ts: execute 返回 JsonValue --- e2e/agent-skill.spec.ts | 8 +- e2e/gm-api.spec.ts | 2 +- e2e/utils.ts | 8 +- src/app/repo/agent_task.ts | 2 +- src/app/service/agent/agent.test.ts | 16 +++- .../service/agent/cattool_executor.test.ts | 11 +-- src/app/service/agent/cattool_executor.ts | 4 +- src/app/service/agent/content_utils.test.ts | 2 +- .../service/agent/mcp_tool_executor.test.ts | 4 +- src/app/service/agent/providers/anthropic.ts | 16 +++- src/app/service/agent/providers/openai.ts | 4 +- src/app/service/agent/system_prompt.test.ts | 2 +- src/app/service/agent/task_scheduler.test.ts | 10 ++- src/app/service/agent/tool_registry.test.ts | 42 +++------ src/app/service/agent/tool_registry.ts | 5 +- src/app/service/agent/types.ts | 22 ++++- src/app/service/content/gm_api/cat_agent.ts | 8 +- .../service/content/gm_api/cat_agent_dom.ts | 32 ++++--- .../content/gm_api/cat_agent_skills.ts | 27 +++--- .../service/content/gm_api/cat_agent_task.ts | 9 +- .../service/content/gm_api/cat_agent_tools.ts | 23 ++--- src/app/service/content/gm_api/gm_api.test.ts | 1 - src/app/service/content/gm_api/gm_api.ts | 1 + src/app/service/service_worker/agent.test.ts | 88 ++++++++++++++----- src/app/service/service_worker/agent.ts | 49 +++++++---- .../service_worker/agent_dom_cdp.test.ts | 26 ++---- .../service/service_worker/agent_dom_cdp.ts | 4 +- src/app/service/service_worker/client.ts | 7 +- .../service/service_worker/gm_api/gm_api.ts | 6 +- .../install/components/SkillInstallView.tsx | 4 +- src/pages/options/routes/AgentCATool.tsx | 41 +++------ .../routes/AgentChat/AttachmentRenderers.tsx | 13 ++- .../options/routes/AgentChat/ChatArea.tsx | 9 +- .../routes/AgentChat/ContentBlockRenderer.tsx | 23 +++-- .../routes/AgentChat/MarkdownRenderer.tsx | 3 +- .../options/routes/AgentChat/MessageItem.tsx | 1 - .../routes/AgentChat/MessageToolbar.tsx | 10 ++- .../routes/AgentChat/chat_utils.test.ts | 16 +--- .../options/routes/AgentChat/chat_utils.ts | 12 +-- src/pages/options/routes/AgentChat/hooks.ts | 8 +- src/pages/options/routes/AgentSkills.tsx | 7 +- src/pages/options/routes/AgentTasks.tsx | 20 ++--- src/types/scriptcat.d.ts | 7 +- src/types/scriptcat.zh-CN.d.ts | 7 +- 44 files changed, 358 insertions(+), 262 deletions(-) diff --git a/e2e/agent-skill.spec.ts b/e2e/agent-skill.spec.ts index f95cbdd8e..569b3d7a2 100644 --- a/e2e/agent-skill.spec.ts +++ b/e2e/agent-skill.spec.ts @@ -26,13 +26,9 @@ return "Hello, " + args.name + "! Welcome!"; test.describe("Agent Skill System", () => { test.setTimeout(300_000); - test("Skill install + load_skill + dynamic CATTool invocation", async ({ - context, - extensionId, - mockLLMResponse, - }) => { + test("Skill install + load_skill + dynamic CATTool invocation", async ({ context, extensionId, mockLLMResponse }) => { let callCount = 0; - mockLLMResponse(({ tools }) => { + mockLLMResponse(({ tools: _tools }) => { callCount++; if (callCount === 1) { // First call: LLM decides to load the skill diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index e70c9a190..b6a023f44 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -43,7 +43,7 @@ const test = base.extend<{ }); // Ensure service worker is registered before handing context to fixtures, // preventing extensionId fixture from timing out with the global 10s timeout. - let [sw] = context.serviceWorkers(); + const [sw] = context.serviceWorkers(); if (!sw) await context.waitForEvent("serviceworker", { timeout: 30_000 }); await use(context); await context.close(); diff --git a/e2e/utils.ts b/e2e/utils.ts index ff33c5134..d1494c557 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -49,11 +49,9 @@ export async function installScriptByCode(context: BrowserContext, extensionId: await page.evaluate((text) => navigator.clipboard.writeText(text), code); await page.keyboard.press("ControlOrMeta+v"); // Wait for Monaco to finish rendering the pasted content (content will differ from template) - await page.waitForFunction( - (init) => document.querySelector(".view-lines")?.textContent !== init, - initialText, - { timeout: 10_000 } - ); + await page.waitForFunction((init) => document.querySelector(".view-lines")?.textContent !== init, initialText, { + timeout: 10_000, + }); // Save await page.keyboard.press("ControlOrMeta+s"); // Wait for save: try arco-message first, then verify via script list diff --git a/src/app/repo/agent_task.ts b/src/app/repo/agent_task.ts index cb887c174..082c1bf24 100644 --- a/src/app/repo/agent_task.ts +++ b/src/app/repo/agent_task.ts @@ -1,5 +1,5 @@ import type { AgentTask, AgentTaskRun } from "@App/app/service/agent/types"; -import { Repo, deletesStorage } from "./repo"; +import { Repo } from "./repo"; export class AgentTaskRepo extends Repo { constructor() { diff --git a/src/app/service/agent/agent.test.ts b/src/app/service/agent/agent.test.ts index 9aee5c960..d34b7a1dc 100644 --- a/src/app/service/agent/agent.test.ts +++ b/src/app/service/agent/agent.test.ts @@ -999,7 +999,12 @@ describe("callLLMWithToolLoop", () => { it("callLLM HTTP 错误 - 纯文本错误体", async () => { const { service } = createTestService(); - fetchSpy.mockResolvedValueOnce(buildErrorResponse(429, "Rate limit exceeded")); + // 429 是可重试错误,需要 mock 足够多的失败响应让 withRetry 用尽重试次数 + const errorResp = () => buildErrorResponse(429, "Rate limit exceeded"); + fetchSpy.mockResolvedValueOnce(errorResp()); + fetchSpy.mockResolvedValueOnce(errorResp()); + fetchSpy.mockResolvedValueOnce(errorResp()); + fetchSpy.mockResolvedValueOnce(errorResp()); await expect( (service as any).callLLMWithToolLoop({ @@ -1009,6 +1014,7 @@ describe("callLLMWithToolLoop", () => { sendEvent: () => {}, signal: new AbortController().signal, scriptToolCallback: null, + delayFn: async () => {}, }) ).rejects.toThrow("API error: 429 - Rate limit exceeded"); }); @@ -1016,7 +1022,12 @@ describe("callLLMWithToolLoop", () => { it("callLLM HTTP 错误 - 空错误体", async () => { const { service } = createTestService(); - fetchSpy.mockResolvedValueOnce(buildErrorResponse(502, "")); + // 502 是可重试错误,需要 mock 足够多的失败响应让 withRetry 用尽重试次数 + const errorResp = () => buildErrorResponse(502, ""); + fetchSpy.mockResolvedValueOnce(errorResp()); + fetchSpy.mockResolvedValueOnce(errorResp()); + fetchSpy.mockResolvedValueOnce(errorResp()); + fetchSpy.mockResolvedValueOnce(errorResp()); await expect( (service as any).callLLMWithToolLoop({ @@ -1026,6 +1037,7 @@ describe("callLLMWithToolLoop", () => { sendEvent: () => {}, signal: new AbortController().signal, scriptToolCallback: null, + delayFn: async () => {}, }) ).rejects.toThrow("API error: 502"); }); diff --git a/src/app/service/agent/cattool_executor.test.ts b/src/app/service/agent/cattool_executor.test.ts index c1d9f8e34..3df25e345 100644 --- a/src/app/service/agent/cattool_executor.test.ts +++ b/src/app/service/agent/cattool_executor.test.ts @@ -308,7 +308,7 @@ describe("CATToolExecutor 类型转换边界值", () => { expect(getCallParams(sender).args).toEqual({ flag: false }); }); - it('boolean 转换:null → false', async () => { + it("boolean 转换:null → false", async () => { const sender = createMockSender(); const record = createRecord([{ name: "flag", type: "boolean", required: false, description: "标记" }]); const executor = new CATToolExecutor(record, sender); @@ -367,10 +367,7 @@ describe("CATToolExecutor @require 加载", () => { it("有 requires 和 requireLoader 时应加载资源并传给 executeCATTool", async () => { const sender = createMockSender(); const record = createRecord([], { - requires: [ - "https://cdn.example.com/lib1.js", - "https://cdn.example.com/lib2.js", - ], + requires: ["https://cdn.example.com/lib1.js", "https://cdn.example.com/lib2.js"], }); const loader: RequireLoader = vi.fn().mockImplementation((url: string) => { if (url.includes("lib1")) return Promise.resolve("var LIB1 = {};"); @@ -502,8 +499,8 @@ describe("CATToolExecutor 超时处理", () => { const err = await errPromise; expect(err).toBeInstanceOf(Error); - expect(err.message).toContain("hang_tool"); - expect(err.message).toContain("timed out"); + expect((err as Error).message).toContain("hang_tool"); + expect((err as Error).message).toContain("timed out"); expect((err as any).errorCode).toBe("tool_timeout"); }); diff --git a/src/app/service/agent/cattool_executor.ts b/src/app/service/agent/cattool_executor.ts index 33c278556..74e7e4add 100644 --- a/src/app/service/agent/cattool_executor.ts +++ b/src/app/service/agent/cattool_executor.ts @@ -1,5 +1,5 @@ import type { MessageSend } from "@Packages/message/types"; -import type { CATToolRecord } from "./types"; +import type { CATToolRecord, JsonValue } from "./types"; import type { ToolExecutor } from "./tool_registry"; import { getCATToolBody } from "@App/pkg/utils/cattool"; import { executeCATTool } from "@App/app/service/offscreen/client"; @@ -39,7 +39,7 @@ export class CATToolExecutor implements ToolExecutor { private configValues?: Record ) {} - async execute(args: Record): Promise { + async execute(args: Record): Promise { // 根据 @param 定义做基本的类型转换 const typedArgs: Record = {}; for (const param of this.record.params) { diff --git a/src/app/service/agent/content_utils.test.ts b/src/app/service/agent/content_utils.test.ts index 9b1e78217..2dd6cce39 100644 --- a/src/app/service/agent/content_utils.test.ts +++ b/src/app/service/agent/content_utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { getTextContent, normalizeContent, isContentBlocks } from "./content_utils"; -import type { ContentBlock, MessageContent } from "./types"; +import type { ContentBlock } from "./types"; describe("content_utils", () => { describe("getTextContent", () => { diff --git a/src/app/service/agent/mcp_tool_executor.test.ts b/src/app/service/agent/mcp_tool_executor.test.ts index f6d76ac05..d01415e63 100644 --- a/src/app/service/agent/mcp_tool_executor.test.ts +++ b/src/app/service/agent/mcp_tool_executor.test.ts @@ -102,9 +102,7 @@ describe("MCPToolExecutor", () => { }); it("image 缺少 mimeType 时应默认为 image/png", async () => { - const mcpContent = [ - { type: "image", data: "abc123" }, - ]; + const mcpContent = [{ type: "image", data: "abc123" }]; const client = createMockClient(mcpContent); const executor = new MCPToolExecutor(client, "no_mime"); diff --git a/src/app/service/agent/providers/anthropic.ts b/src/app/service/agent/providers/anthropic.ts index 75a6b4a4e..eabbe42fe 100644 --- a/src/app/service/agent/providers/anthropic.ts +++ b/src/app/service/agent/providers/anthropic.ts @@ -51,7 +51,10 @@ function convertContentBlocks( } case "audio": // Anthropic 暂不支持音频,降级为文本描述 - result.push({ type: "text", text: `[Audio: ${block.name || "audio"}${block.durationMs ? ` (${(block.durationMs / 1000).toFixed(1)}s)` : ""}]` }); + result.push({ + type: "text", + text: `[Audio: ${block.name || "audio"}${block.durationMs ? ` (${(block.durationMs / 1000).toFixed(1)}s)` : ""}]`, + }); break; } } @@ -127,7 +130,13 @@ export function buildAnthropicRequest( if (systemMessages.length > 0) { const systemBlocks = systemMessages.map((m) => ({ type: "text" as const, - text: typeof m.content === "string" ? m.content : m.content.filter((b) => b.type === "text").map((b) => (b as { type: "text"; text: string }).text).join(""), + text: + typeof m.content === "string" + ? m.content + : m.content + .filter((b) => b.type === "text") + .map((b) => (b as { type: "text"; text: string }).text) + .join(""), })); // 最后一个 system block 加 cache_control(仅在启用缓存时) if (useCache && systemBlocks.length > 0) { @@ -177,7 +186,8 @@ export function parseAnthropicStream( const decoder = new TextDecoder(); // 跟踪 message_start 中的 usage(含 cache 信息),在 message_delta 中合并输出 - let cachedUsage: { inputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } | null = null; + let cachedUsage: { inputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } | null = + null; return (async () => { try { diff --git a/src/app/service/agent/providers/openai.ts b/src/app/service/agent/providers/openai.ts index 1e2dcb15a..61bb951a0 100644 --- a/src/app/service/agent/providers/openai.ts +++ b/src/app/service/agent/providers/openai.ts @@ -126,7 +126,9 @@ export function parseOpenAIStream( return (async () => { // 记录最新的 usage 数据(某些 API 如 Grok 在每个 chunk 都带 usage,而非仅最后一个) - let lastUsage: { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } | undefined; + let lastUsage: + | { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } + | undefined; try { while (!signal.aborted) { diff --git a/src/app/service/agent/system_prompt.test.ts b/src/app/service/agent/system_prompt.test.ts index f0f7a9c5a..73846d510 100644 --- a/src/app/service/agent/system_prompt.test.ts +++ b/src/app/service/agent/system_prompt.test.ts @@ -6,7 +6,7 @@ describe("buildSystemPrompt", () => { it("无 userSystem、无 skillSuffix 时只返回内置提示词", () => { const result = buildSystemPrompt({}); expect(result).toContain("You are ScriptCat Agent"); - expect(result).toContain("## Guidelines"); + expect(result).toContain("## Core Principles"); // 末尾不应有多余的空行 expect(result.endsWith("\n\n")).toBe(false); }); diff --git a/src/app/service/agent/task_scheduler.test.ts b/src/app/service/agent/task_scheduler.test.ts index ca7e21099..04093feb6 100644 --- a/src/app/service/agent/task_scheduler.test.ts +++ b/src/app/service/agent/task_scheduler.test.ts @@ -20,14 +20,20 @@ function makeTask(overrides: Partial = {}): AgentTask { describe("AgentTaskScheduler", () => { let repo: AgentTaskRepo; let runRepo: AgentTaskRunRepo; - let internalExecutor: ReturnType Promise<{ conversationId: string; usage?: { inputTokens: number; outputTokens: number } }>>>; + let internalExecutor: ReturnType< + typeof vi.fn< + (task: AgentTask) => Promise<{ conversationId: string; usage?: { inputTokens: number; outputTokens: number } }> + > + >; let eventEmitter: ReturnType Promise>>; let scheduler: AgentTaskScheduler; beforeEach(() => { repo = new AgentTaskRepo(); runRepo = new AgentTaskRunRepo(); - internalExecutor = vi.fn().mockResolvedValue({ conversationId: "conv-1", usage: { inputTokens: 100, outputTokens: 50 } }); + internalExecutor = vi + .fn() + .mockResolvedValue({ conversationId: "conv-1", usage: { inputTokens: 100, outputTokens: 50 } }); eventEmitter = vi.fn().mockResolvedValue(undefined); scheduler = new AgentTaskScheduler(repo, runRepo, internalExecutor, eventEmitter); }); diff --git a/src/app/service/agent/tool_registry.test.ts b/src/app/service/agent/tool_registry.test.ts index 7eaada1c6..69e1210ce 100644 --- a/src/app/service/agent/tool_registry.test.ts +++ b/src/app/service/agent/tool_registry.test.ts @@ -268,7 +268,12 @@ describe("ToolRegistry", () => { content: "Files generated.", attachments: [ { type: "image", name: "img1.png", mimeType: "image/png", data: "data:image/png;base64,abc" }, - { type: "file", name: "report.xlsx", mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data: "base64data" }, + { + type: "file", + name: "report.xlsx", + mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + data: "base64data", + }, ], }; const executor = createExecutor(async () => structuredResult); @@ -290,9 +295,7 @@ describe("ToolRegistry", () => { const blob = new Blob(["hello"], { type: "text/plain" }); const structuredResult: ToolResultWithAttachments = { content: "File created.", - attachments: [ - { type: "file", name: "data.txt", mimeType: "text/plain", data: blob as unknown as string }, - ], + attachments: [{ type: "file", name: "data.txt", mimeType: "text/plain", data: blob as unknown as string }], }; const executor = createExecutor(async () => structuredResult); registry.registerBuiltin(weatherDef, executor); @@ -360,19 +363,12 @@ describe("ToolRegistry", () => { const structuredResult: ToolResultWithAttachments = { content: "CATTool generated file.", - attachments: [ - { type: "file", name: "output.zip", mimeType: "application/zip", data: "base64zipdata" }, - ], + attachments: [{ type: "file", name: "output.zip", mimeType: "application/zip", data: "base64zipdata" }], }; - const scriptCallback = vi.fn().mockResolvedValue([ - { id: "tc_1", result: JSON.stringify(structuredResult) }, - ]); + const scriptCallback = vi.fn().mockResolvedValue([{ id: "tc_1", result: JSON.stringify(structuredResult) }]); - const results = await registry.execute( - [{ id: "tc_1", name: "script_tool", arguments: "{}" }], - scriptCallback - ); + const results = await registry.execute([{ id: "tc_1", name: "script_tool", arguments: "{}" }], scriptCallback); expect(results[0].result).toBe("CATTool generated file."); expect(results[0].attachments).toHaveLength(1); @@ -385,14 +381,9 @@ describe("ToolRegistry", () => { const mockRepo = createMockChatRepo(); registry.setChatRepo(mockRepo); - const scriptCallback = vi.fn().mockResolvedValue([ - { id: "tc_1", result: JSON.stringify({ data: "hello" }) }, - ]); + const scriptCallback = vi.fn().mockResolvedValue([{ id: "tc_1", result: JSON.stringify({ data: "hello" }) }]); - const results = await registry.execute( - [{ id: "tc_1", name: "script_tool", arguments: "{}" }], - scriptCallback - ); + const results = await registry.execute([{ id: "tc_1", name: "script_tool", arguments: "{}" }], scriptCallback); expect(results[0].result).toBe('{"data":"hello"}'); expect(results[0].attachments).toBeUndefined(); @@ -403,14 +394,9 @@ describe("ToolRegistry", () => { const mockRepo = createMockChatRepo(); registry.setChatRepo(mockRepo); - const scriptCallback = vi.fn().mockResolvedValue([ - { id: "tc_1", result: "plain text result" }, - ]); + const scriptCallback = vi.fn().mockResolvedValue([{ id: "tc_1", result: "plain text result" }]); - const results = await registry.execute( - [{ id: "tc_1", name: "script_tool", arguments: "{}" }], - scriptCallback - ); + const results = await registry.execute([{ id: "tc_1", name: "script_tool", arguments: "{}" }], scriptCallback); expect(results[0].result).toBe("plain text result"); expect(results[0].attachments).toBeUndefined(); diff --git a/src/app/service/agent/tool_registry.ts b/src/app/service/agent/tool_registry.ts index cdd82acd3..d6ece1183 100644 --- a/src/app/service/agent/tool_registry.ts +++ b/src/app/service/agent/tool_registry.ts @@ -57,10 +57,7 @@ export class ToolRegistry { } // 执行工具调用:先查内置工具,未找到则交给脚本回调 - async execute( - toolCalls: ToolCall[], - scriptCallback?: ScriptToolCallback | null - ): Promise { + async execute(toolCalls: ToolCall[], scriptCallback?: ScriptToolCallback | null): Promise { const builtinCalls: ToolCall[] = []; const scriptCalls: ToolCall[] = []; diff --git a/src/app/service/agent/types.ts b/src/app/service/agent/types.ts index 10d27ef54..b7836c41b 100644 --- a/src/app/service/agent/types.ts +++ b/src/app/service/agent/types.ts @@ -1,3 +1,7 @@ +// ---- 通用类型 ---- + +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + // ---- ContentBlock 多模态内容类型 ---- export type TextBlock = { type: "text"; text: string }; @@ -65,7 +69,12 @@ export type ChatMessage = { toolCallId?: string; error?: string; modelId?: string; - usage?: { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number }; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; durationMs?: number; firstTokenMs?: number; parentId?: string; @@ -82,7 +91,16 @@ export type ChatStreamEvent = | { type: "content_block_start"; block: Omit } | { type: "content_block_complete"; block: ImageBlock | FileBlock | AudioBlock } | { type: "new_message" } - | { type: "done"; usage?: { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number }; durationMs?: number } + | { + type: "done"; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + durationMs?: number; + } | { type: "error"; message: string; errorCode?: string }; // UI -> Service Worker 的聊天请求 diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 1af64d5b7..5c25c3403 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -26,8 +26,12 @@ export class ConversationInstance { private commandHandlers: Map = new Map(); private ephemeral: boolean; private systemPrompt?: string; - private messageHistory: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; toolCalls?: ToolCall[] }> = - []; + private messageHistory: Array<{ + role: MessageRole; + content: MessageContent; + toolCallId?: string; + toolCalls?: ToolCall[]; + }> = []; constructor( private conv: Conversation, diff --git a/src/app/service/content/gm_api/cat_agent_dom.ts b/src/app/service/content/gm_api/cat_agent_dom.ts index 5b60663ec..d3dd6f961 100644 --- a/src/app/service/content/gm_api/cat_agent_dom.ts +++ b/src/app/service/content/gm_api/cat_agent_dom.ts @@ -12,11 +12,19 @@ import type { ScrollOptions, WaitForOptions, ExecuteScriptOptions, + TabInfo, + NavigateResult, + PageContent, + ActionResult, + ScrollResult, + WaitForResult, + MonitorResult, + MonitorStatus, } from "@App/app/service/agent/types"; // 运行时 this 是 GM_Base 实例 interface GMBaseContext { - sendMessage: (api: string, params: unknown[]) => Promise; + sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; } @@ -28,7 +36,7 @@ export default class CATAgentDomApi { protected scriptRes?: any; @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.listTabs"(): Promise { + public "CAT.agent.dom.listTabs"(): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "listTabs", scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -36,7 +44,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.navigate"(url: string, options?: NavigateOptions): Promise { + public "CAT.agent.dom.navigate"(url: string, options?: NavigateOptions): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "navigate", url, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -44,7 +52,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.readPage"(options?: ReadPageOptions): Promise { + public "CAT.agent.dom.readPage"(options?: ReadPageOptions): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "readPage", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -52,7 +60,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise { + public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "screenshot", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -60,7 +68,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.click"(selector: string, options?: DomActionOptions): Promise { + public "CAT.agent.dom.click"(selector: string, options?: DomActionOptions): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "click", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -68,7 +76,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.fill"(selector: string, value: string, options?: DomActionOptions): Promise { + public "CAT.agent.dom.fill"(selector: string, value: string, options?: DomActionOptions): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "fill", selector, value, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -76,7 +84,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.scroll"(direction: ScrollDirection, options?: ScrollOptions): Promise { + public "CAT.agent.dom.scroll"(direction: ScrollDirection, options?: ScrollOptions): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "scroll", direction, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -84,7 +92,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.waitFor"(selector: string, options?: WaitForOptions): Promise { + public "CAT.agent.dom.waitFor"(selector: string, options?: WaitForOptions): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "waitFor", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -100,7 +108,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.startMonitor"(tabId: number): Promise { + public "CAT.agent.dom.startMonitor"(tabId: number): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "startMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -108,7 +116,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.stopMonitor"(tabId: number): Promise { + public "CAT.agent.dom.stopMonitor"(tabId: number): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "stopMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, @@ -116,7 +124,7 @@ export default class CATAgentDomApi { } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.peekMonitor"(tabId: number): Promise { + public "CAT.agent.dom.peekMonitor"(tabId: number): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentDom", [ { action: "peekMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, diff --git a/src/app/service/content/gm_api/cat_agent_skills.ts b/src/app/service/content/gm_api/cat_agent_skills.ts index a36e4a7dd..a07e78eec 100644 --- a/src/app/service/content/gm_api/cat_agent_skills.ts +++ b/src/app/service/content/gm_api/cat_agent_skills.ts @@ -1,9 +1,9 @@ +import type { SkillApiRequest, SkillRecord, SkillSummary } from "@App/app/service/agent/types"; import GMContext from "./gm_context"; -import type { SkillApiRequest } from "@App/app/service/agent/types"; // 运行时 this 是 GM_Base 实例 interface GMBaseContext { - sendMessage: (api: string, params: unknown[]) => Promise; + sendMessage: (api: string, params: SkillApiRequest[]) => Promise; scriptRes?: { uuid: string }; } @@ -11,25 +11,28 @@ interface GMBaseContext { // 使用 @GMContext.API 装饰器注册到 "CAT.agent.skills" grant export default class CATAgentSkillsApi { @GMContext.protected() - protected sendMessage!: (api: string, params: any[]) => Promise; + protected sendMessage!: ( + api: string, + params: SkillApiRequest[] + ) => Promise; @GMContext.protected() - protected scriptRes?: any; + protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.list"(): Promise { + public "CAT.agent.skills.list"(): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, - ]); + ]) as Promise; } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.get"(name: string): Promise { + public "CAT.agent.skills.get"(name: string): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "get", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, - ]); + ]) as Promise; } @GMContext.API({ follow: "CAT.agent.skills" }) @@ -37,7 +40,7 @@ export default class CATAgentSkillsApi { skillMd: string, scripts?: Array<{ name: string; code: string }>, references?: Array<{ name: string; content: string }> - ): Promise { + ): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { @@ -47,14 +50,14 @@ export default class CATAgentSkillsApi { references, scriptUuid: ctx.scriptRes?.uuid || "", } as SkillApiRequest, - ]); + ]) as Promise; } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.remove"(name: string): Promise { + public "CAT.agent.skills.remove"(name: string): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "remove", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, - ]); + ]) as Promise; } } diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 72bb97ef9..9d039f9e0 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -55,7 +55,9 @@ export default class CATAgentTaskApi { @GMContext.API({ follow: "CAT.agent.task" }) public "CAT.agent.task.update"(id: string, task: Partial): Promise { const ctx = this as unknown as GMBaseContext; - return ctx.sendMessage("CAT_agentTask", [{ action: "update", id, task } as AgentTaskApiRequest]) as Promise; + return ctx.sendMessage("CAT_agentTask", [ + { action: "update", id, task } as AgentTaskApiRequest, + ]) as Promise; } @GMContext.API({ follow: "CAT.agent.task" }) @@ -73,10 +75,7 @@ export default class CATAgentTaskApi { // 监听任务触发事件 // 利用 EE.on("agentTask:{taskId}", callback) 注册监听 @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.addListener"( - taskId: string, - callback: (trigger: AgentTaskTrigger) => void - ): number { + public "CAT.agent.task.addListener"(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number { const ctx = this as unknown as GMBaseContext; if (!ctx.EE) return 0; diff --git a/src/app/service/content/gm_api/cat_agent_tools.ts b/src/app/service/content/gm_api/cat_agent_tools.ts index 0b9ddfd43..febbebb76 100644 --- a/src/app/service/content/gm_api/cat_agent_tools.ts +++ b/src/app/service/content/gm_api/cat_agent_tools.ts @@ -1,5 +1,6 @@ import GMContext from "./gm_context"; -import type { CATToolApiRequest } from "@App/app/service/agent/types"; +import type { CATToolApiRequest, CATToolRecord, JsonValue } from "@App/app/service/agent/types"; +import type { CATToolSummary } from "@App/app/repo/cattool_repo"; // 运行时 this 是 GM_Base 实例 interface GMBaseContext { @@ -11,40 +12,40 @@ interface GMBaseContext { // 使用 @GMContext.API 装饰器注册到 "CAT.agent.tools" grant export default class CATAgentToolsApi { @GMContext.protected() - protected sendMessage!: (api: string, params: any[]) => Promise; + protected sendMessage!: (api: string, params: unknown[]) => Promise; @GMContext.protected() - protected scriptRes?: any; + protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.tools" }) - public "CAT.agent.tools.install"(code: string): Promise { + public "CAT.agent.tools.install"(code: string): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentTools", [ { action: "install", code, scriptUuid: ctx.scriptRes?.uuid || "" } as CATToolApiRequest, - ]); + ]) as Promise; } @GMContext.API({ follow: "CAT.agent.tools" }) - public "CAT.agent.tools.remove"(name: string): Promise { + public "CAT.agent.tools.remove"(name: string): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentTools", [ { action: "remove", name, scriptUuid: ctx.scriptRes?.uuid || "" } as CATToolApiRequest, - ]); + ]) as Promise; } @GMContext.API({ follow: "CAT.agent.tools" }) - public "CAT.agent.tools.list"(): Promise { + public "CAT.agent.tools.list"(): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentTools", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as CATToolApiRequest, - ]); + ]) as Promise; } @GMContext.API({ follow: "CAT.agent.tools" }) - public "CAT.agent.tools.call"(name: string, params: Record = {}): Promise { + public "CAT.agent.tools.call"(name: string, params: Record = {}): Promise { const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentTools", [ { action: "call", name, params, scriptUuid: ctx.scriptRes?.uuid || "" } as CATToolApiRequest, - ]); + ]) as Promise; } } diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index d2a033315..8a94e1b66 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1324,4 +1324,3 @@ describe("@grant CAT.agent.dom", () => { expect(ret.hasCat).toEqual(false); }); }); - diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b9c81d9ed..468b547b9 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -18,6 +18,7 @@ import GMContext from "./gm_context"; import { type ScriptRunResource } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; +import { ScriptEnvTag } from "@Packages/message/consts"; import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; diff --git a/src/app/service/service_worker/agent.test.ts b/src/app/service/service_worker/agent.test.ts index 5cc4ff7da..acdc6c42d 100644 --- a/src/app/service/service_worker/agent.test.ts +++ b/src/app/service/service_worker/agent.test.ts @@ -1575,11 +1575,25 @@ describe("callLLM 流式响应解析", () => { // 设置 Anthropic model const anthropicModelRepo = { listModels: vi.fn().mockResolvedValue([ - { id: "test-anthropic", name: "Claude", provider: "anthropic", apiBaseUrl: "https://api.anthropic.com", apiKey: "sk-test", model: "claude-3" }, + { + id: "test-anthropic", + name: "Claude", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + apiKey: "sk-test", + model: "claude-3", + }, ]), getModel: vi.fn().mockImplementation((id: string) => { if (id === "test-anthropic") { - return Promise.resolve({ id: "test-anthropic", name: "Claude", provider: "anthropic", apiBaseUrl: "https://api.anthropic.com", apiKey: "sk-test", model: "claude-3" }); + return Promise.resolve({ + id: "test-anthropic", + name: "Claude", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + apiKey: "sk-test", + model: "claude-3", + }); } return Promise.resolve(undefined); }), @@ -1643,6 +1657,8 @@ describe("callLLM 流式响应解析", () => { }); it("API 错误响应(HTTP 500 后重试成功):withRetry 生效", async () => { + vi.useFakeTimers(); + const { service, mockRepo } = createTestService(); const { sender, sentMessages } = createMockSender(); @@ -1664,7 +1680,12 @@ describe("callLLM 流式响应解析", () => { ]) ); - await (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + const chatPromise = (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + + // 推进定时器跳过 withRetry 的退避延迟 + await vi.advanceTimersByTimeAsync(10_000); + + await chatPromise; // fetch 应被调用 2 次(500 + 成功) expect(fetchSpy).toHaveBeenCalledTimes(2); @@ -1672,6 +1693,8 @@ describe("callLLM 流式响应解析", () => { const events = sentMessages.map((m) => m.data); const doneEvents = events.filter((e: any) => e.type === "done"); expect(doneEvents).toHaveLength(1); + + vi.useRealTimers(); }); it("无 response body:抛出 No response body", async () => { @@ -1717,7 +1740,7 @@ describe("callLLM 流式响应解析", () => { mockRepo.getMessages.mockResolvedValue([]); // fetch 抛 AbortError(模拟 signal 取消 fetch) - fetchSpy.mockImplementation((_url, init) => { + fetchSpy.mockImplementation((_url: string, _init: RequestInit) => { // 在 fetch 调用时立即触发 disconnect if (disconnectCb) { disconnectCb(); @@ -1777,8 +1800,12 @@ describe("callLLMWithToolLoop 工具调用循环", () => { function makeToolCallResponse(toolCalls: Array<{ id: string; name: string; arguments: string }>): Response { const chunks: string[] = []; for (const tc of toolCalls) { - chunks.push(`data: {"choices":[{"delta":{"tool_calls":[{"id":"${tc.id}","function":{"name":"${tc.name}","arguments":""}}]}}]}\n\n`); - chunks.push(`data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}}]}\n\n`); + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"id":"${tc.id}","function":{"name":"${tc.name}","arguments":""}}]}}]}\n\n` + ); + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}}]}\n\n` + ); } chunks.push(`data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`); return makeSSEResponse(chunks); @@ -1828,7 +1855,9 @@ describe("callLLMWithToolLoop 工具调用循环", () => { mockRepo.getMessages.mockResolvedValue([]); // 第一次:返回 tool_call - fetchSpy.mockResolvedValueOnce(makeToolCallResponse([{ id: "call_1", name: "echo", arguments: '{"msg":"hello"}' }])); + fetchSpy.mockResolvedValueOnce( + makeToolCallResponse([{ id: "call_1", name: "echo", arguments: '{"msg":"hello"}' }]) + ); // 第二次:纯文本 fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); @@ -1839,7 +1868,7 @@ describe("callLLMWithToolLoop 工具调用循环", () => { expect(events.some((e: any) => e.type === "tool_call_start")).toBe(true); expect(events.some((e: any) => e.type === "tool_call_complete")).toBe(true); const completeEvent = events.find((e: any) => e.type === "tool_call_complete"); - expect(completeEvent.result).toBe('echo: hello'); + expect(completeEvent.result).toBe("echo: hello"); expect(events.some((e: any) => e.type === "new_message")).toBe(true); expect(events.some((e: any) => e.type === "done")).toBe(true); @@ -1862,7 +1891,12 @@ describe("callLLMWithToolLoop 工具调用循环", () => { let callCount = 0; registry.registerBuiltin( { name: "counter", description: "Count", parameters: { type: "object", properties: {} } }, - { execute: async () => { callCount++; return `count=${callCount}`; } } + { + execute: async () => { + callCount++; + return `count=${callCount}`; + }, + } ); mockRepo.listConversations.mockResolvedValue([BASE_CONV]); @@ -2048,7 +2082,7 @@ describe("handleConversationChat 场景补充", () => { const saveCalls = mockRepo.saveConversation.mock.calls; const titleUpdate = saveCalls.find((c: any) => c[0].title !== "New Chat"); expect(titleUpdate).toBeDefined(); - expect(titleUpdate[0].title).toBe(longMessage.slice(0, 30) + "..."); + expect(titleUpdate![0].title).toBe(longMessage.slice(0, 30) + "..."); }); it("ephemeral 模式:不走 repo 持久化", async () => { @@ -2085,8 +2119,24 @@ describe("handleConversationChat 场景补充", () => { // 添加第二个 model const modelRepo = (service as any).modelRepo; modelRepo.getModel.mockImplementation((id: string) => { - if (id === "test-openai") return Promise.resolve({ id: "test-openai", name: "Test", provider: "openai", apiBaseUrl: "", apiKey: "", model: "gpt-4o" }); - if (id === "test-openai-2") return Promise.resolve({ id: "test-openai-2", name: "Test2", provider: "openai", apiBaseUrl: "", apiKey: "", model: "gpt-4o-mini" }); + if (id === "test-openai") + return Promise.resolve({ + id: "test-openai", + name: "Test", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", + }); + if (id === "test-openai-2") + return Promise.resolve({ + id: "test-openai-2", + name: "Test2", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o-mini", + }); return Promise.resolve(undefined); }); @@ -2118,10 +2168,7 @@ describe("handleConversationChat 场景补充", () => { mockRepo.listConversations.mockResolvedValue([]); // 空 - await (service as any).handleConversationChat( - { conversationId: "not-exist", message: "hi" }, - sender - ); + await (service as any).handleConversationChat({ conversationId: "not-exist", message: "hi" }, sender); const events = sentMessages.map((m) => m.data); const errorEvents = events.filter((e: any) => e.type === "error"); @@ -2185,10 +2232,7 @@ describe("handleConversationChat 场景补充", () => { ]); fetchSpy.mockResolvedValueOnce(makeTextResponse("ok")); - await (service as any).handleConversationChat( - { conversationId: "conv-1", message: "继续" }, - sender - ); + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "继续" }, sender); // getSkillScripts 应被调用以预加载 web-skill 的工具 expect(mockSkillRepo.getSkillScripts).toHaveBeenCalledWith("web-skill"); @@ -2219,8 +2263,6 @@ describe.concurrent("AgentService.handleDomApi", () => { const mockHandleDomApi = vi.fn().mockRejectedValue(new Error("DOM action failed")); (service as any).domService = { handleDomApi: mockHandleDomApi }; - await expect(service.handleDomApi({ action: "listTabs", scriptUuid: "test" })).rejects.toThrow( - "DOM action failed" - ); + await expect(service.handleDomApi({ action: "listTabs", scriptUuid: "test" })).rejects.toThrow("DOM action failed"); }); }); diff --git a/src/app/service/service_worker/agent.ts b/src/app/service/service_worker/agent.ts index 630797f05..b877be2e2 100644 --- a/src/app/service/service_worker/agent.ts +++ b/src/app/service/service_worker/agent.ts @@ -13,23 +13,24 @@ import type { ToolDefinition, CATToolApiRequest, CATToolRecord, + JsonValue, DomApiRequest, - MCPApiRequest, SkillApiRequest, SkillMetadata, SkillRecord, + SkillSummary, MessageContent, - ContentBlock, AgentTask, AgentTaskApiRequest, AgentTaskTrigger, + Attachment, } from "@App/app/service/agent/types"; import { getTextContent, isContentBlocks } from "@App/app/service/agent/content_utils"; import { buildOpenAIRequest, parseOpenAIStream } from "@App/app/service/agent/providers/openai"; import { buildAnthropicRequest, parseAnthropicStream } from "@App/app/service/agent/providers/anthropic"; import { AgentChatRepo } from "@App/app/repo/agent_chat"; import { AgentModelRepo } from "@App/app/repo/agent_model"; -import { CATToolRepo } from "@App/app/repo/cattool_repo"; +import { CATToolRepo, type CATToolSummary } from "@App/app/repo/cattool_repo"; import { SkillRepo } from "@App/app/repo/skill_repo"; import { uuidv4 } from "@App/pkg/utils/uuid"; import { ToolRegistry } from "@App/app/service/agent/tool_registry"; @@ -71,7 +72,14 @@ export async function withRetry( ((ms, sig) => new Promise((r) => { const t = setTimeout(r, ms); - sig.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); + sig.addEventListener( + "abort", + () => { + clearTimeout(t); + r(); + }, + { once: true } + ); })); let lastError!: Error; @@ -164,10 +172,8 @@ export class AgentService { this.group.on("removeSkill", (name: string) => this.removeSkill(name)); this.group.on("refreshSkill", (name: string) => this.refreshSkill(name)); this.group.on("getSkillConfigValues", (name: string) => this.skillRepo.getConfigValues(name)); - this.group.on( - "saveSkillConfig", - (params: { name: string; values: Record }) => - this.skillRepo.saveConfigValues(params.name, params.values) + this.group.on("saveSkillConfig", (params: { name: string; values: Record }) => + this.skillRepo.saveConfigValues(params.name, params.values) ); // Skill ZIP 安装页面相关消息 this.group.on("prepareSkillInstall", (zipBase64: string) => this.prepareSkillInstall(zipBase64)); @@ -284,7 +290,7 @@ export class AgentService { } // 直接调用 CATTool - async callCATTool(name: string, params: Record): Promise { + async callCATTool(name: string, params: Record): Promise { const tool = await this.catToolRepo.getTool(name); if (!tool) { throw new Error(`CATTool "${name}" not found`); @@ -403,7 +409,10 @@ export class AgentService { } // 处理 CAT.agent.tools API 请求 - async handleToolsApi(request: CATToolApiRequest, script?: Script): Promise { + async handleToolsApi( + request: CATToolApiRequest, + script?: Script + ): Promise { switch (request.action) { case "install": return this.openCATToolInstallPage(request.code, script?.uuid, script ? i18nName(script) : undefined); @@ -579,7 +588,7 @@ export class AgentService { } // 处理 CAT.agent.skills API 请求 - async handleSkillsApi(request: SkillApiRequest): Promise { + async handleSkillsApi(request: SkillApiRequest): Promise { switch (request.action) { case "list": return this.skillRepo.listSkills(); @@ -1080,6 +1089,8 @@ export class AgentService { skipBuiltinTools?: boolean; // 是否启用 prompt caching,默认 true cache?: boolean; + // 仅供测试注入,跳过重试延迟 + delayFn?: (ms: number, signal: AbortSignal) => Promise; }): Promise { const { model, messages, tools, maxIterations, sendEvent, signal, scriptToolCallback, conversationId } = params; @@ -1102,7 +1113,9 @@ export class AgentService { sendEvent, signal ), - signal + signal, + undefined, + params.delayFn ); if (signal.aborted) return; @@ -1136,7 +1149,7 @@ export class AgentService { // 将 tool 结果加入消息,并通知 UI 工具执行完成 // 收集需要回写附件的 toolCall ID → Attachment[] - const attachmentUpdates = new Map(); + const attachmentUpdates = new Map(); for (const tr of toolResults) { // LLM 上下文只包含文本结果,不含附件 @@ -1216,13 +1229,15 @@ export class AgentService { } // 超过最大迭代次数 - sendEvent({ type: "error", message: `Tool calling loop exceeded maximum iterations (${maxIterations})`, errorCode: "max_iterations" }); + sendEvent({ + type: "error", + message: `Tool calling loop exceeded maximum iterations (${maxIterations})`, + errorCode: "max_iterations", + }); } // 解析消息中所有 ContentBlock 引用的 attachmentId → base64 data URL - private async resolveAttachments( - messages: ChatRequest["messages"] - ): Promise<(id: string) => string | null> { + private async resolveAttachments(messages: ChatRequest["messages"]): Promise<(id: string) => string | null> { const resolved = new Map(); const ids = new Set(); diff --git a/src/app/service/service_worker/agent_dom_cdp.test.ts b/src/app/service/service_worker/agent_dom_cdp.test.ts index 6336e54a0..12fed57b9 100644 --- a/src/app/service/service_worker/agent_dom_cdp.test.ts +++ b/src/app/service/service_worker/agent_dom_cdp.test.ts @@ -57,28 +57,20 @@ describe("agent_dom_cdp", () => { expect(cdpScreenshot).toBeDefined(); }); - it( - "cdpClick 在元素未被遮挡时正常点击", - async () => { - setupClickMocks("hit"); - const result = await cdpClick(999, "#btn"); - expect(result.success).toBe(true); - // 验证 dispatchMouseEvent 被调用(mousePressed + mouseReleased) - const mouseEvents = mockSendCommand.mock.calls.filter( - (c: unknown[]) => c[1] === "Input.dispatchMouseEvent" - ); - expect(mouseEvents).toHaveLength(2); - }, - 1000 - ); + it("cdpClick 在元素未被遮挡时正常点击", async () => { + setupClickMocks("hit"); + const result = await cdpClick(999, "#btn"); + expect(result.success).toBe(true); + // 验证 dispatchMouseEvent 被调用(mousePressed + mouseReleased) + const mouseEvents = mockSendCommand.mock.calls.filter((c: unknown[]) => c[1] === "Input.dispatchMouseEvent"); + expect(mouseEvents).toHaveLength(2); + }, 1000); it("cdpClick 在元素被遮挡时抛出错误", async () => { setupClickMocks("blocked_by:div.modal-overlay"); await expect(cdpClick(999, "#btn")).rejects.toThrow(/Click blocked/); // 验证未发送鼠标事件 - const mouseEvents = mockSendCommand.mock.calls.filter( - (c: unknown[]) => c[1] === "Input.dispatchMouseEvent" - ); + const mouseEvents = mockSendCommand.mock.calls.filter((c: unknown[]) => c[1] === "Input.dispatchMouseEvent"); expect(mouseEvents).toHaveLength(0); }); diff --git a/src/app/service/service_worker/agent_dom_cdp.ts b/src/app/service/service_worker/agent_dom_cdp.ts index 1774e039a..7def148d8 100644 --- a/src/app/service/service_worker/agent_dom_cdp.ts +++ b/src/app/service/service_worker/agent_dom_cdp.ts @@ -94,7 +94,9 @@ export async function cdpClick(tabId: number, selector: string): Promise }; + metadata: { + name: string; + description: string; + config?: Record; + }; prompt: string; scripts: Array<{ name: string; code: string }>; references: Array<{ name: string; content: string }>; diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 2b00b6aa4..237448741 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -28,7 +28,11 @@ import type { import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; import { BrowserNoSupport, notificationsUpdate } from "../utils"; -import { getCATToolGrantsByUuid, getCATToolNameByUuid, CATTOOL_UUID_PREFIX } from "@App/app/service/agent/cattool_executor"; +import { + getCATToolGrantsByUuid, + getCATToolNameByUuid, + CATTOOL_UUID_PREFIX, +} from "@App/app/service/agent/cattool_executor"; import i18n from "@App/locales/locales"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { createObjectURL } from "../../offscreen/client"; diff --git a/src/pages/install/components/SkillInstallView.tsx b/src/pages/install/components/SkillInstallView.tsx index bc5909f7f..ef57d85f0 100644 --- a/src/pages/install/components/SkillInstallView.tsx +++ b/src/pages/install/components/SkillInstallView.tsx @@ -145,7 +145,9 @@ function SkillInstallView({ {/* Config Fields */} {metadata.config && Object.keys(metadata.config).length > 0 && (
- {`${t("agent_skills_config")} (${Object.keys(metadata.config).length}):`} + {`${t("agent_skills_config")} (${Object.keys(metadata.config).length}):`}
{Object.entries(metadata.config).map(([key, field]) => (
diff --git a/src/pages/options/routes/AgentCATool.tsx b/src/pages/options/routes/AgentCATool.tsx index 82eb02a9c..21b982599 100644 --- a/src/pages/options/routes/AgentCATool.tsx +++ b/src/pages/options/routes/AgentCATool.tsx @@ -1,14 +1,4 @@ -import { - Button, - Card, - Drawer, - Empty, - Message, - Popconfirm, - Space, - Tag, - Typography, -} from "@arco-design/web-react"; +import { Button, Card, Drawer, Empty, Message, Popconfirm, Space, Tag, Typography } from "@arco-design/web-react"; import { IconCode, IconDelete, IconEye } from "@arco-design/web-react/icon"; import { useTranslation } from "react-i18next"; import { useCallback, useEffect, useState } from "react"; @@ -112,14 +102,7 @@ function CATToolDetailDrawer({ if (!tool) return null; return ( - + {/* Description */} {tool.description && ( @@ -143,7 +126,11 @@ function CATToolDetailDrawer({
{p.name} {p.type} - {p.required && required} + {p.required && ( + + required + + )}
{p.description && ( @@ -153,7 +140,9 @@ function CATToolDetailDrawer({ {p.enum && p.enum.length > 0 && (
{p.enum.map((v) => ( - {v} + + {v} + ))}
)} @@ -171,7 +160,9 @@ function CATToolDetailDrawer({
{tool.grants.map((g) => ( - {g} + + {g} + ))}
@@ -256,11 +247,7 @@ function AgentCATool() { )} - setDetailVisible(false)} - /> + setDetailVisible(false)} /> ); } diff --git a/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx b/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx index 9df572a83..ea16fd980 100644 --- a/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx +++ b/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx @@ -41,7 +41,10 @@ export function AttachmentImage({ attachment }: { attachment: Attachment }) { className="tw-max-w-xs tw-max-h-48 tw-rounded tw-border tw-border-solid tw-border-[var(--color-border-1)] tw-object-contain" />
- +
{/* 全屏预览 */} @@ -93,9 +96,7 @@ export function AttachmentFile({ attachment }: { attachment: Attachment }) {
{attachment.name} - {sizeText && ( - {sizeText} - )} + {sizeText && {sizeText}}
); @@ -129,9 +130,7 @@ export function AttachmentAudio({ block }: { block: AudioBlock }) { return (
- {block.name && ( - {block.name} - )} + {block.name && {block.name}}
); diff --git a/src/pages/options/routes/AgentChat/ChatArea.tsx b/src/pages/options/routes/AgentChat/ChatArea.tsx index 6a608ac7a..b8bd943c8 100644 --- a/src/pages/options/routes/AgentChat/ChatArea.tsx +++ b/src/pages/options/routes/AgentChat/ChatArea.tsx @@ -222,7 +222,14 @@ export default function ChatArea({ newMessages.push(assistantMsg); setMessages(newMessages); - sendMessage(conversationId, content, createStreamCallback(), createDoneCallback(), selectedModelId, skipUserMessage); + sendMessage( + conversationId, + content, + createStreamCallback(), + createDoneCallback(), + selectedModelId, + skipUserMessage + ); }; // 用 ref 保存 startStreaming 的最新引用,避免 useCallback 闭包陈旧 diff --git a/src/pages/options/routes/AgentChat/ContentBlockRenderer.tsx b/src/pages/options/routes/AgentChat/ContentBlockRenderer.tsx index 5e56ed6b6..40f32b401 100644 --- a/src/pages/options/routes/AgentChat/ContentBlockRenderer.tsx +++ b/src/pages/options/routes/AgentChat/ContentBlockRenderer.tsx @@ -2,13 +2,7 @@ import type { MessageContent, AudioBlock } from "@App/app/service/agent/types"; import MarkdownRenderer from "./MarkdownRenderer"; import { AttachmentImage, AttachmentFile, AttachmentAudio } from "./AttachmentRenderers"; -export default function ContentBlockRenderer({ - content, - className, -}: { - content: MessageContent; - className?: string; -}) { +export default function ContentBlockRenderer({ content, className }: { content: MessageContent; className?: string }) { if (typeof content === "string") { return content ? : null; } @@ -23,14 +17,25 @@ export default function ContentBlockRenderer({ return ( ); case "file": return ( ); case "audio": diff --git a/src/pages/options/routes/AgentChat/MarkdownRenderer.tsx b/src/pages/options/routes/AgentChat/MarkdownRenderer.tsx index 97e052830..2d7882061 100644 --- a/src/pages/options/routes/AgentChat/MarkdownRenderer.tsx +++ b/src/pages/options/routes/AgentChat/MarkdownRenderer.tsx @@ -1,7 +1,8 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; -import { ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@arco-design/web-react"; import { IconCopy, IconCheck } from "@arco-design/web-react/icon"; diff --git a/src/pages/options/routes/AgentChat/MessageItem.tsx b/src/pages/options/routes/AgentChat/MessageItem.tsx index ec773f650..778402048 100644 --- a/src/pages/options/routes/AgentChat/MessageItem.tsx +++ b/src/pages/options/routes/AgentChat/MessageItem.tsx @@ -1,6 +1,5 @@ import { useState, useRef, useEffect } from "react"; import type { ChatMessage } from "@App/app/service/agent/types"; -import MarkdownRenderer from "./MarkdownRenderer"; import ContentBlockRenderer from "./ContentBlockRenderer"; import ThinkingBlock from "./ThinkingBlock"; import ToolCallBlock from "./ToolCallBlock"; diff --git a/src/pages/options/routes/AgentChat/MessageToolbar.tsx b/src/pages/options/routes/AgentChat/MessageToolbar.tsx index 95f46dc53..4039d8288 100644 --- a/src/pages/options/routes/AgentChat/MessageToolbar.tsx +++ b/src/pages/options/routes/AgentChat/MessageToolbar.tsx @@ -4,7 +4,12 @@ import { Popconfirm, Tooltip } from "@arco-design/web-react"; import { IconCopy, IconRefresh, IconDelete } from "@arco-design/web-react/icon"; export type MessageToolbarProps = { - usage?: { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number }; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; durationMs?: number; firstTokenMs?: number; toolCallCount: number; @@ -100,7 +105,8 @@ export default function MessageToolbar({ metaParts.push( {"↑"} - {formatTokens(usage.inputTokens)}{cacheInfo} {"↓"} + {formatTokens(usage.inputTokens)} + {cacheInfo} {"↓"} {formatTokens(usage.outputTokens)} ); diff --git a/src/pages/options/routes/AgentChat/chat_utils.test.ts b/src/pages/options/routes/AgentChat/chat_utils.test.ts index e2ecb15d1..99e13aac6 100644 --- a/src/pages/options/routes/AgentChat/chat_utils.test.ts +++ b/src/pages/options/routes/AgentChat/chat_utils.test.ts @@ -155,9 +155,7 @@ describe("computeRegenerateAction", () => { }); it("没有用户消息时返回 null", () => { - const allMessages: ChatMessage[] = [ - makeMsg({ id: "a1", role: "assistant", content: "hi" }), - ]; + const allMessages: ChatMessage[] = [makeMsg({ id: "a1", role: "assistant", content: "hi" })]; const groups = groupMessages(allMessages); const result = computeRegenerateAction(groups, 0, allMessages); expect(result).toBeNull(); @@ -216,9 +214,7 @@ describe("findNextAssistantGroupIndex", () => { }); it("用户消息后面没有 assistant 组时返回 null", () => { - const allMessages: ChatMessage[] = [ - makeMsg({ id: "u1", role: "user", content: "hello" }), - ]; + const allMessages: ChatMessage[] = [makeMsg({ id: "u1", role: "user", content: "hello" })]; const groups = groupMessages(allMessages); expect(findNextAssistantGroupIndex(groups, 0)).toBeNull(); }); @@ -337,9 +333,7 @@ describe("computeUserRegenerateAction — 用户消息重新生成(bug 修复 }); it("用户消息后面没有回复时:idsToDelete 为空", () => { - const allMessages: ChatMessage[] = [ - makeMsg({ id: "u1", role: "user", content: "hello" }), - ]; + const allMessages: ChatMessage[] = [makeMsg({ id: "u1", role: "user", content: "hello" })]; const result = computeUserRegenerateAction("u1", allMessages); expect(result).not.toBeNull(); @@ -349,9 +343,7 @@ describe("computeUserRegenerateAction — 用户消息重新生成(bug 修复 }); it("消息不存在时返回 null", () => { - const result = computeUserRegenerateAction("nonexistent", [ - makeMsg({ id: "u1", role: "user", content: "hello" }), - ]); + const result = computeUserRegenerateAction("nonexistent", [makeMsg({ id: "u1", role: "user", content: "hello" })]); expect(result).toBeNull(); }); diff --git a/src/pages/options/routes/AgentChat/chat_utils.ts b/src/pages/options/routes/AgentChat/chat_utils.ts index 3d217041a..7c8bf0ea1 100644 --- a/src/pages/options/routes/AgentChat/chat_utils.ts +++ b/src/pages/options/routes/AgentChat/chat_utils.ts @@ -95,10 +95,7 @@ export function computeEditAction( // 根据用户消息在 groups 中的位置,找到对应的 assistant 组索引 // 用于"用户消息重新生成"场景 -export function findNextAssistantGroupIndex( - groups: MessageGroup[], - userGroupIndex: number -): number | null { +export function findNextAssistantGroupIndex(groups: MessageGroup[], userGroupIndex: number): number | null { if (userGroupIndex + 1 < groups.length && groups[userGroupIndex + 1].type === "assistant") { return userGroupIndex + 1; } @@ -110,7 +107,12 @@ export function findNextAssistantGroupIndex( export function computeUserRegenerateAction( messageId: string, allMessages: ChatMessage[] -): { idsToDelete: string[]; remainingMessages: ChatMessage[]; userContent: MessageContent; skipUserMessage: true } | null { +): { + idsToDelete: string[]; + remainingMessages: ChatMessage[]; + userContent: MessageContent; + skipUserMessage: true; +} | null { const idx = allMessages.findIndex((m) => m.id === messageId); if (idx < 0) return null; diff --git a/src/pages/options/routes/AgentChat/hooks.ts b/src/pages/options/routes/AgentChat/hooks.ts index e55a33cf8..2a7b225f0 100644 --- a/src/pages/options/routes/AgentChat/hooks.ts +++ b/src/pages/options/routes/AgentChat/hooks.ts @@ -1,6 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import type { Conversation, ChatMessage, ChatStreamEvent, SkillSummary, MessageContent } from "@App/app/service/agent/types"; +import type { + Conversation, + ChatMessage, + ChatStreamEvent, + SkillSummary, + MessageContent, +} from "@App/app/service/agent/types"; import { AgentChatRepo } from "@App/app/repo/agent_chat"; import { SkillRepo } from "@App/app/repo/skill_repo"; import { message as extensionMessage } from "@App/pages/store/global"; diff --git a/src/pages/options/routes/AgentSkills.tsx b/src/pages/options/routes/AgentSkills.tsx index d42efba45..f3d18d352 100644 --- a/src/pages/options/routes/AgentSkills.tsx +++ b/src/pages/options/routes/AgentSkills.tsx @@ -559,12 +559,7 @@ function AgentSkills() { t={t} /> - setConfigVisible(false)} - t={t} - /> + setConfigVisible(false)} t={t} /> ); } diff --git a/src/pages/options/routes/AgentTasks.tsx b/src/pages/options/routes/AgentTasks.tsx index 12ee600c4..050b55ff9 100644 --- a/src/pages/options/routes/AgentTasks.tsx +++ b/src/pages/options/routes/AgentTasks.tsx @@ -77,9 +77,7 @@ function TaskCard({
{task.mode === "internal" ? "I" : "E"} @@ -112,7 +110,9 @@ function TaskCard({ {task.lastRunStatus ? ( ) : ( @@ -201,8 +201,7 @@ function RunHistoryDrawer({ title: t("agent_tasks_run_usage"), dataIndex: "usage", width: 120, - render: (usage: AgentTaskRun["usage"]) => - usage ? `${usage.inputTokens} / ${usage.outputTokens}` : "-", + render: (usage: AgentTaskRun["usage"]) => (usage ? `${usage.inputTokens} / ${usage.outputTokens}` : "-"), }, ]; @@ -239,8 +238,9 @@ function AgentTasks() { const [tasks, setTasks] = useState([]); const [models, setModels] = useState([]); const [modalVisible, setModalVisible] = useState(false); - const [editingTask, setEditingTask] = - useState>({ ...emptyTask }); + const [editingTask, setEditingTask] = useState>({ + ...emptyTask, + }); const [editingId, setEditingId] = useState(null); const [cronPreview, setCronPreview] = useState(""); const [drawerTask, setDrawerTask] = useState(null); @@ -429,9 +429,7 @@ function AgentTasks() { {/* 模式 */}
-
- {t("type")} -
+
{t("type")}
setEditingTask((prev) => ({ ...prev, mode: value }))} diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 30cbefa7f..46bffd00a 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -1124,8 +1124,6 @@ declare namespace CATAgentDom { url?: string; /** New tab opened as a result. */ newTab?: { tabId: number; url: string }; - /** Dialog that appeared (alert/confirm/prompt). */ - dialog?: { type: "alert" | "confirm" | "prompt"; message: string }; } /** Page content returned by `readPage()`. */ @@ -1364,7 +1362,10 @@ declare namespace CATAgentTask { } /** Options for creating a new task (fields auto-populated by the system are omitted). */ - type AgentTaskCreateOptions = Omit; + type AgentTaskCreateOptions = Omit< + AgentTask, + "id" | "createtime" | "updatetime" | "nextruntime" | "sourceScriptUuid" + >; /** * `CAT.agent.task` — create and manage scheduled agent tasks. diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 8c98f93f2..6b063fe72 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -1131,8 +1131,6 @@ declare namespace CATAgentDom { url?: string; /** 操作导致打开的新标签页。 */ newTab?: { tabId: number; url: string }; - /** 出现的对话框。 */ - dialog?: { type: "alert" | "confirm" | "prompt"; message: string }; } /** `readPage()` 返回的页面内容。 */ @@ -1371,7 +1369,10 @@ declare namespace CATAgentTask { } /** 创建新任务的选项(系统自动填充的字段已省略)。 */ - type AgentTaskCreateOptions = Omit; + type AgentTaskCreateOptions = Omit< + AgentTask, + "id" | "createtime" | "updatetime" | "nextruntime" | "sourceScriptUuid" + >; /** * `CAT.agent.task` — 创建和管理定时 Agent 任务。 From 32e612b677f4c2c59dd2092534e7cd9eb0551012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 14 Mar 2026 20:53:40 +0800 Subject: [PATCH 069/150] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20scriptcat.d?= =?UTF-8?q?.ts=20=E7=B1=BB=E5=9E=8B=E5=A3=B0=E6=98=8E=E4=B8=8E=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E5=AE=9E=E7=8E=B0=E7=9A=84=E5=B7=AE=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stopMonitor 返回类型 Promise → Promise,新增 MonitorResult 接口 - tools.list() 返回类型 CATToolRecord[] → CATToolSummary[](不含 code) - SkillSummary 新增 hasConfig 字段 - SkillRecord 新增 config 字段及 SkillConfigField 接口 - 同步更新中文版 scriptcat.zh-CN.d.ts --- src/types/scriptcat.d.ts | 41 +++++++++++++++++++++++++++++----- src/types/scriptcat.zh-CN.d.ts | 41 +++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 46bffd00a..3465fcbc0 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -1075,6 +1075,9 @@ declare namespace CATAgentTools { updatetime: number; } + /** Summary of a CATTool (without source code). Returned by `list()`. */ + type CATToolSummary = Omit; + /** * `CAT.agent.tools` — manage and invoke CATTools. * @grant CAT.agent.tools @@ -1086,8 +1089,8 @@ declare namespace CATAgentTools { /** Remove a CATTool by name. */ remove(name: string): Promise; - /** List all installed CATTools. */ - list(): Promise; + /** List all installed CATTools (without source code). */ + list(): Promise; /** Invoke a CATTool by name with optional parameters. */ call(name: string, params?: Record): Promise; @@ -1242,6 +1245,14 @@ declare namespace CATAgentDom { tabId?: number; } + /** Result of `stopMonitor()` — collected DOM changes during monitoring. */ + interface MonitorResult { + /** Dialogs captured during monitoring. */ + dialogs: Array<{ type: string; message: string }>; + /** DOM nodes added during monitoring. */ + addedNodes: Array<{ tag: string; id?: string; class?: string; role?: string; text: string }>; + } + /** Result of `peekMonitor()` — summary of DOM changes being monitored. */ interface MonitorStatus { /** Whether any changes were detected. */ @@ -1287,8 +1298,8 @@ declare namespace CATAgentDom { /** Start monitoring DOM changes on a tab (dialogs, added nodes). */ startMonitor(tabId: number): Promise; - /** Stop monitoring DOM changes on a tab. */ - stopMonitor(tabId: number): Promise; + /** Stop monitoring and return collected changes (dialogs, added nodes). */ + stopMonitor(tabId: number): Promise; /** Peek at the current monitor status for a tab. */ peekMonitor(tabId: number): Promise; @@ -1415,16 +1426,36 @@ declare namespace CATAgentSkills { toolNames: string[]; /** Reference document names (from `references/` directory). */ referenceNames: string[]; + /** Whether this Skill has config fields declared. */ + hasConfig?: boolean; /** Installation timestamp. */ installtime: number; /** Last update timestamp. */ updatetime: number; } - /** Full Skill record including the prompt. */ + /** Config field definition declared in SKILL.md frontmatter. */ + interface SkillConfigField { + /** Display title. */ + title: string; + /** Widget type. */ + type: "text" | "number" | "select" | "switch"; + /** Whether the value should be masked (e.g. API keys). */ + secret?: boolean; + /** Whether the field is required. */ + required?: boolean; + /** Default value. */ + default?: unknown; + /** Allowed values (for `select` type). */ + values?: string[]; + } + + /** Full Skill record including the prompt and config schema. */ interface SkillRecord extends SkillSummary { /** SKILL.md body (markdown after frontmatter removal). */ prompt: string; + /** Config schema from SKILL.md frontmatter. */ + config?: Record; } /** diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 6b063fe72..54e0c40c1 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -1082,6 +1082,9 @@ declare namespace CATAgentTools { updatetime: number; } + /** CATTool 摘要(不含源代码)。由 `list()` 返回。 */ + type CATToolSummary = Omit; + /** * `CAT.agent.tools` — 管理和调用 CATTool。 * @grant CAT.agent.tools @@ -1093,8 +1096,8 @@ declare namespace CATAgentTools { /** 按名称卸载 CATTool。 */ remove(name: string): Promise; - /** 列出所有已安装的 CATTool。 */ - list(): Promise; + /** 列出所有已安装的 CATTool(不含源代码)。 */ + list(): Promise; /** 按名称调用 CATTool,可传入参数。 */ call(name: string, params?: Record): Promise; @@ -1249,6 +1252,14 @@ declare namespace CATAgentDom { tabId?: number; } + /** `stopMonitor()` 的结果 — 监控期间收集的 DOM 变更。 */ + interface MonitorResult { + /** 监控期间捕获的对话框。 */ + dialogs: Array<{ type: string; message: string }>; + /** 监控期间新增的 DOM 节点。 */ + addedNodes: Array<{ tag: string; id?: string; class?: string; role?: string; text: string }>; + } + /** `peekMonitor()` 的结果 — 正在监控的 DOM 变更摘要。 */ interface MonitorStatus { /** 是否检测到变更。 */ @@ -1294,8 +1305,8 @@ declare namespace CATAgentDom { /** 开始监控标签页上的 DOM 变更(对话框、新增节点)。 */ startMonitor(tabId: number): Promise; - /** 停止监控标签页上的 DOM 变更。 */ - stopMonitor(tabId: number): Promise; + /** 停止监控并返回收集到的变更(对话框、新增节点)。 */ + stopMonitor(tabId: number): Promise; /** 查看标签页的当前监控状态。 */ peekMonitor(tabId: number): Promise; @@ -1422,16 +1433,36 @@ declare namespace CATAgentSkills { toolNames: string[]; /** 参考资料名称(来自 `references/` 目录)。 */ referenceNames: string[]; + /** 此 Skill 是否声明了配置字段。 */ + hasConfig?: boolean; /** 安装时间戳。 */ installtime: number; /** 最后更新时间戳。 */ updatetime: number; } - /** 包含 prompt 的完整 Skill 记录。 */ + /** SKILL.md frontmatter 中声明的配置字段定义。 */ + interface SkillConfigField { + /** 显示标题。 */ + title: string; + /** 控件类型。 */ + type: "text" | "number" | "select" | "switch"; + /** 值是否应被掩码显示(如 API 密钥)。 */ + secret?: boolean; + /** 是否必填。 */ + required?: boolean; + /** 默认值。 */ + default?: unknown; + /** 允许的值(用于 `select` 类型)。 */ + values?: string[]; + } + + /** 包含 prompt 和配置模式的完整 Skill 记录。 */ interface SkillRecord extends SkillSummary { /** SKILL.md 正文(去除 frontmatter 后的 markdown)。 */ prompt: string; + /** 来自 SKILL.md frontmatter 的配置模式。 */ + config?: Record; } /** From 7efcd3793b630b725112a1ce088d22de06627173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 14 Mar 2026 22:09:55 +0800 Subject: [PATCH 070/150] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20E2E=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20flaky=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-fixtures: 复用 Phase 1 的 extensionId 避免 service worker 已终止时 waitForEvent 永久挂起,主动导航触发 service worker 启动 - options.spec: 用 .menu-tools 精确定位"工具"菜单项,避免匹配 CATool 子菜单 - playwright.config: 本地也启用 1 次重试,容错 Chrome headless 偶发启动问题 --- e2e/agent-fixtures.ts | 43 ++++++++++++++++++++++++++++++++++++------- e2e/options.spec.ts | 8 ++------ playwright.config.ts | 2 +- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/e2e/agent-fixtures.ts b/e2e/agent-fixtures.ts index cf3cd351a..c4e139d39 100644 --- a/e2e/agent-fixtures.ts +++ b/e2e/agent-fixtures.ts @@ -109,26 +109,55 @@ export const test = base.extend({ headless: false, args: ["--headless=new", ...chromeArgs], }); + + // extensionId 与 Phase 1 相同(同一 userDataDir) + (context as any).__extensionId = extensionId; + await use(context); await context.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); }, extensionId: async ({ context }, use) => { - let [background] = context.serviceWorkers(); - if (!background) background = await context.waitForEvent("serviceworker", { timeout: 30_000 }); - const extensionId = background.url().split("/")[2]; + const extensionId: string = (context as any).__extensionId; + + // 确保 service worker 处于活跃状态 + const ensureServiceWorker = async () => { + let sw = context.serviceWorkers().find((w) => w.url().includes(extensionId)); + if (sw) return sw; + + // service worker 未就绪 — 先监听事件,再通过导航触发它启动 + const swPromise = context.waitForEvent("serviceworker", { timeout: 30_000 }); + const wakePage = await context.newPage(); + try { + // 导航到扩展页面强制 service worker 激活 + await wakePage.goto(`chrome-extension://${extensionId}/src/options.html`, { + waitUntil: "commit", + timeout: 10_000, + }); + } catch { + // 即使导航失败(ERR_BLOCKED_BY_CLIENT),请求本身也可能触发 service worker 注册 + } + + sw = context.serviceWorkers().find((w) => w.url().includes(extensionId)); + if (!sw) sw = await swPromise; + await wakePage.close(); + return sw; + }; + + await ensureServiceWorker(); // Dismiss first-use dialog const initPage = await context.newPage(); - await initPage.goto(`chrome-extension://${extensionId}/src/options.html`); - await initPage.waitForLoadState("domcontentloaded"); + await initPage.goto(`chrome-extension://${extensionId}/src/options.html`, { + waitUntil: "domcontentloaded", + }); await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); await initPage.close(); - // Configure mock model in chrome.storage.local via service worker + // 获取活跃的 service worker(导航已唤醒它) let sw = context.serviceWorkers()[0]; - if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 30_000 }); + if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 10_000 }); await sw.evaluate(() => { const modelConfig = { id: "mock-model", diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index 2901e4113..327de276d 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -35,12 +35,8 @@ test.describe("Options Page", () => { .click(); await expect(page).toHaveURL(/.*#\/logger/); - // Click "Tools" / "工具" menu item - await page - .locator(".arco-menu-item") - .filter({ hasText: /tool|工具/i }) - .first() - .click(); + // Click "Tools" / "工具" menu item (use .menu-tools class to avoid hitting "CATool" submenu) + await page.locator(".menu-tools .arco-menu-item").click(); await expect(page).toHaveURL(/.*#\/tools/); // Click "Settings" / "设置" menu item diff --git a/playwright.config.ts b/playwright.config.ts index 9a15dabba..a5bea295d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ }, fullyParallel: false, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, + retries: process.env.CI ? 1 : 1, workers: 1, reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list", outputDir: "test-results", From d49afaf99de896c606897eefdef0c9ea248b2c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 14 Mar 2026 23:43:48 +0800 Subject: [PATCH 071/150] =?UTF-8?q?feat:=20=E5=85=81=E8=AE=B8=20CATTool=20?= =?UTF-8?q?=E9=80=9A=E8=BF=87=20@timeout=20=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E8=B6=85=E6=97=B6=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认仍为 30 秒,支持在 ==CATTool== 头中声明 @timeout(秒)覆盖默认值, 适用于网络爬取、大文件处理等耗时工具场景。 --- .../service/agent/cattool_executor.test.ts | 27 +++++++++ src/app/service/agent/cattool_executor.ts | 10 ++-- src/app/service/agent/types.ts | 2 + src/app/service/service_worker/agent.ts | 2 + src/pkg/utils/cattool.test.ts | 60 +++++++++++++++++++ src/pkg/utils/cattool.ts | 8 ++- 6 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/app/service/agent/cattool_executor.test.ts b/src/app/service/agent/cattool_executor.test.ts index 3df25e345..f5a467790 100644 --- a/src/app/service/agent/cattool_executor.test.ts +++ b/src/app/service/agent/cattool_executor.test.ts @@ -526,6 +526,33 @@ describe("CATToolExecutor 超时处理", () => { expect(getCATToolNameByUuid(capturedUuid)).toBe(""); }); + it("自定义 timeout 应覆盖默认 30s", async () => { + vi.useFakeTimers(); + + const sender = { + sendMessage: vi.fn().mockReturnValue(new Promise(() => {})), + } as any; + + const record = createRecord([], { name: "slow_tool", timeout: 120 }); + const executor = new CATToolExecutor(record, sender); + + const errPromise = executor.execute({}).catch((e) => e); + + // 30s 后不应超时 + await vi.advanceTimersByTimeAsync(30_000); + // 60s 后仍不应超时 + await vi.advanceTimersByTimeAsync(30_000); + + // 推进到 120s 触发超时 + await vi.advanceTimersByTimeAsync(60_000); + + const err = await errPromise; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("slow_tool"); + expect((err as Error).message).toContain("120s"); + expect((err as any).errorCode).toBe("tool_timeout"); + }); + it("30s 内完成的执行不应超时", async () => { vi.useFakeTimers(); diff --git a/src/app/service/agent/cattool_executor.ts b/src/app/service/agent/cattool_executor.ts index 74e7e4add..9333fa80a 100644 --- a/src/app/service/agent/cattool_executor.ts +++ b/src/app/service/agent/cattool_executor.ts @@ -8,8 +8,8 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; // CATTool UUID 前缀,用于在 GM API 请求中识别 CATTool export const CATTOOL_UUID_PREFIX = "cattool-"; -// CATTool 单次执行超时(ms) -const CATTOOL_EXEC_TIMEOUT_MS = 30_000; +// CATTool 默认超时(ms) +const CATTOOL_DEFAULT_TIMEOUT_MS = 30_000; // 全局的 CATTool UUID → 工具信息映射,供 GM API 权限验证时使用 // 直接携带 grants,避免运行时再查 repo(skill 的 CATTool 不在 catToolRepo 中) @@ -77,6 +77,8 @@ export class CATToolExecutor implements ToolExecutor { } const code = getCATToolBody(this.record.code); + const timeoutMs = this.record.timeout ? this.record.timeout * 1000 : CATTOOL_DEFAULT_TIMEOUT_MS; + const timeoutSec = timeoutMs / 1000; try { const execPromise = executeCATTool(this.sender, { uuid, @@ -91,11 +93,11 @@ export class CATToolExecutor implements ToolExecutor { setTimeout( () => reject( - Object.assign(new Error(`CATTool "${this.record.name}" timed out after 30s`), { + Object.assign(new Error(`CATTool "${this.record.name}" timed out after ${timeoutSec}s`), { errorCode: "tool_timeout", }) ), - CATTOOL_EXEC_TIMEOUT_MS + timeoutMs ) ); return await Promise.race([execPromise, timeoutPromise]); diff --git a/src/app/service/agent/types.ts b/src/app/service/agent/types.ts index b7836c41b..4ed811fa9 100644 --- a/src/app/service/agent/types.ts +++ b/src/app/service/agent/types.ts @@ -246,6 +246,7 @@ export type CATToolMetadata = { params: CATToolParam[]; grants: string[]; requires: string[]; + timeout?: number; // 自定义超时时间(秒) }; // OPFS 中存储的 CATTool 记录 @@ -256,6 +257,7 @@ export type CATToolRecord = { params: CATToolParam[]; grants: string[]; requires?: string[]; // @require URL 列表 + timeout?: number; // 自定义超时时间(秒) code: string; // 完整代码(含元数据头) sourceScriptUuid?: string; // 安装来源脚本的 UUID sourceScriptName?: string; // 安装来源脚本的名称 diff --git a/src/app/service/service_worker/agent.ts b/src/app/service/service_worker/agent.ts index b877be2e2..a9269b76d 100644 --- a/src/app/service/service_worker/agent.ts +++ b/src/app/service/service_worker/agent.ts @@ -257,6 +257,7 @@ export class AgentService { params: metadata.params, grants: metadata.grants, requires: metadata.requires.length > 0 ? metadata.requires : undefined, + timeout: metadata.timeout, code, sourceScriptUuid: sourceScriptUuid || existing?.sourceScriptUuid, sourceScriptName: sourceScriptName || existing?.sourceScriptName, @@ -480,6 +481,7 @@ export class AgentService { params: metadata.params, grants: metadata.grants, requires: metadata.requires.length > 0 ? metadata.requires : undefined, + timeout: metadata.timeout, code: script.code, installtime: now, updatetime: now, diff --git a/src/pkg/utils/cattool.test.ts b/src/pkg/utils/cattool.test.ts index 5e87bbcba..7a276a217 100644 --- a/src/pkg/utils/cattool.test.ts +++ b/src/pkg/utils/cattool.test.ts @@ -237,6 +237,66 @@ return "ok"; expect(meta.description).toBe("测试工具"); }); + it("应正确解析 @timeout", () => { + const code = ` +// ==CATTool== +// @name slow_tool +// @description 耗时工具 +// @timeout 120 +// ==/CATTool== +return "ok"; +`; + const meta = parseCATToolMetadata(code)!; + expect(meta.name).toBe("slow_tool"); + expect(meta.timeout).toBe(120); + }); + + it("无 @timeout 时 timeout 应为 undefined", () => { + const code = ` +// ==CATTool== +// @name fast_tool +// @description 快速工具 +// ==/CATTool== +return "ok"; +`; + const meta = parseCATToolMetadata(code)!; + expect(meta.timeout).toBeUndefined(); + }); + + it("@timeout 值无效时应忽略", () => { + const code = ` +// ==CATTool== +// @name bad_timeout +// @timeout abc +// ==/CATTool== +return "ok"; +`; + const meta = parseCATToolMetadata(code)!; + expect(meta.timeout).toBeUndefined(); + }); + + it("@timeout 值为 0 或负数时应忽略", () => { + const code = ` +// ==CATTool== +// @name zero_timeout +// @timeout 0 +// ==/CATTool== +return "ok"; +`; + const meta = parseCATToolMetadata(code)!; + expect(meta.timeout).toBeUndefined(); + + const code2 = ` +// ==CATTool== +// @name neg_timeout +// @timeout -5 +// ==/CATTool== +return "ok"; +`; + const meta2 = parseCATToolMetadata(code2)!; + expect(meta2.timeout).toBeUndefined(); + }); + it("@param [required] 带 enum 时应同时解析", () => { const code = ` // ==CATTool== diff --git a/src/pkg/utils/cattool.ts b/src/pkg/utils/cattool.ts index 2508152cf..a5df12072 100644 --- a/src/pkg/utils/cattool.ts +++ b/src/pkg/utils/cattool.ts @@ -13,6 +13,7 @@ export function parseCATToolMetadata(code: string): CATToolMetadata | null { const params: CATToolParam[] = []; const grants: string[] = []; const requires: string[] = []; + let timeout: number | undefined; for (const line of lines) { const trimmed = line.replace(/^\/\/\s*/, "").trim(); @@ -43,12 +44,17 @@ export function parseCATToolMetadata(code: string): CATToolMetadata | null { case "require": if (val) requires.push(val); break; + case "timeout": { + const n = Number(val); + if (Number.isFinite(n) && n > 0) timeout = n; + break; + } } } if (!name) return null; - return { name, description, params, grants, requires }; + return { name, description, params, grants, requires, ...(timeout !== undefined ? { timeout } : {}) }; } // 解析 @param 行: name type [required] description From 8f2a01aa940a6efe0f2ab1407d077823ae29accc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 15 Mar 2026 09:23:13 +0800 Subject: [PATCH 072/150] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ESLint=20?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=E5=B9=B6=E4=BC=98=E5=8C=96=20E2E=20service?= =?UTF-8?q?=20worker=20=E5=B0=B1=E7=BB=AA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消除 react-hooks/exhaustive-deps 警告,添加 eslint-disable 注释 - JSX 中字符串字面量用花括号包裹,修复 jsx-no-literals 规则 - E2E fixtures 优化 Phase 2 service worker 竞态处理 --- e2e/agent-fixtures.ts | 21 ++++++------ .../components/CloudScriptPlan/index.tsx | 1 + src/pages/components/ScriptSetting/Match.tsx | 1 + .../install/components/SkillInstallView.tsx | 7 ++-- src/pages/install/hooks.tsx | 3 ++ src/pages/options/routes/AgentCATool.tsx | 18 ++++++++--- .../routes/AgentChat/AttachmentRenderers.tsx | 4 +-- src/pages/options/routes/AgentMcp.tsx | 30 +++++++++++------ src/pages/options/routes/AgentSkills.tsx | 32 ++++++++++++------- src/pages/options/routes/AgentTasks.tsx | 4 ++- src/pages/options/routes/Logger.tsx | 2 ++ src/pages/options/routes/ScriptList/index.tsx | 1 + .../options/routes/script/ScriptEditor.tsx | 1 + 13 files changed, 84 insertions(+), 41 deletions(-) diff --git a/e2e/agent-fixtures.ts b/e2e/agent-fixtures.ts index c4e139d39..c70666744 100644 --- a/e2e/agent-fixtures.ts +++ b/e2e/agent-fixtures.ts @@ -110,7 +110,10 @@ export const test = base.extend({ args: ["--headless=new", ...chromeArgs], }); - // extensionId 与 Phase 1 相同(同一 userDataDir) + // 立即等待 service worker 就绪(与 Phase 1 一致,避免事件丢失竞态) + let [bg2] = context.serviceWorkers(); + if (!bg2) bg2 = await context.waitForEvent("serviceworker", { timeout: 30_000 }); + (context as any).__extensionId = extensionId; await use(context); @@ -121,22 +124,21 @@ export const test = base.extend({ extensionId: async ({ context }, use) => { const extensionId: string = (context as any).__extensionId; - // 确保 service worker 处于活跃状态 + // 唤醒可能已空闲终止的 service worker const ensureServiceWorker = async () => { let sw = context.serviceWorkers().find((w) => w.url().includes(extensionId)); if (sw) return sw; - // service worker 未就绪 — 先监听事件,再通过导航触发它启动 + // SW 可能已空闲终止,通过导航重新激活 const swPromise = context.waitForEvent("serviceworker", { timeout: 30_000 }); const wakePage = await context.newPage(); try { - // 导航到扩展页面强制 service worker 激活 await wakePage.goto(`chrome-extension://${extensionId}/src/options.html`, { waitUntil: "commit", timeout: 10_000, }); } catch { - // 即使导航失败(ERR_BLOCKED_BY_CLIENT),请求本身也可能触发 service worker 注册 + // 即使导航失败,请求本身也可能触发 service worker 注册 } sw = context.serviceWorkers().find((w) => w.url().includes(extensionId)); @@ -145,9 +147,7 @@ export const test = base.extend({ return sw; }; - await ensureServiceWorker(); - - // Dismiss first-use dialog + // Dismiss first-use dialog(导航也会唤醒 SW) const initPage = await context.newPage(); await initPage.goto(`chrome-extension://${extensionId}/src/options.html`, { waitUntil: "domcontentloaded", @@ -155,9 +155,8 @@ export const test = base.extend({ await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); await initPage.close(); - // 获取活跃的 service worker(导航已唤醒它) - let sw = context.serviceWorkers()[0]; - if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 10_000 }); + // 确保 SW 处于活跃状态 + const sw = await ensureServiceWorker(); await sw.evaluate(() => { const modelConfig = { id: "mock-model", diff --git a/src/pages/components/CloudScriptPlan/index.tsx b/src/pages/components/CloudScriptPlan/index.tsx index c74710b88..50fe8c930 100644 --- a/src/pages/components/CloudScriptPlan/index.tsx +++ b/src/pages/components/CloudScriptPlan/index.tsx @@ -57,6 +57,7 @@ const CloudScriptPlan: React.FC<{ } }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [script]); return ( { refreshMatch(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [script]); const columns: ColumnProps[] = [ diff --git a/src/pages/install/components/SkillInstallView.tsx b/src/pages/install/components/SkillInstallView.tsx index ef57d85f0..a75cd6ffe 100644 --- a/src/pages/install/components/SkillInstallView.tsx +++ b/src/pages/install/components/SkillInstallView.tsx @@ -64,7 +64,10 @@ function SkillInstallView({ className="tw-flex tw-items-center tw-gap-1 tw-cursor-pointer tw-select-none" onClick={() => setPromptExpanded(!promptExpanded)} > - {t("agent_skills_prompt")}: + + {t("agent_skills_prompt")} + {":"} + {promptExpanded ? : }
{promptExpanded ? ( @@ -165,7 +168,7 @@ function SkillInstallView({ )} {field.secret && ( - secret + {"secret"} )}
diff --git a/src/pages/install/hooks.tsx b/src/pages/install/hooks.tsx index 86de181bf..15fb12d38 100644 --- a/src/pages/install/hooks.tsx +++ b/src/pages/install/hooks.tsx @@ -309,6 +309,7 @@ export function useInstallData() { } else { initAsync(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, loaded]); const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]); @@ -427,6 +428,7 @@ export function useInstallData() { if (upsertScript) { document.title = `${!isUpdate ? t("install_script") : t("update_script")} - ${i18nName(upsertScript!)} - ScriptCat`; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isUpdate, scriptInfo, upsertScript, cattoolMetadata, t]); // 设置脚本状态 @@ -600,6 +602,7 @@ export function useInstallData() { return () => { unmountFileTrack(handle); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [memoWatchFile]); // 检查是否有 uuid 或 file diff --git a/src/pages/options/routes/AgentCATool.tsx b/src/pages/options/routes/AgentCATool.tsx index 21b982599..8ac7d5da9 100644 --- a/src/pages/options/routes/AgentCATool.tsx +++ b/src/pages/options/routes/AgentCATool.tsx @@ -64,13 +64,17 @@ function CATToolCard({ {/* Source */} {tool.sourceScriptName && (
- {t("agent_catool_source")}: {tool.sourceScriptName} + {t("agent_catool_source")} + {": "} + {tool.sourceScriptName}
)} {/* Install time */}
- {t("agent_catool_installed_at")}: {new Date(tool.installtime).toLocaleString()} + {t("agent_catool_installed_at")} + {": "} + {new Date(tool.installtime).toLocaleString()}
{/* Actions */} @@ -118,7 +122,9 @@ function CATToolDetailDrawer({ {tool.params.length > 0 && (
- {t("agent_catool_params")} ({tool.params.length}) + {t("agent_catool_params")} {"("} + {tool.params.length} + {")"}
{tool.params.map((p) => ( @@ -128,7 +134,7 @@ function CATToolDetailDrawer({ {p.type} {p.required && ( - required + {"required"} )}
@@ -156,7 +162,9 @@ function CATToolDetailDrawer({ {tool.grants.length > 0 && (
- {t("agent_catool_grants")} ({tool.grants.length}) + {t("agent_catool_grants")} {"("} + {tool.grants.length} + {")"}
{tool.grants.map((g) => ( diff --git a/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx b/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx index ea16fd980..dc95178e3 100644 --- a/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx +++ b/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx @@ -27,7 +27,7 @@ export function AttachmentImage({ attachment }: { attachment: Attachment }) { if (!blobUrl) { return (
- Loading... + {"Loading..."}
); } @@ -123,7 +123,7 @@ export function AttachmentAudio({ block }: { block: AudioBlock }) { if (!blobUrl) { return (
- Loading audio... + {"Loading audio..."}
); } diff --git a/src/pages/options/routes/AgentMcp.tsx b/src/pages/options/routes/AgentMcp.tsx index 6313aafb8..1e94e6319 100644 --- a/src/pages/options/routes/AgentMcp.tsx +++ b/src/pages/options/routes/AgentMcp.tsx @@ -63,7 +63,7 @@ function ServerCard({
- MCP + {"MCP"}
{server.name} @@ -79,7 +79,7 @@ function ServerCard({
{server.apiKey && (
- API Key + {"API Key"} {server.apiKey.length > 8 ? `${server.apiKey.slice(0, 4)}${"*".repeat(8)}${server.apiKey.slice(-4)}` @@ -89,8 +89,10 @@ function ServerCard({ )} {server.headers && Object.keys(server.headers).length > 0 && (
- Headers - {Object.keys(server.headers).length} custom + {"Headers"} + + {Object.keys(server.headers).length} {"custom"} +
)}
@@ -223,7 +225,10 @@ function ServerDetailDrawer({ )} {tool.inputSchema && formatSchemaParams(tool.inputSchema).length > 0 && (
- {t("agent_mcp_parameters")}: + + {t("agent_mcp_parameters")} + {":"} + {formatSchemaParams(tool.inputSchema).map((param) => ( {param} @@ -279,7 +284,10 @@ function ServerDetailDrawer({ )} {prompt.arguments && prompt.arguments.length > 0 && (
- {t("agent_mcp_parameters")}: + + {t("agent_mcp_parameters")} + {":"} + {prompt.arguments.map((arg) => ( {arg.required ? arg.name : `${arg.name}?`} @@ -535,7 +543,7 @@ function AgentMcp() { {/* URL */}
-
URL
+
{"URL"}
- API Key ({t("agent_mcp_optional")}) + {"API Key ("} + {t("agent_mcp_optional")} + {")"}
- {t("agent_mcp_custom_headers")} ({t("agent_mcp_optional")}) + {t("agent_mcp_custom_headers")} {"("} + {t("agent_mcp_optional")} + {")"}
- Sk + {"Sk"}
{skill.name} @@ -68,12 +68,16 @@ function SkillCard({
{skill.toolNames.length > 0 && ( - {t("agent_skills_tools")}: {skill.toolNames.length} + {t("agent_skills_tools")} + {": "} + {skill.toolNames.length} )} {skill.referenceNames.length > 0 && ( - {t("agent_skills_references")}: {skill.referenceNames.length} + {t("agent_skills_references")} + {": "} + {skill.referenceNames.length} )} {skill.hasConfig && ( @@ -85,7 +89,9 @@ function SkillCard({ {/* Install time */}
- {t("agent_skills_installed_at")}: {new Date(skill.installtime).toLocaleString()} + {t("agent_skills_installed_at")} + {": "} + {new Date(skill.installtime).toLocaleString()}
{/* Actions */} @@ -225,7 +231,7 @@ function SkillConfigModal({ const label = (
{field.title || key} - {field.required && *} + {field.required && {"*"}}
); @@ -255,7 +261,7 @@ function SkillConfigModal({
{field.title || key} - {field.required && *} + {field.required && {"*"}} onChange(v)} />
@@ -287,7 +293,7 @@ function SkillConfigModal({ style={{ width: 520 }} > {loading ? ( -
Loading...
+
{"Loading..."}
) : ( {Object.entries(skill.config).map(([key, field]) => renderField(key, field))} @@ -365,12 +371,12 @@ function SkillDetailModal({ {/* Name & Description (read-only) */}
-
Name
+
{"Name"}
{skill.name}
{skill.description && (
-
Description
+
{"Description"}
{skill.description}
)} @@ -392,7 +398,9 @@ function SkillDetailModal({ {scripts.length > 0 && (
- {t("agent_skills_tools")} ({scripts.length}) + {t("agent_skills_tools")} {"("} + {scripts.length} + {")"}
{scripts.map((s) => ( @@ -416,7 +424,9 @@ function SkillDetailModal({ {references.length > 0 && (
- {t("agent_skills_references")} ({references.length}) + {t("agent_skills_references")} {"("} + {references.length} + {")"}
{references.map((r) => ( diff --git a/src/pages/options/routes/AgentTasks.tsx b/src/pages/options/routes/AgentTasks.tsx index 050b55ff9..575b90139 100644 --- a/src/pages/options/routes/AgentTasks.tsx +++ b/src/pages/options/routes/AgentTasks.tsx @@ -451,7 +451,9 @@ function AgentTasks() { /> {cronPreview && ( - {t("agent_tasks_next_run")}: {cronPreview} + {t("agent_tasks_next_run")} + {": "} + {cronPreview} )}
diff --git a/src/pages/options/routes/Logger.tsx b/src/pages/options/routes/Logger.tsx index 8cf1db927..966c1805f 100644 --- a/src/pages/options/routes/Logger.tsx +++ b/src/pages/options/routes/Logger.tsx @@ -75,6 +75,7 @@ function LoggerPage() { onQueryLog(); setInit(2); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [init]); useEffect(() => { @@ -104,6 +105,7 @@ function LoggerPage() { setInit(1); } }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [startTime, endTime]); return ( diff --git a/src/pages/options/routes/ScriptList/index.tsx b/src/pages/options/routes/ScriptList/index.tsx index da178ee8a..96a969ff6 100644 --- a/src/pages/options/routes/ScriptList/index.tsx +++ b/src/pages/options/routes/ScriptList/index.tsx @@ -256,6 +256,7 @@ function ScriptList() { } }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/src/pages/options/routes/script/ScriptEditor.tsx b/src/pages/options/routes/script/ScriptEditor.tsx index 40a6a694b..69ccfa22c 100644 --- a/src/pages/options/routes/script/ScriptEditor.tsx +++ b/src/pages/options/routes/script/ScriptEditor.tsx @@ -80,6 +80,7 @@ const Editor: React.FC<{ }); callbackEditor(node.editor); return node.editor.dispose.bind(node.editor); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [node?.editor]); return ; From 29cf81cd4336af4d02a8094e1b262543a21bb664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 15 Mar 2026 11:52:53 +0800 Subject: [PATCH 073/150] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=AE?= =?UTF-8?q?=E8=89=B2=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=94=A8=E6=88=B7=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=B0=94=E6=B3=A1=E5=AD=97=E4=BD=93=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 亮色模式使用 var(--color-text-1) 深色文字,暗色模式使用白色 - 用户消息改用 getTextContent 纯文本渲染 - 版本号升级至 1.5.0-alpha --- package.json | 2 +- src/manifest.json | 2 +- src/pages/options/routes/AgentChat/MessageItem.tsx | 8 ++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 92055250d..9c18e5153 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scriptcat", - "version": "1.4.0-beta", + "version": "1.5.0-alpha", "description": "脚本猫,一个可以执行用户脚本的浏览器扩展,万物皆可脚本化,让你的浏览器可以做更多的事情!", "author": "CodFrm", "license": "GPLv3", diff --git a/src/manifest.json b/src/manifest.json index 29a793aa7..45800a971 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "__MSG_scriptcat__", - "version": "1.4.0-beta", + "version": "1.5.0.1001", "author": "CodFrm", "description": "__MSG_scriptcat_description__", "options_ui": { diff --git a/src/pages/options/routes/AgentChat/MessageItem.tsx b/src/pages/options/routes/AgentChat/MessageItem.tsx index 778402048..b30c74bac 100644 --- a/src/pages/options/routes/AgentChat/MessageItem.tsx +++ b/src/pages/options/routes/AgentChat/MessageItem.tsx @@ -152,12 +152,8 @@ export function UserMessageItem({ ) : ( // 只读模式:消息气泡 + 底部工具条 <> -
- {typeof message.content === "string" ? ( - message.content - ) : ( - - )} +
+ {getTextContent(message.content)}
{canInteract && (
From 388b6d66e8beb16b26da239b82ccf2f62948f4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 15 Mar 2026 14:21:48 +0800 Subject: [PATCH 074/150] =?UTF-8?q?feat:=20=E5=B0=86=20prompt=20cache=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=9D=83=E4=BA=A4=E7=BB=99=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=96=B9=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=90=AF=E7=94=A8=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ephemeral 路径不再硬编码 cache: false,改为透传调用方的 cache 参数。 ConversationCreateOptions 新增 cache 字段,CAT API 全链路支持。 --- src/app/service/agent/types.ts | 1 + src/app/service/content/gm_api/cat_agent.ts | 14 ++++++++++++-- src/app/service/service_worker/agent.ts | 4 +++- src/types/scriptcat.d.ts | 2 ++ src/types/scriptcat.zh-CN.d.ts | 2 ++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/app/service/agent/types.ts b/src/app/service/agent/types.ts index 4ed811fa9..1e73da15b 100644 --- a/src/app/service/agent/types.ts +++ b/src/app/service/agent/types.ts @@ -146,6 +146,7 @@ export type ConversationCreateOptions = { tools?: Array) => Promise }>; commands?: Record; // 自定义命令处理器,以 / 开头 ephemeral?: boolean; // 临时会话:不持久化、不加载内置资源、工具由脚本提供 + cache?: boolean; // 是否启用 prompt caching,默认 true }; // conv.chat() 的参数 diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 5c25c3403..0035c0fdd 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -25,6 +25,7 @@ export class ConversationInstance { private toolDefs: ToolDefinition[] = []; private commandHandlers: Map = new Map(); private ephemeral: boolean; + private cache?: boolean; private systemPrompt?: string; private messageHistory: Array<{ role: MessageRole; @@ -42,9 +43,11 @@ export class ConversationInstance { initialTools?: ConversationCreateOptions["tools"], commands?: Record, ephemeral?: boolean, - system?: string + system?: string, + cache?: boolean ) { this.ephemeral = ephemeral || false; + this.cache = cache; this.systemPrompt = system; if (initialTools) { for (const tool of initialTools) { @@ -102,6 +105,9 @@ export class ConversationInstance { scriptUuid: this.scriptUuid, }; + if (this.cache !== undefined) { + connectParams.cache = this.cache; + } if (this.ephemeral) { connectParams.ephemeral = true; connectParams.messages = this.messageHistory; @@ -169,6 +175,9 @@ export class ConversationInstance { scriptUuid: this.scriptUuid, }; + if (this.cache !== undefined) { + connectParams.cache = this.cache; + } if (this.ephemeral) { connectParams.ephemeral = true; connectParams.messages = this.messageHistory; @@ -515,7 +524,8 @@ function buildInstance( options?.tools, options?.commands, options?.ephemeral, - options?.system + options?.system, + options?.cache ); } diff --git a/src/app/service/service_worker/agent.ts b/src/app/service/service_worker/agent.ts index a9269b76d..f65a07a97 100644 --- a/src/app/service/service_worker/agent.ts +++ b/src/app/service/service_worker/agent.ts @@ -1029,6 +1029,7 @@ export class AgentService { messages?: ChatRequest["messages"]; system?: string; modelId?: string; + cache?: boolean; }, sender: IGetSender ) { @@ -1292,6 +1293,7 @@ export class AgentService { ephemeral?: boolean; messages?: ChatRequest["messages"]; system?: string; + cache?: boolean; }, sender: IGetSender ) { @@ -1365,7 +1367,7 @@ export class AgentService { signal: abortController.signal, scriptToolCallback: params.tools && params.tools.length > 0 ? scriptToolCallback : null, skipBuiltinTools: true, - cache: false, // ephemeral 会话轮次少,不需要 prompt caching + cache: params.cache, }); return; } diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 3465fcbc0..4da4c6638 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -877,6 +877,8 @@ declare namespace CATAgent { * Built-in tools/skills are NOT loaded; the script must supply all tools. */ ephemeral?: boolean; + /** Enable prompt caching. Defaults to true. */ + cache?: boolean; } /** Options for a single `chat()` / `chatStream()` call. */ diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 54e0c40c1..9fecfe677 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -884,6 +884,8 @@ declare namespace CATAgent { * 不加载内置工具/Skill,脚本需自行提供所有工具。 */ ephemeral?: boolean; + /** 是否启用 prompt caching,默认 true。 */ + cache?: boolean; } /** 单次 `chat()` / `chatStream()` 调用的选项。 */ From e00df9898524e49982c06036fe26740b2f35b70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 16 Mar 2026 18:06:38 +0800 Subject: [PATCH 075/150] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20CI=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20feature/*=20=E5=88=86=E6=94=AF=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E3=80=81Agent=20=E9=94=99=E8=AF=AF=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E5=8F=8A=20UI=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build.yaml/test.yaml 增加 feature/* 分支触发构建和测试 - Agent 错误消息持久化到 OPFS,刷新后仍可见 - 修复主布局全屏溢出问题 - 优化错误消息展示样式,支持暗色主题 --- .github/workflows/build.yaml | 1 + .github/workflows/test.yaml | 1 + src/app/service/service_worker/agent.ts | 31 ++++++++++++++++-- src/pages/components/layout/MainLayout.tsx | 4 +-- src/pages/components/layout/Sider.tsx | 2 +- .../options/routes/AgentChat/MessageItem.tsx | 9 +++--- src/pages/options/routes/AgentChat/styles.css | 32 +++++++++++++++++++ 7 files changed, 70 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8c8ac15ba..93d73a104 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +5,7 @@ on: branches: - main - release/* + - feature/* - dev paths-ignore: - ".github/**" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 146fb0f31..a5b8ae4a7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,6 +5,7 @@ on: branches: - main - release/* + - feature/* - dev - develop/* pull_request: diff --git a/src/app/service/service_worker/agent.ts b/src/app/service/service_worker/agent.ts index f65a07a97..b0ea43d5f 100644 --- a/src/app/service/service_worker/agent.ts +++ b/src/app/service/service_worker/agent.ts @@ -1232,9 +1232,20 @@ export class AgentService { } // 超过最大迭代次数 + const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; + if (conversationId) { + await this.repo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: maxIterMsg, + createtime: Date.now(), + }); + } sendEvent({ type: "error", - message: `Tool calling loop exceeded maximum iterations (${maxIterations})`, + message: maxIterMsg, errorCode: "max_iterations", }); } @@ -1497,7 +1508,23 @@ export class AgentService { } } catch (e: any) { if (abortController.signal.aborted) return; - sendEvent({ type: "error", message: e.message || "Unknown error", errorCode: classifyErrorCode(e) }); + const errorMsg = e.message || "Unknown error"; + // 持久化错误消息到 OPFS,确保刷新后仍可见 + if (params.conversationId && !params.ephemeral) { + try { + await this.repo.appendMessage({ + id: uuidv4(), + conversationId: params.conversationId, + role: "assistant", + content: "", + error: errorMsg, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞错误事件发送 + } + } + sendEvent({ type: "error", message: errorMsg, errorCode: classifyErrorCode(e) }); } } diff --git a/src/pages/components/layout/MainLayout.tsx b/src/pages/components/layout/MainLayout.tsx index d30f5d349..4ac77fd45 100644 --- a/src/pages/components/layout/MainLayout.tsx +++ b/src/pages/components/layout/MainLayout.tsx @@ -339,7 +339,7 @@ const MainLayout: React.FC<{ }} > {contextHolder} - + diff --git a/src/pages/components/layout/Sider.tsx b/src/pages/components/layout/Sider.tsx index 20febc6b0..0ce5fb638 100644 --- a/src/pages/components/layout/Sider.tsx +++ b/src/pages/components/layout/Sider.tsx @@ -218,7 +218,7 @@ const Sider: React.FC = () => { borderLeft: "1px solid var(--color-bg-5)", overflow: "hidden", padding: 0, - height: "auto", + height: "100%", boxSizing: "border-box", position: "relative", }} diff --git a/src/pages/options/routes/AgentChat/MessageItem.tsx b/src/pages/options/routes/AgentChat/MessageItem.tsx index b30c74bac..03b336362 100644 --- a/src/pages/options/routes/AgentChat/MessageItem.tsx +++ b/src/pages/options/routes/AgentChat/MessageItem.tsx @@ -5,7 +5,7 @@ import ThinkingBlock from "./ThinkingBlock"; import ToolCallBlock from "./ToolCallBlock"; import MessageToolbar from "./MessageToolbar"; import { Message as ArcoMessage, Tooltip } from "@arco-design/web-react"; -import { IconRobot, IconUser, IconEdit, IconCopy, IconRefresh } from "@arco-design/web-react/icon"; +import { IconRobot, IconUser, IconEdit, IconCopy, IconRefresh, IconExclamationCircleFill } from "@arco-design/web-react/icon"; import { useTranslation } from "react-i18next"; import { getTextContent } from "@App/app/service/agent/content_utils"; @@ -39,10 +39,9 @@ function AssistantMessageContent({ message, isStreaming }: { message: ChatMessag {/* 错误 */} {message.error && ( -
- {t("agent_chat_error")} - {": "} - {message.error} +
+ + {message.error}
)}
diff --git a/src/pages/options/routes/AgentChat/styles.css b/src/pages/options/routes/AgentChat/styles.css index e56c4134c..628b98b91 100644 --- a/src/pages/options/routes/AgentChat/styles.css +++ b/src/pages/options/routes/AgentChat/styles.css @@ -298,6 +298,38 @@ body[arco-theme="dark"] .ai-markdown-body .hljs-deletion { background-color: #67060c; } +/* 错误消息 */ +.agent-error-block { + margin-top: 8px; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + border-left: 3px solid #e5484d; + background: #fff0f0; + color: #ce2c31; + font-size: 13px; + line-height: 1.5; +} + +.agent-error-block .agent-error-icon { + flex-shrink: 0; + font-size: 16px; + margin-top: 1px; + color: #e5484d; +} + +body[arco-theme="dark"] .agent-error-block { + background: rgba(229, 72, 77, 0.1); + color: #ff6369; + border-left-color: #e5484d; +} + +body[arco-theme="dark"] .agent-error-block .agent-error-icon { + color: #ff6369; +} + /* 侧边栏会话列表 */ .agent-conversation-item { position: relative; From e4385ee25a8d2d10457144bee58926c3c43f3533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 16 Mar 2026 20:05:22 +0800 Subject: [PATCH 076/150] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Anthropic=20max=5F?= =?UTF-8?q?tokens=20=E5=BF=85=E5=A1=AB=E4=BF=AE=E5=A4=8D=E5=8F=8A=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=20UI=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20max=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Anthropic API 要求 max_tokens 必传,未配置时默认 16384 - OpenAI 仅在用户配置时传 max_tokens - 模型配置弹窗新增 Max Output Tokens 输入框 --- src/app/service/agent/providers/anthropic.ts | 4 +--- src/app/service/agent/providers/openai.ts | 4 ++++ src/locales/ach-UG/translation.json | 1 + src/locales/de-DE/translation.json | 1 + src/locales/en-US/translation.json | 1 + src/locales/ja-JP/translation.json | 1 + src/locales/ru-RU/translation.json | 1 + src/locales/vi-VN/translation.json | 1 + src/locales/zh-CN/translation.json | 1 + src/locales/zh-TW/translation.json | 1 + src/pages/options/routes/AgentProvider.tsx | 15 +++++++++++++++ 11 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/app/service/agent/providers/anthropic.ts b/src/app/service/agent/providers/anthropic.ts index eabbe42fe..70c8af05b 100644 --- a/src/app/service/agent/providers/anthropic.ts +++ b/src/app/service/agent/providers/anthropic.ts @@ -123,9 +123,7 @@ export function buildAnthropicRequest( stream: true, }; - if (config.maxTokens) { - body.max_tokens = config.maxTokens; - } + body.max_tokens = config.maxTokens || 16384; if (systemMessages.length > 0) { const systemBlocks = systemMessages.map((m) => ({ diff --git a/src/app/service/agent/providers/openai.ts b/src/app/service/agent/providers/openai.ts index 61bb951a0..3be110eac 100644 --- a/src/app/service/agent/providers/openai.ts +++ b/src/app/service/agent/providers/openai.ts @@ -93,6 +93,10 @@ export function buildOpenAIRequest( stream_options: { include_usage: true }, }; + if (config.maxTokens) { + body.max_tokens = config.maxTokens; + } + // 添加工具定义 if (request.tools && request.tools.length > 0) { body.tools = request.tools.map((t) => ({ diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index 4dcb700e9..5acc7025e 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -579,6 +579,7 @@ "agent_model_set_default": "Set as Default", "agent_model_default_label": "Default", "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_max_tokens": "Max Output Tokens", "agent_model_no_models": "No models configured", "agent_coming_soon": "Coming soon...", "agent_skills_title": "Skills Management", diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 98ae7c657..4b54b62fb 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -611,6 +611,7 @@ "agent_model_set_default": "Set as Default", "agent_model_default_label": "Default", "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_max_tokens": "Max Output Tokens", "agent_model_no_models": "No models configured", "agent_coming_soon": "Coming soon...", "agent_skills_title": "Skills Management", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index a2739c481..e890ccbed 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -611,6 +611,7 @@ "agent_model_set_default": "Set as Default", "agent_model_default_label": "Default", "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_max_tokens": "Max Output Tokens", "agent_model_no_models": "No models configured", "agent_coming_soon": "Coming soon...", "agent_skills_title": "Skills Management", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index a4690b132..c1252a6fe 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -611,6 +611,7 @@ "agent_model_set_default": "デフォルトに設定", "agent_model_default_label": "デフォルト", "agent_model_delete_confirm": "このモデル設定を削除してもよろしいですか?", + "agent_model_max_tokens": "最大出力トークン数", "agent_model_no_models": "モデル設定がありません", "agent_coming_soon": "開発中...", "agent_skills_title": "Skills 管理", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 5fb3db30c..72650a1db 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -611,6 +611,7 @@ "agent_model_set_default": "Set as Default", "agent_model_default_label": "Default", "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_max_tokens": "Max Output Tokens", "agent_model_no_models": "No models configured", "agent_coming_soon": "Coming soon...", "agent_skills_title": "Skills Management", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index a3ac870e8..58db15d8b 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -611,6 +611,7 @@ "agent_model_set_default": "Set as Default", "agent_model_default_label": "Default", "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_max_tokens": "Max Output Tokens", "agent_model_no_models": "No models configured", "agent_coming_soon": "Coming soon...", "agent_skills_title": "Skills Management", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 9b8bf3f29..1ac6e44d4 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -611,6 +611,7 @@ "agent_model_set_default": "设为默认", "agent_model_default_label": "默认", "agent_model_delete_confirm": "确定要删除此模型配置吗?", + "agent_model_max_tokens": "最大输出 Token 数", "agent_model_no_models": "暂无模型配置", "agent_coming_soon": "开发中...", "agent_skills_title": "Skills 管理", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 83e1213bd..f4630bd37 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -611,6 +611,7 @@ "agent_model_set_default": "設為預設", "agent_model_default_label": "預設", "agent_model_delete_confirm": "確定要刪除此模型配置嗎?", + "agent_model_max_tokens": "最大輸出 Token 數", "agent_model_no_models": "暫無模型配置", "agent_coming_soon": "開發中...", "agent_skills_title": "Skills 管理", diff --git a/src/pages/options/routes/AgentProvider.tsx b/src/pages/options/routes/AgentProvider.tsx index 3b23b1259..e17d8a454 100644 --- a/src/pages/options/routes/AgentProvider.tsx +++ b/src/pages/options/routes/AgentProvider.tsx @@ -3,6 +3,7 @@ import { Card, Empty, Input, + InputNumber, Message, Modal, Popconfirm, @@ -377,6 +378,20 @@ function AgentProvider() {
+ {/* Max Tokens */} +
+
+ {t("agent_model_max_tokens")} +
+ setEditingModel((prev) => ({ ...prev, maxTokens: value || undefined }))} + /> +
+ {/* 测试连接 */}
+
+ ))} +
+ )} + {/* 输入区域 */}